feat: edge-verified identity, prefix-aware page, and job retention - #5
Conversation
Makes a submission attributable to a signed-in person instead of a name somebody typed, by letting this service sit behind the lab's single Caddy edge. The companion route and the framed panel are in ac-organic-lab. Why the edge and not a login here: a session cookie cannot be shared with this gateway on its own address -- raw 100.x addresses cannot carry a `Domain` cookie and *.ts.net is on the Public Suffix List, so browsers drop tailnet-wide cookies (AUTH_DESIGN, "Why sessions can't be shared per-host"). One origin behind the edge is the only arrangement that yields one login, so a page served on this port is architecturally excluded from SSO. ## identity.py Trusts the edge's injected `X-Auth-User` only when the request also carries `X-Edge-Auth` matching `BAMBU_EDGE_SHARED_SECRET` -- something a caller coming straight off the tailnet cannot produce, which matters because this port stays directly reachable. Same mechanism as the xArm's arrangement. Three properties worth reviewing closely, since this is the security-relevant part: - **Fails closed.** No configured secret means no trusted identity, ever -- never "believe the header because we have nothing to check it against". A deployment with no secret behaves exactly as before this commit, which is what makes shipping ahead of deployment safe. - **Constant-time comparison** (`hmac.compare_digest`): `==` on a secret leaks it a byte at a time. - **A verified identity overrides any client-supplied name.** Otherwise a signed-in person could file work under someone else's name. An edge that proves itself but names nobody is anonymous, not authenticated. ## What it records `requested_by_verified` / `approved_by_verified` on every job, and a "(verified identity)" marker in the history note. The distinction is stored per job rather than inferred from how the service happened to be deployed when the job arrived. `GET /whoami` reports what the current request carries, so the page can word itself honestly -- `identity_available` separates "not signed in" from "this deployment cannot tell who you are". It echoes nothing the caller did not already present, and never the secret. ## Prefix-aware page The page derives its API base by stripping the trailing `/ui` from its own URL, so one file serves both the direct deployment (base "") and any edge prefix (`/bambu`), with no server-side rewrite and no build-time config -- the same arrangement as the OT-2 operator SPA. Hardcoded `/printers` would have reached the dashboard instead of this gateway. It also answers at both `/ui` and `/ui/`: serving only one would make Starlette redirect between them with a `Location` that drops the edge prefix, landing the visitor on the dashboard. That is the trap the /xarm5 edge block documents. When an identity is verified the page shows the signed-in account and stops offering a name field; the "no sign-in" wording is only shown when it is true. ## Not deployed Needs root: install the Caddy route, set the same secret on both sides, restart both. The bind stays 0.0.0.0 until then -- reverting it to loopback before the edge route exists would break the page that currently works. Sequence recorded in docs/TODO.md. `uv run ruff check .` and `uv run pytest -q` (152 tests, 22 new) pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQvsfEeDitEyNzbCwrcEdD
Two follow-ups from bringing the pipeline up, both flagged during it.
## Retention
Terminal records used to accumulate forever, and the only way to clear one was
`rm` on the store directory -- which left the running process serving a record
whose file was gone. Both halves are fixed:
- Terminal records are swept at startup once older than
`submissions.retain_terminal_days` (30 by default, null disables).
- `DELETE /submissions/{id}` removes one finished job's record and artifact
immediately.
Three decisions worth recording:
- **A job still in play is never swept, however old.** One stuck in
`validating` is a signal, not litter, and quietly deleting it would destroy
the evidence of whatever wedged it.
- **Only a terminal job can be deleted.** Withdrawing a waiting job is
`cancel`, which leaves a record of the decision; allowing delete there would
erase the decision along with the job.
- **Sweeping at startup, not on a timer.** The store loads from disk once, so a
record removed underneath a running process lingers in memory until restart.
Startup is the one moment the two views are guaranteed to agree -- and that
divergence is precisely what made hand-cleanup necessary during bring-up.
`TERMINAL_STATES` is derived from the transition table (states with no onward
edge) rather than listed a second time, so a state added there cannot be
forgotten here. A test pins that correspondence in both directions.
## docs/EDGE_DEPLOY.md
An ordered runbook for putting the page behind the dashboard's login, since the
remaining work is all root steps and easy to get in the wrong order. It leads
with what the two access paths mean for attribution, validates the Caddyfile
before installing it (a bad config takes the whole dashboard down), reloads
rather than restarts Caddy, and ends with the step that must come last --
narrowing the gateway's bind back to loopback, which is only safe once the edge
route works.
The verification step is the one that matters: a forged `X-Auth-User` presented
directly must come back `verified: false` while `identity_available: true`
confirms the gateway actually picked the secret up. Both were checked against
the real code.
The secret itself is generated into the gitignored `.env` (which the unit
already loads, so the gateway half needs no root) and the runbook reads it back
with `sed` rather than having anyone retype it. It is not in git and not in any
transcript.
`uv run ruff check .` and `uv run pytest -q` (161 tests, 9 new) pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQvsfEeDitEyNzbCwrcEdD
|
Two further commits landed on this branch after the description above — flagging them so the review scope is clear.
|
Makes a submission attributable to a signed-in person instead of a name somebody typed, by letting this service sit behind the lab's single Caddy edge. Companion PR:
AccelerationConsortium/ac-organic-lab#33(the/bambu/*route and the framed panel).Why the edge rather than a login here: a session cookie cannot be shared with this gateway on its own address — raw
100.xaddresses cannot carry aDomaincookie and*.ts.netis on the Public Suffix List, so browsers drop tailnet-wide cookies (AUTH_DESIGN, Why sessions can't be shared per-host). One origin behind the edge is the only arrangement that yields one login, so a page served on this port is architecturally excluded from SSO no matter what we build on it.The security-relevant part — please review
identity.pycloselyIt trusts the edge's injected
X-Auth-Useronly when the request also carriesX-Edge-AuthmatchingBAMBU_EDGE_SHARED_SECRET— something a caller coming straight off the tailnet cannot produce, which matters because this port stays directly reachable. Same mechanism as the xArm's arrangement.Three deliberate properties:
hmac.compare_digest).==on a secret leaks it a byte at a time.11 tests cover it directly, including forged headers, wrong/missing secrets, an unconfigured deployment, and control-character/length handling on the injected name.
What it records
requested_by_verified/approved_by_verifiedon every job, plus a "(verified identity)" marker in the history note. Stored per job rather than inferred later from how the service happened to be deployed when the job arrived.GET /whoamireports what the current request carries so the page can word itself honestly —identity_availableseparates "not signed in" from "this deployment cannot tell who you are". It echoes nothing the caller did not already present, and never the secret (asserted by a test).Prefix-aware page
Derives its API base by stripping the trailing
/uifrom its own URL, so one file serves the direct deployment (base"") and any edge prefix (/bambu) with no server-side rewrite and no build-time config — the same arrangement as the OT-2 SPA. Hardcoded/printerswould have reached the dashboard instead of this gateway.It also answers at both
/uiand/ui/. Serving only one would make Starlette redirect between them with aLocationthat drops the edge prefix, landing the visitor on the dashboard — the trap the/xarm5edge block documents.With a verified identity the page shows the signed-in account and stops offering a name field; the "no sign-in" wording appears only when it is true.
Not deployed — and the bind is deliberately left wide
Three root steps, in order (recorded in
docs/TODO.md):Caddyfile.single-edgeand reload Caddy.BAMBU_EDGE_SHARED_SECRETin Caddy'sEnvironmentFileand in bambu-server's unit env; restart both.127.0.0.1. It is0.0.0.0today so the page is reachable at all; reverting before the route exists would break what currently works.Until step 2, the gateway trusts nothing and the embed shows a blank frame — both fail closed.
uv run ruff check .anduv run pytest -q(152 tests, 22 new) pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01UQvsfEeDitEyNzbCwrcEdD