feat(console): wire View-As view-scope into loaders (flag-off, read-only) [DRAFT - needs founder security review + live login test]#430
Conversation
…nly) Post-OAuth wiring for the founder View-As support tool (spec: docs/specs/VIEW-AS-POST-OAUTH-WIRING-2026-06-15.md), built on the #422 flag-off foundation. Additive, ZERO behavior change while VIEW_AS_ENABLED is off — and it is NOT enabled here. Single resolver: - lib/auth/active-tenant.ts → resolveActiveTenant(user): returns the user's OWN tenant unless the full triple-gate holds (flag ON + signed/unexpired cookie + isFounderEmail), in which case it returns the VIEWED tenant flagged read-only. The triple-gate is inherited from getViewScope/verifyToken (#422). Read-only enforcement (the load-bearing safety property — a lens, never a takeover): - lib/auth/view-as-readonly.ts → blockMutationIfViewing(user, ctx): when a scope is active, audit-logs view_as_blocked_mutation and returns a 403 {code: VIEW_AS_READ_ONLY}; returns null (no-op) otherwise. - Wired into session-authed mutations: app/api/v1/focus/approve, app/api/v1/approvals/[id] (PATCH), app/api/v1/outreach/flush, plus the deep-link approve side-effects in the focus + recovery loaders. Loaders wired to resolveActiveTenant (dashboard, recovery-board, focus/morning- brief): local-Supabase tenant reads now key on the resolved active tenant, and ViewAsBannerSlot (new server component) renders the #422 ViewAsBanner whenever a scope is active. Tests (__tests__/view-as-wiring.test.ts, 13 cases): triple-gate (each gate failing → scope ignored → own tenant), read-only enforcement (403 + audit under scope; null no-op otherwise), tamper/expiry/non-founder honored end-to-end. NOT login-tested (OAuth-dependent live impersonation is spec step 6, a separate human step). Flag OFF. Needs adversarial security review before enabling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Adversarial security review (Claude CLI — design/security gate)Verdict: APPROVED AS A DRAFT — NOT approved to enable. Flag-OFF = zero behavior change, safe to sit on Blocking gap before the flag is EVER enabled — read-only is not comprehensive. Enforcement is per-route on ~3 routes and does NOT touch the central security choke-point ( Required before
Until 1-3 are done, this stays DRAFT, flag-OFF. The build is good; the safety is not yet complete — and the author honestly flagged this gap, which is exactly how it should work. |
🛡️ Cascade Quality Score: 100/100
Threshold: 85/100 | Result: PASS ✅ |
…oint (flag-off) Closes the one blocking gap from the adversarial security review on #430: read-only was enforced per-route on only ~3 mutation routes, leaving ~185 other authenticated mutation routes able to accept writes under an active founder View-As scope. For an impersonation feature that is the safety property, so enforce it at the single layer every API request crosses. WHAT: - proxy.ts (the project's Next 16 middleware choke-point; no middleware.ts): in the authenticated protected-API branch, after the user is resolved (session OR bearer), block every non-GET (POST/PUT/PATCH/DELETE) with 403 {code: VIEW_AS_READ_ONLY} + an audit row BEFORE the handler runs, whenever a VALID founder view-scope cookie is active. Same triple-gate as everywhere else: viewAsEnabled() + signed/unexpired cookie (verifyToken) + isFounderEmail. Now covers ~185 authenticated mutation routes vs the prior 3. - lib/auth/view-as-readonly.ts: add request-cookie-based helpers usable from middleware (no next/headers) — resolveProxyViewScope() (pure, reuses the tested verifyToken), buildProxyReadOnlyBlock() (403 + best-effort audit via the request's own Supabase client), isMutationMethod(). The existing per-route blockMutationIfViewing() stays as defense-in-depth. - Explicitly enumerate the 12 intentionally-uncovered routes (cron + signature-verified webhooks + public PLG + bearer MCP) in a proxy.ts comment so the boundary is not silent; lookalike webhook/a2a routes that require a session ARE covered. SAFETY: - VIEW_AS_ENABLED stays OFF — resolveProxyViewScope returns null when the flag is off, so this is a complete no-op / zero behavior change. - Reuses node:crypto exactly as the existing live cron path already does (timingSafeCompare), so no new middleware-runtime risk. TESTS: new __tests__/view-as-proxy-guard.test.ts (36 cases) proves the central guard blocks POST/PUT/PATCH/DELETE across 7 representative route groups (settings/integrations/billing/team/keys/documents/unguarded), reads pass, non-founder/expired/tampered/no-cookie/flag-off do NOT block, audit row carries method+path, and audit failure still fails closed (403). Full suite 3925 passing, typecheck + lint green. Still flag-OFF, NOT login-tested (separate human step), needs a final adversarial re-review before VIEW_AS_ENABLED is ever enabled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adversarial re-review #2 — central read-only guard (Claude CLI)Verdict: the read-only gap is CLOSED for the impersonation threat model. APPROVED as design — still NOT approved to enable (CI + live login test pending).
Remaining before |
What this is
Post-OAuth wiring for the founder View-As support tool, per
docs/specs/VIEW-AS-POST-OAUTH-WIRING-2026-06-15.md. Builds on the #422 flag-off foundation (signed-cookie boundary + founder-gated admin route + banner + crypto tests). This PR turns the dormant foundation into actual loader behavior — but does NOT enable it.Warning
NOT login-tested. Flag OFF. Needs adversarial security review before enabling.
Live login/impersonation testing is spec step 6 — a separate human step that requires working OAuth. This PR is the code wiring only.
VIEW_AS_ENABLEDis not set anywhere here. Auto-merge is off; please do not merge until the security review + live login test pass.The triple gate (unchanged invariant)
A view-scope is honored only when all three hold, enforced by the #422
getViewScope/verifyToken:viewAsEnabled()flag ON, andisFounderEmail(user.email).If any fails the scope is ignored and the request resolves as the real logged-in user. No RLS rewrite, no service-role escalation. A scope can only narrow a founder's existing access.
Loaders wired (spec section 3)
All three resolve the active tenant through one helper,
resolveActiveTenant(user)— no scattered checks:app/(shell)/dashboard/page.tsxapp/(shell)/operations/recovery/page.tsxapp/(shell)/focus/page.tsx(Focus Mode = morning brief / Daily Revenue Brief)In each: the local-Supabase tenant reads now key on the resolved active tenant, and
ViewAsBannerSlot(new server component) renders the #422ViewAsBannerwhenever a scope is active.How read-only is enforced (the load-bearing safety property)
A view-scope is a lens, never a takeover.
lib/auth/view-as-readonly.tsprovidesblockMutationIfViewing(user, ctx):view_as_blocked_mutationaudit row and returns 403{ error: "read-only", code: "VIEW_AS_READ_ONLY" }.null(mutation proceeds, no-op).Wired into the session-authed mutation paths:
app/api/v1/focus/approveblockMutationIfViewingfirstapp/api/v1/approvals/[id]blockMutationIfViewingfirstapp/api/v1/outreach/flushblockMutationIfViewingfirstfocus/page.tsxdeep-link approve!active.readOnly+ auditrecovery/page.tsxdeep-link approve!active.readOnly+ auditFiles changed (10)
New
lib/auth/active-tenant.ts—resolveActiveTenant()single resolverlib/auth/view-as-readonly.ts—blockMutationIfViewing()+getActiveViewScope()components/admin/ViewAsBannerSlot.tsx— server wrapper that resolves the scope and renders the feat(console): View-As founder support-context foundation (flag-gated, additive) #422 banner__tests__/view-as-wiring.test.ts— 13 wiring testsWired
app/(shell)/dashboard/page.tsx,app/(shell)/operations/recovery/page.tsx,app/(shell)/focus/page.tsxapp/api/v1/focus/approve/route.ts,app/api/v1/approvals/[id]/route.ts,app/api/v1/outreach/flush/route.tsTest coverage
__tests__/view-as-wiring.test.ts(13) +__tests__/view-as.test.ts(#422, 7) all green:resolveActiveTenant: each gate failing (flag off / no cookie / tampered / expired / non-founder) gives the own tenant; all three passing gives the viewed tenant +readOnly.blockMutationIfViewing: scope active gives 403 + audit row; no scope / flag off / non-founder / expired / null user gives null no-op, no audit.tsc --noEmitclean. eslint clean on changed files.Honest gaps / where read-only or correctness might be incomplete (please review hard)
Pulse remote path is NOT view-as-aware.
getRecoveryBoard(user.id)/getLatestMorningBrief(user.id)/getPulseConfig(user.id)resolve by the founder's ownuser.id, so under a view-scope the Pulse-sourced board +sampleModestill reflect the founder's own Pulse config, not the viewed tenant. The view-as tenant swap currently affects only local-Supabase reads (the demo/seed/fallback path). For the spec's documented dogfood case (founder's owntenant_rl_001going real) this is fine; for viewing a different tenant's live Pulse data it is a gap. Threading a viewed-tenant Pulse config is deferred (larger change to the Pulse contract).RLS is the real backstop, not the tenant swap. Loaders use the anon-key server client (the user's RLS). Swapping
tenant_idonly surfaces another tenant's rows if the founder's RLS already permits it. That is intentional (spec: "no RLS bypass"), but it means cross-tenant View-As of live data depends on founder RLS scope — worth confirming during the security review that this matches intent (view = founder's existing reach, narrowed).Mutation coverage is the high-value session paths, NOT exhaustive — this is the biggest risk in the PR and the hardest gate before flag-on. I measured it: there are 103 session-authed write routes (call
auth.getUser()and do an insert/update/delete/upsert) underapp/api/v1/**. This PR guards 2 of them withblockMutationIfViewing(focus/approve,approvals/[id]PATCH) plusoutreach/flush(already disabled) and the two loader deep-link approves. That leaves ~100 unguarded session-mutation routes — including ones the founder UI surfaces right next to "approve":focus/decide(dismiss/snooze/edit),actions/dismiss,pulse/worker/recovery/opportunities/[id]/decide,operations/proposals/[id]/decide,action-map/briefs/[id]/approve,focus/route,focus/bootstrap, plus all settings/integrations/clients/credits/billing writes. I deliberately did not blind-wire all 103 on a security-sensitive, review-gated PR — each needs a human eye. DO NOT enableVIEW_AS_ENABLEDuntil every tenant-mutating session route callsblockMutationIfViewingfirst (or the enforcement is centralized — see note below). Until then, a founder in a view-scope can still mutate the viewed tenant through any unguarded route. (One apparent exception is correct:app/api/v1/admin/view-asDELETE is the Exit path and intentionally has no guard.actions/approveis token-authed — no founder session — so the session guard does not apply there by design.)Recommended hardening for the reviewer to consider: rather than 100 one-line edits, centralize the check — e.g. enforce
blockMutationIfViewing(or a 403 on any non-GET) for authenticated app routes inmiddleware.ts, with an explicit allowlist for the View-As Exit route. That converts "read-only" from opt-in to default-deny and removes the per-route omission risk. I did not do this here because middleware changes are higher-blast-radius and belong in the security review, not a flag-off wiring PR.Settings writes were not individually audited in this pass — folded into gap feat: add AI agent framework setup – BMAD Method, Agency Agents, Supe… #3.
Pre-existing, left untouched (flagged, not fixed):
recovery/page.tsxgates its demo toggles on a hardcoded founder email (V.davidmedina@gmail.com) rather thanisFounderEmail(). Out of scope for this additive PR; noting it because it is adjacent founder-gating.Rollback / kill-switch
Flag OFF gives every entry point a gate-1 failure, so behavior reverts to the normal logged-in user. No data migration to undo (the #422 audit table is additive).
Generated with Claude Code by RelayLaunch