flowgraphapp 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flowgraph/keys.py ADDED
@@ -0,0 +1,133 @@
1
+ """Provider API keys — stored in the OS keychain, never in the wheel or the browser.
2
+
3
+ Resolution precedence (mirrors Simon Willison's `llm` CLI, the de-facto standard):
4
+ explicit value > OS keychain (`keyring`) > environment variable > 0600 config file.
5
+ The 0600 file is a fallback used ONLY when no OS keychain backend is available
6
+ (e.g. headless Linux where keyring degrades to the Null backend). The key source is
7
+ always reported so the UI/trace can be honest about where a secret came from.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Optional, Tuple
16
+
17
+ from .config import config_dir
18
+
19
+ SERVICE = "flowgraph"
20
+
21
+ # provider -> accepted environment-variable names (first hit wins)
22
+ ENV_VARS = {
23
+ "anthropic": ("ANTHROPIC_API_KEY",),
24
+ "openrouter": ("OPENROUTER_API_KEY",),
25
+ "openai": ("OPENAI_API_KEY",),
26
+ "google": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
27
+ "deepseek": ("DEEPSEEK_API_KEY",),
28
+ }
29
+
30
+
31
+ def _keys_file() -> Path:
32
+ return config_dir() / "keys.json"
33
+
34
+
35
+ def _keyring():
36
+ """Return the keyring module iff a real OS backend is available, else None."""
37
+ try:
38
+ import keyring
39
+ kr = keyring.get_keyring()
40
+ backend = type(kr).__module__.lower()
41
+ if "fail" in backend or "null" in backend:
42
+ return None
43
+ return keyring
44
+ except Exception:
45
+ return None
46
+
47
+
48
+ def keyring_available() -> bool:
49
+ return _keyring() is not None
50
+
51
+
52
+ def get_key(provider: str, explicit: Optional[str] = None) -> Tuple[Optional[str], str]:
53
+ """Return (key, source). source in {explicit, keychain, env, file, none}."""
54
+ if explicit:
55
+ return explicit, "explicit"
56
+
57
+ kr = _keyring()
58
+ if kr is not None:
59
+ try:
60
+ v = kr.get_password(SERVICE, provider)
61
+ if v:
62
+ return v, "keychain"
63
+ except Exception:
64
+ pass
65
+
66
+ for env in ENV_VARS.get(provider, ()):
67
+ v = os.environ.get(env)
68
+ if v:
69
+ return v, "env"
70
+
71
+ v = _read_file().get(provider)
72
+ if v:
73
+ return v, "file"
74
+
75
+ return None, "none"
76
+
77
+
78
+ def set_key(provider: str, value: str) -> str:
79
+ """Persist a key. Returns the storage backend used ('keychain' or 'file')."""
80
+ kr = _keyring()
81
+ if kr is not None:
82
+ try:
83
+ kr.set_password(SERVICE, provider, value)
84
+ return "keychain"
85
+ except Exception:
86
+ pass
87
+ data = _read_file()
88
+ data[provider] = value
89
+ _write_file(data)
90
+ return "file"
91
+
92
+
93
+ def delete_key(provider: str) -> None:
94
+ kr = _keyring()
95
+ if kr is not None:
96
+ try:
97
+ kr.delete_password(SERVICE, provider)
98
+ except Exception:
99
+ pass
100
+ data = _read_file()
101
+ if provider in data:
102
+ del data[provider]
103
+ _write_file(data)
104
+
105
+
106
+ def _read_file() -> dict:
107
+ p = _keys_file()
108
+ if not p.is_file():
109
+ return {}
110
+ try:
111
+ return json.loads(p.read_text(encoding="utf-8"))
112
+ except Exception:
113
+ return {}
114
+
115
+
116
+ def _write_file(data: dict) -> None:
117
+ """Atomic 0600 write — the fallback store must never be world-readable."""
118
+ p = _keys_file()
119
+ tmp = p.with_name(p.name + ".tmp")
120
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
121
+ try:
122
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
123
+ json.dump(data, f)
124
+ finally:
125
+ try:
126
+ os.chmod(tmp, 0o600)
127
+ except OSError:
128
+ pass
129
+ os.replace(tmp, p)
130
+ try:
131
+ os.chmod(p, 0o600)
132
+ except OSError:
133
+ pass
flowgraph/providers.py ADDED
@@ -0,0 +1,89 @@
1
+ """LLM provider resolution behind a typed boundary (charter §1 — swappable mechanism).
2
+
3
+ Two pluggable kinds, modelled identically: a local OpenAI-compatible runtime
4
+ (Ollama / LM Studio / llama.cpp — zero key, air-gapped) and BYOK cloud providers
5
+ (key held server-side, never sent to the browser). Default is local-model-first:
6
+ use a reachable local runtime, else fall back to the first BYOK provider with a key.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Optional, Sequence
13
+
14
+ import httpx
15
+
16
+ from . import keys as keymod
17
+ from .config import LOCAL_RUNTIME_PROBES
18
+
19
+ BYOK_BASES = {
20
+ "anthropic": "https://api.anthropic.com",
21
+ "openrouter": "https://openrouter.ai/api",
22
+ "openai": "https://api.openai.com",
23
+ "deepseek": "https://api.deepseek.com",
24
+ }
25
+
26
+ DEFAULT_MODELS = {
27
+ "anthropic": "claude-haiku-4-5-20251001",
28
+ "openrouter": "anthropic/claude-haiku-4.5",
29
+ "openai": "gpt-4o-mini",
30
+ "deepseek": "deepseek-chat",
31
+ "local": "", # let the runtime use its loaded model when unspecified
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class ProviderChoice:
37
+ kind: str # 'local' | 'anthropic' | 'openrouter' | 'openai' | 'deepseek'
38
+ label: str # human-readable, for honest surfacing (e.g. "Ollama (local, no key)")
39
+ base_url: str
40
+ api_key: Optional[str]
41
+ key_source: str # 'none' | 'keychain' | 'env' | 'file' | 'explicit'
42
+
43
+ @property
44
+ def is_anthropic(self) -> bool:
45
+ return self.kind == "anthropic"
46
+
47
+
48
+ async def detect_local_runtime(
49
+ client: httpx.AsyncClient, timeout: float = 0.8
50
+ ) -> Optional[ProviderChoice]:
51
+ """Probe well-known OpenAI-compatible local endpoints; return the first reachable."""
52
+ for name, base in LOCAL_RUNTIME_PROBES:
53
+ try:
54
+ r = await client.get(base + "/v1/models", timeout=timeout)
55
+ if r.status_code < 500:
56
+ return ProviderChoice(
57
+ kind="local",
58
+ label=f"{name} (local, no key)",
59
+ base_url=base,
60
+ api_key=None,
61
+ key_source="none",
62
+ )
63
+ except Exception:
64
+ continue
65
+ return None
66
+
67
+
68
+ async def resolve_provider(
69
+ client: httpx.AsyncClient,
70
+ *,
71
+ prefer_local: bool = True,
72
+ byok_order: Sequence[str] = ("anthropic", "openrouter", "openai", "deepseek"),
73
+ ) -> Optional[ProviderChoice]:
74
+ if prefer_local:
75
+ local = await detect_local_runtime(client)
76
+ if local is not None:
77
+ return local
78
+
79
+ for kind in byok_order:
80
+ key, source = keymod.get_key(kind)
81
+ if key:
82
+ return ProviderChoice(
83
+ kind=kind,
84
+ label=f"{kind} (key from {source})",
85
+ base_url=BYOK_BASES[kind],
86
+ api_key=key,
87
+ key_source=source,
88
+ )
89
+ return None
@@ -0,0 +1,27 @@
1
+ """Non-secret runtime config served at GET /config.
2
+
3
+ This is the seam the SPA reads (build-once / runtime-config pattern) to learn that it
4
+ is running under the local server and should route AI through the same-origin proxy
5
+ (so the key stays server-side). It NEVER contains a provider key — only the active
6
+ provider's kind/label and key SOURCE, for honest surfacing. The SPA consumes this at
7
+ boot (L1, docs/18 §7.1): `store.init()` → `applyRuntimeConfig` → `resolveAiTransport`
8
+ selects the same-origin proxy; in proxy mode the CSP connect-src is tightened to 'self'.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from . import __version__
14
+ from .config import ServerConfig
15
+
16
+
17
+ def runtime_config(config: ServerConfig) -> dict:
18
+ return {
19
+ "mode": "local",
20
+ "version": __version__,
21
+ # Same-origin: the SPA can use relative /api/... paths, so no base URL needed.
22
+ "apiBase": "",
23
+ "proxyEnabled": True,
24
+ "transport": "proxy", # hint: prefer the server-side key proxy over browser BYOK
25
+ "auth": "cookie" if config.auth_enabled else "none",
26
+ "localModelFirst": True,
27
+ }
flowgraph/security.py ADDED
@@ -0,0 +1,291 @@
1
+ """The hardening layer. Assumes ``localhost`` is hostile-reachable.
2
+
3
+ Defenses (all ON by default, fail-closed, enforced as ONE ASGI middleware so they
4
+ cover every route — static assets, API, health, and any future SSE/WebSocket):
5
+
6
+ 1. Host-header allow-list -> the load-bearing DNS-rebinding defense. A rebound
7
+ request arrives with ``Host: attacker.com`` and is rejected. Exact host:port
8
+ match only; substrings/suffixes/missing-Host/0.0.0.0/trailing-dot are rejected.
9
+ 2. Origin validation -> on every WebSocket handshake and every state-changing
10
+ (non-GET) HTTP request, and on any HTTP request that carries a foreign Origin.
11
+ Defeats Cross-Site WebSocket Hijacking (WS is not covered by CORS).
12
+ 3. Session token -> >=256-bit CSPRNG, required on every route except the
13
+ index handshake and /healthz. Constant-time compare. Defends against other local
14
+ users/processes (who can forge Host/Origin but cannot obtain the token).
15
+ 4. Security headers -> strict CSP, nosniff, frame-ancestors 'none', etc. on
16
+ ALL responses (treat rendered/streamed content as untrusted).
17
+
18
+ References: Jupyter (allow_remote_access=False), MCP-SDK CVE-2025-66416 (the trap of
19
+ shipping this OFF by default), CWE-1385 (WS origin), the 0.0.0.0-day class.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hmac
25
+ import secrets
26
+ import time
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Optional, Tuple
30
+
31
+ from .config import PROVIDER_ORIGINS, ServerConfig
32
+
33
+ LOOPBACK_HOSTNAMES = frozenset({"127.0.0.1", "localhost", "::1"})
34
+
35
+ # Paths reachable WITHOUT a token. The index handshake and health only; everything
36
+ # else (assets, /config, /api/*) requires auth.
37
+ _AUTH_EXEMPT_PATHS = frozenset({"/", "/index.html", "/healthz"})
38
+
39
+
40
+ # --------------------------------------------------------------------------- tokens
41
+
42
+ def new_session_token() -> str:
43
+ """256-bit URL-safe token."""
44
+ return secrets.token_urlsafe(32)
45
+
46
+
47
+ def tokens_match(a: str, b: str) -> bool:
48
+ """Constant-time comparison (never use ==)."""
49
+ if not a or not b:
50
+ return False
51
+ return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
52
+
53
+
54
+ @dataclass
55
+ class _Ticket:
56
+ value: str
57
+ expires_at: float
58
+
59
+
60
+ class TicketStore:
61
+ """Single-use, short-TTL tickets that bootstrap a session cookie from the launch URL.
62
+
63
+ The long-lived session token is NEVER placed in a URL (it would leak to logs /
64
+ history). Instead the launch URL carries a one-time ticket; the index route
65
+ exchanges it for an HttpOnly SameSite=Strict cookie and strips it via redirect.
66
+ """
67
+
68
+ def __init__(self, ttl_seconds: int = 600) -> None:
69
+ self._ttl = ttl_seconds
70
+ self._tickets: dict[str, _Ticket] = {}
71
+
72
+ def mint(self) -> str:
73
+ value = secrets.token_urlsafe(24)
74
+ self._tickets[value] = _Ticket(value, time.monotonic() + self._ttl)
75
+ return value
76
+
77
+ def consume(self, value: Optional[str]) -> bool:
78
+ if not value:
79
+ return False
80
+ t = self._tickets.pop(value, None)
81
+ if t is None:
82
+ return False
83
+ return t.expires_at >= time.monotonic()
84
+
85
+
86
+ # ----------------------------------------------------------------------- host/origin
87
+
88
+ def split_host_port(value: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
89
+ """Parse a Host/authority header into (host, port). Rejects malformed input."""
90
+ if not value:
91
+ return None, None
92
+ value = value.strip()
93
+ if value.startswith("["): # IPv6 literal: [::1]:port or [::1]
94
+ end = value.find("]")
95
+ if end == -1:
96
+ return None, None
97
+ host = value[1:end]
98
+ rest = value[end + 1:]
99
+ if rest == "":
100
+ return host, ""
101
+ if rest.startswith(":"):
102
+ return host, rest[1:]
103
+ return None, None
104
+ if value.count(":") == 0:
105
+ return value, ""
106
+ if value.count(":") == 1:
107
+ host, port = value.rsplit(":", 1)
108
+ return host, port
109
+ # bare IPv6 without brackets, or otherwise malformed -> reject
110
+ return None, None
111
+
112
+
113
+ def host_allowed(host_header: Optional[str], bound_port: int) -> bool:
114
+ host, port = split_host_port(host_header)
115
+ if host is None:
116
+ return False
117
+ if host.lower() not in LOOPBACK_HOSTNAMES:
118
+ return False
119
+ # We always bind an explicit port; the Host must carry the matching port.
120
+ return port == str(bound_port)
121
+
122
+
123
+ def allowed_origins(config: ServerConfig) -> frozenset:
124
+ scheme = config.scheme
125
+ port = config.port
126
+ origins = set()
127
+ for h in ("127.0.0.1", "localhost"):
128
+ origins.add(f"{scheme}://{h}:{port}")
129
+ origins.add(f"{scheme}://[::1]:{port}")
130
+ return frozenset(origins)
131
+
132
+
133
+ def origin_allowed(origin: Optional[str], config: ServerConfig) -> bool:
134
+ if not origin:
135
+ return False
136
+ return origin in allowed_origins(config)
137
+
138
+
139
+ # ------------------------------------------------------------------- security headers
140
+
141
+ def safe_static_path(static_dir: Path, rel: str) -> Optional[Path]:
142
+ """Resolve ``rel`` under ``static_dir``, or None if it would escape (path traversal)."""
143
+ base = static_dir.resolve()
144
+ candidate = (base / rel).resolve()
145
+ try:
146
+ candidate.relative_to(base)
147
+ except ValueError:
148
+ return None
149
+ return candidate
150
+
151
+
152
+ def content_security_policy(config: ServerConfig) -> str:
153
+ # connect-src: in PROXY mode (the default — see config.proxy_mode / GET /config) the SPA
154
+ # routes ALL AI through the same-origin proxy, so it never needs a provider or local-runtime
155
+ # origin — tighten to 'self' (docs/18 §4.8/§7.4). In BYOK-direct mode (proxy_mode=False) the
156
+ # browser calls providers/local runtimes itself, so those origins must be permitted.
157
+ if config.proxy_mode:
158
+ connect = ["'self'", *config.extra_connect_src]
159
+ else:
160
+ connect = ["'self'", *PROVIDER_ORIGINS]
161
+ for h in ("127.0.0.1", "localhost"):
162
+ connect += [f"http://{h}:11434", f"http://{h}:1234", f"http://{h}:8080"]
163
+ connect += list(config.extra_connect_src)
164
+ return "; ".join([
165
+ "default-src 'self'",
166
+ "script-src 'self'",
167
+ "style-src 'self' 'unsafe-inline'", # React/Tailwind inline styles
168
+ "img-src 'self' data: blob:",
169
+ "font-src 'self' data:",
170
+ "worker-src 'self' blob:", # pdf.worker
171
+ "connect-src " + " ".join(connect),
172
+ "base-uri 'self'",
173
+ "form-action 'self'",
174
+ "object-src 'none'",
175
+ "frame-ancestors 'none'",
176
+ ])
177
+
178
+
179
+ def security_headers(config: ServerConfig) -> list[tuple[bytes, bytes]]:
180
+ headers = {
181
+ "content-security-policy": content_security_policy(config),
182
+ "x-content-type-options": "nosniff",
183
+ "x-frame-options": "DENY",
184
+ "referrer-policy": "same-origin",
185
+ "permissions-policy": "geolocation=(), microphone=(), camera=()",
186
+ "cross-origin-opener-policy": "same-origin",
187
+ "cross-origin-resource-policy": "same-origin",
188
+ }
189
+ if config.scheme == "https":
190
+ headers["strict-transport-security"] = "max-age=63072000"
191
+ return [(k.encode(), v.encode()) for k, v in headers.items()]
192
+
193
+
194
+ # --------------------------------------------------------------------- ASGI middleware
195
+
196
+ def _header(scope_headers, name: bytes) -> Optional[str]:
197
+ for k, v in scope_headers:
198
+ if k == name:
199
+ try:
200
+ return v.decode("latin-1")
201
+ except Exception:
202
+ return None
203
+ return None
204
+
205
+
206
+ def _cookie_token(scope_headers) -> Optional[str]:
207
+ raw = _header(scope_headers, b"cookie")
208
+ if not raw:
209
+ return None
210
+ for part in raw.split(";"):
211
+ part = part.strip()
212
+ if part.startswith("fg_session="):
213
+ return part[len("fg_session="):]
214
+ return None
215
+
216
+
217
+ class LocalSecurityMiddleware:
218
+ """One ASGI middleware enforcing Host/Origin/token + security headers on every route."""
219
+
220
+ def __init__(self, app, config: ServerConfig) -> None:
221
+ self.app = app
222
+ self.config = config
223
+
224
+ async def __call__(self, scope, receive, send):
225
+ if scope["type"] not in ("http", "websocket"):
226
+ return await self.app(scope, receive, send)
227
+
228
+ headers = scope.get("headers", [])
229
+ host = _header(headers, b"host")
230
+ origin = _header(headers, b"origin")
231
+ is_ws = scope["type"] == "websocket"
232
+ method = "GET" if is_ws else scope.get("method", "GET")
233
+ path = scope.get("path", "/")
234
+
235
+ # 1. Host allow-list — the primary DNS-rebinding defense. Every request.
236
+ if not host_allowed(host, self.config.port):
237
+ return await self._deny(scope, send, 403, "Forbidden host")
238
+
239
+ # 2. Origin validation — WS always; HTTP on non-safe methods or any foreign Origin.
240
+ if is_ws:
241
+ if not origin_allowed(origin, self.config):
242
+ return await self._deny(scope, send, 403, "Forbidden origin")
243
+ else:
244
+ if method not in ("GET", "HEAD", "OPTIONS"):
245
+ if not origin_allowed(origin, self.config):
246
+ return await self._deny(scope, send, 403, "Forbidden origin")
247
+ elif origin is not None and not origin_allowed(origin, self.config):
248
+ return await self._deny(scope, send, 403, "Forbidden origin")
249
+
250
+ # 3. Token auth — unless disabled. The index handshake (ticket->cookie) and
251
+ # /healthz are exempt; the index route itself enforces ticket-or-401.
252
+ if self.config.auth_enabled and not self._is_authed(headers):
253
+ if not (method in ("GET", "HEAD") and path in _AUTH_EXEMPT_PATHS):
254
+ return await self._deny(scope, send, 401, "Authentication required")
255
+
256
+ # Passed the gate — inject security headers on the way out.
257
+ await self.app(scope, receive, self._wrap_send(send))
258
+
259
+ def _is_authed(self, headers) -> bool:
260
+ bearer = _header(headers, b"authorization")
261
+ if bearer and bearer.lower().startswith("bearer "):
262
+ if tokens_match(bearer[7:].strip(), self.config.session_token):
263
+ return True
264
+ cookie = _cookie_token(headers)
265
+ if cookie and tokens_match(cookie, self.config.session_token):
266
+ return True
267
+ return False
268
+
269
+ def _wrap_send(self, send):
270
+ extra = security_headers(self.config)
271
+
272
+ async def wrapped(message):
273
+ if message["type"] == "http.response.start":
274
+ message = dict(message)
275
+ message["headers"] = list(message.get("headers", [])) + extra
276
+ await send(message)
277
+
278
+ return wrapped
279
+
280
+ async def _deny(self, scope, send, status: int, detail: str):
281
+ if scope["type"] == "websocket":
282
+ # Reject the handshake before accept.
283
+ await send({"type": "websocket.close", "code": 1008})
284
+ return
285
+ body = detail.encode("utf-8")
286
+ start_headers = [
287
+ (b"content-type", b"text/plain; charset=utf-8"),
288
+ (b"content-length", str(len(body)).encode()),
289
+ ] + security_headers(self.config)
290
+ await send({"type": "http.response.start", "status": status, "headers": start_headers})
291
+ await send({"type": "http.response.body", "body": body})
flowgraph/server.py ADDED
@@ -0,0 +1,96 @@
1
+ """The FastAPI app: hardened static SPA host + the /api/ai/chat proxy.
2
+
3
+ Routes (all behind LocalSecurityMiddleware — Host/Origin/token + security headers):
4
+ GET /healthz liveness + version (Host-checked, no token)
5
+ GET /config non-secret runtime config for the SPA (auth required)
6
+ POST /api/ai/chat LLM proxy, key stays server-side (auth + Origin required)
7
+ GET /{path} SPA: ticket->cookie handshake at root, then static + SPA fallback
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from fastapi import FastAPI, Request
15
+ from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, RedirectResponse
16
+
17
+ from . import __version__
18
+ from .ai_proxy import proxy_chat
19
+ from .config import ServerConfig, resolve_static_dir
20
+ from .runtime_config import runtime_config
21
+ from .security import LocalSecurityMiddleware, TicketStore, safe_static_path, tokens_match
22
+
23
+
24
+ def _request_authed(request: Request, config: ServerConfig) -> bool:
25
+ auth = request.headers.get("authorization")
26
+ if auth and auth.lower().startswith("bearer ") and tokens_match(auth[7:].strip(), config.session_token):
27
+ return True
28
+ ck = request.cookies.get("fg_session")
29
+ return bool(ck and tokens_match(ck, config.session_token))
30
+
31
+
32
+ def create_app(config: ServerConfig, tickets: TicketStore) -> FastAPI:
33
+ static_dir = Path(config.static_dir or resolve_static_dir()).resolve()
34
+ config.static_dir = static_dir
35
+ index_html = static_dir / "index.html"
36
+
37
+ app = FastAPI(title="FlowGraph (local)", version=__version__, docs_url=None, redoc_url=None, openapi_url=None)
38
+ app.add_middleware(LocalSecurityMiddleware, config=config)
39
+
40
+ @app.get("/healthz")
41
+ async def healthz():
42
+ return {"status": "ok", "version": __version__}
43
+
44
+ @app.get("/config")
45
+ async def config_route():
46
+ return runtime_config(config)
47
+
48
+ @app.post("/api/ai/chat")
49
+ async def ai_chat(request: Request):
50
+ try:
51
+ body = await request.json()
52
+ except Exception:
53
+ return JSONResponse({"text": None, "error": "invalid-json"}, status_code=400)
54
+ if not isinstance(body, dict) or not isinstance(body.get("messages"), list):
55
+ return JSONResponse({"text": None, "error": "messages-required"}, status_code=400)
56
+ result = await proxy_chat(body, prefer_local=config.local_model_first)
57
+ status = 200 if not result.get("error") else 502
58
+ return JSONResponse(result, status_code=status)
59
+
60
+ @app.get("/{full_path:path}")
61
+ async def spa(full_path: str, request: Request):
62
+ is_root = full_path in ("", "index.html")
63
+
64
+ # Ticket -> cookie handshake (bootstraps the session from the launch URL).
65
+ if is_root and config.auth_enabled and not _request_authed(request, config):
66
+ ticket = request.query_params.get("ticket")
67
+ if ticket and tickets.consume(ticket):
68
+ resp = RedirectResponse(url="/", status_code=302)
69
+ resp.set_cookie(
70
+ "fg_session",
71
+ config.session_token,
72
+ httponly=True,
73
+ samesite="strict",
74
+ secure=(config.scheme == "https"),
75
+ path="/",
76
+ )
77
+ return resp
78
+ return PlainTextResponse(
79
+ "FlowGraph is running. Open it using the URL printed in the terminal "
80
+ "where you launched `flowgraph` (it carries a one-time access ticket).",
81
+ status_code=401,
82
+ )
83
+
84
+ # Serve a static file (path-safe) or fall back to index.html for client-side routes.
85
+ candidate = safe_static_path(static_dir, full_path)
86
+ if candidate is None:
87
+ return PlainTextResponse("Not found", status_code=404) # path traversal blocked
88
+ if candidate.is_file():
89
+ return FileResponse(candidate)
90
+ # Missing asset (has an extension) -> 404; missing route -> SPA fallback.
91
+ last = full_path.rsplit("/", 1)[-1]
92
+ if "." in last:
93
+ return PlainTextResponse("Not found", status_code=404)
94
+ return FileResponse(index_html)
95
+
96
+ return app