From 60a247cd70986981af02422a15e40e692c39eb84 Mon Sep 17 00:00:00 2001 From: "Victor \"David\" Medina" Date: Tue, 16 Jun 2026 01:18:52 -0400 Subject: [PATCH 1/3] feat(console): wire View-As view-scope into loaders (flag-off, read-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- __tests__/view-as-wiring.test.ts | 215 +++++++++++++++++++++++ app/(shell)/dashboard/page.tsx | 9 +- app/(shell)/focus/page.tsx | 45 +++-- app/(shell)/operations/recovery/page.tsx | 52 +++--- app/api/v1/approvals/[id]/route.ts | 6 + app/api/v1/focus/approve/route.ts | 6 + app/api/v1/outreach/flush/route.ts | 5 + components/admin/ViewAsBannerSlot.tsx | 27 +++ lib/auth/active-tenant.ts | 91 ++++++++++ lib/auth/view-as-readonly.ts | 90 ++++++++++ 10 files changed, 502 insertions(+), 44 deletions(-) create mode 100644 __tests__/view-as-wiring.test.ts create mode 100644 components/admin/ViewAsBannerSlot.tsx create mode 100644 lib/auth/active-tenant.ts create mode 100644 lib/auth/view-as-readonly.ts diff --git a/__tests__/view-as-wiring.test.ts b/__tests__/view-as-wiring.test.ts new file mode 100644 index 00000000..6f623fd4 --- /dev/null +++ b/__tests__/view-as-wiring.test.ts @@ -0,0 +1,215 @@ +/** + * View-As WIRING tests — the post-OAuth integration layer on top of the #422 + * crypto foundation (covered separately in __tests__/view-as.test.ts). + * + * Proves the two load-bearing safety properties end-to-end through the helpers + * the loaders + mutation routes actually call: + * + * 1. resolveActiveTenant() honors the TRIPLE GATE — if the flag is off, the + * caller is not a founder, or the cookie is missing/forged/expired, the + * view-scope is IGNORED and the user's OWN tenant resolves (zero behavior + * change). Only when all three hold does the VIEWED tenant resolve, flagged + * read-only. + * 2. blockMutationIfViewing() enforces READ-ONLY — a live scope returns a 403 + * {code: VIEW_AS_READ_ONLY} and writes an audit row; no scope returns null + * (mutation proceeds, no-op when the flag is off). + * + * Crypto note: SECRET defaults to "view-as-dev-insecure-secret" because no + * VIEW_AS_SECRET / SUPABASE_SERVICE_ROLE_KEY override is set here; packScope in + * this test signs with the same default, so signatures verify. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { packScope, VIEW_AS_COOKIE, type ViewScope } from "@/lib/auth/view-as-token"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +// Founder gate is deterministic: founder@relaylaunch.com is a founder, nobody else. +vi.mock("@/lib/constants", () => ({ + isFounderEmail: (email: string | null | undefined) => + (email ?? "").toLowerCase() === "founder@relaylaunch.com", + FOUNDER_EMAILS: ["founder@relaylaunch.com"], +})); + +// Cookie jar shared by next/headers and the supabase server stub. +let cookieJar: Map; +vi.mock("next/headers", () => ({ + cookies: async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value === undefined ? undefined : { name, value }; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + getAll: () => Array.from(cookieJar.entries()).map(([name, value]) => ({ name, value })), + }), +})); + +// profiles.tenant_id lookup for getOwnTenantId(). Always returns tenant_own. +vi.mock("@/lib/supabase/server", () => ({ + createClient: async () => ({ + from: () => ({ + select: () => ({ + eq: () => ({ + single: async () => ({ data: { tenant_id: "tenant_own" }, error: null }), + }), + }), + // audit-log insert path + insert: async () => ({ data: null, error: null }), + }), + }), +})); + +// Capture audit-log writes so we can assert the blocked-mutation row. +const auditSpy = vi.fn(async () => {}); +vi.mock("@/lib/audit-log", () => ({ + auditLog: (entry: unknown) => auditSpy(entry), +})); + +import { resolveActiveTenant } from "@/lib/auth/active-tenant"; +import { blockMutationIfViewing, VIEW_AS_READ_ONLY_CODE } from "@/lib/auth/view-as-readonly"; + +const FOUNDER = { id: "user_founder", email: "founder@relaylaunch.com" }; +const NON_FOUNDER = { id: "user_client", email: "client@example.com" }; + +function validScope(overrides: Partial = {}): ViewScope { + return { + tenantId: "tenant_viewed", + label: "Demo Spa", + mode: "client_admin", + sessionId: "sess_test", + exp: Date.now() + 60_000, + ...overrides, + }; +} + +/** Put a (valid by default) signed view-scope cookie in the jar. */ +function setScopeCookie(scope: ViewScope = validScope()) { + cookieJar.set(VIEW_AS_COOKIE, packScope(scope)); +} + +beforeEach(() => { + cookieJar = new Map(); + auditSpy.mockClear(); + // Default: feature flag ON for these tests; individual cases flip it off. + vi.stubEnv("VIEW_AS_ENABLED", "true"); + vi.stubEnv("NEXT_PUBLIC_VIEW_AS_ENABLED", ""); +}); + +// ── resolveActiveTenant: triple-gate ───────────────────────────────────────── + +describe("resolveActiveTenant — triple gate", () => { + it("returns the VIEWED tenant (read-only) when all three gates pass", async () => { + setScopeCookie(); + const active = await resolveActiveTenant(FOUNDER); + expect(active.tenantId).toBe("tenant_viewed"); + expect(active.ownTenantId).toBe("tenant_own"); + expect(active.isViewing).toBe(true); + expect(active.readOnly).toBe(true); + expect(active.viewAs?.sessionId).toBe("sess_test"); + }); + + it("GATE 1 (flag): flag OFF → scope ignored, resolves OWN tenant, not viewing", async () => { + vi.stubEnv("VIEW_AS_ENABLED", ""); + setScopeCookie(); // cookie present + valid, but flag is off + const active = await resolveActiveTenant(FOUNDER); + expect(active.tenantId).toBe("tenant_own"); + expect(active.isViewing).toBe(false); + expect(active.readOnly).toBe(false); + expect(active.viewAs).toBeNull(); + }); + + it("GATE 2 (cookie): no cookie → resolves OWN tenant, not viewing", async () => { + // no setScopeCookie() + const active = await resolveActiveTenant(FOUNDER); + expect(active.tenantId).toBe("tenant_own"); + expect(active.isViewing).toBe(false); + }); + + it("GATE 2 (cookie): TAMPERED cookie → scope ignored, OWN tenant", async () => { + const t = packScope(validScope()); + cookieJar.set(VIEW_AS_COOKIE, "X" + t.slice(1)); // mutate body, signature breaks + const active = await resolveActiveTenant(FOUNDER); + expect(active.tenantId).toBe("tenant_own"); + expect(active.isViewing).toBe(false); + }); + + it("GATE 2 (cookie): EXPIRED scope → scope ignored, OWN tenant", async () => { + setScopeCookie(validScope({ exp: Date.now() - 1_000 })); + const active = await resolveActiveTenant(FOUNDER); + expect(active.tenantId).toBe("tenant_own"); + expect(active.isViewing).toBe(false); + }); + + it("GATE 3 (founder): NON-founder with a valid cookie → scope ignored, OWN tenant", async () => { + setScopeCookie(); // valid signed cookie... + const active = await resolveActiveTenant(NON_FOUNDER); // ...but not a founder + expect(active.tenantId).toBe("tenant_own"); + expect(active.isViewing).toBe(false); + expect(active.readOnly).toBe(false); + }); + + it("null user → no tenant, no scope", async () => { + setScopeCookie(); + const active = await resolveActiveTenant(null); + expect(active.tenantId).toBeNull(); + expect(active.ownTenantId).toBeNull(); + expect(active.isViewing).toBe(false); + }); +}); + +// ── blockMutationIfViewing: read-only enforcement ──────────────────────────── + +describe("blockMutationIfViewing — read-only enforcement", () => { + it("BLOCKS the mutation (403 + audit) when a founder view-scope is active", async () => { + setScopeCookie(); + const res = await blockMutationIfViewing(FOUNDER, { action: "approve", resource: "recovery:abc" }); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + const body = await res!.json(); + expect(body.error).toBe("read-only"); + expect(body.code).toBe(VIEW_AS_READ_ONLY_CODE); + expect(body.viewed_tenant_id).toBe("tenant_viewed"); + + // audit row written for the blocked attempt + expect(auditSpy).toHaveBeenCalledTimes(1); + const entry = auditSpy.mock.calls[0][0] as { action: string; details: Record }; + expect(entry.action).toBe("view_as_blocked_mutation"); + expect(entry.details.attempted_action).toBe("approve"); + expect(entry.details.viewed_tenant_id).toBe("tenant_viewed"); + }); + + it("ALLOWS the mutation (returns null, no audit) when NO scope is active", async () => { + // no cookie → no scope + const res = await blockMutationIfViewing(FOUNDER, { action: "approve" }); + expect(res).toBeNull(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it("ALLOWS the mutation when the flag is OFF even with a valid cookie (kill-switch)", async () => { + vi.stubEnv("VIEW_AS_ENABLED", ""); + setScopeCookie(); + const res = await blockMutationIfViewing(FOUNDER, { action: "approve" }); + expect(res).toBeNull(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it("ALLOWS the mutation for a NON-founder (a forged cookie cannot block a real user's writes)", async () => { + setScopeCookie(); + const res = await blockMutationIfViewing(NON_FOUNDER, { action: "approve" }); + expect(res).toBeNull(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it("ALLOWS the mutation when an EXPIRED scope cookie is present", async () => { + setScopeCookie(validScope({ exp: Date.now() - 1_000 })); + const res = await blockMutationIfViewing(FOUNDER, { action: "approve" }); + expect(res).toBeNull(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it("null user → no-op (returns null)", async () => { + setScopeCookie(); + const res = await blockMutationIfViewing(null, { action: "approve" }); + expect(res).toBeNull(); + }); +}); diff --git a/app/(shell)/dashboard/page.tsx b/app/(shell)/dashboard/page.tsx index 9ce44412..7d11bd8a 100644 --- a/app/(shell)/dashboard/page.tsx +++ b/app/(shell)/dashboard/page.tsx @@ -1,5 +1,7 @@ import { redirect } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; +import { resolveActiveTenant } from "@/lib/auth/active-tenant"; +import { ViewAsBannerSlot } from "@/components/admin/ViewAsBannerSlot"; import { Shield, TrendingUp } from "lucide-react"; import { TierAwareBanner } from "@/components/billing/TierAwareBanner"; import { CheckoutSuccessBanner } from "@/components/billing/CheckoutSuccessBanner"; @@ -99,7 +101,11 @@ export default async function DashboardPage({ let leadData: LeadMetric[] = []; let changes: ChangeRequest[] = []; - const tenantId = profile?.tenant_id; + // Resolve the active tenant through the single View-As-aware helper. With no + // active founder view-scope (incl. flag OFF) this is exactly the user's own + // profile.tenant_id — zero behavior change. + const active = await resolveActiveTenant(user); + const tenantId = active.tenantId; if (user && tenantId) { const [autoRes, agentRes, leadRes, changesRes] = await Promise.all([ supabase @@ -138,6 +144,7 @@ export default async function DashboardPage({ return (
+

Dashboard

diff --git a/app/(shell)/focus/page.tsx b/app/(shell)/focus/page.tsx index aba6087b..80f6a59f 100644 --- a/app/(shell)/focus/page.tsx +++ b/app/(shell)/focus/page.tsx @@ -1,6 +1,9 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; +import { resolveActiveTenant } from "@/lib/auth/active-tenant"; +import { blockMutationIfViewing } from "@/lib/auth/view-as-readonly"; +import { ViewAsBannerSlot } from "@/components/admin/ViewAsBannerSlot"; import { getLatestMorningBrief, getRecoveryBoard } from "@/lib/pulse/remote-client"; import { getPulseConfig } from "@/lib/pulse/config"; import { getVerticalById } from "@/lib/data/verticals"; @@ -74,8 +77,19 @@ export default async function FocusPage({ searchParams }: { searchParams: Search const verticalId = (profile?.business_vertical as BusinessVertical | null) ?? "professional-services"; const pulseVertical = VERTICAL_TO_PULSE[verticalId]; + // Single View-As-aware tenant resolution. No active scope (incl. flag OFF) → + // the user's own profile.tenant_id, unchanged. When a founder view-scope is + // active, reads use the viewed tenant and writes are blocked (read-only lens). + const active = await resolveActiveTenant(user); + const activeTenantId = active.tenantId; + + // READ-ONLY: block the deep-link approve mutation under an active view-scope. + if (params.action === "approve" && params.id && active.readOnly) { + await blockMutationIfViewing(user, { action: "focus_deeplink_approve", resource: `recovery_item:${params.id}` }); + } + // Handle deep-link auto-approve from email Action Brief - if (params.action === "approve" && params.id && profile?.tenant_id) { + if (params.action === "approve" && params.id && profile?.tenant_id && !active.readOnly) { const { getSupabaseAdmin } = await import("@/lib/supabase/admin"); const admin = getSupabaseAdmin(); const approvedAt = new Date().toISOString(); @@ -179,12 +193,12 @@ export default async function FocusPage({ searchParams }: { searchParams: Search let lifetimeRecoveredCents = 0; let isRevenueEstimated = false; - if (profile?.tenant_id) { + if (activeTenantId) { const [streakResult, conversionResult, approvedCountResult] = await Promise.all([ supabase .from("recovery_items") .select("decided_at") - .eq("tenant_id", profile.tenant_id) + .eq("tenant_id", activeTenantId) .eq("status", "approved") .not("decided_at", "is", null) .order("decided_at", { ascending: false }) @@ -192,11 +206,11 @@ export default async function FocusPage({ searchParams }: { searchParams: Search supabase .from("recovery_conversions") .select("revenue") - .eq("tenant_id", profile.tenant_id), + .eq("tenant_id", activeTenantId), supabase .from("recovery_items") .select("id", { count: "exact", head: true }) - .eq("tenant_id", profile.tenant_id) + .eq("tenant_id", activeTenantId) .eq("status", "approved"), ]); @@ -242,22 +256,22 @@ export default async function FocusPage({ searchParams }: { searchParams: Search } } - let outcomeSummary = await getOutcomeSummaryOrEmpty(profile?.tenant_id ?? null).catch(() => getOutcomeSummaryOrEmpty(null)); - let pendingOutcomeReceipts = profile?.tenant_id - ? await getPendingOutcomes(profile.tenant_id).catch(() => []) + let outcomeSummary = await getOutcomeSummaryOrEmpty(activeTenantId ?? null).catch(() => getOutcomeSummaryOrEmpty(null)); + let pendingOutcomeReceipts = activeTenantId + ? await getPendingOutcomes(activeTenantId).catch(() => []) : []; let revenueAttribution = EMPTY_REVENUE_ATTRIBUTION; let monthlyRevenueSummary = EMPTY_MONTHLY_REVENUE_SUMMARY; - if (profile?.tenant_id) { + if (activeTenantId) { try { [revenueAttribution, monthlyRevenueSummary] = await Promise.all([ - getRevenueAttribution(profile.tenant_id), - getMonthlyRevenueSummary(profile.tenant_id), + getRevenueAttribution(activeTenantId), + getMonthlyRevenueSummary(activeTenantId), ]); } catch (error) { logger.warn( - { err: error, tenantId: profile.tenant_id }, + { err: error, tenantId: activeTenantId }, "Failed to load Focus Mode revenue attribution summary", ); } @@ -283,11 +297,11 @@ export default async function FocusPage({ searchParams }: { searchParams: Search } // Local fallback: read from Supabase recovery_items when Pulse is unavailable - if (actions.length === 0 && profile?.tenant_id) { + if (actions.length === 0 && activeTenantId) { const { data: localItems } = await supabase .from("recovery_items") .select("*") - .eq("tenant_id", profile.tenant_id) + .eq("tenant_id", activeTenantId) .not("status", "in", '("approved","dismissed")') .order("created_at", { ascending: false }) .limit(5); @@ -321,7 +335,7 @@ export default async function FocusPage({ searchParams }: { searchParams: Search const { data: opps } = await supabase .from("recovery_opportunities") .select("*") - .eq("tenant_id", profile.tenant_id) + .eq("tenant_id", activeTenantId) .not("status", "in", '("approved","dismissed","completed")') .order("created_at", { ascending: false }) .limit(5); @@ -378,6 +392,7 @@ export default async function FocusPage({ searchParams }: { searchParams: Search return ( + { - const supabase = await createClient(); - - const { data: profile } = await supabase - .from("profiles") - .select("tenant_id") - .eq("user_id", userId) - .single(); - - const tenantId = profile?.tenant_id; +async function getLocalRecoveryBoard(tenantId: string | null): Promise { if (!tenantId) return null; + const supabase = await createClient(); const { data: items } = await supabase .from("recovery_items") @@ -181,6 +175,11 @@ export default async function RecoveryPage({ searchParams }: { searchParams: Sea const { data: { user } } = await supabase.auth.getUser(); if (!user) redirect("/login"); + // Single View-As-aware tenant resolution. No active scope (incl. flag OFF) → + // the user's own tenant, unchanged. + const active = await resolveActiveTenant(user); + const activeTenantId = active.tenantId; + const params = await searchParams; const demo = params.demo === "auto-repair" ? "auto-repair" : params.demo === "wellness" ? "wellness" : undefined; const explicitVertical = parseVertical(params.vertical); @@ -194,17 +193,14 @@ export default async function RecoveryPage({ searchParams }: { searchParams: Sea ? getVerticalById(pulseConfig.vertical) : undefined; - // Handle deep-link auto-approve from email Action Brief links - if (params.action === "approve" && params.id) { + // Handle deep-link auto-approve from email Action Brief links. + // READ-ONLY: under an active View-As scope this mutation is blocked — the + // founder is viewing, not acting as the tenant. The board still renders. + if (params.action === "approve" && params.id && !active.readOnly) { const { getSupabaseAdmin } = await import("@/lib/supabase/admin"); const admin = getSupabaseAdmin(); - const { data: profile } = await supabase - .from("profiles") - .select("tenant_id") - .eq("user_id", user.id) - .single(); - if (profile?.tenant_id) { + if (activeTenantId) { await admin .from("recovery_items") .update({ @@ -213,10 +209,15 @@ export default async function RecoveryPage({ searchParams }: { searchParams: Sea decided_at: new Date().toISOString(), }) .eq("id", params.id) - .eq("tenant_id", profile.tenant_id) + .eq("tenant_id", activeTenantId) .eq("status", "pending"); } // Continue loading the board; the item will now show as approved + } else if (params.action === "approve" && params.id && active.readOnly) { + const { blockMutationIfViewing } = await import("@/lib/auth/view-as-readonly"); + // Audit the blocked attempt (return value intentionally ignored — this is a + // server-component render path, not an API response). + await blockMutationIfViewing(user, { action: "recovery_deeplink_approve", resource: `recovery_item:${params.id}` }); } let board: RecoveryBoard | null = null; @@ -229,19 +230,13 @@ export default async function RecoveryPage({ searchParams }: { searchParams: Sea board = await getRecoveryBoard(user.id, { demo, vertical }); } catch { connectionError = "No recovery opportunities yet. Import your client list or run a demo seed to see recovery recommendations here."; - board = await getLocalRecoveryBoard(user.id); + board = await getLocalRecoveryBoard(activeTenantId); } // Fetch client health scores for dot indicators let clientHealthMap: Record = {}; if (board && board.opportunities && board.opportunities.length > 0) { - const { data: profile } = await supabase - .from("profiles") - .select("tenant_id") - .eq("user_id", user.id) - .single(); - - if (profile?.tenant_id) { + if (activeTenantId) { const clientIds = board.opportunities .filter((o) => o.target.type === "client") .map((o) => o.target.id); @@ -250,7 +245,7 @@ export default async function RecoveryPage({ searchParams }: { searchParams: Sea const { data: healthRows } = await supabase .from("client_health_scores") .select("client_id, risk_level") - .eq("tenant_id", profile.tenant_id) + .eq("tenant_id", activeTenantId) .in("client_id", clientIds); if (healthRows) { @@ -267,6 +262,7 @@ export default async function RecoveryPage({ searchParams }: { searchParams: Sea return ( + ; +} diff --git a/lib/auth/active-tenant.ts b/lib/auth/active-tenant.ts new file mode 100644 index 00000000..8b200e5a --- /dev/null +++ b/lib/auth/active-tenant.ts @@ -0,0 +1,91 @@ +/** + * Active-tenant resolution — the SINGLE place every server loader resolves + * "which tenant am I rendering?". + * + * Normally the active tenant is just the logged-in user's own tenant. The ONLY + * thing that can change that is a founder View-As scope, and only when the full + * triple-gate holds: + * + * 1. feature flag `viewAsEnabled()` is ON, AND + * 2. a signed view-scope cookie verifies (HMAC-SHA256, not expired, 30-min TTL), AND + * 3. `isFounderEmail(user.email)` is true. + * + * All three are enforced inside `getViewScope()` (lib/auth/view-as.ts → + * verifyToken). If ANY gate fails, `getViewScope` returns null and we resolve the + * user's own tenant exactly as before. When the flag is OFF this helper is a + * no-op wrapper around the existing `profiles.tenant_id` lookup — ZERO behavior + * change for a normal logged-in user. + * + * SECURITY: a View-As scope can only NARROW a founder's existing access (it + * changes WHICH tenant_id the loader asks for). It never grants a non-founder + * anything, never rewrites RLS, and never escalates privilege. When a scope is + * active the resolution is flagged `isViewing` + `readOnly` so mutation routes + * can refuse writes (a lens, never a takeover). + */ +import type { User } from "@supabase/supabase-js"; +import { createClient } from "@/lib/supabase/server"; +import { getViewScope, type ViewScope } from "@/lib/auth/view-as"; + +export interface ActiveTenant { + /** The tenant_id loaders should query. Either the user's own, or (under a + * valid founder View-As scope) the viewed tenant. */ + tenantId: string | null; + /** The user's own tenant_id, always — independent of any view-scope. */ + ownTenantId: string | null; + /** True only when a valid founder View-As scope is active. */ + isViewing: boolean; + /** When viewing, writes are blocked. Mirrors `isViewing` (kept explicit so the + * read-only intent is obvious at call sites). */ + readOnly: boolean; + /** The active view-scope, when present (null otherwise). */ + viewAs: ViewScope | null; +} + +/** + * Look up the user's own tenant_id from their profile. Runs under the user's + * own RLS (anon key + their cookies) — exactly the lookup the loaders already do + * inline today. + */ +async function getOwnTenantId(userId: string): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from("profiles") + .select("tenant_id") + .eq("user_id", userId) + .single(); + return (data?.tenant_id as string | null) ?? null; +} + +/** + * Resolve the active tenant for the current request. Every dashboard / + * recovery-board / morning-brief server loader must go through this — do not + * scatter the view-scope check. + * + * @param user the authenticated Supabase user (null → no tenant, no scope). + */ +export async function resolveActiveTenant( + user: Pick | null, +): Promise { + if (!user) { + return { tenantId: null, ownTenantId: null, isViewing: false, readOnly: false, viewAs: null }; + } + + const ownTenantId = await getOwnTenantId(user.id); + + // getViewScope enforces the WHOLE triple-gate (flag + signed/unexpired cookie + + // isFounderEmail). When the flag is off, or the cookie is missing/forged/expired, + // or the user is not a founder, this is null and we fall back to the own tenant. + const viewAs = await getViewScope(user.email); + + if (viewAs && viewAs.tenantId) { + return { + tenantId: viewAs.tenantId, + ownTenantId, + isViewing: true, + readOnly: true, + viewAs, + }; + } + + return { tenantId: ownTenantId, ownTenantId, isViewing: false, readOnly: false, viewAs: null }; +} diff --git a/lib/auth/view-as-readonly.ts b/lib/auth/view-as-readonly.ts new file mode 100644 index 00000000..632e3dc3 --- /dev/null +++ b/lib/auth/view-as-readonly.ts @@ -0,0 +1,90 @@ +/** + * View-As READ-ONLY enforcement — the safety property that makes View-As a + * *lens, not a takeover*. + * + * When a founder has an active View-As scope, every mutation (approve / send / + * settings / any POST·PUT·DELETE that changes tenant data) must be BLOCKED. The + * founder is looking at another tenant's data to find holdups; they must not be + * able to act AS that tenant. (The only intentional exception is the View-As + * admin route's own DELETE, which EXITS the scope — that route does not call + * this guard.) + * + * Mechanism: `blockMutationIfViewing(user, ...)` resolves the active scope via + * the same triple-gate as everything else (`getViewScope` = flag + signed/ + * unexpired cookie + founder email). If a scope is active it writes an audit row + * and returns a ready-to-send 403; otherwise it returns null and the caller + * proceeds exactly as before. + * + * FLAG-OFF / NORMAL USER: `getViewScope` returns null, so this returns null and + * is a complete no-op — zero behavior change for a normal logged-in user. + */ +import { NextResponse } from "next/server"; +import type { User } from "@supabase/supabase-js"; +import { getViewScope, type ViewScope } from "@/lib/auth/view-as"; +import { auditLog } from "@/lib/audit-log"; + +export const VIEW_AS_READ_ONLY_CODE = "VIEW_AS_READ_ONLY"; +export const VIEW_AS_READ_ONLY_STATUS = 403; + +/** + * Resolve the active founder View-As scope for this request, or null. Thin + * wrapper over getViewScope so routes/components have one import for "am I in a + * view-scope right now?" — the full triple-gate is enforced inside. + */ +export async function getActiveViewScope( + userEmail: string | null | undefined, +): Promise { + return getViewScope(userEmail); +} + +export interface BlockMutationContext { + /** Short action verb for the audit trail, e.g. "approve", "outreach_flush". */ + action: string; + /** The resource being protected, e.g. "recovery_opportunity:". */ + resource?: string; +} + +/** + * If a View-As scope is active, block the mutation: audit-log the attempt and + * return a 403 NextResponse the caller should return immediately. Returns null + * when there is no active scope (the common case — caller proceeds normally). + * + * Usage (place FIRST, right after you resolve the authenticated user): + * + * const blocked = await blockMutationIfViewing(user, { action: "approve", resource: `recovery:${id}` }); + * if (blocked) return blocked; + */ +export async function blockMutationIfViewing( + user: Pick | null, + ctx: BlockMutationContext, +): Promise { + if (!user) return null; + + const scope = await getViewScope(user.email); + if (!scope) return null; + + // A scope is active → this is a founder viewing another tenant. Refuse the write. + await auditLog({ + userId: user.id, + action: "view_as_blocked_mutation", + resource: ctx.resource ?? `view_as:${scope.tenantId}`, + details: { + attempted_action: ctx.action, + viewed_tenant_id: scope.tenantId, + session_id: scope.sessionId, + label: scope.label, + }, + statusCode: VIEW_AS_READ_ONLY_STATUS, + }); + + return NextResponse.json( + { + error: "read-only", + code: VIEW_AS_READ_ONLY_CODE, + message: + "You are in a read-only View-As support context. Exit View-As to make changes in your own account.", + viewed_tenant_id: scope.tenantId, + }, + { status: VIEW_AS_READ_ONLY_STATUS }, + ); +} From 7f3a2b5d50c1b4ab6e5407bc544fb3552615c84d Mon Sep 17 00:00:00 2001 From: "Victor \"David\" Medina" Date: Tue, 16 Jun 2026 07:11:13 -0400 Subject: [PATCH 2/3] feat(console): centralize View-As read-only guard at proxy.ts choke-point (flag-off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- __tests__/view-as-proxy-guard.test.ts | 343 ++++++++++++++++++++++++++ lib/auth/view-as-readonly.ts | 118 +++++++++ proxy.ts | 64 +++++ 3 files changed, 525 insertions(+) create mode 100644 __tests__/view-as-proxy-guard.test.ts diff --git a/__tests__/view-as-proxy-guard.test.ts b/__tests__/view-as-proxy-guard.test.ts new file mode 100644 index 00000000..b191235d --- /dev/null +++ b/__tests__/view-as-proxy-guard.test.ts @@ -0,0 +1,343 @@ +/** + * CENTRAL View-As read-only guard — proxy.ts choke-point tests. + * + * The per-route blockMutationIfViewing() (covered in view-as-wiring.test.ts) only + * protects the handful of routes that remember to call it. This suite proves the + * CENTRAL guard in proxy.ts — the one layer every /api request crosses — refuses + * EVERY non-GET request when a valid founder View-As scope is active, across + * representative route groups (settings, integrations, billing, team, generic), + * NOT just the 3 hand-wired routes. It also proves reads pass, non-founders / + * expired / tampered cookies are ignored, and the whole thing is a no-op when + * VIEW_AS_ENABLED is off. + * + * Harness mirrors __tests__/middleware.test.ts (mocked next/server + @supabase/ssr + * + rate-limit) but is self-contained so it can drive the View-As cookie + the + * audit-insert path without disturbing the main middleware assertions. + * + * Crypto note: SECRET defaults to "view-as-dev-insecure-secret" (no + * VIEW_AS_SECRET / SUPABASE_SERVICE_ROLE_KEY here), and packScope() signs with + * the same default, so the signed cookies verify. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { packScope, VIEW_AS_COOKIE, type ViewScope } from "@/lib/auth/view-as-token"; + +// ── Mocks ──────────────────────────────────────────────────────────── + +const mockCheckRateLimit = vi.fn(); +const mockGetClientIP = vi.fn().mockReturnValue("127.0.0.1"); +const mockRateLimitResponse = vi.fn(); +const mockGetRateLimitMode = vi.fn().mockReturnValue("distributed"); +const mockIsRateLimitDegraded = vi.fn().mockReturnValue(false); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args), + getClientIP: (...args: unknown[]) => mockGetClientIP(...args), + getRateLimitMode: (...args: unknown[]) => mockGetRateLimitMode(...args), + isRateLimitDegraded: (...args: unknown[]) => mockIsRateLimitDegraded(...args), + rateLimitResponse: (...args: unknown[]) => mockRateLimitResponse(...args), +})); + +// Deterministic founder gate: founder@relaylaunch.com is the only founder. +vi.mock("@/lib/constants", () => ({ + isFounderEmail: (email: string | null | undefined) => + (email ?? "").toLowerCase() === "founder@relaylaunch.com", + FOUNDER_EMAILS: ["founder@relaylaunch.com"], +})); + +// Supabase mock — auth.getUser + from().select()...maybeSingle() for tier/profile +// lookups AND from("audit_logs").insert() for the central guard's audit write. +const mockGetUser = vi.fn(); +const mockMaybeSingle = vi.fn(); +const mockAuditInsert = vi.fn().mockResolvedValue({ data: null, error: null }); + +vi.mock("@supabase/ssr", () => ({ + createServerClient: vi.fn(() => ({ + auth: { getUser: mockGetUser }, + from: (_table: string) => ({ + select: () => ({ + eq: () => ({ + maybeSingle: mockMaybeSingle, + single: mockMaybeSingle, + }), + }), + insert: (...args: unknown[]) => mockAuditInsert(...args), + }), + })), +})); + +// Next.js mock — track responses (status/headers/body/type). +class MockHeaders { + private store = new Map(); + set(key: string, value: string) { this.store.set(key, value); } + get(key: string) { return this.store.get(key) ?? null; } + has(key: string) { return this.store.has(key); } +} + +class MockCookies { + private store = new Map(); + set(name: string, value: string) { this.store.set(name, { name, value }); } + get(name: string) { return this.store.get(name); } + getAll() { return [...this.store.values()]; } +} + +interface MockResponse { + status: number; + headers: MockHeaders; + cookies: MockCookies; + body?: unknown; + redirectUrl?: string; + _type: "next" | "redirect" | "json" | "raw"; +} + +function createMockResponse(status: number, type: MockResponse["_type"], extra: Partial = {}): MockResponse { + return { status, headers: new MockHeaders(), cookies: new MockCookies(), _type: type, ...extra }; +} + +vi.mock("next/server", () => { + class MockNextResponse { + status: number; + headers: MockHeaders; + cookies: MockCookies; + body: string; + _type: string; + constructor(body: string, init?: { status?: number }) { + this.status = init?.status || 200; + this.headers = new MockHeaders(); + this.cookies = new MockCookies(); + this.body = body; + this._type = "raw"; + } + static next() { return createMockResponse(200, "next"); } + static redirect(url: URL | { toString(): string }) { + return createMockResponse(307, "redirect", { redirectUrl: url.toString() }); + } + static json(data: unknown, init?: { status?: number }) { + return createMockResponse(init?.status || 200, "json", { body: data }); + } + } + return { NextResponse: MockNextResponse }; +}); + +// Deterministic nonce. +vi.stubGlobal("crypto", { + getRandomValues: (arr: Uint8Array) => { arr.fill(42); return arr; }, +}); + +// ── Helpers ────────────────────────────────────────────────────────── + +function createCloneableURL(pathname: string) { + const url = new URL(`https://deck.relaylaunch.com${pathname}`); + (url as URL & { clone: () => URL }).clone = () => new URL(url.toString()); + return url as URL & { clone: () => URL }; +} + +function createRequest(pathname: string, options: { + method?: string; + origin?: string; + cookies?: Record; +} = {}) { + const url = createCloneableURL(pathname); + const reqHeaders = new MockHeaders(); + // Default to the allowed origin so the CSRF check passes and we reach the guard. + reqHeaders.set("origin", options.origin ?? "https://deck.relaylaunch.com"); + + const reqCookies = new MockCookies(); + if (options.cookies) { + for (const [k, v] of Object.entries(options.cookies)) reqCookies.set(k, v); + } + + return { + nextUrl: url, + url: url.toString(), + method: options.method || "GET", + headers: reqHeaders, + cookies: reqCookies, + }; +} + +const FOUNDER = { id: "user_founder", email: "founder@relaylaunch.com" }; +const CLIENT = { id: "user_client", email: "client@example.com" }; + +function validScope(overrides: Partial = {}): ViewScope { + return { + tenantId: "tenant_viewed", + label: "Demo Spa", + mode: "client_admin", + sessionId: "sess_test", + exp: Date.now() + 60_000, + ...overrides, + }; +} + +/** Signed, valid-by-default View-As cookie value. */ +function scopeCookie(scope: ViewScope = validScope()): string { + return packScope(scope); +} + +// Representative mutation routes spanning DIFFERENT route groups — explicitly +// NOT the 3 hand-wired ones (approvals / focus/approve / outreach/flush). The +// point is the CENTRAL guard covers everything, including these. +const REPRESENTATIVE_MUTATION_ROUTES = [ + "/api/v1/settings/profile", + "/api/v1/integrations/google/connect", + "/api/v1/billing/checkout", + "/api/v1/team/invitations", + "/api/v1/keys", + "/api/v1/documents", + "/api/v1/some-brand-new-route-nobody-guarded", +]; + +// ── Import middleware AFTER mocks ──────────────────────────────────── +let middleware: (request: ReturnType) => Promise; + +beforeEach(async () => { + process.env.NEXT_PUBLIC_SUPABASE_URL = "https://test.supabase.co"; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "test-anon-key"; + process.env.ALLOWED_ORIGINS = "https://deck.relaylaunch.com"; + vi.stubEnv("NODE_ENV", "production"); + // Feature flag ON by default for this suite; individual cases flip it OFF. + vi.stubEnv("VIEW_AS_ENABLED", "true"); + vi.stubEnv("NEXT_PUBLIC_VIEW_AS_ENABLED", ""); + + mockCheckRateLimit.mockResolvedValue({ allowed: true, remaining: 99, retryAfterMs: 0 }); + mockGetRateLimitMode.mockReturnValue("distributed"); + mockIsRateLimitDegraded.mockReturnValue(false); + mockRateLimitResponse.mockReturnValue(createMockResponse(429, "json", { body: { error: "Rate limited" } })); + + // Default authenticated principal = the FOUNDER (so the guard can fire). + mockGetUser.mockResolvedValue({ data: { user: FOUNDER } }); + mockMaybeSingle.mockResolvedValue({ data: { tenant_id: "tenant_own", plan_tier: "Founder", role: "admin" } }); + mockAuditInsert.mockClear(); + mockAuditInsert.mockResolvedValue({ data: null, error: null }); + + const mod = await import("../proxy"); + middleware = mod.proxy as unknown as typeof middleware; +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +// ===================================================================== +// 1. Central guard BLOCKS mutations across representative route groups +// ===================================================================== + +describe("central View-As guard — blocks ALL mutation routes (not just the 3)", () => { + for (const route of REPRESENTATIVE_MUTATION_ROUTES) { + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + it(`blocks ${method} ${route} with 403 VIEW_AS_READ_ONLY under an active founder scope`, async () => { + const res = await middleware(createRequest(route, { + method, + cookies: { [VIEW_AS_COOKIE]: scopeCookie() }, + })); + expect(res.status).toBe(403); + expect((res.body as { code?: string })?.code).toBe("VIEW_AS_READ_ONLY"); + expect((res.body as { viewed_tenant_id?: string })?.viewed_tenant_id).toBe("tenant_viewed"); + // Security headers still applied to the 403. + expect(res.headers.get("Content-Security-Policy")).toBeTruthy(); + }); + } + } + + it("writes a view_as_blocked_mutation audit row (method + path) on a blocked write", async () => { + await middleware(createRequest("/api/v1/settings/profile", { + method: "POST", + cookies: { [VIEW_AS_COOKIE]: scopeCookie() }, + })); + expect(mockAuditInsert).toHaveBeenCalledTimes(1); + const row = mockAuditInsert.mock.calls[0][0] as { + action: string; status_code: number; details: Record; + }; + expect(row.action).toBe("view_as_blocked_mutation"); + expect(row.status_code).toBe(403); + expect(row.details.viewed_tenant_id).toBe("tenant_viewed"); + expect(row.details.method).toBe("POST"); + expect(row.details.path).toBe("/api/v1/settings/profile"); + expect(row.details.enforced_by).toBe("proxy_central_guard"); + }); +}); + +// ===================================================================== +// 2. Reads still pass under an active scope (lens, not lockout) +// ===================================================================== + +describe("central View-As guard — reads pass", () => { + it("ALLOWS GET under an active founder scope (200, no audit, no block)", async () => { + const res = await middleware(createRequest("/api/v1/settings/profile", { + method: "GET", + cookies: { [VIEW_AS_COOKIE]: scopeCookie() }, + })); + expect(res.status).toBe(200); + expect((res.body as { code?: string })?.code).toBeUndefined(); + expect(mockAuditInsert).not.toHaveBeenCalled(); + }); +}); + +// ===================================================================== +// 3. Triple-gate: non-founder / expired / tampered / flag-off are ignored +// ===================================================================== + +describe("central View-As guard — triple gate (mutation is NOT blocked when a gate fails)", () => { + it("GATE 1 (flag OFF): a valid cookie does NOT block — VIEW_AS_ENABLED off is a full no-op", async () => { + vi.stubEnv("VIEW_AS_ENABLED", ""); + const res = await middleware(createRequest("/api/v1/settings/profile", { + method: "POST", + cookies: { [VIEW_AS_COOKIE]: scopeCookie() }, + })); + expect(res.status).toBe(200); + expect(mockAuditInsert).not.toHaveBeenCalled(); + }); + + it("GATE 3 (founder): a NON-founder with a valid cookie is NOT blocked (forged cookie can't lock a real user out)", async () => { + mockGetUser.mockResolvedValue({ data: { user: CLIENT } }); + const res = await middleware(createRequest("/api/v1/settings/profile", { + method: "POST", + cookies: { [VIEW_AS_COOKIE]: scopeCookie() }, + })); + expect(res.status).toBe(200); + expect(mockAuditInsert).not.toHaveBeenCalled(); + }); + + it("GATE 2 (expiry): an EXPIRED scope cookie does NOT block", async () => { + const res = await middleware(createRequest("/api/v1/settings/profile", { + method: "POST", + cookies: { [VIEW_AS_COOKIE]: scopeCookie(validScope({ exp: Date.now() - 1_000 })) }, + })); + expect(res.status).toBe(200); + expect(mockAuditInsert).not.toHaveBeenCalled(); + }); + + it("GATE 2 (signature): a TAMPERED cookie does NOT block", async () => { + const good = scopeCookie(); + const tampered = "X" + good.slice(1); // break the signed body + const res = await middleware(createRequest("/api/v1/settings/profile", { + method: "POST", + cookies: { [VIEW_AS_COOKIE]: tampered }, + })); + expect(res.status).toBe(200); + expect(mockAuditInsert).not.toHaveBeenCalled(); + }); + + it("no cookie at all → normal mutation passes (200)", async () => { + const res = await middleware(createRequest("/api/v1/settings/profile", { method: "POST" })); + expect(res.status).toBe(200); + expect(mockAuditInsert).not.toHaveBeenCalled(); + }); +}); + +// ===================================================================== +// 4. Audit failure must never let the write through (fail-closed) +// ===================================================================== + +describe("central View-As guard — fail-closed on audit error", () => { + it("still returns 403 even if the audit insert rejects", async () => { + mockAuditInsert.mockRejectedValue(new Error("db down")); + const res = await middleware(createRequest("/api/v1/settings/profile", { + method: "POST", + cookies: { [VIEW_AS_COOKIE]: scopeCookie() }, + })); + expect(res.status).toBe(403); + expect((res.body as { code?: string })?.code).toBe("VIEW_AS_READ_ONLY"); + }); +}); diff --git a/lib/auth/view-as-readonly.ts b/lib/auth/view-as-readonly.ts index 632e3dc3..742a6331 100644 --- a/lib/auth/view-as-readonly.ts +++ b/lib/auth/view-as-readonly.ts @@ -21,11 +21,25 @@ import { NextResponse } from "next/server"; import type { User } from "@supabase/supabase-js"; import { getViewScope, type ViewScope } from "@/lib/auth/view-as"; +import { + VIEW_AS_COOKIE, + verifyToken, + viewAsEnabled, +} from "@/lib/auth/view-as-token"; +import { isFounderEmail } from "@/lib/constants"; import { auditLog } from "@/lib/audit-log"; export const VIEW_AS_READ_ONLY_CODE = "VIEW_AS_READ_ONLY"; export const VIEW_AS_READ_ONLY_STATUS = 403; +/** Non-GET methods are the ones the central guard refuses under a view-scope. */ +const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +/** Is this HTTP method a mutation (write) the read-only guard must block? */ +export function isMutationMethod(method: string | null | undefined): boolean { + return MUTATION_METHODS.has((method ?? "").toUpperCase()); +} + /** * Resolve the active founder View-As scope for this request, or null. Thin * wrapper over getViewScope so routes/components have one import for "am I in a @@ -88,3 +102,107 @@ export async function blockMutationIfViewing( { status: VIEW_AS_READ_ONLY_STATUS }, ); } + +// ───────────────────────────────────────────────────────────────────────────── +// CENTRAL (CHOKE-POINT) read-only guard — runs in proxy.ts for EVERY /api route. +// +// The per-route blockMutationIfViewing() above is correct but only covers the +// handful of routes that remember to call it. The real safety property — "a +// View-As scope can read but NEVER write" — has to hold for ALL ~100 mutation +// routes. proxy.ts is the one layer every API request crosses, so we enforce it +// there too (defense-in-depth: the per-route guards stay as a second wall). +// +// proxy.ts runs in the Node middleware runtime and resolves the request cookie +// from `request.cookies` (NOT next/headers), so this path is deliberately pure + +// request-driven: it reuses the SAME triple-gate as everything else +// (viewAsEnabled() + verifyToken() signature/expiry + isFounderEmail) but takes +// the raw cookie value and email as inputs. No next/headers, no implicit env +// cookie store — safe to call from middleware. +// +// FLAG-OFF: viewAsEnabled() is false → resolveProxyViewScope returns null → +// complete no-op. Zero behavior change until VIEW_AS_ENABLED is turned on. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Resolve the active founder View-As scope from a RAW cookie value (the form + * proxy.ts has via `request.cookies.get(VIEW_AS_COOKIE)?.value`), enforcing the + * full triple-gate. Returns null unless the flag is on, the requester is a + * founder, and the cookie's signature + expiry verify. Pure — no I/O. + */ +export function resolveProxyViewScope(input: { + cookieValue: string | undefined | null; + userEmail: string | null | undefined; +}): ViewScope | null { + return verifyToken(input.cookieValue, { + enabled: viewAsEnabled(), + isFounder: isFounderEmail(input.userEmail), + }); +} + +/** Re-export so proxy.ts has one import surface for the central guard. */ +export { VIEW_AS_COOKIE }; + +/** A minimal insert sink so the central guard can audit via the middleware's + * own Supabase client (avoids next/headers inside middleware). */ +export interface AuditInserter { + from(table: "audit_logs"): { + insert(row: Record): Promise | unknown; + }; +} + +/** + * Build the 403 read-only NextResponse for the central guard (same contract as + * the per-route guard) and best-effort write the blocked-mutation audit row via + * the provided inserter. The audit write is fire-and-forget: it never throws and + * never blocks the 403. + * + * @param scope the active view-scope (already resolved + verified) + * @param ctx method + path of the blocked request (for the audit trail) + * @param userId the authenticated founder's id (audit actor), if known + * @param inserter the request-scoped Supabase client (or null to skip auditing) + */ +export function buildProxyReadOnlyBlock( + scope: ViewScope, + ctx: { method: string; path: string }, + userId: string | null | undefined, + inserter: AuditInserter | null, +): NextResponse { + if (inserter) { + try { + // Fire-and-forget — mirror auditLog(): a failed audit must never break + // the request (and definitely must not let the write through). + Promise.resolve( + inserter.from("audit_logs").insert({ + user_id: userId ?? null, + action: "view_as_blocked_mutation", + resource: `view_as:${scope.tenantId}`, + details: { + attempted_action: `${ctx.method} ${ctx.path}`, + method: ctx.method, + path: ctx.path, + viewed_tenant_id: scope.tenantId, + session_id: scope.sessionId, + label: scope.label, + enforced_by: "proxy_central_guard", + }, + status_code: VIEW_AS_READ_ONLY_STATUS, + }), + ).catch(() => { + /* audit best-effort only */ + }); + } catch { + /* audit best-effort only */ + } + } + + return NextResponse.json( + { + error: "read-only", + code: VIEW_AS_READ_ONLY_CODE, + message: + "You are in a read-only View-As support context. Exit View-As to make changes in your own account.", + viewed_tenant_id: scope.tenantId, + }, + { status: VIEW_AS_READ_ONLY_STATUS }, + ); +} diff --git a/proxy.ts b/proxy.ts index 4ede792a..00eec2c0 100644 --- a/proxy.ts +++ b/proxy.ts @@ -11,6 +11,13 @@ import { FOUNDER_EMAILS } from "@/lib/constants"; import { auditLog } from "@/lib/audit-log"; import { timingSafeCompare } from "@/lib/security"; import { ABSORBED_REDIRECTS } from "@/lib/absorbed-redirects"; +import { + isMutationMethod, + resolveProxyViewScope, + buildProxyReadOnlyBlock, + VIEW_AS_COOKIE, + type AuditInserter, +} from "@/lib/auth/view-as-readonly"; // ── Nonce-based CSP ───────────────────────────────────────────────── function generateNonce(): string { @@ -418,6 +425,29 @@ export async function proxy(request: NextRequest) { return applySecurityHeaders(cronResponse, nonce); } + // ── View-As central-guard SCOPE NOTE (explicit, not silent) ─────── + // The CENTRAL read-only guard (in the authenticated protected-API branch + // below) covers EVERY authenticated mutation route (~185 of them). The cron + // branch (above) and the public-allowlist branch (below) `return` BEFORE that + // guard, so the following 12 mutation routes are NOT covered. That is + // intentional and safe — none is a "write AS the viewed tenant" vector + // reachable with a founder session cookie: + // CRON (gated by CRON_SECRET bearer, no session cookie): + // • /api/v1/action-map/ingest • /api/v1/shadow-board/scan + // SIGNATURE-VERIFIED INBOUND WEBHOOKS (HMAC/Stripe sig, no session): + // • /api/v1/billing/webhook • /api/v1/sync/receive + // • /api/webhooks/inbound-email + // PUBLIC PLG / DEMO (unauthenticated lead-gen; no tenant-private write): + // • /api/v1/waitlist • /api/v1/benchmark • /api/v1/quiz + // • /api/v1/analytics/funnel • /api/council • /api/council/stream + // BEARER-TOKEN MCP (auth is the MCP token; View-As cookie doesn't ride along): + // • /api/mcp + // NOTE: lookalike routes whose NAME contains "webhook"/"a2a" but that are NOT + // in the allowlist (e.g. /api/v1/webhooks, /api/v1/pulse/webhook, /api/a2a, + // /api/v1/a2a/invoke) DO require a session and ARE covered by the central + // guard. If any bypass route above ever becomes a tenant-private write + // reachable with a founder session cookie, move it behind the central guard + // (or add its own blockMutationIfViewing) — do NOT silently widen this set. // Public API routes: pass through without auth (rate limiting still applies) if (isPublicApi) { const pubResponse = NextResponse.next({ request }); @@ -455,6 +485,12 @@ export async function proxy(request: NextRequest) { const { data: { user }, } = await supabase.auth.getUser(); + // The authenticated principal for this request — from the session cookie + // or (fallback) a Bearer token. Used below for the central View-As + // read-only guard. Hoisted so both auth paths feed the same check. + let resolvedUser: { id: string; email?: string } | null = user + ? { id: user.id, email: user.email ?? undefined } + : null; if (!user) { // Also accept Bearer token for programmatic access const authHeader = request.headers.get("authorization"); @@ -480,6 +516,34 @@ export async function proxy(request: NextRequest) { nonce, ); } + resolvedUser = { id: tokenUser.id, email: tokenUser.email ?? undefined }; + } + + // ── CENTRAL View-As READ-ONLY guard (the safety property) ────────── + // For an authenticated mutation (POST/PUT/PATCH/DELETE), if a VALID + // founder View-As scope is active (triple-gate: flag ON + signed/unexpired + // cookie + isFounderEmail), refuse the write here — BEFORE the handler runs + // — so EVERY mutation route is covered, not just the few that call the + // per-route guard. Reads (GET/HEAD/OPTIONS) and non-founders/expired + // cookies fall straight through. Flag-OFF → resolveProxyViewScope returns + // null → no-op. Audited via this request's own Supabase client (no + // next/headers in middleware). + if (isMutationMethod(method) && resolvedUser) { + const scope = resolveProxyViewScope({ + cookieValue: request.cookies.get(VIEW_AS_COOKIE)?.value, + userEmail: resolvedUser.email, + }); + if (scope) { + return applySecurityHeaders( + buildProxyReadOnlyBlock( + scope, + { method, path: pathname }, + resolvedUser.id, + supabase as unknown as AuditInserter, + ), + nonce, + ); + } } } From 4b3ecf57ae86f0ec959eb9cda94a1ce783666519 Mon Sep 17 00:00:00 2001 From: "Victor \"David\" Medina" Date: Thu, 18 Jun 2026 00:55:19 -0400 Subject: [PATCH 3/3] fix(demo): update investor walkthrough model counts to 17 to match canon --- app/demo/investor/layout.tsx | 2 +- app/demo/investor/page.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/demo/investor/layout.tsx b/app/demo/investor/layout.tsx index 34eac6eb..7b78701d 100644 --- a/app/demo/investor/layout.tsx +++ b/app/demo/investor/layout.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; export const metadata: Metadata = { title: "Investor Demo | Relay Deck", description: - "Seeded walkthrough of Relay Deck's AI operations platform. 16 Rooms, 15 models across 7 providers, and owner-approved recommendations.", + "Seeded walkthrough of Relay Deck's AI operations platform. 16 Rooms, 17 models across 7 providers, and owner-approved recommendations.", openGraph: { title: "Investor Demo | Relay Deck", description: diff --git a/app/demo/investor/page.tsx b/app/demo/investor/page.tsx index d5259dd0..7f441b43 100644 --- a/app/demo/investor/page.tsx +++ b/app/demo/investor/page.tsx @@ -226,7 +226,7 @@ export default function InvestorDemoPage() { Pro Plan · $299/mo

- 16 Rooms · 13 AI models · 17 Review Modes + 16 Rooms · 17 AI models · 17 Review Modes

@@ -242,7 +242,7 @@ export default function InvestorDemoPage() {

{DEMO_COMPANY.plan} Plan · $299/mo

-

16 Rooms · 15 models · 7 providers

+

16 Rooms · 17 models · 7 providers

@@ -559,7 +559,7 @@ export default function InvestorDemoPage() {
- Relay Deck · Seeded demo · {DEMO_COMPANY.plan} Plan · 7 providers · 15 models + Relay Deck · Seeded demo · {DEMO_COMPANY.plan} Plan · 7 providers · 17 models
RelayLaunch LLC · Founder-led