Item 1 / edge functions of the Auth Window. Ticket POPPY-SEC-AAL2-SERVER. This is a plan for a watched window — nothing here has been run. Deploy touches pure-mcp (~1,477 calls/48h), so it is the highest-blast-radius remaining step.
SUPABASE_DEPLOY_FUNCTION) on hand. You don’t run code.aal2 to smoke-test a privileged action (e.g. a Poppy connector run) after deploy.x-mcp-key principal (poppy, cron, lane-a…), and the function runs as service-role (bypasses RLS). There is no user JWT and no aal claim in a normal call. So “require an aal2 JWT” cannot be read from the existing request — the human’s step-up level must be forwarded to the function.Consequence: aal2 gating applies only to human-initiated privileged actions (a person told Poppy/Chat to send/outbound/mutate). Pure machine crons have no human and must stay authorized by their token/scope — they are not aal-gated (or they break).
Add an actor-JWT channel: the human-facing caller (pure-chat-api / client) forwards the signed-in user’s access token in a dedicated header x-actor-jwt. The edge function verifies it server-side (calls GET /auth/v1/user with that token 200 means valid & unexpired) and reads the aal claim. Privileged tools/action-classes require aal=aal2; everything else is unchanged.
/auth/v1/user check (or JWT-secret signature verify) is the server-side guarantee — “not just client JS.”verify_jwt change: stays false; x-mcp-key remains the transport auth. The actor check is additional, in-function.| Action / tool | Gate? | Why |
|---|---|---|
connector.run (external_send, outbound) | YES aal2 | real-world side effects on behalf of a human |
release.publish / release.revert | YES aal2 | deploys production |
memory.set on standard.*, board.dispatch (privileged) | YES aal2 | governance / config mutation |
board.list, memory.get, qa.*, read tools | no | read-only |
| machine crons (no human actor) | no | authorized by token/scope; no human aal exists |
Exact privileged set is confirmed in pre-flight with the Operator before deploy.
// added near the top, after SB_URL / SRK async function actorAal(req){ const jwt = req.headers.get("x-actor-jwt") || ""; if(!jwt) return {aal:null, ok:false, reason:"no actor jwt"}; // verify live against GoTrue (rejects forged/expired) const u = await fetch(`${SB_URL}/auth/v1/user`,{headers:{apikey:SRK, Authorization:"Bearer "+jwt}}); if(!u.ok) return {aal:null, ok:false, reason:"actor jwt invalid ("+u.status+")"}; let aal="aal1"; try{ aal = JSON.parse(atob(jwt.split(".")[1].replace(/-/g,"+").replace(/_/g,"/"))).aal || "aal1"; }catch(_){} return {aal, ok:true}; } const PRIVILEGED = new Set(["connector.run","release.publish","release.revert"]); function needsAal2(name,a){ if(PRIVILEGED.has(name)) return true; if(name==="memory.set" && String(a&&a.key||"").startsWith("standard.")) return true; return false; } // inside Deno.serve, AFTER principal check + BEFORE tool(): if(needsAal2(name,args)){ const act = await actorAal(req); if(!act.ok || act.aal!=="aal2"){ await audit(principal,name,args,false,"aal2 required: "+(act.reason||act.aal)); return json({error:"step-up required (aal2) for "+name, code:"AAL2_REQUIRED"}, 403); } }
Callers of privileged actions (pure-chat-api, Poppy’s human-triggered path) must add x-actor-jwt: <user access token>. Machine crons don’t call privileged actions, so no change.
{}). Redeploy from puremlstech/puremls @ Testin.MikeSupabase : supabase/functions/pure-mcp/index.ts. Before deploy, confirm that file is the live v53 source (its header says “paste this whole file … then Deploy”); if in doubt, reconcile first. A wrong source overwrites a live, heavily-used function.SUPABASE_DEPLOY_FUNCTION — ref=mbnakfrkfwxinhxlisyq, slug=pure-mcp, verify_jwt=false (unchanged), full source (entrypoint + any deps). Preserve ALL tools + x-mcp-key auth.x-actor-jwt. Confirm crons/automations do NOT call the privileged set (else they 403).index.ts == deployed v53 (section 6 caution).x-actor-jwt in the same or prior deploy, or the privileged action will 403 for everyone until it does.| Test | Expect |
|---|---|
| Read tool (board.list) via any principal | works (unchanged) |
| Machine cron runs its normal tools | works (no privileged/human actions) |
| Human (aal2) triggers connector.run w/ x-actor-jwt | executes |
| Human (aal1) triggers connector.run | 403 AAL2_REQUIRED |
| connector.run with no/forged x-actor-jwt | 403 |
Redeploy the prior Git source (drop the helper + gate) via the same Composio call — seconds, no data change. Or, if only the gate misbehaves, ship a one-line needsAal2()false hotfix. Kill-switch option: env EDGE_AAL2_OFF=true read at top of needsAal2 (add in the same deploy) to disable without a rollback.
GO if: privileged set confirmed, actor-JWT forwarding is live on the human path, Git source verified == deployed, crons confirmed clear of privileged actions, Operator signed-in at aal2 to test, low-traffic window.
NO-GO if: any cron/automation calls a privileged action (would 403), or the human path doesn’t yet send x-actor-jwt, or source-match is unconfirmed.
Prep authored 7/7/26 (Shattique). Companion to /pure-cp5-runbook.html and /pure-cp7-runbook.html. Client + RLS aal2 already live; this closes the edge-function half of POPPY-SEC-AAL2-SERVER. Status: awaiting scheduled watched window + Operator go.