diff --git a/CLAUDE.md b/CLAUDE.md index f480f7d..1bc3c53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,6 +201,11 @@ store / metadata sidecar. checklist rollup that tags a card, `goal_detail()` adds the markdown for the detail page. Counting skips fenced code blocks, so a `- [ ]` inside a snippet isn't mistaken for a real task. No GOAL.md ⇒ `goal: null` (a valid state). +- `projectflags.py` — a project's `public/feature-flags.json`: the query-param + feature flags its frontend ships behind, each with an `enabled` "for everyone" + default. Also mints and verifies the **admin token** (HMAC-SHA256, key at + `/data/flags-secret` — delete it to revoke every token ever issued). See + *Feature flags* below. - `memories.py` — surfaces the assistant's per-repo memory files (frontmatter). - `templates.py` — browse `~/projects/templates/` scaffolds. - `schemas.py` — Pydantic response models mirroring `frontend/src/types.ts`. They @@ -508,6 +513,8 @@ backend proxies to it over `host.docker.internal:8790` (Bearer `SIDECAR_TOKEN`): --model --remote-control` (detached). Transcript lands in the watched projects dir and the new conversation shows up in the viewer within ~2s. - `POST /api/resume` / `POST /api/interrupt` → continue / SIGINT an existing run. +- `POST /api/project-flags/publish` → sidecar `/publish-flags`: `zipgo deploy + --no-delete` of one project's `feature-flags.json` onto its live hosts. `model` is the `claude --model` argument the composer's **model tag** carries — a family alias (`opus`) for a family's newest model, or a pinned id @@ -516,6 +523,40 @@ family alias (`opus`) for a family's newest model, or a pinned id own. The sidecar only shape-checks the value (it goes on a command line) — the pickable set is whatever `/api/models` returned. +### Feature flags (project page → Feature flags) + +Features ship behind `?flag=1` so they can be shown before they're finished. +Each project declares its flags in `public/feature-flags.json`; the widget at +`flags.dev.gabvdl.xyz/script.js` (source: `~/projects/feature-flags`) resolves +them on the deployed site and, for an admin browser only, renders a toggle panel. + +Flags are **declared in code and toggled here** — the card cannot create or +delete them, because a flag with no code behind it is dead weight. + +| endpoint | auth | who calls it | +|---|---|---| +| `GET/PUT /api/project-flags` | Authelia (same origin) | the `FlagsEditor` card | +| `POST /api/project-flags/admin-link` | Authelia | the *Admin link* button | +| `POST /api/project-flags/publish` | Authelia | the *Publish now* button | +| `POST /api/flags/publish` | **minted token** | the panel on the live site | + +Three things are load-bearing about that last row: + +1. The public sites have no Authelia session, so the panel carries a token the + *Admin link* button minted (stored in its browser after one visit to + `?ff-admin=`), and `projectflags.verify_token` checks its HMAC. +2. Its Traefik router (`services/ai-agent/traefik.yml`) puts `/api/flags` on + `api-chain`, not `auth-chain` — forward-auth would answer the CORS preflight + with a login redirect the browser rejects, so the request could never reach + the token check. +3. The panel POSTs `text/plain` so there *is* no preflight (a CORS simple + request). Don't "fix" it to `application/json`. + +Publishing pushes the one JSON file to the live site with no rebuild. The +container has neither zipgo nor the deploy key, so the host sidecar does it — +`POST /publish-flags`, a *fixed* operation (one named file, to the hosts the +project's own `package.json` declares), never a generic exec endpoint. + ### Model tags Models are a first-class tag on both sides of the app (`frontend/src/lib/models.tsx`): diff --git a/backend/main.py b/backend/main.py index 359029c..ef42063 100644 --- a/backend/main.py +++ b/backend/main.py @@ -53,6 +53,7 @@ import notify_audio as notify_audio_mod import notif_read as notif_read_mod import plans as plans_mod import project_costs as project_costs_mod +import projectflags import projects as projects_mod import scaffold as scaffold_mod import schemas @@ -3163,6 +3164,189 @@ def project_env_save(body: EnvSaveBody): return _project_env_payload(body.slug, entry) +# ── project feature flags ───────────────────────────────────────────────────── +# A project declares its query-param feature flags in `public/feature-flags.json` +# (see backend/projectflags.py). Three consumers: +# +# * this page's editor — flips the "for everyone" default, same origin +# * the admin-link button — mints the token that unlocks the panel on the +# deployed site +# * the panel on that site — POSTs back to /api/flags/publish, cross-origin +# and token-authed (it has no lab session) + +def _flags_payload(slug: str, entry: pathlib.Path) -> dict: + state = projectflags.read(entry) + return { + "slug": slug, + "path": state["path"], + "exists": state["exists"], + "error": state["error"], + "flags": state["flags"], + "hosts": projectflags.deploy_hosts(entry), + } + + +def _flags_entry(slug: str) -> pathlib.Path: + entry = projects_mod._safe_entry(slug) + if entry is None: + raise HTTPException(404, "project not found") + return entry + + +@app.get("/api/project-flags", responses=_r(schemas.ProjectFlagsResponse)) +def project_flags(slug: str): + """The project's declared feature flags and their for-everyone defaults.""" + return _flags_payload(slug, _flags_entry(slug)) + + +class FlagSetItem(BaseModel): + key: str + enabled: bool + + +class FlagsSaveBody(BaseModel): + slug: str + set: list[FlagSetItem] = [] + + +@app.put("/api/project-flags", responses=_r(schemas.ProjectFlagsResponse)) +def project_flags_save(body: FlagsSaveBody): + """Flip the for-everyone default of flags the project already declares. + + Deliberately toggle-only: a flag with no code behind it is dead weight, so + creating and deleting them stays with whoever ships the feature (by editing + the file). Unknown keys are rejected rather than silently created.""" + entry = _flags_entry(body.slug) + state = projectflags.read(entry) + if state["error"]: + raise HTTPException(409, f"{state['path']} is unreadable: {state['error']}") + by_key = {f["key"]: f for f in state["flags"]} + for item in body.set: + if item.key not in by_key: + raise HTTPException(400, f"unknown flag: {item.key!r}") + by_key[item.key]["enabled"] = item.enabled + projectflags.write(entry, list(by_key.values())) + return _flags_payload(body.slug, entry) + + +class FlagsSlugBody(BaseModel): + slug: str + + +@app.post("/api/project-flags/admin-link", responses=_r(schemas.FlagAdminLinkResponse)) +def project_flags_admin_link(body: FlagsSlugBody): + """Mint the one-time link that unlocks the admin panel on the deployed site. + + Reachable only from behind Authelia (this whole API is), which is what makes + the token a credential worth trusting. Opening the link once stores it in + that browser; `?ff-admin=` with no value signs out again.""" + entry = _flags_entry(body.slug) + token = projectflags.mint_token() + return { + "token": token, + "links": [ + {"host": host, "url": f"https://{host}/?ff-admin={token}"} + for host in projectflags.deploy_hosts(entry) + ], + } + + +def _publish_flags(slug: str, entry: pathlib.Path) -> list[str]: + """Push the project's flag file to its live sites, no rebuild. + + The container has neither zipgo nor the deploy key, so the host sidecar runs + the actual `zipgo deploy --no-delete` of that single file.""" + hosts = projectflags.deploy_hosts(entry) + if not hosts: + return [] + state = projectflags.read(entry) + if not state["exists"] or state["error"]: + raise HTTPException(409, "no readable flag file to publish") + out = _sidecar_call("/publish-flags", {"project": slug, "path": state["path"], + "hosts": hosts}) + published = out.get("published") + return published if isinstance(published, list) else [] + + +@app.post("/api/project-flags/publish", responses=_r(schemas.FlagPublishResponse)) +def project_flags_publish(body: FlagsSlugBody): + """Publish the committed flag file to the live sites.""" + entry = _flags_entry(body.slug) + published = _publish_flags(body.slug, entry) + return {"slug": body.slug, "published": published, "detail": None, + "flags": projectflags.read(entry)["flags"]} + + +# Origins the deployed sites live on. The panel's publish call carries its own +# token, so this is not the auth boundary — it just keeps the endpoint from +# being usable as a generic cross-origin write target from anywhere. +_FLAGS_ORIGIN_RE = re.compile(r"^https://([a-z0-9-]+\.)*gabvdl\.xyz$") + + +def _flags_cors(origin: str | None) -> dict: + if origin and _FLAGS_ORIGIN_RE.match(origin): + return {"Access-Control-Allow-Origin": origin, "Vary": "Origin"} + return {} + + +@app.post("/api/flags/publish") +async def flags_publish(request: Request): + """Cross-origin flag flip from the admin panel on a deployed site. + + Reached without any lab session, so the bearer here is the minted token and + its HMAC is checked before anything is written. The panel sends `text/plain` + so the browser treats this as a CORS *simple request* — no preflight, which + matters because the forward-auth in front of this API answers an + unauthenticated `OPTIONS` with a redirect the browser would reject.""" + origin = request.headers.get("origin") + cors = _flags_cors(origin) + + def fail(code: int, detail: str): + return JSONResponse({"detail": detail}, status_code=code, headers=cors) + + try: + body = json.loads((await request.body()).decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return fail(400, "malformed body") + if not isinstance(body, dict): + return fail(400, "malformed body") + + if projectflags.verify_token(body.get("token")) is None: + return fail(401, "invalid or expired admin token") + + slug = body.get("project") + key = body.get("key") + if not isinstance(slug, str) or not isinstance(key, str): + return fail(400, "project and key are required") + entry = projects_mod._safe_entry(slug) + if entry is None: + return fail(404, f"unknown project: {slug}") + + state = projectflags.read(entry) + if state["error"]: + return fail(409, f"{state['path']} is unreadable: {state['error']}") + by_key = {f["key"]: f for f in state["flags"]} + if key not in by_key: + return fail(400, f"unknown flag: {key}") + by_key[key]["enabled"] = bool(body.get("enabled")) + projectflags.write(entry, list(by_key.values())) + + # Getting the file live is best-effort: the repo is already updated, and a + # sidecar that is down must not read as "the toggle failed". + published: list[str] = [] + detail = None + try: + published = _publish_flags(slug, entry) + except HTTPException as exc: + detail = str(exc.detail) + + return JSONResponse( + {"slug": slug, "published": published, "detail": detail, + "flags": projectflags.read(entry)["flags"]}, + headers=cors, + ) + + # Raster formats that are safe to render inline on the app origin. Anything # else served from user/repo-supplied bytes (SVG can carry scripts, HTML is # HTML) goes out as a download so it can never script against the API's cookies. diff --git a/backend/projectflags.py b/backend/projectflags.py new file mode 100644 index 0000000..ab027f4 --- /dev/null +++ b/backend/projectflags.py @@ -0,0 +1,261 @@ +"""Read/write a project's ``feature-flags.json`` — the per-project declaration +of the query-param feature flags its frontend ships behind. + +The file lives in the project's **served** directory so the deployed site can +fetch it at ``/feature-flags.json``: + + /public/feature-flags.json (Vite/CRA — preferred) + /feature-flags.json (fallback, plain static sites) + +Shape:: + + { + "flags": [ + { + "key": "archives", # the query param: ?archives=1 + "label": "Archives", + "description": "Past-grid browser in the header nav", + "enabled": false, # the *for everyone* default + "since": "2026-08-17" + } + ] + } + +``enabled`` is the only field the admin panel flips for everyone; a visitor's +own ``?key=1`` / panel toggle is a client-side override that never touches this +file. Unknown keys on a flag are preserved across a round trip, so a project can +carry extra metadata the editor doesn't know about. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import re +import secrets +import tempfile +import time +from pathlib import Path +from typing import Any + +FILENAME = "feature-flags.json" + +# A flag key doubles as a URL query parameter and a storage key, so keep it to +# the characters that are unambiguous in both. +KEY_RE = re.compile(r"^[a-z][a-z0-9-]{0,47}$") + +# Fields the editor owns. Anything else on a flag object is passed through. +_KNOWN = ("key", "label", "description", "enabled", "since") + + +def flags_path(entry: Path) -> Path: + """Where this project's flags file lives (or would be created). + + An existing file wins wherever it is; otherwise ``public/`` is preferred + when the project has one (every Vite template does), so a newly declared + flag is served by the deployed site without a build-config change. + """ + public = entry / "public" / FILENAME + root = entry / FILENAME + if public.is_file(): + return public + if root.is_file(): + return root + return public if (entry / "public").is_dir() else root + + +def _coerce(raw: Any, index: int) -> dict | None: + """Normalise one entry of the ``flags`` array; None if unusable.""" + if not isinstance(raw, dict): + return None + key = raw.get("key") + if not isinstance(key, str) or not KEY_RE.match(key): + return None + flag = dict(raw) + flag["key"] = key + flag["label"] = raw["label"] if isinstance(raw.get("label"), str) else key + flag["description"] = raw["description"] if isinstance(raw.get("description"), str) else "" + flag["enabled"] = bool(raw.get("enabled")) + if not isinstance(raw.get("since"), str): + flag.pop("since", None) + return flag + + +def read(entry: Path) -> dict: + """Parse the project's flags file. A missing file is not an error — it reads + as an empty, not-yet-created flag set. A malformed one is reported rather + than silently emptied, so the editor never offers to overwrite a file it + failed to understand.""" + path = flags_path(entry) + if not path.is_file(): + return {"path": None, "exists": False, "error": None, "flags": []} + rel = str(path.relative_to(entry)) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + return {"path": rel, "exists": True, "error": str(exc), "flags": []} + raw = data.get("flags") if isinstance(data, dict) else data + if not isinstance(raw, list): + return {"path": rel, "exists": True, "error": "no `flags` array", "flags": []} + flags = [f for f in (_coerce(r, i) for i, r in enumerate(raw)) if f is not None] + # Last write wins on a duplicated key — the file is hand-editable, and a + # duplicate would otherwise make the editor's save ambiguous. + seen: dict[str, dict] = {} + for f in flags: + seen[f["key"]] = f + return {"path": rel, "exists": True, "error": None, "flags": list(seen.values())} + + +def write(entry: Path, flags: list[dict]) -> dict: + """Replace the project's flag list, preserving any sibling top-level keys + (``$schema``, project metadata…) already in the file.""" + path = flags_path(entry) + doc: dict[str, Any] = {} + if path.is_file(): + try: + existing = json.loads(path.read_text(encoding="utf-8")) + if isinstance(existing, dict): + doc = {k: v for k, v in existing.items() if k != "flags"} + except (OSError, ValueError): + doc = {} + doc["flags"] = flags + path.parent.mkdir(parents=True, exist_ok=True) + body = json.dumps(doc, indent=2, ensure_ascii=False) + "\n" + # Atomic replace so a half-written file can never be served. + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".flags-", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(body) + os.replace(tmp, path) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + return read(entry) + + +def merge(current: list[dict], updates: list[dict], allow_create: bool) -> list[dict]: + """Apply editor updates onto the current list. + + ``updates`` is the full desired list — order included, since the panel shows + flags in file order. Unknown fields on an existing flag survive because the + stored object is updated in place rather than rebuilt. + """ + by_key = {f["key"]: f for f in current} + out: list[dict] = [] + for upd in updates: + key = upd.get("key") + if not isinstance(key, str) or not KEY_RE.match(key): + raise ValueError(f"invalid flag key: {key!r}") + prev = by_key.get(key) + if prev is None and not allow_create: + raise ValueError(f"unknown flag: {key!r}") + flag = dict(prev) if prev else {"key": key} + for field in ("label", "description", "since"): + if field in upd: + flag[field] = upd[field] + if "enabled" in upd: + flag["enabled"] = bool(upd["enabled"]) + flag.setdefault("label", key) + flag.setdefault("description", "") + flag.setdefault("enabled", False) + out.append(flag) + return out + + +# ── admin token ─────────────────────────────────────────────────────────────── +# The deployed sites are public and have no session with the lab, so the admin +# panel is unlocked by a token instead: mint it here (behind Authelia), carry it +# to the site once as `?ff-admin=…`, and it lives in that browser's +# localStorage. Showing the panel is a client-side check; *publishing* a flag +# for everyone comes back here and is verified below, which is the boundary that +# actually matters. + +TOKEN_PREFIX = "ffa1" +TOKEN_TTL = 365 * 24 * 3600 # a year — this is a bookmarklet-grade credential + +_SECRET_PATH = Path(os.environ.get("FLAGS_SECRET_PATH", "/data/flags-secret")) + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _unb64(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def _secret() -> bytes: + """The HMAC key, created on first use. Deleting the file revokes every token + ever minted — that is the intended panic button.""" + try: + return bytes.fromhex(_SECRET_PATH.read_text().strip()) + except (OSError, ValueError): + pass + raw = secrets.token_bytes(32) + _SECRET_PATH.parent.mkdir(parents=True, exist_ok=True) + # Write via a private temp file so the secret is never briefly world-readable. + fd, tmp = tempfile.mkstemp(dir=str(_SECRET_PATH.parent), prefix=".secret-") + try: + os.chmod(tmp, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(raw.hex()) + os.replace(tmp, _SECRET_PATH) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + return raw + + +def mint_token(subject: str = "gabrielvidal", ttl: int = TOKEN_TTL) -> str: + now = int(time.time()) + payload = _b64(json.dumps({"sub": subject, "iat": now, "exp": now + ttl}).encode()) + sig = _b64(hmac.new(_secret(), payload.encode(), hashlib.sha256).digest()) + return f"{TOKEN_PREFIX}.{payload}.{sig}" + + +def verify_token(token: str | None) -> dict | None: + """Decoded payload for a valid, unexpired token; None otherwise.""" + if not isinstance(token, str): + return None + parts = token.split(".") + if len(parts) != 3 or parts[0] != TOKEN_PREFIX: + return None + _, payload, sig = parts + expected = _b64(hmac.new(_secret(), payload.encode(), hashlib.sha256).digest()) + if not hmac.compare_digest(sig, expected): + return None + try: + data = json.loads(_unb64(payload)) + except (ValueError, TypeError): + return None + if not isinstance(data, dict) or int(data.get("exp", 0)) < time.time(): + return None + return data + + +def revoke_all() -> None: + """Rotate the signing key, invalidating every issued token.""" + try: + _SECRET_PATH.unlink() + except FileNotFoundError: + pass + + +# ── deploy hosts ────────────────────────────────────────────────────────────── +def deploy_hosts(entry: Path) -> list[str]: + """The hosts this project deploys to, from `package.json` → `zipgo.deploy`. + + These are where an admin link points and where a publish pushes the file. + """ + try: + pkg = json.loads((entry / "package.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + deploy = (pkg.get("zipgo") or {}).get("deploy") if isinstance(pkg, dict) else None + if not isinstance(deploy, dict): + return [] + return [h for h in deploy if isinstance(h, str) and h] diff --git a/backend/schemas.py b/backend/schemas.py index 35425a3..6ff75e6 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -997,6 +997,46 @@ class ProjectEnvResponse(Schema): vars: list[ProjectEnvVar] +class FeatureFlag(Schema): + key: str + label: str + description: str + # The "for everyone" default, as committed in the project's flag file. + enabled: bool + since: Optional[str] = None + + +class ProjectFlagsResponse(Schema): + slug: str + # Repo-relative path of the flag file (None when the project has none yet). + path: Optional[str] = None + exists: bool + # Set when the file is present but unparseable — the editor refuses to + # overwrite a file it could not read. + error: Optional[str] = None + flags: list[FeatureFlag] + # Hosts from package.json → zipgo.deploy: where a publish pushes the file. + hosts: list[str] + + +class FlagAdminLink(Schema): + host: str + url: str + + +class FlagAdminLinkResponse(Schema): + token: str + links: list[FlagAdminLink] + + +class FlagPublishResponse(Schema): + slug: str + # Hosts the flag file was pushed to live; empty when nothing was published. + published: list[str] + detail: Optional[str] = None + flags: list[FeatureFlag] + + # ── templates ───────────────────────────────────────────────────────────────── class TemplateSummary(Schema): name: str diff --git a/frontend/openapi.json b/frontend/openapi.json index c248910..8f4ab58 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -2372,6 +2372,178 @@ } } }, + "/api/project-flags": { + "get": { + "summary": "Project Flags", + "description": "The project's declared feature flags and their for-everyone defaults.", + "operationId": "project_flags_api_project_flags_get", + "parameters": [ + { + "name": "slug", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Slug" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectFlagsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "summary": "Project Flags Save", + "description": "Flip the for-everyone default of flags the project already declares.\n\nDeliberately toggle-only: a flag with no code behind it is dead weight, so\ncreating and deleting them stays with whoever ships the feature (by editing\nthe file). Unknown keys are rejected rather than silently created.", + "operationId": "project_flags_save_api_project_flags_put", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlagsSaveBody" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectFlagsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/project-flags/admin-link": { + "post": { + "summary": "Project Flags Admin Link", + "description": "Mint the one-time link that unlocks the admin panel on the deployed site.\n\nReachable only from behind Authelia (this whole API is), which is what makes\nthe token a credential worth trusting. Opening the link once stores it in\nthat browser; `?ff-admin=` with no value signs out again.", + "operationId": "project_flags_admin_link_api_project_flags_admin_link_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlagsSlugBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlagAdminLinkResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/project-flags/publish": { + "post": { + "summary": "Project Flags Publish", + "description": "Publish the committed flag file to the live sites.", + "operationId": "project_flags_publish_api_project_flags_publish_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlagsSlugBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlagPublishResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/flags/publish": { + "post": { + "summary": "Flags Publish", + "description": "Cross-origin flag flip from the admin panel on a deployed site.\n\nReached without any lab session, so the bearer here is the minted token and\nits HMAC is checked before anything is written. The panel sends `text/plain`\nso the browser treats this as a CORS *simple request* \u2014 no preflight, which\nmatters because the forward-auth in front of this API answers an\nunauthenticated `OPTIONS` with a redirect the browser would reject.", + "operationId": "flags_publish_api_flags_publish_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, "/api/project-asset": { "get": { "summary": "Project Asset", @@ -6472,6 +6644,45 @@ ], "title": "EnvSetItem" }, + "FeatureFlag": { + "properties": { + "key": { + "type": "string", + "title": "Key" + }, + "label": { + "type": "string", + "title": "Label" + }, + "description": { + "type": "string", + "title": "Description" + }, + "enabled": { + "type": "boolean", + "title": "Enabled" + }, + "since": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + } + }, + "type": "object", + "required": [ + "key", + "label", + "description", + "enabled" + ], + "title": "FeatureFlag" + }, "FeedNotification": { "properties": { "id": { @@ -6737,6 +6948,137 @@ ], "title": "FileEntry" }, + "FlagAdminLink": { + "properties": { + "host": { + "type": "string", + "title": "Host" + }, + "url": { + "type": "string", + "title": "Url" + } + }, + "type": "object", + "required": [ + "host", + "url" + ], + "title": "FlagAdminLink" + }, + "FlagAdminLinkResponse": { + "properties": { + "token": { + "type": "string", + "title": "Token" + }, + "links": { + "items": { + "$ref": "#/components/schemas/FlagAdminLink" + }, + "type": "array", + "title": "Links" + } + }, + "type": "object", + "required": [ + "token", + "links" + ], + "title": "FlagAdminLinkResponse" + }, + "FlagPublishResponse": { + "properties": { + "slug": { + "type": "string", + "title": "Slug" + }, + "published": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Published" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + }, + "flags": { + "items": { + "$ref": "#/components/schemas/FeatureFlag" + }, + "type": "array", + "title": "Flags" + } + }, + "type": "object", + "required": [ + "slug", + "published", + "flags" + ], + "title": "FlagPublishResponse" + }, + "FlagSetItem": { + "properties": { + "key": { + "type": "string", + "title": "Key" + }, + "enabled": { + "type": "boolean", + "title": "Enabled" + } + }, + "type": "object", + "required": [ + "key", + "enabled" + ], + "title": "FlagSetItem" + }, + "FlagsSaveBody": { + "properties": { + "slug": { + "type": "string", + "title": "Slug" + }, + "set": { + "items": { + "$ref": "#/components/schemas/FlagSetItem" + }, + "type": "array", + "title": "Set", + "default": [] + } + }, + "type": "object", + "required": [ + "slug" + ], + "title": "FlagsSaveBody" + }, + "FlagsSlugBody": { + "properties": { + "slug": { + "type": "string", + "title": "Slug" + } + }, + "type": "object", + "required": [ + "slug" + ], + "title": "FlagsSlugBody" + }, "ForkBody": { "properties": { "id": { @@ -10004,6 +10346,62 @@ ], "title": "ProjectEnvVar" }, + "ProjectFlagsResponse": { + "properties": { + "slug": { + "type": "string", + "title": "Slug" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + }, + "exists": { + "type": "boolean", + "title": "Exists" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "flags": { + "items": { + "$ref": "#/components/schemas/FeatureFlag" + }, + "type": "array", + "title": "Flags" + }, + "hosts": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Hosts" + } + }, + "type": "object", + "required": [ + "slug", + "exists", + "flags", + "hosts" + ], + "title": "ProjectFlagsResponse" + }, "ProjectSummary": { "properties": { "dir": { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index da43aaa..4088f33 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -23,10 +23,13 @@ import type { NotifyLogEntry, NotifyResult, EnvSaveBody, + FlagAdminLinkResponse, + FlagPublishResponse, PlanDetail, PlanSummary, ProjectDetail, ProjectEnvResponse, + ProjectFlagsResponse, ProjectSummary, SearchConv, ServiceDetail, @@ -619,6 +622,50 @@ export async function saveProjectEnv(body: EnvSaveBody): Promise { + const r = await apiFetch(`/api/project-flags?slug=${encodeURIComponent(slug)}`, { + headers: { Accept: "application/json" }, + }); + if (!r.ok) throw new Error(`project-flags: ${r.status}`); + return r.json(); +} + +/** Flip the "for everyone" default of flags the project already declares. */ +export async function saveProjectFlags(body: { + slug: string; + set: { key: string; enabled: boolean }[]; +}): Promise { + const r = await apiFetch("/api/project-flags", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`project-flags save: ${r.status} ${await r.text().catch(() => "")}`); + return r.json(); +} + +/** Mint the `?ff-admin=…` link that unlocks the admin panel on the live site. */ +export async function mintFlagsAdminLink(slug: string): Promise { + const r = await apiFetch("/api/project-flags/admin-link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slug }), + }); + if (!r.ok) throw new Error(`admin-link: ${r.status} ${await r.text().catch(() => "")}`); + return r.json(); +} + +/** Push the committed flag file to the live sites (no rebuild). */ +export async function publishProjectFlags(slug: string): Promise { + const r = await apiFetch("/api/project-flags/publish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slug }), + }); + if (!r.ok) throw new Error(`publish: ${r.status} ${await r.text().catch(() => "")}`); + return r.json(); +} + export async function fetchConversationCommits(id: string): Promise { const r = await apiFetch(`/api/conversation-commits?id=${encodeURIComponent(id)}`, { headers: { Accept: "application/json" }, diff --git a/frontend/src/business/projects/components/FlagsEditor.tsx b/frontend/src/business/projects/components/FlagsEditor.tsx new file mode 100644 index 0000000..261466f --- /dev/null +++ b/frontend/src/business/projects/components/FlagsEditor.tsx @@ -0,0 +1,215 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Check, Copy, Flag, KeyRound, Loader2, Rocket } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { QK, useProjectFlags } from "@/lib/queries"; +import { mintFlagsAdminLink, publishProjectFlags, saveProjectFlags } from "@/api"; +import type { FlagPublishResponse, ProjectFlagsResponse } from "@/types"; + +/** + * Project page → feature flags. + * + * Flags are **declared in code** — `public/feature-flags.json`, written by + * whoever ships the feature. This card only flips each one's *for everyone* + * default, hands out the admin link that unlocks the panel on the live site, + * and pushes the file to that site so a flip takes effect without a rebuild. + * + * Saving is immediate per row (one toggle = one write): there is a single + * boolean per flag, so a draft/save/discard cycle would be ceremony around a + * switch. + */ +export function FlagsEditor({ slug }: { slug: string }) { + const { data, isLoading } = useProjectFlags(slug); + const qc = useQueryClient(); + const [link, setLink] = useState(null); + const [copied, setCopied] = useState(false); + const [pending, setPending] = useState(null); + + const applied = (saved: ProjectFlagsResponse) => { + qc.setQueryData(QK.projectFlags(slug), saved); + }; + + const save = useMutation({ + mutationFn: saveProjectFlags, + onSuccess: applied, + onSettled: () => setPending(null), + }); + const publish = useMutation({ + mutationFn: () => publishProjectFlags(slug), + onSuccess: (out: FlagPublishResponse) => { + if (data) applied({ ...data, flags: out.flags }); + }, + }); + const mint = useMutation({ + mutationFn: () => mintFlagsAdminLink(slug), + onSuccess: (out) => { + setLink(out.links[0]?.url ?? null); + setCopied(false); + }, + }); + + const toggle = (key: string, enabled: boolean) => { + setPending(key); + save.mutate({ slug, set: [{ key, enabled }] }); + }; + + const copyLink = () => { + if (!link) return; + navigator.clipboard?.writeText(link).then( + () => setCopied(true), + () => setCopied(false), + ); + }; + + if (isLoading && !data) { + return ( + +
Loading feature flags…
+
+ ); + } + + const flags = data?.flags ?? []; + const hosts = data?.hosts ?? []; + const error = save.error ?? publish.error ?? mint.error; + + return ( + + {data?.error && ( +

+ {data.path} is unreadable ({data.error}) — fix the file before editing here. +

+ )} + + {flags.length === 0 && !data?.error && ( +

+ No flags declared. Add them to{" "} + {data?.path ?? "public/feature-flags.json"} when you ship a feature behind{" "} + ?flag=1 — this card toggles them, it doesn't invent them. +

+ )} + +
+ {flags.map((f) => ( +
+
+
+ {f.label} + ?{f.key}=1 + {f.since && ( + since {f.since} + )} +
+ {f.description && ( +
{f.description}
+ )} +
+ +
+ ))} +
+ +
+ + + + + {publish.isSuccess && !publish.isPending && ( + + {publish.data.published.length + ? `Live on ${publish.data.published.join(", ")}` + : "Nothing published — no deploy host."} + + )} + {!publish.isSuccess && flags.length > 0 && ( + + Toggles are saved to the repo; publish to make them live now. + + )} +
+ + {link && ( +
+ + {link} + + +
+ )} + + {error &&

{String(error)}

} +
+ ); +} + +function Card({ children }: { children: React.ReactNode }) { + return ( +
+
+ + Feature flags +
+ {children} +
+ ); +} diff --git a/frontend/src/business/projects/pages/Project.tsx b/frontend/src/business/projects/pages/Project.tsx index 0b00020..742682e 100644 --- a/frontend/src/business/projects/pages/Project.tsx +++ b/frontend/src/business/projects/pages/Project.tsx @@ -22,6 +22,7 @@ import { Markdown } from "@/technical/Markdown"; import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal"; import { GoalChecklist } from "@/business/projects/components/GoalChecklist"; import { EnvEditor } from "@/business/projects/components/EnvEditor"; +import { FlagsEditor } from "@/business/projects/components/FlagsEditor"; import { StateBadge, StatusChecks } from "@/business/conversations/components/ConvMetaBits"; import type { ProjectDetail, @@ -273,6 +274,10 @@ export function Project() { edited from here. */} {!project.isRoot && } + {/* feature flags — flip a `?flag=1` feature on for everyone, and mint + the link that unlocks the admin panel on the deployed site. */} + {!project.isRoot && } + {/* recent commits */} {project.commits.length > 0 && (
diff --git a/frontend/src/generated/api.ts b/frontend/src/generated/api.ts index f3d193b..8a860a6 100644 --- a/frontend/src/generated/api.ts +++ b/frontend/src/generated/api.ts @@ -46,6 +46,10 @@ import type { DiffResult, EnvSaveBody, FileEntry, + FlagAdminLinkResponse, + FlagPublishResponse, + FlagsSaveBody, + FlagsSlugBody, ForkBody, FormCreateBody, FormGetApiFormsFormIdGetParams, @@ -84,6 +88,8 @@ import type { ProjectDetailApiProjectGetParams, ProjectEnvApiProjectEnvGetParams, ProjectEnvResponse, + ProjectFlagsApiProjectFlagsGetParams, + ProjectFlagsResponse, ProjectsResponse, ResumeBody, SaveBody, @@ -3475,6 +3481,275 @@ export const projectEnvSaveApiProjectEnvPut = async (envSaveBody: EnvSaveBody, o +export type projectFlagsApiProjectFlagsGetResponse200 = { + data: ProjectFlagsResponse + status: 200 +} + +export type projectFlagsApiProjectFlagsGetResponse422 = { + data: HTTPValidationError + status: 422 +} + +export type projectFlagsApiProjectFlagsGetResponseSuccess = (projectFlagsApiProjectFlagsGetResponse200) & { + headers: Headers; +}; +export type projectFlagsApiProjectFlagsGetResponseError = (projectFlagsApiProjectFlagsGetResponse422) & { + headers: Headers; +}; + +export type projectFlagsApiProjectFlagsGetResponse = (projectFlagsApiProjectFlagsGetResponseSuccess | projectFlagsApiProjectFlagsGetResponseError) + +export const getProjectFlagsApiProjectFlagsGetUrl = (params: ProjectFlagsApiProjectFlagsGetParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/api/project-flags?${stringifiedParams}` : `/api/project-flags` +} + +/** + * The project's declared feature flags and their for-everyone defaults. + * @summary Project Flags + */ +export const projectFlagsApiProjectFlagsGet = async (params: ProjectFlagsApiProjectFlagsGetParams, options?: RequestInit): Promise => { + + const res = await fetch(getProjectFlagsApiProjectFlagsGetUrl(params), + { + ...options, + method: 'GET' + + + } +) + + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: projectFlagsApiProjectFlagsGetResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as projectFlagsApiProjectFlagsGetResponse +} + + + +export type projectFlagsSaveApiProjectFlagsPutResponse200 = { + data: ProjectFlagsResponse + status: 200 +} + +export type projectFlagsSaveApiProjectFlagsPutResponse422 = { + data: HTTPValidationError + status: 422 +} + +export type projectFlagsSaveApiProjectFlagsPutResponseSuccess = (projectFlagsSaveApiProjectFlagsPutResponse200) & { + headers: Headers; +}; +export type projectFlagsSaveApiProjectFlagsPutResponseError = (projectFlagsSaveApiProjectFlagsPutResponse422) & { + headers: Headers; +}; + +export type projectFlagsSaveApiProjectFlagsPutResponse = (projectFlagsSaveApiProjectFlagsPutResponseSuccess | projectFlagsSaveApiProjectFlagsPutResponseError) + +export const getProjectFlagsSaveApiProjectFlagsPutUrl = () => { + + + + + return `/api/project-flags` +} + +/** + * Flip the for-everyone default of flags the project already declares. + * + * Deliberately toggle-only: a flag with no code behind it is dead weight, so + * creating and deleting them stays with whoever ships the feature (by editing + * the file). Unknown keys are rejected rather than silently created. + * @summary Project Flags Save + */ +export const projectFlagsSaveApiProjectFlagsPut = async (flagsSaveBody: FlagsSaveBody, options?: RequestInit): Promise => { + + const res = await fetch(getProjectFlagsSaveApiProjectFlagsPutUrl(), + { + ...options, + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(flagsSaveBody) + } +) + + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: projectFlagsSaveApiProjectFlagsPutResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as projectFlagsSaveApiProjectFlagsPutResponse +} + + + +export type projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse200 = { + data: FlagAdminLinkResponse + status: 200 +} + +export type projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse422 = { + data: HTTPValidationError + status: 422 +} + +export type projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponseSuccess = (projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse200) & { + headers: Headers; +}; +export type projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponseError = (projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse422) & { + headers: Headers; +}; + +export type projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse = (projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponseSuccess | projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponseError) + +export const getProjectFlagsAdminLinkApiProjectFlagsAdminLinkPostUrl = () => { + + + + + return `/api/project-flags/admin-link` +} + +/** + * Mint the one-time link that unlocks the admin panel on the deployed site. + * + * Reachable only from behind Authelia (this whole API is), which is what makes + * the token a credential worth trusting. Opening the link once stores it in + * that browser; `?ff-admin=` with no value signs out again. + * @summary Project Flags Admin Link + */ +export const projectFlagsAdminLinkApiProjectFlagsAdminLinkPost = async (flagsSlugBody: FlagsSlugBody, options?: RequestInit): Promise => { + + const res = await fetch(getProjectFlagsAdminLinkApiProjectFlagsAdminLinkPostUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(flagsSlugBody) + } +) + + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse +} + + + +export type projectFlagsPublishApiProjectFlagsPublishPostResponse200 = { + data: FlagPublishResponse + status: 200 +} + +export type projectFlagsPublishApiProjectFlagsPublishPostResponse422 = { + data: HTTPValidationError + status: 422 +} + +export type projectFlagsPublishApiProjectFlagsPublishPostResponseSuccess = (projectFlagsPublishApiProjectFlagsPublishPostResponse200) & { + headers: Headers; +}; +export type projectFlagsPublishApiProjectFlagsPublishPostResponseError = (projectFlagsPublishApiProjectFlagsPublishPostResponse422) & { + headers: Headers; +}; + +export type projectFlagsPublishApiProjectFlagsPublishPostResponse = (projectFlagsPublishApiProjectFlagsPublishPostResponseSuccess | projectFlagsPublishApiProjectFlagsPublishPostResponseError) + +export const getProjectFlagsPublishApiProjectFlagsPublishPostUrl = () => { + + + + + return `/api/project-flags/publish` +} + +/** + * Publish the committed flag file to the live sites. + * @summary Project Flags Publish + */ +export const projectFlagsPublishApiProjectFlagsPublishPost = async (flagsSlugBody: FlagsSlugBody, options?: RequestInit): Promise => { + + const res = await fetch(getProjectFlagsPublishApiProjectFlagsPublishPostUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(flagsSlugBody) + } +) + + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: projectFlagsPublishApiProjectFlagsPublishPostResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as projectFlagsPublishApiProjectFlagsPublishPostResponse +} + + + +export type flagsPublishApiFlagsPublishPostResponse200 = { + data: unknown + status: 200 +} + +export type flagsPublishApiFlagsPublishPostResponseSuccess = (flagsPublishApiFlagsPublishPostResponse200) & { + headers: Headers; +}; +; + +export type flagsPublishApiFlagsPublishPostResponse = (flagsPublishApiFlagsPublishPostResponseSuccess) + +export const getFlagsPublishApiFlagsPublishPostUrl = () => { + + + + + return `/api/flags/publish` +} + +/** + * Cross-origin flag flip from the admin panel on a deployed site. + * + * Reached without any lab session, so the bearer here is the minted token and + * its HMAC is checked before anything is written. The panel sends `text/plain` + * so the browser treats this as a CORS *simple request* — no preflight, which + * matters because the forward-auth in front of this API answers an + * unauthenticated `OPTIONS` with a redirect the browser would reject. + * @summary Flags Publish + */ +export const flagsPublishApiFlagsPublishPost = async ( options?: RequestInit): Promise => { + + const res = await fetch(getFlagsPublishApiFlagsPublishPostUrl(), + { + ...options, + method: 'POST' + + + } +) + + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: flagsPublishApiFlagsPublishPostResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as flagsPublishApiFlagsPublishPostResponse +} + + + export type projectAssetApiProjectAssetGetResponse200 = { data: unknown status: 200 diff --git a/frontend/src/generated/model/featureFlag.ts b/frontend/src/generated/model/featureFlag.ts new file mode 100644 index 0000000..5f9561a --- /dev/null +++ b/frontend/src/generated/model/featureFlag.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * ai-agent + * OpenAPI spec version: 0.1.0 + */ + +export interface FeatureFlag { + key: string; + label: string; + description: string; + enabled: boolean; + since?: string | null; +} diff --git a/frontend/src/generated/model/flagAdminLink.ts b/frontend/src/generated/model/flagAdminLink.ts new file mode 100644 index 0000000..00ffd49 --- /dev/null +++ b/frontend/src/generated/model/flagAdminLink.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * ai-agent + * OpenAPI spec version: 0.1.0 + */ + +export interface FlagAdminLink { + host: string; + url: string; +} diff --git a/frontend/src/generated/model/flagAdminLinkResponse.ts b/frontend/src/generated/model/flagAdminLinkResponse.ts new file mode 100644 index 0000000..be94626 --- /dev/null +++ b/frontend/src/generated/model/flagAdminLinkResponse.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * ai-agent + * OpenAPI spec version: 0.1.0 + */ +import type { FlagAdminLink } from './flagAdminLink.ts'; + +export interface FlagAdminLinkResponse { + token: string; + links: FlagAdminLink[]; +} diff --git a/frontend/src/generated/model/flagPublishResponse.ts b/frontend/src/generated/model/flagPublishResponse.ts new file mode 100644 index 0000000..7deadd8 --- /dev/null +++ b/frontend/src/generated/model/flagPublishResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * ai-agent + * OpenAPI spec version: 0.1.0 + */ +import type { FeatureFlag } from './featureFlag.ts'; + +export interface FlagPublishResponse { + slug: string; + published: string[]; + detail?: string | null; + flags: FeatureFlag[]; +} diff --git a/frontend/src/generated/model/flagSetItem.ts b/frontend/src/generated/model/flagSetItem.ts new file mode 100644 index 0000000..d25f293 --- /dev/null +++ b/frontend/src/generated/model/flagSetItem.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * ai-agent + * OpenAPI spec version: 0.1.0 + */ + +export interface FlagSetItem { + key: string; + enabled: boolean; +} diff --git a/frontend/src/generated/model/flagsSaveBody.ts b/frontend/src/generated/model/flagsSaveBody.ts new file mode 100644 index 0000000..bbed0ab --- /dev/null +++ b/frontend/src/generated/model/flagsSaveBody.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * ai-agent + * OpenAPI spec version: 0.1.0 + */ +import type { FlagSetItem } from './flagSetItem.ts'; + +export interface FlagsSaveBody { + slug: string; + set?: FlagSetItem[]; +} diff --git a/frontend/src/generated/model/flagsSlugBody.ts b/frontend/src/generated/model/flagsSlugBody.ts new file mode 100644 index 0000000..7cbbd3c --- /dev/null +++ b/frontend/src/generated/model/flagsSlugBody.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * ai-agent + * OpenAPI spec version: 0.1.0 + */ + +export interface FlagsSlugBody { + slug: string; +} diff --git a/frontend/src/generated/model/index.ts b/frontend/src/generated/model/index.ts index 2391a69..293b60e 100644 --- a/frontend/src/generated/model/index.ts +++ b/frontend/src/generated/model/index.ts @@ -80,11 +80,18 @@ export * from './diffResult.ts'; export * from './diffResultSource.ts'; export * from './envSaveBody.ts'; export * from './envSetItem.ts'; +export * from './featureFlag.ts'; export * from './feedNotification.ts'; export * from './fileDiff.ts'; export * from './fileDiffStatus.ts'; export * from './fileEntry.ts'; export * from './fileEntryKind.ts'; +export * from './flagAdminLink.ts'; +export * from './flagAdminLinkResponse.ts'; +export * from './flagPublishResponse.ts'; +export * from './flagSetItem.ts'; +export * from './flagsSaveBody.ts'; +export * from './flagsSlugBody.ts'; export * from './forkBody.ts'; export * from './forkedFrom.ts'; export * from './formCreateBody.ts'; @@ -170,6 +177,8 @@ export * from './projectDetailApiProjectGetParams.ts'; export * from './projectEnvApiProjectEnvGetParams.ts'; export * from './projectEnvResponse.ts'; export * from './projectEnvVar.ts'; +export * from './projectFlagsApiProjectFlagsGetParams.ts'; +export * from './projectFlagsResponse.ts'; export * from './projectsResponse.ts'; export * from './projectSummary.ts'; export * from './resumeBody.ts'; diff --git a/frontend/src/generated/model/projectFlagsApiProjectFlagsGetParams.ts b/frontend/src/generated/model/projectFlagsApiProjectFlagsGetParams.ts new file mode 100644 index 0000000..eb139fe --- /dev/null +++ b/frontend/src/generated/model/projectFlagsApiProjectFlagsGetParams.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * ai-agent + * OpenAPI spec version: 0.1.0 + */ + +export type ProjectFlagsApiProjectFlagsGetParams = { +slug: string; +}; diff --git a/frontend/src/generated/model/projectFlagsResponse.ts b/frontend/src/generated/model/projectFlagsResponse.ts new file mode 100644 index 0000000..18c52ba --- /dev/null +++ b/frontend/src/generated/model/projectFlagsResponse.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * ai-agent + * OpenAPI spec version: 0.1.0 + */ +import type { FeatureFlag } from './featureFlag.ts'; + +export interface ProjectFlagsResponse { + slug: string; + path?: string | null; + exists: boolean; + error?: string | null; + flags: FeatureFlag[]; + hosts: string[]; +} diff --git a/frontend/src/lib/queries.ts b/frontend/src/lib/queries.ts index dce8954..06bfe0c 100644 --- a/frontend/src/lib/queries.ts +++ b/frontend/src/lib/queries.ts @@ -24,6 +24,7 @@ import { fetchNotifyLog, fetchPlans, fetchProjectEnv, + fetchProjectFlags, fetchProjects, fetchServices, fetchSkills, @@ -77,6 +78,7 @@ export const QK = { commitDiff: (repo: string, sha: string) => ["commit-diff", repo, sha] as const, projects: ["projects"] as const, projectEnv: (slug: string) => ["project-env", slug] as const, + projectFlags: (slug: string) => ["project-flags", slug] as const, services: ["services"] as const, skills: ["skills"] as const, // Shared "agents" prefix: the catalog and every detail page are all derived @@ -405,6 +407,15 @@ export function useProjectEnv(slug: string) { }); } +/** A project's declared feature flags and their for-everyone defaults. */ +export function useProjectFlags(slug: string) { + return useQuery({ + queryKey: QK.projectFlags(slug), + queryFn: () => fetchProjectFlags(slug), + enabled: !!slug, + }); +} + export function useServices() { return useQuery({ queryKey: QK.services, queryFn: fetchServices }); } diff --git a/sidecar/sidecar.py b/sidecar/sidecar.py index 27f1c70..37c33a1 100644 --- a/sidecar/sidecar.py +++ b/sidecar/sidecar.py @@ -39,8 +39,10 @@ import logging import os import pathlib import re +import shutil import signal import subprocess +import tempfile import time import uuid as uuidlib @@ -775,3 +777,83 @@ def interrupt(body: InterruptBody, pid_path.unlink(missing_ok=True) return {"sessionId": sid, "pid": pid, "signal": "SIGINT", "ok": True} + + +# ---- publish a project's feature flags ------------------------------------- +# Flipping a flag "for everyone" rewrites `/public/feature-flags.json` +# in the repo, but the live site keeps serving its deployed copy until the next +# build. Pushing that one file makes the flip take effect immediately. +# +# The backend can't do it itself: the container has neither zipgo nor the deploy +# key. So it asks here — and this stays a *fixed* operation (one named file, to +# hosts the project's own package.json declares) rather than an exec endpoint, +# because "run this command on the host" is not something the bearer token +# should ever buy. + +PROJECTS_DIR = pathlib.Path( + os.environ.get("SIDECAR_PROJECTS_DIR", pathlib.Path.home() / "projects")) +ZIPGO_BIN = os.environ.get("ZIPGO_BIN", "zipgo") +# Same target every project's scripts/deploy.sh uses (raspy2 over Tailscale). +ZIPGO_SSH = os.environ.get( + "ZIPGO_SSH", "gabrielvidal@100.74.118.12:/home/gabrielvidal/services/domains") +FLAGS_FILENAME = "feature-flags.json" +HOST_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$") + + +class PublishFlagsBody(BaseModel): + project: str # ~/projects/ + path: str # repo-relative, must end in feature-flags.json + hosts: list[str] # zipgo hosts from the project's package.json + + +@app.post("/publish-flags") +def publish_flags(body: PublishFlagsBody, + authorization: str | None = Header(default=None)) -> dict: + _auth(authorization) + + name = pathlib.Path(body.project).name + if not name or name.startswith("."): + raise HTTPException(400, "invalid project") + root = (PROJECTS_DIR / name).resolve() + try: + root.relative_to(PROJECTS_DIR.resolve()) + except ValueError: + raise HTTPException(400, "invalid project") + + rel = pathlib.PurePosixPath(body.path) + if rel.is_absolute() or ".." in rel.parts or rel.name != FLAGS_FILENAME: + raise HTTPException(400, f"path must be a relative {FLAGS_FILENAME}") + src = (root / rel).resolve() + try: + src.relative_to(root) + except ValueError: + raise HTTPException(400, "path escapes the project") + if not src.is_file(): + raise HTTPException(404, f"{body.path} not found in {name}") + + hosts = [h for h in body.hosts if HOST_RE.match(h or "")] + if not hosts: + raise HTTPException(400, "no valid hosts") + + # zipgo syncs a *directory* into the remote site folder, so stage the single + # file in one of its own. `--no-delete` is what keeps this from wiping the + # deployed site down to just this file. + published, errors = [], [] + with tempfile.TemporaryDirectory(prefix="ff-publish-") as staging: + shutil.copyfile(src, pathlib.Path(staging) / FLAGS_FILENAME) + for host in hosts: + cmd = [ZIPGO_BIN, "deploy", staging + "/", "-d", host, + "--no-delete", "--ssh", ZIPGO_SSH] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except (OSError, subprocess.TimeoutExpired) as e: + errors.append(f"{host}: {e}") + continue + if proc.returncode == 0: + published.append(host) + else: + errors.append(f"{host}: {(proc.stderr or proc.stdout).strip()[:200]}") + + if not published: + raise HTTPException(502, "publish failed — " + "; ".join(errors)) + return {"project": name, "published": published, "errors": errors}