feat(flags): feature-flag control on the project page #5

Open
gabrielvidal wants to merge 1 commits from feature-flags into main
21 changed files with 1678 additions and 0 deletions

View File

@@ -201,6 +201,11 @@ store / metadata sidecar.
checklist rollup that tags a card, `goal_detail()` adds the markdown for the 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 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). 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). - `memories.py` — surfaces the assistant's per-repo memory files (frontmatter).
- `templates.py` — browse `~/projects/templates/` scaffolds. - `templates.py` — browse `~/projects/templates/` scaffolds.
- `schemas.py` — Pydantic response models mirroring `frontend/src/types.ts`. They - `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 <model> --remote-control` (detached). Transcript lands in the watched --model <model> --remote-control` (detached). Transcript lands in the watched
projects dir and the new conversation shows up in the viewer within ~2s. 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/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 `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 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 own. The sidecar only shape-checks the value (it goes on a command line) — the
pickable set is whatever `/api/models` returned. 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=<token>`), 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 ### Model tags
Models are a first-class tag on both sides of the app (`frontend/src/lib/models.tsx`): Models are a first-class tag on both sides of the app (`frontend/src/lib/models.tsx`):

View File

@@ -53,6 +53,7 @@ import notify_audio as notify_audio_mod
import notif_read as notif_read_mod import notif_read as notif_read_mod
import plans as plans_mod import plans as plans_mod
import project_costs as project_costs_mod import project_costs as project_costs_mod
import projectflags
import projects as projects_mod import projects as projects_mod
import scaffold as scaffold_mod import scaffold as scaffold_mod
import schemas import schemas
@@ -3163,6 +3164,189 @@ def project_env_save(body: EnvSaveBody):
return _project_env_payload(body.slug, entry) 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 # 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 # 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. # HTML) goes out as a download so it can never script against the API's cookies.

261
backend/projectflags.py Normal file
View File

@@ -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``:
<project>/public/feature-flags.json (Vite/CRA — preferred)
<project>/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]

View File

@@ -997,6 +997,46 @@ class ProjectEnvResponse(Schema):
vars: list[ProjectEnvVar] 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 ───────────────────────────────────────────────────────────────── # ── templates ─────────────────────────────────────────────────────────────────
class TemplateSummary(Schema): class TemplateSummary(Schema):
name: str name: str

View File

@@ -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": { "/api/project-asset": {
"get": { "get": {
"summary": "Project Asset", "summary": "Project Asset",
@@ -6472,6 +6644,45 @@
], ],
"title": "EnvSetItem" "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": { "FeedNotification": {
"properties": { "properties": {
"id": { "id": {
@@ -6737,6 +6948,137 @@
], ],
"title": "FileEntry" "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": { "ForkBody": {
"properties": { "properties": {
"id": { "id": {
@@ -10004,6 +10346,62 @@
], ],
"title": "ProjectEnvVar" "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": { "ProjectSummary": {
"properties": { "properties": {
"dir": { "dir": {

View File

@@ -23,10 +23,13 @@ import type {
NotifyLogEntry, NotifyLogEntry,
NotifyResult, NotifyResult,
EnvSaveBody, EnvSaveBody,
FlagAdminLinkResponse,
FlagPublishResponse,
PlanDetail, PlanDetail,
PlanSummary, PlanSummary,
ProjectDetail, ProjectDetail,
ProjectEnvResponse, ProjectEnvResponse,
ProjectFlagsResponse,
ProjectSummary, ProjectSummary,
SearchConv, SearchConv,
ServiceDetail, ServiceDetail,
@@ -619,6 +622,50 @@ export async function saveProjectEnv(body: EnvSaveBody): Promise<ProjectEnvRespo
return r.json(); return r.json();
} }
export async function fetchProjectFlags(slug: string): Promise<ProjectFlagsResponse> {
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<ProjectFlagsResponse> {
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<FlagAdminLinkResponse> {
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<FlagPublishResponse> {
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<ConversationCommits> { export async function fetchConversationCommits(id: string): Promise<ConversationCommits> {
const r = await apiFetch(`/api/conversation-commits?id=${encodeURIComponent(id)}`, { const r = await apiFetch(`/api/conversation-commits?id=${encodeURIComponent(id)}`, {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },

View File

@@ -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<string | null>(null);
const [copied, setCopied] = useState(false);
const [pending, setPending] = useState<string | null>(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 (
<Card>
<div className="text-sm text-muted-foreground">Loading feature flags…</div>
</Card>
);
}
const flags = data?.flags ?? [];
const hosts = data?.hosts ?? [];
const error = save.error ?? publish.error ?? mint.error;
return (
<Card>
{data?.error && (
<p className="mb-2 text-[12px] text-destructive">
<code>{data.path}</code> is unreadable ({data.error}) — fix the file before editing here.
</p>
)}
{flags.length === 0 && !data?.error && (
<p className="mb-2 text-[12px] text-muted-foreground">
No flags declared. Add them to{" "}
<code>{data?.path ?? "public/feature-flags.json"}</code> when you ship a feature behind{" "}
<code>?flag=1</code> — this card toggles them, it doesn&apos;t invent them.
</p>
)}
<div className="flex flex-col gap-1">
{flags.map((f) => (
<div
key={f.key}
className="flex items-start gap-2 rounded-md border border-border px-2 py-1.5"
>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-[12px] font-medium">{f.label}</span>
<code className="font-mono text-[10.5px] text-muted-foreground">?{f.key}=1</code>
{f.since && (
<span className="text-[10px] text-muted-foreground/70">since {f.since}</span>
)}
</div>
{f.description && (
<div className="mt-0.5 text-[11px] text-muted-foreground">{f.description}</div>
)}
</div>
<label className="flex shrink-0 items-center gap-1.5 pt-0.5 text-[11px] text-muted-foreground">
{pending === f.key ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<input
type="checkbox"
checked={f.enabled}
disabled={save.isPending}
onChange={(e) => toggle(f.key, e.target.checked)}
className="accent-primary"
/>
)}
everyone
</label>
</div>
))}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={() => mint.mutate()}
disabled={mint.isPending || hosts.length === 0}
title={
hosts.length === 0
? "No deploy host in package.json → zipgo.deploy"
: "Mint a link that unlocks the admin panel on the live site"
}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1 text-[12px] text-muted-foreground hover:text-foreground",
(mint.isPending || hosts.length === 0) && "opacity-50",
)}
>
{mint.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<KeyRound className="h-3 w-3" />
)}
Admin link
</button>
<button
onClick={() => publish.mutate()}
disabled={publish.isPending || flags.length === 0 || hosts.length === 0}
title="Push feature-flags.json to the live site — no rebuild"
className={cn(
"inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 text-[12px] font-medium text-primary-foreground",
(publish.isPending || flags.length === 0 || hosts.length === 0) && "opacity-50",
)}
>
{publish.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Rocket className="h-3 w-3" />
)}
Publish now
</button>
{publish.isSuccess && !publish.isPending && (
<span className="text-[11px] text-muted-foreground">
{publish.data.published.length
? `Live on ${publish.data.published.join(", ")}`
: "Nothing published — no deploy host."}
</span>
)}
{!publish.isSuccess && flags.length > 0 && (
<span className="text-[11px] text-muted-foreground/70">
Toggles are saved to the repo; publish to make them live now.
</span>
)}
</div>
{link && (
<div className="mt-2 flex items-center gap-1.5 rounded-md border border-dashed border-border px-2 py-1">
<code className="min-w-0 flex-1 truncate font-mono text-[10.5px] text-muted-foreground">
{link}
</code>
<button
onClick={copyLink}
title="Copy — open it once in the browser you want the panel in"
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-primary" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
)}
{error && <p className="mt-2 text-[11px] text-destructive">{String(error)}</p>}
</Card>
);
}
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="mb-4 rounded-xl border border-border bg-card p-3">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<Flag className="h-3.5 w-3.5 text-primary" />
Feature flags
</div>
{children}
</div>
);
}

View File

@@ -22,6 +22,7 @@ import { Markdown } from "@/technical/Markdown";
import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal"; import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal";
import { GoalChecklist } from "@/business/projects/components/GoalChecklist"; import { GoalChecklist } from "@/business/projects/components/GoalChecklist";
import { EnvEditor } from "@/business/projects/components/EnvEditor"; import { EnvEditor } from "@/business/projects/components/EnvEditor";
import { FlagsEditor } from "@/business/projects/components/FlagsEditor";
import { StateBadge, StatusChecks } from "@/business/conversations/components/ConvMetaBits"; import { StateBadge, StatusChecks } from "@/business/conversations/components/ConvMetaBits";
import type { import type {
ProjectDetail, ProjectDetail,
@@ -273,6 +274,10 @@ export function Project() {
edited from here. */} edited from here. */}
{!project.isRoot && <EnvEditor slug={project.dir} />} {!project.isRoot && <EnvEditor slug={project.dir} />}
{/* 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 && <FlagsEditor slug={project.dir} />}
{/* recent commits */} {/* recent commits */}
{project.commits.length > 0 && ( {project.commits.length > 0 && (
<Section icon={GitCommit} title="Recent commits"> <Section icon={GitCommit} title="Recent commits">

View File

@@ -46,6 +46,10 @@ import type {
DiffResult, DiffResult,
EnvSaveBody, EnvSaveBody,
FileEntry, FileEntry,
FlagAdminLinkResponse,
FlagPublishResponse,
FlagsSaveBody,
FlagsSlugBody,
ForkBody, ForkBody,
FormCreateBody, FormCreateBody,
FormGetApiFormsFormIdGetParams, FormGetApiFormsFormIdGetParams,
@@ -84,6 +88,8 @@ import type {
ProjectDetailApiProjectGetParams, ProjectDetailApiProjectGetParams,
ProjectEnvApiProjectEnvGetParams, ProjectEnvApiProjectEnvGetParams,
ProjectEnvResponse, ProjectEnvResponse,
ProjectFlagsApiProjectFlagsGetParams,
ProjectFlagsResponse,
ProjectsResponse, ProjectsResponse,
ResumeBody, ResumeBody,
SaveBody, 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<projectFlagsApiProjectFlagsGetResponse> => {
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<projectFlagsSaveApiProjectFlagsPutResponse> => {
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<projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse> => {
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<projectFlagsPublishApiProjectFlagsPublishPostResponse> => {
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<flagsPublishApiFlagsPublishPostResponse> => {
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 = { export type projectAssetApiProjectAssetGetResponse200 = {
data: unknown data: unknown
status: 200 status: 200

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -80,11 +80,18 @@ export * from './diffResult.ts';
export * from './diffResultSource.ts'; export * from './diffResultSource.ts';
export * from './envSaveBody.ts'; export * from './envSaveBody.ts';
export * from './envSetItem.ts'; export * from './envSetItem.ts';
export * from './featureFlag.ts';
export * from './feedNotification.ts'; export * from './feedNotification.ts';
export * from './fileDiff.ts'; export * from './fileDiff.ts';
export * from './fileDiffStatus.ts'; export * from './fileDiffStatus.ts';
export * from './fileEntry.ts'; export * from './fileEntry.ts';
export * from './fileEntryKind.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 './forkBody.ts';
export * from './forkedFrom.ts'; export * from './forkedFrom.ts';
export * from './formCreateBody.ts'; export * from './formCreateBody.ts';
@@ -170,6 +177,8 @@ export * from './projectDetailApiProjectGetParams.ts';
export * from './projectEnvApiProjectEnvGetParams.ts'; export * from './projectEnvApiProjectEnvGetParams.ts';
export * from './projectEnvResponse.ts'; export * from './projectEnvResponse.ts';
export * from './projectEnvVar.ts'; export * from './projectEnvVar.ts';
export * from './projectFlagsApiProjectFlagsGetParams.ts';
export * from './projectFlagsResponse.ts';
export * from './projectsResponse.ts'; export * from './projectsResponse.ts';
export * from './projectSummary.ts'; export * from './projectSummary.ts';
export * from './resumeBody.ts'; export * from './resumeBody.ts';

View File

@@ -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;
};

View File

@@ -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[];
}

View File

@@ -24,6 +24,7 @@ import {
fetchNotifyLog, fetchNotifyLog,
fetchPlans, fetchPlans,
fetchProjectEnv, fetchProjectEnv,
fetchProjectFlags,
fetchProjects, fetchProjects,
fetchServices, fetchServices,
fetchSkills, fetchSkills,
@@ -77,6 +78,7 @@ export const QK = {
commitDiff: (repo: string, sha: string) => ["commit-diff", repo, sha] as const, commitDiff: (repo: string, sha: string) => ["commit-diff", repo, sha] as const,
projects: ["projects"] as const, projects: ["projects"] as const,
projectEnv: (slug: string) => ["project-env", slug] as const, projectEnv: (slug: string) => ["project-env", slug] as const,
projectFlags: (slug: string) => ["project-flags", slug] as const,
services: ["services"] as const, services: ["services"] as const,
skills: ["skills"] as const, skills: ["skills"] as const,
// Shared "agents" prefix: the catalog and every detail page are all derived // 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() { export function useServices() {
return useQuery({ queryKey: QK.services, queryFn: fetchServices }); return useQuery({ queryKey: QK.services, queryFn: fetchServices });
} }

View File

@@ -39,8 +39,10 @@ import logging
import os import os
import pathlib import pathlib
import re import re
import shutil
import signal import signal
import subprocess import subprocess
import tempfile
import time import time
import uuid as uuidlib import uuid as uuidlib
@@ -775,3 +777,83 @@ def interrupt(body: InterruptBody,
pid_path.unlink(missing_ok=True) pid_path.unlink(missing_ok=True)
return {"sessionId": sid, "pid": pid, "signal": "SIGINT", "ok": True} return {"sessionId": sid, "pid": pid, "signal": "SIGINT", "ok": True}
# ---- publish a project's feature flags -------------------------------------
# Flipping a flag "for everyone" rewrites `<project>/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/<project>
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}