From 05ead47a4d93ebb33c8f2ec13b0eb1b947e6fb34 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 03:26:09 -0400 Subject: [PATCH 1/4] A rollout preview: walk the product as any role, in a tenant of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "right now i have no way of seeing what users will see once rolled out." So this is a preview, not an impersonation console and not a debug switch. A preview account signs in, picks a role, and from that point the SUBJECT of its session is a real seeded user who holds that role — in a separate institution, with a world seeded for it. FIDELITY. The substitution happens once, in the NextAuth session callback, at the single point where identity enters the application. Sixty-one files call auth(); not one is modified. getUserContext reads the persona's rows, rbac.ts and capabilities.ts decide from them, resolveTenantScope derives the tenant from their memberships, and (app)/layout.tsx judges them like anybody else. They are not expected to behave like production — they are the same code on the same kind of rows, with no branch to diverge at. Measured in a browser: the OSE Director persona is offered 17 capabilities and the Staff persona 5, and /admin/metering answers 200 for one and 404 for the other, through hasCapability and nothing else. THE ALLOWLIST is MASTER_ACCESS_EMAILS, and unset, empty and whitespace all mean the feature does not exist — /preview 404s for everybody including the Director, the eligibility branch is unreachable, no session carries a preview field. Verified against a running server with the variable removed, not only in unit tests. It is an environment variable rather than a row because a row can be written by anything that can write rows; entering this boundary should be a deployment decision. THE ELIGIBILITY EXCEPTION is a separate door, decided BEFORE the roster read and returning the distinct reason "preview-access". The registry is not consulted at all — no RestrictedIdentity read, no count, no seal read, nothing written — and the test asserts the absence of the queries rather than the presence of the answer. Adding a roster row instead was rejected: the seal is proof the registry matches the OSE workbook, and this address is not in it, so the row would either fail the next verification or force the check to tolerate strangers. THE WORLD is not thin: two clubs so switching is visible, a filled board with last year's holders, a budget whose actuals are the sum of a real ledger, approvals stopped at two different gates, nine calendar entries, seven deliverables including one overdue, and a vault whose LESSON cards are the thing that surface exists for. Re-running the seed resets it; two consecutive re-seeds left the real tenant byte-identical across seven counted tables. ISOLATION is structural rather than filtered. A separate Institution, and resolveTenantScope already derives the acting tenant from the acting user's own memberships and refuses one they are not a member of. Proved in both directions in a browser: the preview Director sees no Simon club, the real Director sees no preview club and gets a 404 on /preview. AUDIT. actorId is already the persona, because the session's subject is the persona. The human arrives on metadata.preview via TenantScope.actor.onBehalfOf and a Prisma extension on auditEvent.create — forty-four call sites write audit rows and editing all of them would work until the forty-fifth. Verified on an ordinary Memory.CardCreated row written by a call site that has never heard of this feature. WORKSPACES. #110 merged mid-build, so this imports it rather than adopting it later: entering a persona redirects to /workspace, ADR-0019's front door, which resolves the workspace from the persona's own rows. No copy of the workspace table exists here. Because a persona is a real user with real rows, defaultWorkspace() needed no preview-specific code — the Director lands on /admin and the member on /dashboard for the same reason a real one does, and e2e asserts that for all nine roles. Twenty negative controls, each broken to RED and restored to GREEN, in scripts/preview-negative-controls.sh. On the first run three of them passed over a sabotaged build and said so: two were breaking nothing (an inserted object key a later key overrode; a SQL error piped to /dev/null) and the third revealed there was no test covering the persona refusal at all. All three are fixed and the missing test is written. Also fixed, because this tripped them and they were right: a tenant literal in core code, a middleware matcher that must describe (app) exactly, and a static import that would have pulled NextAuth into sixty modules' graphs. --- apps/web/.env.example | 19 + apps/web/e2e/preview-disabled.spec.ts | 60 + apps/web/e2e/preview.spec.ts | 293 +++++ apps/web/scripts/preview-negative-controls.sh | 265 +++++ apps/web/scripts/preview-personas.mjs | 143 +++ apps/web/scripts/seed-preview-world.mjs | 1035 +++++++++++++++++ apps/web/scripts/verify-preview-audit.mjs | 191 +++ apps/web/src/app/(app)/layout.tsx | 18 + apps/web/src/app/preview/actions.ts | 158 +++ apps/web/src/app/preview/page.tsx | 195 ++++ .../src/components/preview/PreviewBadge.tsx | 56 + apps/web/src/lib/auth.ts | 35 +- apps/web/src/lib/auth/restricted-registry.ts | 28 + apps/web/src/lib/db.ts | 8 +- apps/web/src/lib/preview/access.test.ts | 118 ++ apps/web/src/lib/preview/access.ts | 107 ++ apps/web/src/lib/preview/allowlist.test.ts | 98 ++ apps/web/src/lib/preview/allowlist.ts | 114 ++ apps/web/src/lib/preview/attribution.ts | 82 ++ .../src/lib/preview/audit-attribution.test.ts | 108 ++ apps/web/src/lib/preview/audit-attribution.ts | 101 ++ apps/web/src/lib/preview/gate-order.test.ts | 138 +++ apps/web/src/lib/preview/personas.test.ts | 183 +++ apps/web/src/lib/preview/personas.ts | 292 +++++ apps/web/src/lib/preview/subject.test.ts | 177 +++ apps/web/src/lib/preview/subject.ts | 277 +++++ apps/web/src/lib/tenancy/context.ts | 26 +- apps/web/src/lib/tenant-scope.ts | 22 +- apps/web/src/middleware.ts | 7 + apps/web/src/types/next-auth.d.ts | 21 +- docs/RUNBOOK.md | 77 ++ .../decisions/ADR-0020-the-rollout-preview.md | 186 +++ docs/decisions/README.md | 3 +- 33 files changed, 4632 insertions(+), 9 deletions(-) create mode 100644 apps/web/e2e/preview-disabled.spec.ts create mode 100644 apps/web/e2e/preview.spec.ts create mode 100755 apps/web/scripts/preview-negative-controls.sh create mode 100644 apps/web/scripts/preview-personas.mjs create mode 100644 apps/web/scripts/seed-preview-world.mjs create mode 100644 apps/web/scripts/verify-preview-audit.mjs create mode 100644 apps/web/src/app/preview/actions.ts create mode 100644 apps/web/src/app/preview/page.tsx create mode 100644 apps/web/src/components/preview/PreviewBadge.tsx create mode 100644 apps/web/src/lib/preview/access.test.ts create mode 100644 apps/web/src/lib/preview/access.ts create mode 100644 apps/web/src/lib/preview/allowlist.test.ts create mode 100644 apps/web/src/lib/preview/allowlist.ts create mode 100644 apps/web/src/lib/preview/attribution.ts create mode 100644 apps/web/src/lib/preview/audit-attribution.test.ts create mode 100644 apps/web/src/lib/preview/audit-attribution.ts create mode 100644 apps/web/src/lib/preview/gate-order.test.ts create mode 100644 apps/web/src/lib/preview/personas.test.ts create mode 100644 apps/web/src/lib/preview/personas.ts create mode 100644 apps/web/src/lib/preview/subject.test.ts create mode 100644 apps/web/src/lib/preview/subject.ts create mode 100644 docs/decisions/ADR-0020-the-rollout-preview.md diff --git a/apps/web/.env.example b/apps/web/.env.example index 6dea7c1d..983d2ef2 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -117,3 +117,22 @@ EDGE_HOST_SECRET= # Skip the migration bootstrap entirely (scripts/db-bootstrap.mjs). # SKIP_DB_BOOTSTRAP=true + +# ── The rollout preview ─────────────────────────────────────────────────────── + +# Addresses that may walk the product as any role, to see what the pilot's users +# will see. Comma-separated, and OFF in every deployment. +# +# Unset, empty and whitespace are the same answer: the feature does not exist. +# /preview 404s for everybody, the eligibility branch is unreachable, and no +# session carries a preview field — a deployment that never sets this is +# indistinguishable from one where none of it was built. That is why it is a +# deployment decision and not a database row; see ADR-0019. +# +# The address does NOT need to be on the restricted roster, and is deliberately +# not put on it: it is admitted through a separate door that never reads the +# registry and never writes to it. Every admission logs loudly. +# +# Build the world it previews with: +# MASTER_ACCESS_EMAILS=… node scripts/seed-preview-world.mjs +# MASTER_ACCESS_EMAILS= diff --git a/apps/web/e2e/preview-disabled.spec.ts b/apps/web/e2e/preview-disabled.spec.ts new file mode 100644 index 00000000..32ea39f7 --- /dev/null +++ b/apps/web/e2e/preview-disabled.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test" + +/** + * The deployment where MASTER_ACCESS_EMAILS is not set. + * + * This is the pilot's configuration, and the requirement is stronger than "the + * preview is off": it must be indistinguishable from a build where none of this + * was written. The unit tests prove the branch is unreachable; this proves the + * SERVER agrees, which is the only version of the claim that survives somebody + * typing the URL. + * + * Run against a server started WITHOUT the variable: + * + * PREVIEW_EXPECT_DISABLED=1 npx playwright test e2e/preview-disabled.spec.ts + */ + +test.skip( + process.env.PREVIEW_EXPECT_DISABLED !== "1", + "Only meaningful against a server started with MASTER_ACCESS_EMAILS unset.", +) + +const PREVIEW_ADDRESS = "satvik@tenurework.com" + +test.describe("with the allowlist unset", () => { + test("the chooser route does not exist for anybody", async ({ page }) => { + // Signed in as the real tenant's Director — the widest-privileged account + // there is. Even they get a 404, because the route is not gated on a role; + // it is gated on a deployment decision that was not taken. + await page.context().clearCookies() + await page.goto("/signin") + const form = page.getByRole("region", { name: "Pilot access" }) + await form.getByLabel("Email address").fill("director@tenure.demo") + const passphrase = process.env.DEV_LOGIN_PASSPHRASE + if (passphrase) await form.getByLabel("Access passphrase").fill(passphrase) + await form.getByRole("button", { name: "Sign in" }).click() + await page.waitForURL(/\/dashboard/) + + const response = await page.goto("/preview") + expect(response?.status()).toBe(404) + }) + + test("the preview address has no standing of its own", async ({ page }) => { + await page.context().clearCookies() + await page.goto("/signin") + const form = page.getByRole("region", { name: "Pilot access" }) + await form.getByLabel("Email address").fill(PREVIEW_ADDRESS) + const passphrase = process.env.DEV_LOGIN_PASSPHRASE + if (passphrase) await form.getByLabel("Access passphrase").fill(passphrase) + await form.getByRole("button", { name: "Sign in" }).click() + + // It is an ordinary account holding no membership and no seat, so the + // entitlement gate in `(app)/layout.tsx` sends it where it sends anybody + // else in that state. No chooser, no persona, no badge. + await page.waitForURL(/\/access-pending|\/signin/) + expect(page.url()).not.toContain("/preview") + expect(page.url()).not.toContain("/dashboard") + await page.setViewportSize({ width: 1280, height: 800 }) + await page.screenshot({ path: "test-results/preview-18-disabled-no-standing.png" }) + }) +}) diff --git a/apps/web/e2e/preview.spec.ts b/apps/web/e2e/preview.spec.ts new file mode 100644 index 00000000..aa82d344 --- /dev/null +++ b/apps/web/e2e/preview.spec.ts @@ -0,0 +1,293 @@ +import { expect, test, type Page } from "@playwright/test" + +/** + * The rollout preview, walked the way a person walks it. + * + * ── What these assert that a unit test cannot ─────────────────────────────── + * + * That the surfaces are not blank. Every other check in this feature proves a + * rule; this one proves there is something to look at, which is the actual + * requirement — "no way of seeing what users will see once rolled out" is not + * answered by an empty dashboard rendered through a correct authorization path. + * + * ── Why it signs in through the form ──────────────────────────────────────── + * + * Because the preview's eligibility exception lives in `gateOnEligibility`, + * which every provider calls, and the point is that the door is the shared gate + * rather than a provider-specific hole. Whichever provider the run has, the + * same gate decides. + */ + +const PREVIEW_EMAIL = process.env.MASTER_ACCESS_EMAILS?.split(",")[0]?.trim() ?? "" + +test.skip( + !PREVIEW_EMAIL, + "MASTER_ACCESS_EMAILS is not set for this run, so the preview does not exist — which is the intended state for the pilot.", +) + +async function signInAsPreviewAccount(page: Page) { + await page.context().clearCookies() + await page.goto("/signin") + const form = page.getByRole("region", { name: "Pilot access" }) + await expect( + form, + "The server under test is not offering the pilot sign-in form (AUTH_DEV_LOGIN is off)", + ).toBeVisible() + await form.getByLabel("Email address").fill(PREVIEW_EMAIL) + const passphrase = process.env.DEV_LOGIN_PASSPHRASE + if (passphrase) await form.getByLabel("Access passphrase").fill(passphrase) + await form.getByRole("button", { name: "Sign in" }).click() +} + +/** + * The landing path each role's WORKSPACE begins at, per ADR-0019. + * + * Restated here rather than imported because a Playwright spec asserting the + * product's routing should not read the routing table it is checking — that + * would pass whatever the table said. These are the three `landingPath` values + * from `lib/workspaces.ts`, mapped to the roles that hold each workspace. + */ +const LANDS_ON: Record = { + "OSE Director": /\/admin/, + "OSE Staff": /\/admin/, + "OSE Advisor": /\/orgs/, + "Club Advisor": /\/orgs/, + "Club President": /\/dashboard/, + "VP Finance & Operations": /\/dashboard/, + "VP Events & Partnerships": /\/dashboard/, + "VP Marketing & Communications": /\/dashboard/, + "Club Member": /\/dashboard/, +} + +/** Sign in and enter one persona by the label the chooser renders. */ +async function enterPersona(page: Page, label: string) { + await signInAsPreviewAccount(page) + // The chooser, not the dashboard. This is the requirement "sign in → a NEW + // page", and it is the first thing that would break if the layout redirect + // were removed. + await page.waitForURL(/\/preview/) + // Anchored at the start. The Club Advisor's copy explains that it has "the + // same standing as the OSE Advisor", so an unanchored /OSE Advisor/ matches + // two buttons and fails as a strict-mode violation nobody can read. + await page.getByRole("button", { name: new RegExp(`^${label}`) }).click() + + // Through /workspace, which resolves the workspace from the persona's OWN + // rows and forwards. Nothing in the preview decides this, which is why the + // Director arrives at the administration console and the member at the club + // dashboard without either being named anywhere in this feature. + await page.waitForURL(LANDS_ON[label] ?? /\/dashboard/) +} + +test.describe("the rollout preview", () => { + test("sign-in lands on the chooser, not the dashboard", async ({ page }) => { + await signInAsPreviewAccount(page) + await page.waitForURL(/\/preview/) + await expect(page.getByText("Choose whose product you want to look at")).toBeVisible() + + // Every role a real person could hold is offered. + for (const label of [ + "OSE Director", + "OSE Staff", + "OSE Advisor", + "Club President", + "VP Finance & Operations", + "VP Events & Partnerships", + "VP Marketing & Communications", + "Club Advisor", + "Club Member", + ]) { + await expect(page.getByRole("button", { name: new RegExp(`^${label}`) })).toBeVisible() + } + + await page.setViewportSize({ width: 1280, height: 800 }) + await page.screenshot({ path: "test-results/preview-01-chooser.png" }) + }) + + test("every role lands in the workspace its role entitles it to", async ({ page }) => { + // The adoption of ADR-0019, asserted rather than asserted-about. The preview + // redirects to /workspace and contributes no opinion; `defaultWorkspace()` + // reads each persona's memberships and seats and decides. If this feature + // had forked the workspace table, this test would be checking the fork + // against itself — it is checking the product's own routing. + for (const [label, landing] of Object.entries(LANDS_ON)) { + await enterPersona(page, label) + expect(page.url()).toMatch(landing) + } + }) + + test("the President sees a club with a roster, money and a queue", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + await enterPersona(page, "Club President") + + // The badge is the only thing on screen that says this is not production. + await expect(page.getByText("Previewing as")).toBeVisible() + await page.screenshot({ path: "test-results/preview-02-president-dashboard.png" }) + + // Two clubs, because the President holds a seat in one and membership in + // the other — which is what makes club switching visible. + await page.goto("/orgs") + await expect(page.getByText("Preview Consulting Group")).toBeVisible() + await expect(page.getByText("Preview Analytics Society")).toBeVisible() + await page.screenshot({ path: "test-results/preview-03-president-orgs.png" }) + + // People is not blank. + await page.goto("/orgs/preview-consulting-group/members") + await expect(page.getByText("Amara Osei").first()).toBeVisible() + await expect(page.getByText("Daniel Okafor").first()).toBeVisible() + await page.screenshot({ path: "test-results/preview-04-president-people.png" }) + + // The ledger is real: a figure with transactions underneath it. + await page.goto("/orgs/preview-consulting-group/finance") + await expect(page.getByText("Catering & Food").first()).toBeVisible() + await page.screenshot({ path: "test-results/preview-05-president-finance.png" }) + + // The vault holds the thing it exists for. + await page.goto("/orgs/preview-consulting-group/memory") + await expect( + page.getByText("Do not book the ballroom before the sponsor money is confirmed"), + ).toBeVisible() + await page.screenshot({ path: "test-results/preview-06-president-vault.png" }) + }) + + test("the OSE Director sees the console; the Staff persona sees less of it", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + + await enterPersona(page, "OSE Director") + await page.goto("/admin") + await expect(page.getByRole("heading", { name: /Administration|Admin/i }).first()).toBeVisible() + await page.screenshot({ path: "test-results/preview-07-director-admin.png" }) + + // The capability that is Director-only. `capabilities.ts` puts club.create + // at OSE_DIRECTOR and directory.manage at OSE_STAFF, so the console offers + // the Director both and the Staff persona only the second. + const directorSeesCharter = await page.getByText(/Charter clubs/i).count() + + await enterPersona(page, "OSE Staff") + await page.goto("/admin") + await page.screenshot({ path: "test-results/preview-08-staff-admin.png" }) + const staffSeesCharter = await page.getByText(/Charter clubs/i).count() + + // The whole value of the preview in one assertion: the difference on screen + // between two roles is produced by the real capability table, not by + // anything this feature added. + expect(directorSeesCharter).toBeGreaterThan(0) + expect(staffSeesCharter).toBe(0) + }) + + test("the SERVER refuses the Staff persona what only the Director may see", async ({ page }) => { + // The test above asserts the console does not OFFER the control. This one + // asserts the server does not SERVE it, which is the difference between a + // hidden button and an authorization decision — and the only one of the two + // that would still hold for somebody typing the URL. + // + // `/admin/metering` is guarded on `billing.viewMeter`, which + // `capabilities.ts` places at OSE_DIRECTOR and documents at length as + // Director-only for a commercial reason. Nothing about the preview is + // involved in that decision: it runs `hasCapability` against the persona's + // own membership row and calls `notFound()`. + await enterPersona(page, "OSE Director") + const asDirector = await page.goto("/admin/metering") + expect(asDirector?.status()).toBe(200) + await page.setViewportSize({ width: 1280, height: 800 }) + await page.screenshot({ path: "test-results/preview-16-director-metering.png" }) + + await enterPersona(page, "OSE Staff") + const asStaff = await page.goto("/admin/metering") + expect(asStaff?.status()).toBe(404) + await page.screenshot({ path: "test-results/preview-17-staff-metering-refused.png" }) + }) + + test("switching role changes the product, and the chooser says so", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + await enterPersona(page, "VP Finance & Operations") + await page.goto("/orgs/preview-consulting-group/finance") + await expect(page.getByText("Catering & Food").first()).toBeVisible() + await page.screenshot({ path: "test-results/preview-09-vpfinance-finance.png" }) + + // Back to the chooser, which remembers where you are. + await page.getByRole("link", { name: "Switch role" }).click() + await page.waitForURL(/\/preview/) + await expect(page.getByText(/You are currently previewing as/)).toBeVisible() + await page.screenshot({ path: "test-results/preview-10-chooser-while-assuming.png" }) + }) + + test("the preview's world and the real tenant's cannot see each other", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + + // ── Preview → real ────────────────────────────────────────────────────── + await enterPersona(page, "OSE Director") + // The OSE Director is the widest possible reader: `canListAllOrgs` is any + // OSE membership, so if any tenant's clubs were going to leak into another, + // this is the persona that would see them. + await page.goto("/orgs") + await expect(page.getByText("Preview Consulting Group")).toBeVisible() + for (const realClub of ["Simon Consulting Club", "Simon Women in Business"]) { + await expect(page.getByText(realClub)).toHaveCount(0) + } + await page.screenshot({ path: "test-results/preview-13-director-sees-only-preview.png" }) + + // ── Real → preview ────────────────────────────────────────────────────── + // The real tenant's Director, signed in the ordinary way, must not see a + // single preview row. + await page.context().clearCookies() + await page.goto("/signin") + const form = page.getByRole("region", { name: "Pilot access" }) + await form.getByLabel("Email address").fill("director@tenure.demo") + const passphrase = process.env.DEV_LOGIN_PASSPHRASE + if (passphrase) await form.getByLabel("Access passphrase").fill(passphrase) + await form.getByRole("button", { name: "Sign in" }).click() + // /admin, not /dashboard: the real Director goes through the same + // /workspace front door and lands in the administration workspace, exactly + // as the Director PERSONA does. That the two agree is the point. + await page.waitForURL(/\/admin/) + + // No preview badge: this is not a preview session, and `session.preview` is + // undefined for an address that is not on the allowlist. + await expect(page.getByText("Previewing as")).toHaveCount(0) + + await page.goto("/orgs") + await expect(page.getByText("Simon Consulting Club")).toBeVisible() + for (const previewClub of ["Preview Consulting Group", "Preview Analytics Society"]) { + await expect(page.getByText(previewClub)).toHaveCount(0) + } + await page.screenshot({ path: "test-results/preview-14-real-director-sees-no-preview.png" }) + + // And the chooser is not theirs to open. + const response = await page.goto("/preview") + expect(response?.status()).toBe(404) + }) + + test("a write made while assuming is attributed to both identities", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + await enterPersona(page, "Club President") + + // A real write, through the real server action, on a real surface. The + // audit row it produces is what `scripts/verify-preview-audit.mjs` then + // asserts carries BOTH identities — the persona as the actor, and the human + // behind it on the metadata. + await page.goto("/orgs/preview-consulting-group/memory") + const title = `Preview attribution check ${Date.now()}` + await page.getByPlaceholder("Catering contact for spring gala").fill(title) + await page.getByPlaceholder("The details your successor will thank you for.").fill( + "Written by the preview, to prove the audit row names the human who wrote it.", + ) + await page.getByRole("button", { name: "Save card" }).click() + await expect(page.getByText(title)).toBeVisible() + await page.screenshot({ path: "test-results/preview-15-write-while-assuming.png" }) + }) + + test("a member sees their club and none of the board's controls", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + await enterPersona(page, "Club Member") + await page.screenshot({ path: "test-results/preview-11-member-dashboard.png" }) + + // Read: yes. The club is theirs and they can see it. + await page.goto("/orgs/preview-consulting-group/members") + await expect(page.getByText("Amara Osei").first()).toBeVisible() + + // Write: no. `canManageRoster` is PRESIDENT-or-Director, and a member is + // neither — so the roster controls are simply not on the page. + await expect(page.getByRole("button", { name: /Assign|Remove/i })).toHaveCount(0) + await page.screenshot({ path: "test-results/preview-12-member-people.png" }) + }) +}) diff --git a/apps/web/scripts/preview-negative-controls.sh b/apps/web/scripts/preview-negative-controls.sh new file mode 100755 index 00000000..b4b5d31e --- /dev/null +++ b/apps/web/scripts/preview-negative-controls.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# +# Negative controls for the rollout preview. +# +# apps/web/scripts/preview-negative-controls.sh +# +# ── Why this file exists ──────────────────────────────────────────────────── +# +# A passing test proves the code does what the test says. It does not prove the +# test would notice if the code stopped. Every check below is therefore run +# twice: once against a deliberately broken build of the thing it guards, where +# it MUST fail, and once against the restored source, where it must pass. +# +# A control that stays green through its own break is not a control, and this +# script's whole output is the evidence that none of them do. +# +# It edits tracked source files and restores them with `git checkout --`. That +# is destructive by design, so it refuses to run with a dirty tree: an +# uncommitted change in one of these files would be destroyed by the restore. +set -uo pipefail + +cd "$(dirname "$0")/.." || exit 1 +ROOT="$(pwd)" + +export DATABASE_URL="${DATABASE_URL:-postgresql://account_clawteam1@localhost:5432/tenure_preview?schema=public}" + +PASS=0 +FAIL=0 + +say() { printf '\n\033[1m%s\033[0m\n' "$*"; } +ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; PASS=$((PASS+1)); } +bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; FAIL=$((FAIL+1)); } + +# ── The dirty-tree refusal ────────────────────────────────────────────────── +if [ -n "$(git status --porcelain -- . 2>/dev/null)" ]; then + echo "Refusing to run: apps/web has uncommitted changes." + echo "This script breaks tracked files on purpose and restores them with" + echo "\`git checkout --\`, which would destroy that work. Commit first." + exit 1 +fi + +# Restore everything on any exit path, including a Ctrl-C halfway through a +# break. Leaving a sabotaged source file behind is the worst outcome this script +# could have. +restore_all() { git checkout -- "$ROOT/src" "$ROOT/scripts" 2>/dev/null || true; } +trap restore_all EXIT INT TERM + +# ── Helpers ───────────────────────────────────────────────────────────────── + +# Assert a command fails. Used against a broken build. +expect_red() { + local label="$1"; shift + if "$@" >/tmp/nc-out.log 2>&1; then + bad "RED expected but the control PASSED over a broken build — $label" + echo " (the control does not actually test what it claims)" + else + ok "RED as expected — $label" + fi +} + +# Assert a command succeeds. Used against restored source. +expect_green() { + local label="$1"; shift + if "$@" >/tmp/nc-out.log 2>&1; then + ok "GREEN as expected — $label" + else + bad "GREEN expected but the control FAILED on restored source — $label" + tail -20 /tmp/nc-out.log | sed 's/^/ /' + fi +} + +# Replace a literal string in a tracked file, asserting the anchor matched. +# A scripted edit that silently matches nothing is a no-op that reads as a +# successful break, which would make the following RED check a false green. +break_file() { + local file="$1" from="$2" to="$3" + python3 - "$file" "$from" "$to" <<'PY' +import io, sys +path, frm, to = sys.argv[1], sys.argv[2], sys.argv[3] +s = io.open(path, encoding="utf-8").read() +if frm not in s: + sys.stderr.write(f"ANCHOR NOT FOUND in {path}: {frm[:70]!r}\n") + raise SystemExit(2) +io.open(path, "w", encoding="utf-8").write(s.replace(frm, to, 1)) +PY + local status=$? + if [ $status -ne 0 ]; then + bad "could not break $file — the anchor did not match, so the next check would be meaningless" + return 1 + fi + return 0 +} + +jest_preview() { npx jest src/lib/preview --silent; } + +# ═══════════════════════════════════════════════════════════════════════════ +say "CONTROL 1 — the feature does not exist unless MASTER_ACCESS_EMAILS is set" +# Break: make the allowlist treat a blank value as configured, which is the +# specific mistake — an entry that normalises to "" and then matches. +if break_file src/lib/preview/allowlist.ts \ + ' .filter((entry) => entry.length > 0 && entry.includes("@")),' \ + ' .filter(() => true),'; then + expect_red "whitespace/empty parses to a configured allowlist" jest_preview +fi +restore_all +expect_green "unset, empty and whitespace all mean disabled" jest_preview + +# Break: make previewAccessEnabled ignore the variable entirely. +if break_file src/lib/preview/allowlist.ts \ + ' return previewAccessEmails(env).size > 0' \ + ' return true'; then + expect_red "the feature reports itself enabled with no allowlist" jest_preview +fi +restore_all + +# ═══════════════════════════════════════════════════════════════════════════ +say "CONTROL 2 — a different address at the same domain is refused" +# Break: match on the DOMAIN rather than the address, which is the §3.2 mistake +# — treating a shared domain as proof of standing. +if break_file src/lib/preview/allowlist.ts \ + ' return previewAccessEmails(env).has(normalized)' \ + ' const domains = new Set([...previewAccessEmails(env)].map((e) => e.split("@")[1])) + return domains.has(normalized.split("@")[1])'; then + expect_red "any address at an allowlisted domain is admitted" jest_preview +fi +restore_all +expect_green "only the exact allowlisted address is admitted" jest_preview + +# ═══════════════════════════════════════════════════════════════════════════ +say "CONTROL 3 — the roster path is untouched, and is never the preview's path" +# Break: admit the preview account by REUSING the roster's allow. This is the +# dangerous shortcut — it works, the account gets in, and the audit trail then +# claims the workbook admitted somebody it has never heard of. +if break_file src/lib/preview/access.ts \ + ' return { allow: true, reason: "preview-access", email: normalizeEmail(email as string) }' \ + ' return { allow: true, reason: "on the approved roster" as "preview-access", email: normalizeEmail(email as string) }'; then + expect_red "the preview reuses the roster's reason" jest_preview +fi +restore_all + +# Break: consult the roster BEFORE the preview door, so a sealed registry +# refuses the preview account and a roster row becomes the only way in. +if break_file src/lib/auth/restricted-registry.ts \ + ' const preview = decidePreviewAccess(user.email) + if (preview) {' \ + ' const decisionFirst = await isEligible(user.email) + void decisionFirst + const preview = decidePreviewAccess(user.email) + if (preview) {'; then + expect_red "the roster is consulted for the preview address" npx jest src/lib/preview/gate-order --silent +fi +restore_all +expect_green "the roster is never consulted for the preview address" npx jest src/lib/preview/gate-order --silent + +# ═══════════════════════════════════════════════════════════════════════════ +say "CONTROL 4 — a persona is refused unless its rows are really there" +# Break: stop checking that the persona holds nothing outside the preview. +# Nothing writes such a row today, which is exactly why the check is cheap and +# why losing it would be invisible. +# The first version of this control ran `tsc --noEmit` against the break, which +# of course still compiled — so it reported PASSED over a sabotaged build and +# said so. That failure is the reason this file exists: there was no test +# covering the refusal at all, and the control was measuring the wrong thing. +if break_file src/lib/preview/subject.ts \ + ' if (outside > 0) {' \ + ' if (false && outside > 0) {'; then + expect_red "a persona with standing in a real tenant is still admitted" \ + npx jest src/lib/preview/subject --silent +fi +restore_all + +if break_file src/lib/preview/subject.ts \ + ' if (inside === 0) {' \ + ' if (false && inside === 0) {'; then + expect_red "a bare User row is accepted as a persona" \ + npx jest src/lib/preview/subject --silent +fi +restore_all + +# A cookie value off the wire reaching the database. +if break_file src/lib/preview/personas.ts \ + ' return ( + typeof value === "string" && (PREVIEW_PERSONA_ORDER as readonly string[]).includes(value) + )' \ + ' return typeof value === "string" && value in PREVIEW_PERSONAS'; then + expect_red "\"__proto__\" names a persona" jest_preview +fi +restore_all +expect_green "every persona refusal holds" npx jest src/lib/preview/subject --silent + +# ═══════════════════════════════════════════════════════════════════════════ +say "CONTROL 5 — the preview world is populated and isolated" +expect_green "seeded world verifies" node scripts/seed-preview-world.mjs --verify + +# Break the DATA rather than the code: give a preview persona a seat in the real +# tenant, which is the leak the check exists to find. +# psql rejects Prisma's `?schema=` query parameter outright — "invalid URI +# query parameter". The first version of this control piped that error to +# /dev/null, so the INSERT never ran, the verifier correctly found nothing +# wrong, and the control reported PASSED over a break that had not happened. +# A scripted edit that silently matches nothing is a no-op that reads as +# success, and that is as true of SQL as it is of sed. +PSQL_URL="${DATABASE_URL%%\?*}" + +leak() { + psql -v ON_ERROR_STOP=1 -q "$PSQL_URL" -c " + insert into \"InstitutionMembership\" (id, \"userId\", \"institutionId\", role, \"createdAt\", \"updatedAt\") + select 'nc-leak-row', u.id, i.id, 'OSE_STAFF', now(), now() + from \"User\" u, \"Institution\" i + where u.email = 'preview.director@tenure.invalid' and i.slug = 'simon-ose';" +} +unleak() { + psql -v ON_ERROR_STOP=1 -q "$PSQL_URL" \ + -c "delete from \"InstitutionMembership\" where id = 'nc-leak-row';" +} + +if ! leak; then + bad "could not insert the leak row — the next check would be meaningless" +else + # Assert the break really landed, rather than trusting the exit code. + planted=$(psql -tA "$PSQL_URL" -c "select count(*) from \"InstitutionMembership\" where id='nc-leak-row';") + if [ "$planted" != "1" ]; then + bad "the leak row is not present after inserting it (found $planted)" + else + expect_red "a persona holding a seat in the real tenant" node scripts/seed-preview-world.mjs --verify + fi + unleak >/dev/null 2>&1 +fi +expect_green "isolation restored" node scripts/seed-preview-world.mjs --verify + +# ═══════════════════════════════════════════════════════════════════════════ +say "CONTROL 6 — an audit row names both the real actor and the assumed role" +expect_green "attribution present on an ordinary write" node scripts/verify-preview-audit.mjs + +# Break: let the caller's metadata win the merge, so a writer could erase the +# attribution. +# Two earlier versions of this control failed to break anything, both the same +# way: they INSERTED a `preview` key while leaving the real one further down the +# literal, and in an object literal the last key wins. The break that actually +# inverts the rule is to stop writing the real key at all, so the caller's +# `preview` — the forged one the test supplies — is what survives the spread. +if break_file src/lib/preview/audit-attribution.ts \ + ' preview: { + realActorId: onBehalfOf.realPrincipalId,' \ + ' previewDISABLED: { + realActorId: onBehalfOf.realPrincipalId,'; then + expect_red "attribution can be overwritten by the caller" npx jest src/lib/preview/audit-attribution --silent +fi +restore_all +expect_green "attribution cannot be forged" npx jest src/lib/preview/audit-attribution --silent + +# ═══════════════════════════════════════════════════════════════════════════ +say "CONTROL 7 — the catalog and the seed cannot drift" +if break_file scripts/preview-personas.mjs \ + ' email: "preview.vpfinance@tenure.invalid",' \ + ' email: "preview.vp-finance@tenure.invalid",'; then + expect_red "an address in the catalog that the seed never creates" jest_preview +fi +restore_all +expect_green "catalog and seed agree" jest_preview + +# ═══════════════════════════════════════════════════════════════════════════ +say "RESULT" +printf ' %d passed, %d failed\n\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/apps/web/scripts/preview-personas.mjs b/apps/web/scripts/preview-personas.mjs new file mode 100644 index 00000000..4748b88d --- /dev/null +++ b/apps/web/scripts/preview-personas.mjs @@ -0,0 +1,143 @@ +/** + * The structural half of the preview persona catalog, for the ESM scripts. + * + * ── Why this file exists and is not the whole catalog ─────────────────────── + * + * `scripts/*.mjs` ship as ESM into the runtime image and cannot import + * `src/lib/preview/personas.ts`. That is the same constraint `scripts/term.mjs` + * documents, and this follows the same rule it sets: the duplication is + * STRUCTURAL ONLY — keys, addresses, institution roles, seats, club slugs — and + * it is checked against the TypeScript module by + * `src/lib/preview/personas-match-the-seed.test.ts`, which fails loudly when the + * two drift. + * + * The user-facing copy — what each role is, who holds it, what they can do — + * lives only in the TypeScript module, because nothing here renders. + * + * The consequence of drift is worth naming, because it is silent otherwise: an + * address in one file and not the other produces a persona the chooser offers + * and the seed never creates, which `resolvePreviewSubject` then refuses at the + * moment somebody clicks it. The test is what turns that into a red build. + */ + +export const PREVIEW_INSTITUTION_ID = "tenure-rollout-preview" +export const PREVIEW_INSTITUTION_SLUG = "tenure-rollout-preview" +export const PREVIEW_INSTITUTION_NAME = "Tenure Rollout Preview" + +export const PREVIEW_CLUB_A_SLUG = "preview-consulting-group" +export const PREVIEW_CLUB_B_SLUG = "preview-analytics-society" + +/** + * The seats every preview club carries. + * + * This is `STARTER_SEATS` from `src/lib/clubs.ts` — the list `createClub` + * actually writes — in the order it writes them. A near-miss on a name is not + * cosmetic: `isFinanceRole` is a regular expression over the seat NAME, so + * "VP Finance" instead of "VP Finance & Operations" still matches, while + * "Finance VP" would change who may edit a budget in the preview and nowhere + * else. + */ +export const PREVIEW_SEATS = [ + { name: "President", scope: "PRESIDENT", code: "PRES" }, + { name: "VP Finance & Operations", scope: "FUNCTIONAL", code: "VPFO" }, + { name: "VP Marketing & Communications", scope: "FUNCTIONAL", code: "VPMC" }, + { name: "VP Events & Partnerships", scope: "FUNCTIONAL", code: "VPEP" }, + { name: "Member", scope: "MEMBER", code: "MEMB" }, +] + +/** Structural facts only. The copy lives in src/lib/preview/personas.ts. */ +export const PREVIEW_PERSONAS = [ + { + key: "ose-director", + email: "preview.director@tenure.invalid", + holder: "Renee Baptiste", + institutionRole: "OSE_DIRECTOR", + seats: [], + advisesOrgSlugs: [], + }, + { + key: "ose-staff", + email: "preview.staff@tenure.invalid", + holder: "Marcus Reyes", + institutionRole: "OSE_STAFF", + seats: [], + advisesOrgSlugs: [], + }, + { + key: "ose-advisor", + email: "preview.oseadvisor@tenure.invalid", + holder: "Dr. Ellen Cho", + institutionRole: "OSE_ADVISOR", + seats: [], + advisesOrgSlugs: [], + }, + { + key: "club-president", + email: "preview.president@tenure.invalid", + holder: "Amara Osei", + institutionRole: null, + seats: [ + { orgSlug: PREVIEW_CLUB_A_SLUG, seatName: "President" }, + { orgSlug: PREVIEW_CLUB_B_SLUG, seatName: "Member" }, + ], + advisesOrgSlugs: [], + }, + { + key: "club-vp-finance", + email: "preview.vpfinance@tenure.invalid", + holder: "Daniel Okafor", + institutionRole: null, + seats: [{ orgSlug: PREVIEW_CLUB_A_SLUG, seatName: "VP Finance & Operations" }], + advisesOrgSlugs: [], + }, + { + key: "club-vp-events", + email: "preview.vpevents@tenure.invalid", + holder: "Sofia Marchetti", + institutionRole: null, + seats: [{ orgSlug: PREVIEW_CLUB_A_SLUG, seatName: "VP Events & Partnerships" }], + advisesOrgSlugs: [], + }, + { + key: "club-vp-marketing", + email: "preview.vpmarketing@tenure.invalid", + holder: "Jonah Feldman", + institutionRole: null, + seats: [{ orgSlug: PREVIEW_CLUB_A_SLUG, seatName: "VP Marketing & Communications" }], + advisesOrgSlugs: [], + }, + { + key: "club-advisor", + email: "preview.clubadvisor@tenure.invalid", + holder: "Prof. Alan Beckett", + institutionRole: "OSE_ADVISOR", + seats: [], + advisesOrgSlugs: [PREVIEW_CLUB_A_SLUG], + }, + { + key: "club-member", + email: "preview.member@tenure.invalid", + holder: "Aisha Nnamdi", + institutionRole: null, + seats: [{ orgSlug: PREVIEW_CLUB_A_SLUG, seatName: "Member" }], + advisesOrgSlugs: [], + }, +] + +/** + * The address the preview account signs in with. + * + * Read from `MASTER_ACCESS_EMAILS` so the seed cannot create a login the gate + * will refuse. The parsing is the same shape as + * `src/lib/preview/allowlist.ts` — split, trim, lowercase, require an `@` — + * and the seed refuses rather than guessing when the variable is unset, because + * a preview world with no way in is a silent half-build. + */ +export function masterAccessEmails(env = process.env) { + const raw = env.MASTER_ACCESS_EMAILS + if (typeof raw !== "string") return [] + return raw + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0 && entry.includes("@")) +} diff --git a/apps/web/scripts/seed-preview-world.mjs b/apps/web/scripts/seed-preview-world.mjs new file mode 100644 index 00000000..f1253ada --- /dev/null +++ b/apps/web/scripts/seed-preview-world.mjs @@ -0,0 +1,1035 @@ +/** + * The world the rollout preview walks through. + * + * node scripts/seed-preview-world.mjs # build / rebuild it + * node scripts/seed-preview-world.mjs --verify # assert it is intact and isolated + * + * ── Why this is a whole institution ───────────────────────────────────────── + * + * The preview must not read, write or surface the real Simon cohort's rows, and + * the real product must never surface the preview's. A flag on each row would + * make that a filter every one of several hundred queries has to remember; a + * separate institution makes it a property of the data. + * + * It is not merely convenient — it is enforced by machinery that already + * exists. `resolveTenantScope` derives the acting tenant from the acting USER's + * own memberships and refuses an institution they are not a member of. Every + * preview persona holds memberships and seats in this institution and nowhere + * else (`resolvePreviewSubject` refuses one that does not), and no Simon officer + * holds anything here. So a preview persona cannot open a scope on Simon and a + * Simon officer cannot open one here — not because a filter was applied, but + * because there is no row that would let them. + * + * The bonus the brief predicted is real: this is the second tenant, so the + * pilot's tenancy is now exercised by data somebody looks at every day rather + * than by a CI fixture nobody reads. + * + * ── Why it is not thin ────────────────────────────────────────────────────── + * + * The question being answered is "what will people see once this is rolled + * out". An empty dashboard answers it wrongly rather than not at all — it says + * the product is empty. So every surface a persona can land on has something on + * it: seats with holders and predecessors, a budget whose actuals are the sum + * of a real ledger, approvals stopped at two different gates, a calendar with + * a past and a future, deliverables including one that is late, and a vault + * with the lesson a president leaves their successor. + * + * ── Idempotence ───────────────────────────────────────────────────────────── + * + * The whole institution is deleted and rebuilt on every run, which resets the + * preview world exactly and touches nothing else: every statement below is + * bounded by this institution's id or by rows underneath it. The two models + * that do not cascade from `Institution` — `AuditEvent`, deliberately, and + * `SeatMeterEvent` — are removed explicitly first, so the delete fails loudly + * if a third is ever added rather than silently orphaning it. + */ + +import { PrismaClient } from "@prisma/client" +import { currentTerm, priorTerm } from "./term.mjs" +import { + PREVIEW_INSTITUTION_ID, + PREVIEW_INSTITUTION_NAME, + PREVIEW_INSTITUTION_SLUG, + PREVIEW_CLUB_A_SLUG, + PREVIEW_CLUB_B_SLUG, + PREVIEW_PERSONAS, + PREVIEW_SEATS, + masterAccessEmails, +} from "./preview-personas.mjs" + +// The unextended client, for the reason `ci-two-tenant-fixture.mjs` gives: this +// is control-plane work that creates a tenant, and it must be able to write +// across both. Using the app's client would need an unscoped grant per +// statement and would prove nothing extra. +const db = new PrismaClient({ log: ["error"] }) + +const TERM = currentTerm() +const PRIOR = priorTerm() + +/** One clock reading for the whole run, so nothing drifts mid-seed. */ +const NOW = new Date() +const days = (n) => new Date(NOW.getTime() + n * 86_400_000) + +const CLUB_A = { + slug: PREVIEW_CLUB_A_SLUG, + code: "PCG", + name: "Preview Consulting Group", + shortName: "Consulting Group", + acronym: "PCG", + category: "PROFESSIONAL", + description: + "Case practice, sponsor treks and the fall recruiting push. The fuller of the two preview clubs — this is the one to look at first.", +} + +const CLUB_B = { + slug: PREVIEW_CLUB_B_SLUG, + code: "PAS", + name: "Preview Analytics Society", + shortName: "Analytics Society", + acronym: "PAS", + category: "PROFESSIONAL", + description: + "A second club, so that switching between clubs is something you can see rather than something you have to imagine.", +} + +/** + * Directory people who are not login accounts. + * + * The real seed makes exactly this distinction and gives the reason: seeding a + * roster as `User` rows would make every one of them an account somebody could + * sign in as. The preview keeps the distinction because the People surface + * renders BOTH — `assignments` (accounts) and `holdings` (directory records) — + * and a club whose roster is nine login accounts and nothing else would not + * look like a club at Simon looks. + */ +const DIRECTORY = [ + { name: "Amara Osei", email: "preview.president@tenure.invalid", kind: "STUDENT" }, + { name: "Daniel Okafor", email: "preview.vpfinance@tenure.invalid", kind: "STUDENT" }, + { name: "Sofia Marchetti", email: "preview.vpevents@tenure.invalid", kind: "STUDENT" }, + { name: "Jonah Feldman", email: "preview.vpmarketing@tenure.invalid", kind: "STUDENT" }, + { name: "Aisha Nnamdi", email: "preview.member@tenure.invalid", kind: "STUDENT" }, + { name: "Marcus Liu", email: "preview.mliu@tenure.invalid", kind: "STUDENT" }, + { name: "Hannah Petrov", email: "preview.hpetrov@tenure.invalid", kind: "STUDENT" }, + { name: "Owen Achebe", email: "preview.oachebe@tenure.invalid", kind: "STUDENT" }, + { name: "Ines Duarte", email: "preview.iduarte@tenure.invalid", kind: "STUDENT" }, + { name: "Lena Kowalski", email: "preview.lkowalski@tenure.invalid", kind: "STUDENT" }, + { name: "Tomas Ferreira", email: "preview.tferreira@tenure.invalid", kind: "STUDENT" }, + // Last year's board — "who had this job before me and how do I reach them" is + // the handoff question Tenure exists to answer, so the preview has an answer. + { name: "Rachel Amherst", email: "preview.ramherst@tenure.invalid", kind: "STUDENT" }, + { name: "Devin Park", email: "preview.dpark@tenure.invalid", kind: "STUDENT" }, + { + name: "Prof. Alan Beckett", + email: "preview.clubadvisor@tenure.invalid", + kind: "ADVISOR", + affiliation: "Faculty — Strategy", + }, + { + name: "Dr. Ellen Cho", + email: "preview.oseadvisor@tenure.invalid", + kind: "ADVISOR", + affiliation: "Office of Student Engagement", + }, +] + +/** Extra login accounts, so the Member seat has a roster rather than one row. */ +const EXTRA_MEMBERS = [ + { email: "preview.mliu@tenure.invalid", name: "Marcus Liu", club: PREVIEW_CLUB_A_SLUG, status: "ACTIVE" }, + { email: "preview.hpetrov@tenure.invalid", name: "Hannah Petrov", club: PREVIEW_CLUB_A_SLUG, status: "ACTIVE" }, + { email: "preview.oachebe@tenure.invalid", name: "Owen Achebe", club: PREVIEW_CLUB_A_SLUG, status: "ACTIVE" }, + { email: "preview.iduarte@tenure.invalid", name: "Ines Duarte", club: PREVIEW_CLUB_B_SLUG, status: "ACTIVE" }, + { email: "preview.lkowalski@tenure.invalid", name: "Lena Kowalski", club: PREVIEW_CLUB_B_SLUG, status: "ACTIVE" }, + // An incoming officer, so SHADOW — "read-only preview before the term begins" + // — is a state you can look at rather than a line in the schema. + { + email: "preview.tferreira@tenure.invalid", + name: "Tomas Ferreira", + club: PREVIEW_CLUB_A_SLUG, + seatName: "VP Marketing & Communications", + status: "SHADOW", + startDate: days(120), + }, +] + +// ─── Reset ──────────────────────────────────────────────────────────────────── + +/** + * Remove the preview institution and everything under it, and nothing else. + * + * Every statement is bounded by this institution. The two explicit deletes + * ahead of the cascade are the models whose relation to `Institution` carries + * no `onDelete: Cascade`: + * + * · `AuditEvent` — deliberately, and the schema says why at length: a cascade + * would let deleting a tenant erase its audit trail, which is the one thing + * an append-only log must not allow. Removing the rows as a separate, + * visible act is exactly what the schema comment asks a deleting caller to + * do. + * · `SeatMeterEvent` — the billing meter, for the same reason: it is what an + * institution is invoiced against. + * + * If a third such model is ever added, this delete fails on its foreign key + * rather than quietly leaving orphans, and that is the desired outcome. + */ +async function reset() { + const existing = await db.institution.findUnique({ + where: { id: PREVIEW_INSTITUTION_ID }, + select: { id: true }, + }) + if (!existing) return { removed: false } + + // Notifications hang off User, which is platform-global and therefore + // survives the institution — so a re-run would otherwise accumulate one + // person's notifications for ever. + const personaEmails = PREVIEW_PERSONAS.map((p) => p.email) + const extraEmails = EXTRA_MEMBERS.map((m) => m.email) + const users = await db.user.findMany({ + where: { email: { in: [...personaEmails, ...extraEmails] } }, + select: { id: true }, + }) + if (users.length > 0) { + await db.notification.deleteMany({ where: { userId: { in: users.map((u) => u.id) } } }) + } + + await db.auditEvent.deleteMany({ where: { institutionId: PREVIEW_INSTITUTION_ID } }) + await db.seatMeterEvent.deleteMany({ where: { institutionId: PREVIEW_INSTITUTION_ID } }) + await db.institution.delete({ where: { id: PREVIEW_INSTITUTION_ID } }) + return { removed: true } +} + +// ─── Build ──────────────────────────────────────────────────────────────────── + +async function build() { + const masters = masterAccessEmails() + if (masters.length === 0) { + console.error("❌ MASTER_ACCESS_EMAILS is unset, empty or blank.") + console.error("") + console.error(" The preview world would have no way in: the address that signs in is the") + console.error(" one on that allowlist, and seeding a login the eligibility gate will refuse") + console.error(" is a half-build that looks finished. Set it and run again, e.g.") + console.error("") + console.error(' MASTER_ACCESS_EMAILS=satvik@tenurework.com node scripts/seed-preview-world.mjs') + // Not process.exit: stderr is a pipe under Docker and CI, and exiting in the + // same tick truncates the report the operator is meant to act on. The same + // reasoning scripts/seed.mjs gives for its refusals. + process.exitCode = 1 + return false + } + + const { removed } = await reset() + + const institution = await db.institution.create({ + data: { + id: PREVIEW_INSTITUTION_ID, + name: PREVIEW_INSTITUTION_NAME, + slug: PREVIEW_INSTITUTION_SLUG, + // Deliberately NOT simon.rochester.edu. The domain is used for verified + // email matching, and giving the preview the pilot's domain would be the + // one field capable of introducing them to each other. + domain: "preview.tenure.invalid", + timeZone: "America/New_York", + }, + }) + + // ── The account that signs in ───────────────────────────────────────────── + // + // It holds NO membership and NO seat, and that is not an oversight. A preview + // account with standing of its own would be a second kind of user with a + // second set of entitlements, and every screen it reached would be that + // user's rather than a role's. Everything it can see, it sees through a + // persona — which is why `(app)/layout.tsx` sends it to the chooser. + const masterUsers = [] + for (const email of masters) { + masterUsers.push( + await db.user.upsert({ + where: { email }, + update: { name: "Rollout preview" }, + create: { email, name: "Rollout preview" }, + }), + ) + } + + // ── Clubs ───────────────────────────────────────────────────────────────── + const orgs = {} + for (const club of [CLUB_A, CLUB_B]) { + orgs[club.slug] = await db.organization.create({ + data: { + institutionId: institution.id, + name: club.name, + shortName: club.shortName, + acronym: club.acronym, + slug: club.slug, + description: club.description, + category: club.category, + status: "ACTIVE", + rosterNote: `Preview board, ${TERM}`, + }, + }) + } + + // ── Seats ───────────────────────────────────────────────────────────────── + // + // `positionCode` is globally unique, so the preview prefixes its own. A + // collision with a real Simon seat would be the one place these two tenants + // could touch, and it would fail at insert rather than silently — but only + // because the constraint happens to be global, which ADR-0004 is in the + // middle of changing. The prefix is what makes it safe either way. + const seats = {} + for (const club of [CLUB_A, CLUB_B]) { + seats[club.slug] = {} + for (const [index, seat] of PREVIEW_SEATS.entries()) { + seats[club.slug][seat.name] = await db.role.create({ + data: { + organizationId: orgs[club.slug].id, + institutionId: institution.id, + name: seat.name, + scope: seat.scope, + positionCode: `PVW-${club.code}-${seat.code}`, + seatOrder: index, + }, + }) + } + } + + // ── Directory people ────────────────────────────────────────────────────── + const people = {} + for (const person of DIRECTORY) { + people[person.email] = await db.directoryPerson.create({ + data: { + institutionId: institution.id, + name: person.name, + email: person.email, + kind: person.kind, + affiliation: person.affiliation ?? null, + }, + }) + } + + // ── Persona accounts, memberships and seats ─────────────────────────────── + const personaUsers = {} + for (const persona of PREVIEW_PERSONAS) { + const user = await db.user.upsert({ + where: { email: persona.email }, + update: { name: persona.holder }, + create: { email: persona.email, name: persona.holder }, + }) + personaUsers[persona.key] = user + + if (persona.institutionRole) { + await db.institutionMembership.create({ + data: { userId: user.id, institutionId: institution.id, role: persona.institutionRole }, + }) + } + + for (const seat of persona.seats) { + const role = seats[seat.orgSlug]?.[seat.seatName] + if (!role) throw new Error(`persona ${persona.key} wants a seat that does not exist: ${seat.orgSlug}/${seat.seatName}`) + await db.roleAssignment.create({ + data: { + userId: user.id, + roleId: role.id, + // The assignment's tenant is the SEAT's tenant, derived rather than + // assumed — the correction scripts/seed.mjs documents. + institutionId: role.institutionId, + status: "ACTIVE", + startDate: days(-210), + }, + }) + } + + for (const slug of persona.advisesOrgSlugs) { + await db.organizationAdvisor.create({ + data: { + organizationId: orgs[slug].id, + institutionId: institution.id, + personId: people[persona.email].id, + }, + }) + } + } + + // The OSE advisor advises the institution rather than a club — no + // OrganizationAdvisor row, which is the visible difference between the two + // advisor personas and the reason both are offered. + + // ── Extra members, and one incoming officer ─────────────────────────────── + for (const member of EXTRA_MEMBERS) { + const user = await db.user.upsert({ + where: { email: member.email }, + update: { name: member.name }, + create: { email: member.email, name: member.name }, + }) + const role = seats[member.club][member.seatName ?? "Member"] + await db.roleAssignment.create({ + data: { + userId: user.id, + roleId: role.id, + institutionId: role.institutionId, + status: member.status, + startDate: member.startDate ?? days(-180), + }, + }) + } + + // ── Seat holdings: this year's board, and last year's ───────────────────── + const holdings = [ + { club: PREVIEW_CLUB_A_SLUG, seat: "President", email: "preview.president@tenure.invalid", term: TERM, current: true }, + { club: PREVIEW_CLUB_A_SLUG, seat: "VP Finance & Operations", email: "preview.vpfinance@tenure.invalid", term: TERM, current: true }, + { club: PREVIEW_CLUB_A_SLUG, seat: "VP Events & Partnerships", email: "preview.vpevents@tenure.invalid", term: TERM, current: true }, + { club: PREVIEW_CLUB_A_SLUG, seat: "VP Marketing & Communications", email: "preview.vpmarketing@tenure.invalid", term: TERM, current: true }, + { club: PREVIEW_CLUB_A_SLUG, seat: "Member", email: "preview.member@tenure.invalid", term: TERM, current: true }, + // Last year's holders — the people a successor is told to call. + { club: PREVIEW_CLUB_A_SLUG, seat: "President", email: "preview.ramherst@tenure.invalid", term: PRIOR, current: false }, + { club: PREVIEW_CLUB_A_SLUG, seat: "VP Finance & Operations", email: "preview.dpark@tenure.invalid", term: PRIOR, current: false }, + { club: PREVIEW_CLUB_B_SLUG, seat: "President", email: "preview.iduarte@tenure.invalid", term: TERM, current: true }, + { club: PREVIEW_CLUB_B_SLUG, seat: "VP Finance & Operations", email: "preview.lkowalski@tenure.invalid", term: TERM, current: true }, + ] + for (const holding of holdings) { + await db.seatHolding.create({ + data: { + roleId: seats[holding.club][holding.seat].id, + personId: people[holding.email].id, + institutionId: institution.id, + term: holding.term, + isCurrent: holding.current, + }, + }) + } + + const clubA = orgs[PREVIEW_CLUB_A_SLUG] + const clubB = orgs[PREVIEW_CLUB_B_SLUG] + + // ── Vendors ─────────────────────────────────────────────────────────────── + const caterer = await db.vendor.create({ + data: { + institutionId: institution.id, + organizationId: clubA.id, + name: "Genesee Valley Catering", + contactName: "Maria Alvarez", + contactEmail: "orders@gvcatering.invalid", + contactPhone: "+1 585 555 0142", + notes: "Net 30. Needs headcount 5 business days out; will hold a tentative number for 48h.", + }, + }) + const printer = await db.vendor.create({ + data: { + institutionId: institution.id, + organizationId: clubA.id, + name: "Riverside Print & Sign", + contactName: "Kwame Boateng", + contactEmail: "hello@riversideprint.invalid", + notes: "Student-org rate applies if you say so at quote time. They will not apply it retroactively.", + }, + }) + + // ── Budget, lines, and a ledger the actuals are the SUM of ──────────────── + // + // `actualCents` is documented as the cache of Σ amountCents and has one + // writer. Seeding the two independently is how a club comes to show $1,875 + // spent with an empty drill-down, so the lines below are created at zero and + // every figure on screen is the consequence of a posting underneath it. + await db.budget.create({ + data: { + institutionId: institution.id, + organizationId: clubA.id, + period: "FULL_YEAR", + academicYear: TERM, + totalCents: 1_150_000, + allocatedCents: 1_150_000, + notes: "Allocated at the September OSE budget hearing. Travel is provisional on the spring trek being approved.", + }, + }) + + const LINES = [ + { category: "Catering & Food", budgeted: 250_000 }, + { category: "Venue & Space", budgeted: 180_000 }, + { category: "Speaker Honoraria", budgeted: 200_000 }, + { category: "Marketing & Print", budgeted: 90_000 }, + { category: "Travel (Career Treks)", budgeted: 300_000, forecast: 265_000 }, + { category: "Club Swag", budgeted: 80_000 }, + { category: "Software & Tools", budgeted: 50_000 }, + ] + const lines = {} + for (const [index, line] of LINES.entries()) { + lines[line.category] = await db.budgetLine.create({ + data: { + organizationId: clubA.id, + academicYear: TERM, + category: line.category, + budgetedCents: line.budgeted, + actualCents: 0, + forecastCents: line.forecast ?? null, + sortOrder: index, + source: "manual", + }, + }) + } + + const LEDGER = [ + { line: "Catering & Food", kind: "SPEND", cents: 128_400, description: "Kickoff mixer — 120 covers", vendor: caterer.id, daysAgo: 47, memo: "Headcount came in 8 over; they honoured the quote." }, + { line: "Catering & Food", kind: "SPEND", cents: 74_250, description: "Case competition lunch", vendor: caterer.id, daysAgo: 21 }, + { line: "Catering & Food", kind: "REIMBURSEMENT", cents: -12_000, description: "Refund — two trays not delivered", vendor: caterer.id, daysAgo: 14 }, + { line: "Venue & Space", kind: "SPEND", cents: 90_000, description: "Schlegel ballroom deposit", daysAgo: 52 }, + { line: "Venue & Space", kind: "SPEND", cents: 41_500, description: "AV rental and setup", daysAgo: 20 }, + { line: "Speaker Honoraria", kind: "SPEND", cents: 150_000, description: "Fall speaker series — three sessions", daysAgo: 33 }, + { line: "Marketing & Print", kind: "SPEND", cents: 38_600, description: "Recruiting posters and table banner", vendor: printer.id, daysAgo: 40, memo: "Student-org rate applied." }, + { line: "Marketing & Print", kind: "SPEND", cents: 21_400, description: "Case book printing", vendor: printer.id, daysAgo: 11 }, + { line: "Club Swag", kind: "SPEND", cents: 88_000, description: "Quarter-zips, 60 units", daysAgo: 60, memo: "Over the line by $80. Flagged to the President at the time." }, + { line: "Software & Tools", kind: "SPEND", cents: 50_000, description: "Case-prep platform, annual seat block", daysAgo: 70 }, + ] + for (const [index, entry] of LEDGER.entries()) { + await db.ledgerEntry.create({ + data: { + organizationId: clubA.id, + budgetLineId: lines[entry.line].id, + academicYear: TERM, + kind: entry.kind, + amountCents: entry.cents, + description: entry.description, + memo: entry.memo ?? null, + vendorId: entry.vendor ?? null, + occurredAt: days(-entry.daysAgo), + // Every entry in the product comes through lib/ledger.ts and requires a + // source event key; the seed mints its own in the same shape, unique + // per organization, so a re-run cannot double-post. + sourceEventKey: `preview-seed:${TERM}:${index}`, + }, + }) + } + + // Make the cache true, from the postings rather than alongside them. + for (const category of Object.keys(lines)) { + const agg = await db.ledgerEntry.aggregate({ + where: { budgetLineId: lines[category].id }, + _sum: { amountCents: true }, + }) + await db.budgetLine.update({ + where: { id: lines[category].id }, + data: { actualCents: agg._sum.amountCents ?? 0 }, + }) + } + + // A smaller budget for club B, so the OSE portfolio roll-up has more than one + // club in it and the two clubs are visibly different sizes. + for (const [index, [category, budgeted, actual]] of [ + ["Events & Programming", 140_000, 96_500], + ["Workshops & Data Sets", 80_000, 44_000], + ["Operations", 40_000, 12_750], + ].entries()) { + const line = await db.budgetLine.create({ + data: { + organizationId: clubB.id, + academicYear: TERM, + category, + budgetedCents: budgeted, + actualCents: actual, + sortOrder: index, + source: "manual", + }, + }) + await db.ledgerEntry.create({ + data: { + organizationId: clubB.id, + budgetLineId: line.id, + academicYear: TERM, + kind: "ADJUSTMENT", + amountCents: actual, + description: "Opening balance", + memo: "Recorded with the budget, before any transaction was posted.", + occurredAt: days(-90), + sourceEventKey: `preview-seed:${TERM}:b:${index}`, + }, + }) + } + + // ── Approvals, stopped at two different gates ───────────────────────────── + // + // One waiting on the President and one waiting on OSE, so that neither + // queue is empty whichever persona you pick — the specific thing the brief + // asked for, and the specific thing an empty preview gets wrong. + const president = personaUsers["club-president"] + const vpEvents = personaUsers["club-vp-events"] + const vpFinance = personaUsers["club-vp-finance"] + const director = personaUsers["ose-director"] + + const approvals = [ + { + type: "EVENT", + title: "Spring career trek — New York, 14–16 March", + description: + "Two days of firm visits with 18 students. Asking for the travel line plus a partial subsidy on lodging. Firms confirmed: three of five.", + status: "PENDING_PRESIDENT", + submittedBy: vpEvents.id, + steps: [{ from: "DRAFT", to: "PENDING_PRESIDENT", actor: vpEvents.id, role: "VP Events & Partnerships", daysAgo: 3 }], + }, + { + type: "BUDGET", + title: "Reallocate $1,200 from Swag to Speaker Honoraria", + description: + "Swag came in under after the second quote. The fourth speaker in the series wants an honorarium we did not budget for.", + status: "PENDING_OSE", + submittedBy: vpFinance.id, + steps: [ + { from: "DRAFT", to: "PENDING_PRESIDENT", actor: vpFinance.id, role: "VP Finance & Operations", daysAgo: 9 }, + { from: "PENDING_PRESIDENT", to: "PENDING_OSE", actor: president.id, role: "President", daysAgo: 6, reason: "Agreed — the speaker is worth more to us than the quarter-zips." }, + ], + }, + { + type: "VENDOR", + title: "Engage Genesee Valley Catering for the spring banquet", + description: "Same vendor as the kickoff. Net 30, quote attached, headcount to be confirmed five days out.", + status: "APPROVED", + submittedBy: vpEvents.id, + steps: [ + { from: "DRAFT", to: "PENDING_PRESIDENT", actor: vpEvents.id, role: "VP Events & Partnerships", daysAgo: 30 }, + { from: "PENDING_PRESIDENT", to: "PENDING_OSE", actor: president.id, role: "President", daysAgo: 27 }, + { from: "PENDING_OSE", to: "APPROVED", actor: director.id, role: "OSE_DIRECTOR", daysAgo: 24, reason: "Approved. Keep the receipts for the tray shortfall." }, + ], + }, + { + type: "COMMUNICATION", + title: "All-school email announcing the case competition", + description: "Draft copy for the school-wide send, plus the graphic.", + status: "NEEDS_CHANGES", + submittedBy: personaUsers["club-vp-marketing"].id, + steps: [ + { from: "DRAFT", to: "PENDING_PRESIDENT", actor: personaUsers["club-vp-marketing"].id, role: "VP Marketing & Communications", daysAgo: 5 }, + { from: "PENDING_PRESIDENT", to: "NEEDS_CHANGES", actor: president.id, role: "President", daysAgo: 2, reason: "Date is wrong in the second paragraph, and we need the sponsor's logo on it before this goes school-wide." }, + ], + }, + ] + + const createdApprovals = [] + for (const approval of approvals) { + const row = await db.approvalRequest.create({ + data: { + institutionId: institution.id, + organizationId: clubA.id, + type: approval.type, + title: approval.title, + description: approval.description, + status: approval.status, + submittedById: approval.submittedBy, + }, + }) + createdApprovals.push(row) + for (const step of approval.steps) { + await db.approvalStep.create({ + data: { + approvalId: row.id, + fromStatus: step.from, + toStatus: step.to, + actorId: step.actor, + actorRoleContext: step.role, + reason: step.reason ?? null, + occurredAt: days(-step.daysAgo), + }, + }) + } + } + + // ── Calendar ────────────────────────────────────────────────────────────── + const EVENTS = [ + { club: clubA, title: "Fall kickoff mixer", startDaysAgo: 47, hours: 3, venue: "Schlegel Hall atrium", status: "PUBLISHED", audience: "SCHOOL_WIDE", capacity: 140 }, + { club: clubA, title: "Case interview bootcamp — session 1", startDaysAgo: 33, hours: 2, venue: "Gleason 318", status: "PUBLISHED", audience: "CLUB_MEMBERS", capacity: 40 }, + { club: clubA, title: "Case competition — semifinals", startDaysAgo: 21, hours: 5, venue: "Schlegel Hall 207", status: "PUBLISHED", audience: "CLUB_MEMBERS", capacity: 60 }, + { club: clubA, title: "Board meeting — mid-year review", startDaysAgo: -4, hours: 1, venue: "Carol Simon Hall 106", status: "APPROVED", audience: "BOARD_ONLY", capacity: 8 }, + { club: clubA, title: "Sponsor coffee — Deloitte", startDaysAgo: -11, hours: 1, venue: "Off campus — Java's", status: "APPROVED", audience: "INVITE_ONLY", capacity: 12 }, + { club: clubA, title: "Spring career trek — New York", startDaysAgo: -38, hours: 48, venue: "New York, NY", status: "PENDING_APPROVAL", audience: "CLUB_MEMBERS", capacity: 18, approvalIndex: 0 }, + { club: clubA, title: "Spring banquet", startDaysAgo: -76, hours: 4, venue: "Genesee Valley Club", status: "DRAFT", audience: "CLUB_MEMBERS", capacity: 90 }, + { club: clubB, title: "Intro to dbt — workshop", startDaysAgo: -6, hours: 2, venue: "Gleason 201", status: "APPROVED", audience: "CLUB_MEMBERS", capacity: 30 }, + { club: clubB, title: "Analytics speaker — Paychex", startDaysAgo: -19, hours: 1, venue: "Schlegel Hall 103", status: "APPROVED", audience: "SCHOOL_WIDE", capacity: 80 }, + ] + for (const event of EVENTS) { + const startAt = days(-event.startDaysAgo) + await db.event.create({ + data: { + institutionId: institution.id, + organizationId: event.club.id, + title: event.title, + startAt, + endAt: new Date(startAt.getTime() + event.hours * 3_600_000), + timezone: "America/New_York", + venue: event.venue, + capacity: event.capacity, + audience: event.audience, + status: event.status, + approvalId: + event.approvalIndex !== undefined ? createdApprovals[event.approvalIndex].id : null, + }, + }) + } + + // ── Deliverables ────────────────────────────────────────────────────────── + // + // Institution-wide OSE deadlines, published to every club at once. One is + // overdue, several are ahead, and one is behind us and settled. + // + // `Deliverable` carries no completion column in this schema — nothing marks + // one done — so "done" is expressed the only honest way available: a due date + // that has passed on an item nobody is chasing. The overdue one is the recent + // miss; the settled one is months back. If completion state is added later, + // this seed should set it rather than keep implying it by date. + const DELIVERABLES = [ + { key: "preview-club-registration", title: "Annual club registration", dueDaysAgo: 121, seat: "PRESIDENT", kind: "DEADLINE", description: "Renew the club's registration for the academic year. Settled — filed in August." }, + { key: "preview-budget-submission", title: "Budget submission for the year", dueDaysAgo: 96, seat: "FINANCE", kind: "DEADLINE", description: "Submit the full-year budget request to OSE. Settled — heard at the September hearing." }, + { key: "preview-treasurer-training", title: "Treasurer training", dueDaysAgo: 5, seat: "FINANCE", kind: "TRAINING", description: "Mandatory session on reimbursement rules and the new ledger. OVERDUE — the Consulting Group has not attended." }, + { key: "preview-midyear-checkin", title: "Mid-year check-in with OSE", dueDaysAgo: -9, seat: "PRESIDENT", kind: "MEETING", description: "Thirty minutes with your OSE contact: membership, money, and what is not working." }, + { key: "preview-event-window-spring", title: "Spring event submission window", dueDaysAgo: -23, seat: "ALL", kind: "WINDOW", description: "Every spring event needs to be in the calendar by this date to get a room." }, + { key: "preview-transition-plan", title: "Board transition plan", dueDaysAgo: -68, seat: "PRESIDENT", kind: "DEADLINE", description: "Name your successor and record the handoff. This is the one that decides whether next year's board starts from zero." }, + { key: "preview-annual-report", title: "Annual report and roster confirmation", dueDaysAgo: -104, seat: "ALL", kind: "DEADLINE", description: "Final membership numbers, spend against budget, and what the club actually did." }, + ] + for (const deliverable of DELIVERABLES) { + await db.deliverable.create({ + data: { + institutionId: institution.id, + key: deliverable.key, + title: deliverable.title, + description: deliverable.description, + dueAt: days(-deliverable.dueDaysAgo), + seat: deliverable.seat, + kind: deliverable.kind, + source: "Preview world (seeded)", + }, + }) + } + + // ── The vault ───────────────────────────────────────────────────────────── + // + // A LESSON is the thing this surface exists for: the hard-won insight a + // president leaves for a successor they will probably never meet. A vault + // holding only contact cards would show the feature and miss the point. + const CARDS = [ + { + type: "LESSON", + title: "Do not book the ballroom before the sponsor money is confirmed", + seat: "President", + body: "We put down a $900 deposit on Schlegel in September on the strength of a verbal from a sponsor who then restructured their campus budget in October. The deposit was non-refundable inside 30 days and we ate it. Get the commitment in writing, or book a room that lets you cancel. The Events VP does not have the standing to chase a sponsor's finance team — that call has to come from the President.", + }, + { + type: "LESSON", + title: "The reimbursement clock starts at the receipt date, not the filing date", + seat: "VP Finance & Operations", + body: "Two members lost $140 between them last spring because they sat on receipts for six weeks. OSE will not process anything over 30 days old and there is no appeal. Say this at the first meeting, say it again before every event, and file on the members' behalf if you have to.", + }, + { + type: "PLAYBOOK", + title: "Running the case competition, end to end", + seat: "President", + body: "T-10 weeks: lock the sponsor and the date. T-8: room booking through OSE (the ballroom needs the event window). T-6: open registration, cap at 60. T-4: judges confirmed — three practitioners, one faculty. T-2: cases printed (Riverside, student-org rate, ask at quote time). T-1: catering headcount to Genesee Valley. Day of: doors 8:30, brief judges 9:00, teams present 10:00-14:00, results 15:00. Debrief within 48 hours while people still remember.", + }, + { + type: "CONTACT", + title: "Erin Vasquez — OSE, our programme contact", + seat: "President", + body: "Handles room bookings, the event window, and anything to do with the registration renewal. Responds to email within a day; will not answer questions about money — that is the budget hearing. Best reached before 11am.", + }, + { + type: "VENDOR", + title: "Genesee Valley Catering — what we have learned", + seat: "VP Events & Partnerships", + body: "Net 30. They will hold a tentative headcount for 48 hours and want the real number five business days out. They short-delivered two trays at the kickoff and refunded without an argument when we sent photographs. Ask for Maria; the general orders inbox is slower.", + }, + { + type: "THREAD", + title: "Should the case bootcamp be open to the whole school?", + seat: "President", + body: "Open question from the mid-year board meeting. Arguments for: recruiting pipeline, goodwill with OSE, and the room is half empty anyway. Against: our own members paid dues and get less attention, and the last school-wide session had a 40% no-show. Nobody has decided. Next board should.", + }, + ] + for (const card of CARDS) { + await db.memoryRecord.create({ + data: { + institutionId: institution.id, + organizationId: clubA.id, + roleId: seats[PREVIEW_CLUB_A_SLUG][card.seat]?.id ?? null, + title: card.title, + type: card.type, + content: { body: card.body }, + authorId: president.id, + }, + }) + } + await db.memoryRecord.create({ + data: { + institutionId: institution.id, + organizationId: clubB.id, + roleId: seats[PREVIEW_CLUB_B_SLUG]["President"].id, + title: "Dataset licences we already hold", + type: "PLAYBOOK", + content: { + body: "The school's Compustat and CRSP access covers student-org use for coursework-adjacent projects. You do not need to buy anything for a workshop. Ask the library, not the vendor.", + }, + }, + }) + + // ── Feed ────────────────────────────────────────────────────────────────── + const POSTS = [ + { org: clubA, author: president.id, title: "Semifinal results", body: "Semifinals are done. Congratulations to the four teams through — finals are the Friday after break, same room, same judges.", daysAgo: 19 }, + { org: clubA, author: vpEvents.id, title: "Spring trek is with the President", body: "Submitted and waiting on approval. Three firms confirmed so far; I will chase the other two this week.", daysAgo: 3 }, + { org: clubA, author: vpFinance.id, title: "Receipts inside 30 days, please", body: "OSE will not process anything older and there is no appeal. I would rather file it for you than watch you lose the money.", daysAgo: 8 }, + { org: clubB, author: personaUsers["club-president"].id, title: "dbt workshop — bring a laptop", body: "Next Tuesday, Gleason 201. We will not have spares, and the install takes ten minutes if you do it beforehand.", daysAgo: 2 }, + ] + for (const post of POSTS) { + await db.feedPost.create({ + data: { + institutionId: institution.id, + organizationId: post.org.id, + authorId: post.author, + title: post.title, + body: post.body, + createdAt: days(-post.daysAgo), + }, + }) + } + + // ── Documents ───────────────────────────────────────────────────────────── + // + // Metadata only: no object is uploaded, and the storage layer resolves these + // to a signed URL on demand. Listed, searchable and archivable, which is what + // the documents surface renders. + const DOCS = [ + { title: "Spring trek — firm confirmations", key: "spring-trek-confirmations.pdf", mime: "application/pdf", bytes: 184_320, archived: false }, + { title: "Case competition sponsor deck", key: "case-comp-sponsor-deck.pdf", mime: "application/pdf", bytes: 2_411_008, archived: false }, + { title: "Genesee Valley catering quote", key: "gv-catering-quote.pdf", mime: "application/pdf", bytes: 96_256, archived: false }, + { title: "Last year's sponsor deck", key: "sponsor-deck-prior-year.pdf", mime: "application/pdf", bytes: 1_998_848, archived: true }, + ] + for (const doc of DOCS) { + await db.document.create({ + data: { + institutionId: institution.id, + organizationId: clubA.id, + title: doc.title, + objectKey: `${institution.id}/${clubA.id}/${doc.key}`, + mimeType: doc.mime, + sizeBytes: doc.bytes, + isArchived: doc.archived, + }, + }) + } + + // ── The audit trail is not empty either ─────────────────────────────────── + // + // An OSE Advisor's single capability is `audit.view`, so the audit log IS + // their workspace. Landing them on an empty table would be the same failure + // as an empty dashboard, one surface over. + for (const event of [ + { actor: director.id, role: "OSE_DIRECTOR", action: "Admin.approval.override", resourceType: "ApprovalRequest", resourceId: createdApprovals[2].id, outcome: "ALLOW", reason: "Vendor engagement approved at the October review.", daysAgo: 24 }, + { actor: director.id, role: "OSE_DIRECTOR", action: "Admin.role.assign", resourceType: "RoleAssignment", outcome: "ALLOW", reason: "Incoming VP Marketing seated as SHADOW ahead of the spring term.", daysAgo: 12 }, + { actor: personaUsers["ose-staff"].id, role: "OSE_STAFF", action: "Admin.club.edit", resourceType: "Organization", resourceId: clubA.id, outcome: "ALLOW", reason: "Description updated after the roster meeting.", daysAgo: 18 }, + { actor: personaUsers["ose-staff"].id, role: "OSE_STAFF", action: "Admin.budget.override", resourceType: "BudgetLine", outcome: "DENY", reason: "Staff do not hold budget.override — the Director does.", daysAgo: 7 }, + ]) { + await db.auditEvent.create({ + data: { + institutionId: institution.id, + actorId: event.actor, + actorRole: event.role, + action: event.action, + resourceType: event.resourceType, + resourceId: event.resourceId ?? null, + organizationId: clubA.id, + outcome: event.outcome, + reason: event.reason, + occurredAt: days(-event.daysAgo), + }, + }) + } + + const counts = await summarise() + console.log(`${removed ? "♻️ Rebuilt" : "✅ Created"} the preview world — institution "${PREVIEW_INSTITUTION_SLUG}"`) + console.log(` sign in as: ${masterUsers.map((u) => u.email).join(", ")}`) + console.log( + ` ${counts.orgs} clubs, ${counts.seats} seats, ${counts.assignments} assignments, ` + + `${counts.people} directory people, ${counts.holdings} seat holdings`, + ) + console.log( + ` ${counts.approvals} approvals, ${counts.events} events, ${counts.deliverables} deliverables, ` + + `${counts.cards} vault cards, ${counts.ledger} ledger entries, ${counts.audit} audit rows`, + ) + return true +} + +async function summarise() { + const orgIds = ( + await db.organization.findMany({ + where: { institutionId: PREVIEW_INSTITUTION_ID }, + select: { id: true }, + }) + ).map((o) => o.id) + const [orgs, seats, assignments, people, holdings, approvals, events, deliverables, cards, ledger, audit] = + await Promise.all([ + db.organization.count({ where: { institutionId: PREVIEW_INSTITUTION_ID } }), + db.role.count({ where: { institutionId: PREVIEW_INSTITUTION_ID } }), + db.roleAssignment.count({ where: { institutionId: PREVIEW_INSTITUTION_ID } }), + db.directoryPerson.count({ where: { institutionId: PREVIEW_INSTITUTION_ID } }), + db.seatHolding.count({ where: { institutionId: PREVIEW_INSTITUTION_ID } }), + db.approvalRequest.count({ where: { institutionId: PREVIEW_INSTITUTION_ID } }), + db.event.count({ where: { institutionId: PREVIEW_INSTITUTION_ID } }), + db.deliverable.count({ where: { institutionId: PREVIEW_INSTITUTION_ID } }), + db.memoryRecord.count({ where: { institutionId: PREVIEW_INSTITUTION_ID } }), + db.ledgerEntry.count({ where: { organizationId: { in: orgIds } } }), + db.auditEvent.count({ where: { institutionId: PREVIEW_INSTITUTION_ID } }), + ]) + return { orgs, seats, assignments, people, holdings, approvals, events, deliverables, cards, ledger, audit } +} + +// ─── Verify ─────────────────────────────────────────────────────────────────── + +/** + * Assert the preview world exists, is populated, and touches nothing real. + * + * The isolation half runs in BOTH directions, because they can fail + * independently: a preview persona holding a seat at Simon and a Simon officer + * holding one here are two different mistakes, and a check that only looks one + * way passes while the other is live. + */ +async function verify() { + const problems = [] + + const institution = await db.institution.findUnique({ + where: { id: PREVIEW_INSTITUTION_ID }, + select: { id: true, slug: true }, + }) + if (!institution) { + console.error(`❌ No preview institution. Run: node scripts/seed-preview-world.mjs`) + process.exitCode = 1 + return + } + + const counts = await summarise() + // Thresholds, not exact numbers. The point of each is that the SURFACE it + // feeds is not blank, and pinning an exact count would make adding a second + // vault card a failing test. + const required = { + orgs: 2, + seats: 10, + assignments: 8, + people: 10, + holdings: 8, + approvals: 4, + events: 8, + deliverables: 6, + cards: 6, + ledger: 12, + audit: 4, + } + for (const [key, min] of Object.entries(required)) { + if (counts[key] < min) problems.push(`${key}: expected >= ${min}, found ${counts[key]}`) + } + + // Every persona resolvable, and entitled HERE — the same check + // `resolvePreviewSubject` makes at the moment somebody clicks the option. + for (const persona of PREVIEW_PERSONAS) { + const user = await db.user.findUnique({ where: { email: persona.email }, select: { id: true } }) + if (!user) { + problems.push(`persona ${persona.key}: no user row for ${persona.email}`) + continue + } + const [inside, outside] = await Promise.all([ + Promise.all([ + db.institutionMembership.count({ where: { userId: user.id, institutionId: PREVIEW_INSTITUTION_ID } }), + db.roleAssignment.count({ where: { userId: user.id, institutionId: PREVIEW_INSTITUTION_ID } }), + ]).then(([m, a]) => m + a), + Promise.all([ + db.institutionMembership.count({ where: { userId: user.id, institutionId: { not: PREVIEW_INSTITUTION_ID } } }), + db.roleAssignment.count({ where: { userId: user.id, institutionId: { not: PREVIEW_INSTITUTION_ID } } }), + ]).then(([m, a]) => m + a), + ]) + if (inside === 0) problems.push(`persona ${persona.key}: holds nothing in the preview institution`) + if (outside > 0) problems.push(`persona ${persona.key}: LEAKS — holds ${outside} membership(s)/seat(s) outside the preview`) + } + + // The preview account itself holds nothing anywhere. If it ever did, it would + // reach the product as itself rather than through a role, and the preview + // would be showing a user that does not exist in the rollout. + for (const email of masterAccessEmails()) { + const user = await db.user.findUnique({ where: { email }, select: { id: true } }) + if (!user) { + problems.push(`preview account ${email}: no user row — sign-in would fail`) + continue + } + const [memberships, assignments] = await Promise.all([ + db.institutionMembership.count({ where: { userId: user.id } }), + db.roleAssignment.count({ where: { userId: user.id } }), + ]) + if (memberships + assignments > 0) { + problems.push( + `preview account ${email}: holds ${memberships + assignments} membership(s)/seat(s) of its own — it should hold none`, + ) + } + } + + // The other direction. Nobody with standing outside the preview may have + // standing inside it. + const intruders = await db.institutionMembership.findMany({ + where: { + institutionId: PREVIEW_INSTITUTION_ID, + user: { institutionMembership: { some: { institutionId: { not: PREVIEW_INSTITUTION_ID } } } }, + }, + select: { userId: true }, + }) + if (intruders.length > 0) { + problems.push(`${intruders.length} user(s) hold an OSE membership BOTH here and in a real tenant`) + } + const crossSeated = await db.roleAssignment.findMany({ + where: { + institutionId: PREVIEW_INSTITUTION_ID, + user: { roleAssignments: { some: { institutionId: { not: PREVIEW_INSTITUTION_ID } } } }, + }, + select: { userId: true }, + }) + if (crossSeated.length > 0) { + problems.push(`${crossSeated.length} user(s) hold a club seat BOTH here and in a real tenant`) + } + + // A seat whose club belongs to somebody else. Unwritable today — the + // composite foreign key refuses it — and this is the query that notices if it + // ever stops being. + const disagreeing = await db.role.count({ + where: { + institutionId: PREVIEW_INSTITUTION_ID, + organization: { institutionId: { not: PREVIEW_INSTITUTION_ID } }, + }, + }) + if (disagreeing > 0) problems.push(`${disagreeing} preview seats belong to a club in another institution`) + + // Money that adds up. `actualCents` is the cache of Σ amountCents, and a seed + // that broke the invariant would show a figure with an empty drill-down. + const lines = await db.budgetLine.findMany({ + where: { organization: { institutionId: PREVIEW_INSTITUTION_ID } }, + select: { id: true, category: true, actualCents: true }, + }) + for (const line of lines) { + const agg = await db.ledgerEntry.aggregate({ + where: { budgetLineId: line.id }, + _sum: { amountCents: true }, + }) + const sum = agg._sum.amountCents ?? 0 + if (sum !== line.actualCents) { + problems.push(`budget line "${line.category}": actual ${line.actualCents} != ledger sum ${sum}`) + } + } + + console.log( + `preview: orgs=${counts.orgs} seats=${counts.seats} assignments=${counts.assignments} ` + + `people=${counts.people} holdings=${counts.holdings} approvals=${counts.approvals} ` + + `events=${counts.events} deliverables=${counts.deliverables} cards=${counts.cards} ` + + `ledger=${counts.ledger} audit=${counts.audit}`, + ) + + if (problems.length) { + console.error("❌ Preview world verification failed:") + for (const problem of problems) console.error(` - ${problem}`) + process.exitCode = 1 + return + } + console.log("✅ Preview world is populated and isolated from every other tenant.") +} + +const mode = process.argv.includes("--verify") ? verify : build +await mode() + .catch((e) => { + console.error(e) + process.exitCode = 1 + }) + .finally(() => db.$disconnect()) diff --git a/apps/web/scripts/verify-preview-audit.mjs b/apps/web/scripts/verify-preview-audit.mjs new file mode 100644 index 00000000..4502f886 --- /dev/null +++ b/apps/web/scripts/verify-preview-audit.mjs @@ -0,0 +1,191 @@ +/** + * Assert that work done while assuming a role is attributed to BOTH identities. + * + * node scripts/verify-preview-audit.mjs + * + * Run after `e2e/preview.spec.ts`, which is what produces the rows: it enters + * personas and then writes a memory card as the Club President, through the + * ordinary server action, on the ordinary surface. + * + * ── Why an ordinary row is the one that matters ───────────────────────────── + * + * `Preview.PersonaEntered` is written by the preview's own server action and + * names both identities because that action was written to. It proves almost + * nothing on its own — of course the feature attributes its own event. + * + * The row worth checking is one written by a call site that has never heard of + * the preview. There are forty-four of them, none were modified, and the + * attribution reaches them through a Prisma extension reading the tenant scope. + * `Memory.CardCreated` is one of those forty-four. If it carries the real + * actor, the mechanism works for the other forty-three as well — and if it does + * not, the feature's audit claim is false no matter what its own row says. + * + * ── What "both" means here ────────────────────────────────────────────────── + * + * actorId → the ASSUMED identity. Already correct without any + * change, because the session's subject IS the persona + * — which is the same fact that makes every + * authorization decision in the request a real one. + * metadata.preview → the HUMAN. This is what the extension adds. + * + * A row with only the first is a row that cannot answer "who was actually at + * the keyboard"; a row with only the second could not answer "what could they + * do at the time". + */ + +import { PrismaClient } from "@prisma/client" +import { PREVIEW_INSTITUTION_ID, PREVIEW_PERSONAS, masterAccessEmails } from "./preview-personas.mjs" + +const db = new PrismaClient({ log: ["error"] }) + +/** Rows written by call sites that know nothing about the preview. */ +const ORDINARY = { not: "Preview" } + +async function main() { + const problems = [] + + const masters = masterAccessEmails() + if (masters.length === 0) { + console.error("❌ MASTER_ACCESS_EMAILS is unset, so no preview session can have happened.") + process.exitCode = 1 + return + } + + const realUsers = await db.user.findMany({ + where: { email: { in: masters } }, + select: { id: true, email: true }, + }) + const realIds = new Set(realUsers.map((u) => u.id)) + if (realIds.size === 0) { + console.error(`❌ No user row for ${masters.join(", ")} — run the preview seed first.`) + process.exitCode = 1 + return + } + + const personaUsers = await db.user.findMany({ + where: { email: { in: PREVIEW_PERSONAS.map((p) => p.email) } }, + select: { id: true, email: true }, + }) + const personaIds = new Map(personaUsers.map((u) => [u.id, u.email])) + + // ── 1. The feature's own event ──────────────────────────────────────────── + const entered = await db.auditEvent.findMany({ + where: { institutionId: PREVIEW_INSTITUTION_ID, action: "Preview.PersonaEntered" }, + orderBy: { occurredAt: "desc" }, + }) + if (entered.length === 0) { + problems.push("no Preview.PersonaEntered rows — has anybody entered a persona?") + } + for (const row of entered) { + if (!realIds.has(row.actorId ?? "")) { + problems.push(`Preview.PersonaEntered ${row.id}: actorId is not the real account`) + } + const meta = row.metadata ?? {} + if (!meta.realActorEmail || !meta.assumedRole) { + problems.push(`Preview.PersonaEntered ${row.id}: metadata does not name both identities`) + } + } + + // ── 2. The rows that matter: written by call sites that know nothing ────── + const ordinary = await db.auditEvent.findMany({ + where: { + institutionId: PREVIEW_INSTITUTION_ID, + actorId: { in: [...personaIds.keys()] }, + action: ORDINARY.not ? undefined : undefined, + NOT: { action: { startsWith: "Preview." } }, + }, + orderBy: { occurredAt: "desc" }, + }) + + // Only rows a PREVIEW SESSION produced can carry attribution. The seed writes + // four audit rows of its own, with no session behind them, and those must NOT + // be counted as failures — a seeded row with no human at a keyboard has no + // real actor to name. They are told apart by having no `preview` key at all, + // which is why the check below asserts over the rows that DO claim one plus a + // separate assertion that at least one exists. + const attributed = ordinary.filter((row) => row.metadata && row.metadata.preview) + + if (attributed.length === 0) { + problems.push( + "no ORDINARY audit row carries preview attribution. Either no write was made while " + + "assuming a role, or the Prisma extension in lib/preview/audit-attribution.ts is not " + + "reaching that call site. Run e2e/preview.spec.ts first.", + ) + } + + for (const row of attributed) { + const preview = row.metadata.preview + if (!realIds.has(preview.realActorId)) { + problems.push(`${row.action} ${row.id}: metadata.preview.realActorId is not the real account`) + } + if (!masters.includes(preview.realActorEmail)) { + problems.push(`${row.action} ${row.id}: metadata.preview.realActorEmail is not on the allowlist`) + } + if (!preview.assumedRole || !preview.assumedPersonaKey) { + problems.push(`${row.action} ${row.id}: metadata.preview does not name the assumed role`) + } + // The other half, and the one that is easy to lose sight of: the ACTOR is + // still the persona. If this ever became the real account, every + // authorization decision on the row would be unexplainable. + if (!personaIds.has(row.actorId ?? "")) { + problems.push(`${row.action} ${row.id}: actorId is not a preview persona`) + } + } + + // ── 3. Nothing outside the preview institution was attributed ───────────── + // + // A preview stamp on a real tenant's audit row would mean the tenant scope + // carried a preview actor into somebody else's request. + // Filtered in JS rather than with a Prisma JSON `path` predicate: that form + // needs a scalar operator alongside it and returns P2019 without one, and the + // question here — "does this row have a `preview` key at all" — is not a + // scalar comparison. Reading the column and asking in JavaScript answers it + // exactly, and the row count outside the preview is small enough to scan. + const outside = await db.auditEvent.findMany({ + where: { institutionId: { not: PREVIEW_INSTITUTION_ID } }, + select: { id: true, action: true, institutionId: true, actorId: true, metadata: true }, + }) + const leakedRows = outside.filter((row) => row.metadata && row.metadata.preview !== undefined) + const leaked = leakedRows.length + for (const row of leakedRows) { + problems.push(`${row.action} ${row.id} in ${row.institutionId} carries preview attribution`) + } + + // The other direction of the same worry: a preview identity acting in a real + // tenant at all. It cannot happen — `resolveTenantScope` derives the tenant + // from the acting user's own memberships and a persona has none outside the + // preview — and this is the query that would notice if that ever changed. + const foreignActors = outside.filter( + (row) => personaIds.has(row.actorId ?? "") || realIds.has(row.actorId ?? ""), + ) + for (const row of foreignActors) { + problems.push(`${row.action} ${row.id}: a preview identity acted in ${row.institutionId}`) + } + + console.log( + `audit: ${entered.length} persona entries, ${ordinary.length} ordinary rows by a persona, ` + + `${attributed.length} of them attributed, ${leaked} leaked`, + ) + for (const row of attributed.slice(0, 3)) { + const p = row.metadata.preview + console.log( + ` ${row.action}: actor=${personaIds.get(row.actorId)} (${p.assumedRole}) ` + + `on behalf of ${p.realActorEmail}`, + ) + } + + if (problems.length) { + console.error("❌ Preview audit attribution failed:") + for (const problem of problems) console.error(` - ${problem}`) + process.exitCode = 1 + return + } + console.log("✅ Every audited write made while assuming names both the real actor and the role.") +} + +await main() + .catch((e) => { + console.error(e) + process.exitCode = 1 + }) + .finally(() => db.$disconnect()) diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index c65da22b..38ad7b74 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -14,6 +14,7 @@ import { Footer } from "@/components/shell/Footer" import { MainRegion } from "@/components/shell/MainRegion" import { AIProvider } from "@/components/ai/AIProvider" import { TenureAIPanel } from "@/components/ai/TenureAIPanel" +import { PreviewBadge } from "@/components/preview/PreviewBadge" import { signOutAction } from "./actions" import { switchWorkspace } from "./workspace/actions" @@ -36,6 +37,21 @@ export default async function AppLayout({ const session = await auth() if (!session?.user) redirect("/signin") + // A rollout-preview account that has not chosen a role yet. + // + // Deliberately BEFORE the queries below rather than after the entitlement + // gate, and the ordering is not cosmetic: a preview account holds no + // membership and no seat of its own, so `isEntitled` is false for it and it + // would otherwise land on /access-pending — technically correct, and the + // wrong answer to "sign in and you get the chooser". `session.preview` is + // `undefined` on every ordinary sign-in and on every deployment with + // MASTER_ACCESS_EMAILS unset, so this line is unreachable in the pilot. + // + // Once a role IS chosen, `session.user.id` is already the persona's, and + // everything below this point runs for that person with no further branch — + // which is the entire fidelity argument in one comment. + if (session.preview && !session.preview.assumed) redirect("/preview") + const [ctx, unreadNotifications, me] = await Promise.all([ getUserContext(session.user.id), db.notification.count({ where: { userId: session.user.id, readAt: null } }), @@ -114,6 +130,8 @@ export default async function AppLayout({ content region scrolls. */}