feat(flags): feature-flag control on the project page #5
41
CLAUDE.md
41
CLAUDE.md
@@ -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`):
|
||||||
|
|||||||
184
backend/main.py
184
backend/main.py
@@ -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
261
backend/projectflags.py
Normal 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]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
215
frontend/src/business/projects/components/FlagsEditor.tsx
Normal file
215
frontend/src/business/projects/components/FlagsEditor.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
14
frontend/src/generated/model/featureFlag.ts
Normal file
14
frontend/src/generated/model/featureFlag.ts
Normal 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;
|
||||||
|
}
|
||||||
11
frontend/src/generated/model/flagAdminLink.ts
Normal file
11
frontend/src/generated/model/flagAdminLink.ts
Normal 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;
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/flagAdminLinkResponse.ts
Normal file
12
frontend/src/generated/model/flagAdminLinkResponse.ts
Normal 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[];
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/flagPublishResponse.ts
Normal file
14
frontend/src/generated/model/flagPublishResponse.ts
Normal 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[];
|
||||||
|
}
|
||||||
11
frontend/src/generated/model/flagSetItem.ts
Normal file
11
frontend/src/generated/model/flagSetItem.ts
Normal 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;
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/flagsSaveBody.ts
Normal file
12
frontend/src/generated/model/flagsSaveBody.ts
Normal 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[];
|
||||||
|
}
|
||||||
10
frontend/src/generated/model/flagsSlugBody.ts
Normal file
10
frontend/src/generated/model/flagsSlugBody.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
16
frontend/src/generated/model/projectFlagsResponse.ts
Normal file
16
frontend/src/generated/model/projectFlagsResponse.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user