Skip to content

Fabricate: /authorize/<id> approval page + approve/decline RPCs - #516

Merged
ecto merged 1 commit into
mainfrom
claude/authorize-route
Jul 9, 2026
Merged

ecto merged 1 commit into
mainfrom
claude/authorize-route

Conversation

@ecto

@ecto ecto commented Jul 9, 2026

Copy link
Copy Markdown
Owner

What

The landing page for the L2 elicitation lane — and the first real approval write-path for Fabricate spend.

PR #511's authorize_spend flow deep-links the human to https://vcad.io/authorize/<authorization_id>, but that URL 404'd and nothing anywhere could flip a spend_authorizations row: migration 027 deliberately left users read-only, with every write behind service-role-only RPCs. The agent could propose a spend; the human had no way to answer.

Migration 035_approve_spend_authorization.sql

  • approve_spend_authorization(p_authorization_id uuid) returns jsonb — pending_human + unexpired + owned by auth.uid() → authorized, stamps approved_by/approved_at
  • decline_spend_authorization(p_authorization_id uuid) returns jsonb — same guards → revoked, stamps revoked_at
  • Returns {ok:true, status} or {ok:false, reason} with reasons not_found (not-owner and unauthenticated fold in — foreign ids are indistinguishable from missing), not_pending (with current status), expired
  • GRANT EXECUTE ... TO authenticated only — this is the human's own action under RLS-equivalent guards inside the function (ownership check + pending-only transition + FOR UPDATE row lock so a concurrent approve/decline race has exactly one winner)
  • No wallet/ledger touches. debit_wallet (027) remains service-role only and independently re-verifies status, expiry, ownership, caps, and allowlists at consume time — approving here unlocks at most one bounded, already-proposed spend

Verified behaviorally on an ephemeral Postgres (stubbed auth schema + settable uid, 027 applied first): happy paths, unauth, foreign-owner, re-approve, re-decline, expired (row untouched), consumed, unknown-id, and grant checks (authenticated yes, anon no) all pass. CI's "Verify migrations" job re-applies the chain against a real Supabase stack.

App /authorize/:id

  • AuthorizePage mounted standalone from main.tsx by pathname match — the same zero-risk pattern as /cli-auth, so the editor load path is untouched
  • Requires a permanent identity (Supabase anon sessions have an auth.uid() but would RLS-read nothing and mislabel the row as not-found); signed-out visitors get the app's standard sign-in flow, and the OAuth popup returns them to the same URL
  • Loads the row through the user's own Supabase client (RLS read), shows the spending cap (from max_amount_minor), fab/process allowlists, kind, and a live expiry countdown
  • Approve / Decline call the new RPCs, then re-read the row — the DB is the truth about where the lifecycle landed, never the RPC echo. Already-actioned states (authorized / consumed / revoked / expired) and not-found each get explicit copy
  • Root-caused the 404: neither vercel.json had a rewrite for /authorize/* — added to both deploy configs
  • UUID-strict route parser (lib/authorize-route.ts) with vitest coverage; junk ids fall through to the editor

Verification

  • Ephemeral-Postgres behavioral suite for 035 (above)
  • VCAD_WASM_SKIP=1 npm run build --workspaces --if-present clean; npm run build -w @vcad/app clean
  • App tests green: 8 files, 55 tests (5 new)
  • Rendered end-to-end in headless Chrome against the dev server: /authorize/<uuid> shows the approval card (sign-in gate when signed out), non-uuid paths fall through to the editor

🤖 Generated with Claude Code

The agent-side elicitation lane (authorize_spend, PR #511) deep-links
humans to https://vcad.io/authorize/<authorization_id> — but that URL
404'd and there was no approval write-path anywhere: migration 027 left
users read-only on spend_authorizations, with all writes service-role
only. This is the missing human half of the handshake.

Migration 035:
- approve_spend_authorization(uuid) / decline_spend_authorization(uuid)
  SECURITY DEFINER RPCs, granted to authenticated only. RLS-equivalent
  guards inside: auth.uid() must own the row, only pending_human +
  unexpired rows transition, row locked FOR UPDATE so concurrent
  decisions get exactly one winner. Not-owner folds into not_found so
  foreign ids are indistinguishable from missing ones.
- No wallet/ledger touches — debit_wallet (027) still re-verifies
  status, expiry, ownership, caps, and allowlists at consume time.
- Verified behaviorally on an ephemeral Postgres with a stubbed auth
  schema: happy paths stamp approved_by/approved_at/revoked_at; unauth,
  foreign-owner, re-approve, expired, and consumed all return the
  guarded {ok:false, reason} shapes; anon has no execute grant.

App:
- AuthorizePage at /authorize/<id>, mounted standalone from main.tsx by
  pathname match (same zero-risk pattern as /cli-auth). Requires a
  permanent identity (anon sessions would RLS-read nothing and mislabel
  the row not-found), loads the row via the user's own client, renders
  cap / fab allowlist / live expiry countdown, and resolves every
  outcome by re-reading the DB row — never inferring from the RPC echo.
- Vercel rewrites for /authorize/(.*) in both deploy configs (the 404
  root cause), plus a uuid-strict route parser with vitest coverage so
  junk ids fall through to the editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

4 Skipped Deployments
Project Deployment Actions Updated (UTC)
mecheval Ignored Ignored Jul 9, 2026 2:23am
vcad Ignored Ignored Jul 9, 2026 2:23am
vcad-docs Ignored Ignored Jul 9, 2026 2:23am
vcad-mcp Ignored Ignored Jul 9, 2026 2:23am

Request Review

@ecto
ecto merged commit 251cc44 into main Jul 9, 2026
15 checks passed
@chojiai

chojiai Bot commented Jul 9, 2026

Copy link
Copy Markdown

What shipped

When a Fabricate agent proposes a spend and sends you a link to approve it, that link previously returned a 404 — there was no page there, and no way to respond. You can now visit that link to see a full summary of the proposed charge (spending cap, allowed processes, expiry time) and either approve or decline it with a single click. Nothing is charged until you approve, and approving only unlocks the specific amount the agent already proposed.


Plain-English summary generated by Choji from this pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant