Skip to content

feat(console): wire View-As view-scope into loaders (flag-off, read-only) [DRAFT - needs founder security review + live login test]#430

Draft
Victor "David" Medina (Victor-David-Medina) wants to merge 3 commits into
mainfrom
feat/view-as-wire-loaders
Draft

feat(console): wire View-As view-scope into loaders (flag-off, read-only) [DRAFT - needs founder security review + live login test]#430
Victor "David" Medina (Victor-David-Medina) wants to merge 3 commits into
mainfrom
feat/view-as-wire-loaders

Conversation

@Victor-David-Medina

@Victor-David-Medina Victor "David" Medina (Victor-David-Medina) commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

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_ENABLED is 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:

  1. viewAsEnabled() flag ON, and
  2. signed cookie verifies (HMAC-SHA256, not expired, 30-min TTL), and
  3. isFounderEmail(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.tsx
  • app/(shell)/operations/recovery/page.tsx
  • app/(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 #422 ViewAsBanner whenever 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.ts provides blockMutationIfViewing(user, ctx):

  • scope active: writes a view_as_blocked_mutation audit row and returns 403 { error: "read-only", code: "VIEW_AS_READ_ONLY" }.
  • no scope (incl. flag off / non-founder / expired): returns null (mutation proceeds, no-op).

Wired into the session-authed mutation paths:

Path Type Guard
app/api/v1/focus/approve POST blockMutationIfViewing first
app/api/v1/approvals/[id] PATCH (approve/reject) blockMutationIfViewing first
app/api/v1/outreach/flush POST (send) blockMutationIfViewing first
focus/page.tsx deep-link approve server side-effect gated with !active.readOnly + audit
recovery/page.tsx deep-link approve server side-effect gated with !active.readOnly + audit

Files changed (10)

New

Wired

  • app/(shell)/dashboard/page.tsx, app/(shell)/operations/recovery/page.tsx, app/(shell)/focus/page.tsx
  • app/api/v1/focus/approve/route.ts, app/api/v1/approvals/[id]/route.ts, app/api/v1/outreach/flush/route.ts

Test coverage

__tests__/view-as-wiring.test.ts (13) + __tests__/view-as.test.ts (#422, 7) all green:

  • Triple-gate via resolveActiveTenant: each gate failing (flag off / no cookie / tampered / expired / non-founder) gives the own tenant; all three passing gives the viewed tenant + readOnly.
  • Read-only via blockMutationIfViewing: scope active gives 403 + audit row; no scope / flag off / non-founder / expired / null user gives null no-op, no audit.
  • Full suite: 338 files, 3889 tests, 0 failures. tsc --noEmit clean. eslint clean on changed files.

Honest gaps / where read-only or correctness might be incomplete (please review hard)

  1. Pulse remote path is NOT view-as-aware. getRecoveryBoard(user.id) / getLatestMorningBrief(user.id) / getPulseConfig(user.id) resolve by the founder's own user.id, so under a view-scope the Pulse-sourced board + sampleMode still 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 own tenant_rl_001 going 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).

  2. RLS is the real backstop, not the tenant swap. Loaders use the anon-key server client (the user's RLS). Swapping tenant_id only 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).

  3. 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) under app/api/v1/**. This PR guards 2 of them with blockMutationIfViewing (focus/approve, approvals/[id] PATCH) plus outreach/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 enable VIEW_AS_ENABLED until every tenant-mutating session route calls blockMutationIfViewing first (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-as DELETE is the Exit path and intentionally has no guard. actions/approve is 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 in middleware.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.

  4. Settings writes were not individually audited in this pass — folded into gap feat: add AI agent framework setup – BMAD Method, Agency Agents, Supe… #3.

  5. Pre-existing, left untouched (flagged, not fixed): recovery/page.tsx gates its demo toggles on a hardcoded founder email (V.davidmedina@gmail.com) rather than isFounderEmail(). 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

…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>
@vercel

vercel Bot commented Jun 16, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
relaylaunch-console Ready Ready Preview, Comment Jun 18, 2026 5:03am

@Victor-David-Medina

Copy link
Copy Markdown
Collaborator Author

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 main as additive code. Solid foundation: resolveActiveTenant(), blockMutationIfViewing() (403 + audit), read-side wiring on the 3 shell pages, the most dangerous mutation routes guarded (approve / focus-approve / outreach-flush), 3889/3889 tests.

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 (proxy.ts). ~100 other mutation routes (settings, integrations, etc.) would accept writes under an active founder view-scope. A "lens" that can still mutate ~100 endpoints is a takeover, not a lens.

Required before VIEW_AS_ENABLED is turned on:

  1. Centralize the read-only guard: block ALL non-GET (POST/PUT/PATCH/DELETE) when a valid founder view-scope cookie is present, at the one layer every API route passes through. Confirm whether that's proxy.ts or a shared route wrapper, and enforce there. Keep the 3 per-route guards as defense-in-depth.
  2. Live login/impersonation test (spec step 6) — not done; do not claim it.
  3. Re-run this adversarial pass after the central guard lands.

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.

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

🛡️ Cascade Quality Score: 100/100

Category Score Status
TypeScript 20/20
ESLint 20/20
Brand Compliance 15/15
Test Suite 25/25
Build 20/20

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>
@Victor-David-Medina

Copy link
Copy Markdown
Collaborator Author

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).

  • Right choke-point: the central guard sits in proxy.ts (the one layer every /api request crosses), after user-resolution, before handler pass-through. Coverage 3 → ~185 of 197 mutation routes via the same triple-gate (viewAsEnabled + verified/unexpired cookie + isFounderEmail) → 403 VIEW_AS_READ_ONLY + audit, fail-closed (403 even if the audit insert drops).
  • Behavior verified, not just reported: the 36 new tests assert mutations are blocked across 7 route groups (settings/integrations/billing/team/keys/documents/new-route) × 4 methods, GET passes, non-founder/expired/tampered/flag-off do NOT block. Passing tests = the guard's placement is effective.
  • The 12 bypasses are JUSTIFIED for this threat model (impersonation = no write to the viewed tenant's data via a founder cookie): cron is CRON_SECRET-bearer (not cookie-driven), webhooks are signature-verified, the public PLG routes (waitlist/quiz/benchmark/analytics/council) write lead/analytics rows not tenant-private data and aren't authenticated, and /api/mcp uses its own bearer (the view-as cookie can't ride it). None is a viewed-tenant mutation vector.
  • One future-watch (from the author's honest flags): if any MCP code path ever honors the view-as cookie, add the guard to /api/mcp. Today it's safe.

Remaining before VIEW_AS_ENABLED is turned on: (1) Build+Lint+Test green (Quality Gate already ✓); (2) a live login/impersonation test (human step — not done, not claimed); (3) my final sign-off after that test. Stays DRAFT, flag-OFF until then. The author flagged its own gaps honestly both passes — exactly how this should work.

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