Approvals
The decision endpoint behind the launch gate. Every real-world action (publish, launch, budget change, brand/positioning/angle activation) stops at a pending approval; this endpoint records the client's yes/no. In live mode an approve mints a server-side one-time capability token that the executing agent consumes — the token never appears in any HTTP response, and what was approved is cryptographically bound to what runs (payload hash + action type). Full gate mechanics: conventions §7.
| Endpoint | Auth | Rate limit | Body cap |
|---|---|---|---|
POST /api/approvals |
session verified inside live handler; public in demo | 30/min per IP | 1 MB |
POST /api/approvals
Request (JSON)
| Field | Required | Notes |
|---|---|---|
approvalId |
yes | non-empty string |
decision |
yes | exact lowercase "approve" or "reject" — compared with ===; "Approve", "REJECT", numbers, or a missing field are all rejected. There is deliberately no default: an unrecognized decision can never approve (fail-closed, #93). |
Malformed bodies are swallowed and trip the same fail-closed 400.
Responses
| Status | Body |
|---|---|
| 200 (live) | { "ok": true, "status": "approved" | "rejected", "message": "On it — I'll take it from here…" | "No problem — I'll leave it as it is." } (no mode field) |
| 200 (live, activation failed post-approve) | { "ok": false, "status": "approved", "message": "I saved your approval, but hit a snag applying it — please try that once more in a moment." } — the decision persisted, the action did not (honesty rule, #92) |
200 (demo, id = apv_demo_page_edit) |
{ "ok": true, "status": ..., "message": <jargon-guarded demo reply>, "mode": "demo" } |
| 404 (demo, any other id) | { "ok": false, "reason": "unknown approval" } — note reason, not error |
| 400 (fail-closed) | `{ "ok": false, "error": "Send { approvalId, decision: "approve" |
| 500 (live failure) | { "ok": false, "error": "That didn’t go through — please sign in again and retry." } — covers no-session, already-decided, expired, and DB failures when configured; never masked as a demo 404 |
| 411 / 413 / 429 | shared envelopes (conventions §4–5) |
What an approve triggers (live)
approvalsrow →approved(+decided_by,decided_at,token_hash), guarded by.eq('status','pending')against double-decide races.- For document gates (
brand_board,positioning_confirm,angle_choice) the activation runs inline throughverifyAndConsume— the row endsconsumed. Meta/page actions (meta_launch,page_publish, …) currently record the decision; their adapters consume the token when live wiring lands. - A fire-and-forget pipeline nudge (the scheduled tick is the guarantee).
Notes for callers
- Single-use by design, not idempotent: a second POST for the same id finds the row no longer
pending; when configured, the live handler throws and the route answers an honest 500{ "ok": false, "error": "That didn’t go through — please sign in again and retry." }(keyless demo still answers 404unknown approval). This includes retrying after the "hit a snag" honesty response — the row is alreadyapproved/consumed(atomic consume+activate is tracked in #92). - Expired approvals are rejected at decision time (WSTG-BUSL-01): deciding an approval past its expiry window flips the row to
expired, mints no token, and surfaces as the honest 500 above. Expiry is enforced both here and again at consume time. - No 401 exists here: an unauthenticated caller against a configured platform gets the honest 500 (session verification happens inside the live handler); only a keyless deployment falls through to the demo branch (404 for real UUIDs).
- Tenant isolation: the live handler re-reads the approval through the caller's RLS-scoped client before deciding; rows you can't see are
unknown. - A 30/min-per-IP rate limit and 1 MB body cap guard this route (added in API slice 6), in addition to the fail-closed validation.
Source:
app/api/approvals/route.ts,src/lib/live/approvals.ts,src/lib/approval-gate/index.ts,lib/approvals.ts(demo fixtures)