Repository navigation
Fabricate: /authorize/<id> approval page + approve/decline RPCs - #516
Merged
Merged
Conversation
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>
What shippedWhen 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The landing page for the L2 elicitation lane — and the first real approval write-path for Fabricate spend.
PR #511's
authorize_spendflow deep-links the human tohttps://vcad.io/authorize/<authorization_id>, but that URL 404'd and nothing anywhere could flip aspend_authorizationsrow: 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.sqlapprove_spend_authorization(p_authorization_id uuid) returns jsonb— pending_human + unexpired + owned byauth.uid()→authorized, stampsapproved_by/approved_atdecline_spend_authorization(p_authorization_id uuid) returns jsonb— same guards →revoked, stampsrevoked_at{ok:true, status}or{ok:false, reason}with reasonsnot_found(not-owner and unauthenticated fold in — foreign ids are indistinguishable from missing),not_pending(with current status),expiredGRANT EXECUTE ... TO authenticatedonly — this is the human's own action under RLS-equivalent guards inside the function (ownership check + pending-only transition +FOR UPDATErow lock so a concurrent approve/decline race has exactly one winner)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 spendVerified behaviorally on an ephemeral Postgres (stubbed
authschema + settable uid, 027 applied first): happy paths, unauth, foreign-owner, re-approve, re-decline, expired (row untouched), consumed, unknown-id, and grant checks (authenticatedyes,anonno) all pass. CI's "Verify migrations" job re-applies the chain against a real Supabase stack.App
/authorize/:idAuthorizePagemounted standalone frommain.tsxby pathname match — the same zero-risk pattern as/cli-auth, so the editor load path is untouchedauth.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 URLmax_amount_minor), fab/process allowlists, kind, and a live expiry countdownvercel.jsonhad a rewrite for/authorize/*— added to both deploy configslib/authorize-route.ts) with vitest coverage; junk ids fall through to the editorVerification
VCAD_WASM_SKIP=1 npm run build --workspaces --if-presentclean;npm run build -w @vcad/appclean/authorize/<uuid>shows the approval card (sign-in gate when signed out), non-uuid paths fall through to the editor🤖 Generated with Claude Code