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.
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
|