Project One

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)

  1. approvals row → approved (+ decided_by, decided_at, token_hash), guarded by .eq('status','pending') against double-decide races.
  2. For document gates (brand_board, positioning_confirm, angle_choice) the activation runs inline through verifyAndConsume — the row ends consumed. Meta/page actions (meta_launch, page_publish, …) currently record the decision; their adapters consume the token when live wiring lands.
  3. 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 404 unknown approval). This includes retrying after the "hit a snag" honesty response — the row is already approved/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)