Docs / docs (push) Successful in 42s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 10s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 9s
Playwright Tests / merge-reports (push) Failing after 2m17s
A provider set under Settings (OpenAI-compatible; key encrypted in flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs the FastMCP tools in-process as the person chatting, streams the rounds over SSE and keeps one transcript per user. The panel floats on any screen and shares the editor's lane: a settings panel keeps the top half, the chat the bottom. The MCP client is now built once, for the endpoint and the chat. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
123 lines
3.8 KiB
Python
123 lines
3.8 KiB
Python
"""Encrypted credential store for node integrations.
|
|
|
|
Nodes never hold credentials: a parameter written as ``{"$secret": "name"}``
|
|
is replaced with the stored value when the node is built. Secrets are kept
|
|
encrypted outside the flows repository, so what gets committed and shared
|
|
never contains a password.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
|
|
SECRET_REF = "$secret"
|
|
|
|
|
|
class SecretsUnreadable(RuntimeError):
|
|
"""The store exists but does not decrypt with the current SECRET_KEY."""
|
|
|
|
|
|
class SecretNotFound(KeyError):
|
|
"""A node asked for a secret that is not in the store."""
|
|
|
|
def __init__(self, name: str) -> None:
|
|
super().__init__(name)
|
|
self.name = name
|
|
|
|
def __str__(self) -> str:
|
|
return f"No secret named '{self.name}' — add it under Secrets."
|
|
|
|
|
|
def fernet(key: str) -> Fernet:
|
|
"""The cipher a store keyed on the app key uses, so every one of them
|
|
turns unreadable together when ``SECRET_KEY`` changes."""
|
|
return Fernet(base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest()))
|
|
|
|
|
|
class SecretsStore:
|
|
"""Named credentials, encrypted at rest with a key derived from the app key."""
|
|
|
|
def __init__(self, path: Path, key: str) -> None:
|
|
self._path = path
|
|
self._fernet = fernet(key)
|
|
|
|
def _read(self) -> dict[str, str]:
|
|
if not self._path.exists():
|
|
return {}
|
|
try:
|
|
data: dict[str, str] = json.loads(
|
|
self._fernet.decrypt(self._path.read_bytes())
|
|
)
|
|
return data
|
|
except (InvalidToken, ValueError) as exc:
|
|
# A changed app key makes existing secrets unreadable. Fail loudly:
|
|
# flows coming up with silently missing credentials are far harder
|
|
# to diagnose than a node that reports why it did not load.
|
|
raise SecretsUnreadable(
|
|
f"Cannot decrypt {self._path} — it was written with a "
|
|
"different SECRET_KEY. Re-encrypt it with "
|
|
"scripts/rotate_secret_key.py."
|
|
) from exc
|
|
|
|
def _write(self, data: dict[str, str]) -> None:
|
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._path.write_bytes(self._fernet.encrypt(json.dumps(data).encode()))
|
|
|
|
def list(self) -> list[str]:
|
|
return sorted(self._read())
|
|
|
|
def get(self, name: str) -> str:
|
|
data = self._read()
|
|
if name not in data:
|
|
raise SecretNotFound(name)
|
|
return data[name]
|
|
|
|
def set(self, name: str, value: str) -> None:
|
|
data = self._read()
|
|
data[name] = value
|
|
self._write(data)
|
|
|
|
def delete(self, name: str) -> None:
|
|
data = self._read()
|
|
if data.pop(name, None) is None:
|
|
raise SecretNotFound(name)
|
|
self._write(data)
|
|
|
|
|
|
_store: SecretsStore | None = None
|
|
|
|
|
|
def init_secrets(path: Path, key: str) -> SecretsStore:
|
|
"""Create the process-wide store (called once at startup)."""
|
|
global _store
|
|
_store = SecretsStore(path, key)
|
|
return _store
|
|
|
|
|
|
def get_secrets() -> SecretsStore:
|
|
if _store is None:
|
|
raise RuntimeError("Secrets store not initialised")
|
|
return _store
|
|
|
|
|
|
def get(name: str) -> str:
|
|
"""Look up a secret by name — the entry point for custom node code."""
|
|
return get_secrets().get(name)
|
|
|
|
|
|
def resolve_params(params: Any) -> Any:
|
|
"""Replace every ``{"$secret": "name"}`` reference with its value."""
|
|
if isinstance(params, dict):
|
|
if set(params) == {SECRET_REF} and isinstance(params[SECRET_REF], str):
|
|
return get(params[SECRET_REF])
|
|
return {k: resolve_params(v) for k, v in params.items()}
|
|
if isinstance(params, list):
|
|
return [resolve_params(v) for v in params]
|
|
return params
|