diff --git a/.github/workflows/master-preview-access.yml b/.github/workflows/master-preview-access.yml index 01b619f1..c3808d1a 100644 --- a/.github/workflows/master-preview-access.yml +++ b/.github/workflows/master-preview-access.yml @@ -34,13 +34,22 @@ name: Master preview access # (FORCE_CHANGE_PASSWORD → # challenge-required, refused) # 2. seed the preview world ← the slow, fallible part -# 3. set the permanent password ← the only step that ARMS sign-in +# 3. set the permanent password ← the last step that can arm sign-in # # So a run killed anywhere — including by this job's own timeout — leaves an # account that cannot sign in, never one that signs in to an empty world. The # reverse order would make a half-finished run look like "the data is gone", # which is precisely the impression a preview exists to prevent. # +# The password is necessary and NOT sufficient, and step 2 is what enforces +# that. `MASTER_ACCESS_EMAILS` on the running service is the door the preview +# account is admitted through (ADR-0020 §2); the seeder writes no +# `RestrictedIdentity` and no seal row, so with the variable unset the address +# is refused at sign-in exactly like any address the Simon roster has never +# heard of. Rather than seed a world nobody can open, the seeder REFUSES when +# the `--email` it is given is not on that allowlist — which fails this job +# BEFORE step 3, leaving no password behind. +# # ── This workflow prints a password into the run summary ──────────────────── # # That is deliberate and it is the thing the user asked for. The tradeoff is @@ -106,7 +115,16 @@ jobs: # membership (lib/tenant-scope.ts resolveTenantScope), so a membership in # this institution — and ONLY this one — is what keeps the preview off the # real cohort's rows in both directions. - PREVIEW_SLUG: simon-ose-preview + # + # Renamed from `simon-ose-preview` on merging the rollout-preview boundary + # and chooser (#129), which seeds this same tenant and names it from + # `apps/web/src/lib/preview/personas.ts`. Two preview tenants would have + # been the defect. The name moved THIS way because `fork-prevention.test.ts` + # fails any new file under `src` carrying a `simon` literal and holds its + # allowance total equal to a ceiling, so the app cannot spell the old name + # without an exemption that gate is built to refuse — measured, not assumed. + # This file is outside `src` and may name whatever it points at. + PREVIEW_SLUG: tenure-rollout-preview # The entrypoint mode that seeds the preview tenant. Must match a dispatch # in apps/web/scripts/entrypoint.sh EXACTLY; an unmatched mode now exits 64 @@ -573,10 +591,24 @@ jobs: echo "| tenant | \`$PREVIEW_SLUG\` — synthetic, seeded for this account |" echo "| Cognito pool | \`$POOL_ID\` |" echo "" - echo "This account holds membership in \`$PREVIEW_SLUG\` and nowhere else, so every" - echo "query it makes is scoped there by the ordinary tenancy mechanism. It cannot" + echo "This account holds NO membership and NO seat of its own. It signs in and lands" + echo "on the role chooser at \`/preview\`, and everything it can see it sees THROUGH a" + echo "persona whose memberships and seats are in \`$PREVIEW_SLUG\` and nowhere else — so" + echo "every query it makes is scoped there by the ordinary tenancy mechanism. It cannot" echo "read the Simon cohort's rows and the Simon cohort cannot read its rows." echo "Every screen is the real screen, rendered through the real authorization path." + echo "" + echo "### One thing this workflow cannot do for you" + echo "" + echo "\`MASTER_ACCESS_EMAILS\` must contain \`$PREVIEW_EMAIL\` **on the running service**." + echo "That variable is the door (ADR-0020 §2): the seeder writes no \`RestrictedIdentity\`" + echo "and no seal row, deliberately, so with the variable unset this account is refused" + echo "at sign-in like any address the Simon roster has never heard of. It is a change to" + echo "\`environment\` in \`infrastructure/terraform/ecs.tf\` followed by a rollout — a" + echo "deployment decision on purpose, and the reason \`/preview\` does not exist by" + echo "default. The seed step above REFUSES rather than reporting success when the" + echo "address it was given is not on that allowlist, so a green run means the door is" + echo "already open." } >> "$GITHUB_STEP_SUMMARY" echo "::notice title=Preview account ready::The password is in this run's summary." diff --git a/apps/web/.env.example b/apps/web/.env.example index 42004dbe..347cb29f 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -142,6 +142,31 @@ 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. +# +# Which also means this variable is NOT optional for the preview to work. The +# seeder writes no RestrictedIdentity row, so with this unset the address is +# refused at sign-in like any other stranger — including by +# .github/workflows/master-preview-access.yml, whose seed step refuses rather +# than leaving a password behind for an account the gate will turn away. +# +# Build the world it previews with: +# MASTER_ACCESS_EMAILS=… node scripts/seed-preview-world.mjs +# MASTER_ACCESS_EMAILS= + # ── Tenant configuration packs ──────────────────────────────────────────────── # Where to read this tenant's configuration packs from — the institutional diff --git a/apps/web/e2e/preview-disabled.spec.ts b/apps/web/e2e/preview-disabled.spec.ts new file mode 100644 index 00000000..972c2d18 --- /dev/null +++ b/apps/web/e2e/preview-disabled.spec.ts @@ -0,0 +1,71 @@ +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() + // Wherever their WORKSPACE puts them, which for this account is `/admin`. + // This waited for `/dashboard` and hung for the full timeout: ADR-0019 makes + // the landing path a function of role, and an OSE Director's is the console + // — `/signin` sends them to `/workspace`, which redirects. `preview.spec.ts` + // in this same change already encodes that (`"OSE Director": /\/admin/`), + // so the two specs disagreed about the product and only one of them ran. + // + // Left as an alternation rather than pinned to `/admin`, because the landing + // is not what is being tested here: the assertion is the 404 below, and this + // line only has to establish that the widest-privileged account is signed in + // before it asks for the route. + await page.waitForURL(/\/admin|\/dashboard|\/orgs/) + + 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..593d7ae1 --- /dev/null +++ b/apps/web/scripts/preview-personas.mjs @@ -0,0 +1,168 @@ +/** + * 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. + */ + +/** + * The preview tenant, named ONCE. + * + * There is exactly one preview tenant. `main` shipped a second seeder on the + * same day (#130) that called it `simon-ose-preview`, and two preview tenants — + * one seeded by the production workflow with no chooser in front of it, one + * seeded here with the chooser — would have been the real defect of that merge. + * So the workflow's `PREVIEW_SLUG`, the pin in `preview-access-contract.test.ts` + * and the comment in `auth/restricted-registry.ts` were all moved onto this + * name instead. + * + * The name went THIS way rather than the other because the repository already + * decides the question: `fork-prevention.test.ts` fails any NEW file under + * `src` carrying a `/[Ss]imon|[Rr]ochester/` literal, and holds the allowance + * total equal to a ceiling so an exemption cannot be added without lowering + * something else. `simon-ose-preview` contains `simon-ose`, so putting it in + * `src/lib/preview/personas.ts` failed that gate — measured, not assumed. A + * tenant-neutral name is what the gate is asking for, and the workflow and the + * contract test are outside `src` and may name whatever they point at. + * + * The id is pinned to the slug so a re-seed is addressable without a lookup. + * `reset()` in the seeder resolves by id OR slug, because a tenant seeded by + * the version of this script that shipped on `main` carries a generated id and + * a different slug. + */ +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/preview-sign-in-warning.mjs b/apps/web/scripts/preview-sign-in-warning.mjs index 9ef8b808..48f24a81 100644 --- a/apps/web/scripts/preview-sign-in-warning.mjs +++ b/apps/web/scripts/preview-sign-in-warning.mjs @@ -25,10 +25,20 @@ * condition and there is no third case to get wrong. * * Since #127 a roster row admits only alongside a seal in its OWN institution, - * and the preview tenant has no seal and must never have one, so the row the - * seeder writes does not admit anybody by itself. That is the hole closing, and - * it means a preview can seed perfectly and then refuse the person it was - * seeded for — which is an hour of debugging the wrong table if nobody says so. + * and the preview tenant has no seal and must never have one, so no row in it + * could admit anybody by itself. That is the hole closing, and it means a + * preview can seed perfectly and then refuse the person it was seeded for — + * which is an hour of debugging the wrong table if nobody says so. + * + * ── What the rollout-preview chooser changed, and what it did not ─────────── + * + * It changed WHOSE door this describes. That branch stopped writing the row + * altogether and admits the operator through `decidePreviewAccess`, ahead of + * the roster, on `MASTER_ACCESS_EMAILS` — so the roster is the fallback door + * rather than the only one, and `seed-preview-world.mjs` prints this under the + * condition that makes it true: if that variable is ever unset on the running + * service. The RULE below is unchanged, because it was never about the preview + * tenant's own rows; it is the gate's, read over the sealed institutions. * * @param {{ email: string, anySeal: boolean, onSealedRoster: boolean }} facts * @returns {string | null} the warning to print, or null when they will get in @@ -48,8 +58,8 @@ export function previewSignInWarning({ email, anySeal, onSealedRoster }) { ` An institution is sealed, so the restricted gate is enforcing, and since\n` + ` issue #127 a roster row only admits alongside a seal in its OWN institution.\n` + ` No sealed institution holds an ACTIVE row for this address.\n` + - ` The preview tenant has no seal and must never have one (invariant 1), so the\n` + - ` row this seeder just wrote does not admit anybody by itself.\n` + + ` The preview tenant has no seal and must never have one (invariant 1), so no\n` + + ` row in it could admit anybody by itself — which is why the seeder writes none.\n` + ` Add the operator to the SEALED institution's roster deliberately:\n` + ` node scripts/seed-restricted-registry.mjs --help\n` ) diff --git a/apps/web/scripts/seed-preview-world.mjs b/apps/web/scripts/seed-preview-world.mjs index c3fd6293..0327afec 100644 --- a/apps/web/scripts/seed-preview-world.mjs +++ b/apps/web/scripts/seed-preview-world.mjs @@ -1,23 +1,41 @@ -#!/usr/bin/env node /** - * Seed the ROLLOUT-PREVIEW tenant: a whole synthetic institution that looks and - * behaves like Simon OSE and contains no real person. + * The world the rollout preview walks through. * - * ── Why this exists ───────────────────────────────────────────────────────── + * node scripts/seed-preview-world.mjs # build / rebuild it + * node scripts/seed-preview-world.mjs --verify # assert it is intact and isolated + * node scripts/seed-preview-world.mjs --email # …and assert can get in * - * The product owner's words: "right now i have no way of seeing what users will - * see once rolled out". A role chooser over an EMPTY database answers nothing — - * a President landing on a blank dashboard tells you nothing about what a - * President sees. So this seeds a populated world, and the preview account holds - * membership in it rather than in the real tenant. + * ── How production invokes it ─────────────────────────────────────────────── * - * Tenancy is resolved from MEMBERSHIP, not from the hostname - * (`lib/tenant-scope.ts` — `resolveTenantScope(userId)` reads institutionRoles, - * falling back to orgRoles). So the isolation here is the ordinary tenancy - * mechanism doing its ordinary job, with no special case anywhere. That is why - * the preview needs no hostname, no certificate and no chokepoint exception. + * `.github/workflows/master-preview-access.yml` dispatches an ECS command + * override that becomes `sh scripts/entrypoint.sh seed-preview-world --email + *
`, so `--email` is not decoration: it is the only argument the + * production path passes, and a script that ignored it would report success + * over a world that address cannot open. It is an ASSERTION, not a substitute + * for `MASTER_ACCESS_EMAILS` — see `assertOperatorCanGetIn` below for why + * folding it into the allowlist would be the "half-build that looks finished" + * this script already refuses to produce. * - * ── The three invariants, and why each is not merely hygiene ──────────────── + * ── 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 two rows this seeder must never write, and why #127 sharpened both ── + * + * Carried forward from the seeder this file replaces, and from #127, which + * landed while this branch was open and changed what each one costs. * * 1. NO `RestrictedRegistrySeal` ROW. EVER. * `restricted-registry.ts`'s seal lookup is still `findFirst` with NO where @@ -29,498 +47,1319 @@ * #127 made this invariant MORE load-bearing, not less. Before it, a stray * preview seal kept the real gate armed over the real roster and the harm was * that an operator's `--unseal` appeared to do nothing. Now the gate enforces - * over THE SEALED INSTITUTION'S roster — so a preview seal, with simon-ose + * over THE SEALED INSTITUTION'S roster — so a preview seal, with the pilot * unsealed, would refuse the entire real cohort. Louder, and still a lockout. * - * 2. EXACTLY ONE `RestrictedIdentity`, for the operator. - * The lookup IS institution-paired now (#127): a row admits only alongside a - * seal in ITS OWN institution, and this tenant is never sealed. So a row here - * is no longer a row the real pilot's gate would admit — which is the hole - * closing, and it cuts the other way too. READ THIS BEFORE DEBUGGING A - * REFUSED PREVIEW SIGN-IN: while some other institution is sealed, the row - * written below does NOT admit the operator. They have to be on the SEALED - * institution's roster, put there deliberately and attributably by - * `seed-restricted-registry.mjs`. This seeder says so at the end of its run - * rather than leaving it to be discovered at the sign-in form. - * The constraint itself stays, because it is one broken invariant away from - * mattering again and because the synthetic officers below are CONTENT — - * User, Role, RoleAssignment, so the surfaces render — and must never become - * identities. + * 2. NO `RestrictedIdentity` ROW AT ALL — not even the operator's. + * The seeder on `main` wrote exactly one, for the operator, because the + * lookup was unscoped and every row here was a row the real pilot's gate + * would admit. This branch writes none: the operator is admitted by + * `decidePreviewAccess`, ahead of the roster, reading `MASTER_ACCESS_EMAILS` + * and never touching that table (ADR-0020 §2). Strictly stronger, so the + * change was kept rather than reconciled the other way. + * #127 is the reason it is also the only version that WORKS. A roster row now + * admits only alongside a seal in ITS OWN institution, and this tenant is + * never sealed — so the row the old seeder wrote would not have admitted the + * operator at all while any other institution was sealed. What that seeder + * printed as a warning, this one enforces before it writes anything: see + * `assertOperatorCanGetIn`, and the gate-state note at the end of a run. + * The synthetic officers below stay CONTENT — User, Role, RoleAssignment, so + * the surfaces render — and must never become identities. + * + * 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. * - * 3. IT REFUSES TO RUN AGAINST THE REAL TENANT. - * Unconditionally, before any write, with no flag and no override. A seeder - * that CAN be pointed at the live pilot is one typo away from resetting it. + * ── Why it is not thin ────────────────────────────────────────────────────── * - * ── Writing is the default, deliberately ──────────────────────────────────── + * 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. * - * The coordinator's original contract said `--dry-run` should be the default. - * That is wrong for how this is actually invoked: `entrypoint.sh` dispatches - * `node scripts/seed-preview-world.mjs --email ` with no other argument, - * so a dry-run default would make the workflow seed nothing and report success. - * The gate lives one level up — the workflow is dispatch-only and requires the - * operator to type a confirmation — so this writes, and `--dry-run` is available - * for inspection. + * ── 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 { previewSignInWarning } from "./preview-sign-in-warning.mjs" - import { suggestSeatFunctions } from "./seat-functions.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" -const db = new PrismaClient() +// 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"] }) -/** The tenant this seeder exists to build. */ -const DEFAULT_SLUG = "simon-ose-preview" +// ─── Arguments ──────────────────────────────────────────────────────────────── /** - * The default operator. Named here rather than only in the workflow so the - * contract test can prove the single RestrictedIdentity write belongs to the - * preview operator and to nobody else. + * Parsed rather than sniffed with `argv.includes`. + * + * `includes` cannot tell an unknown flag from an absent one, and the production + * dispatch passes an argument this script did not previously read. A typo'd or + * renamed flag would then be silently ignored and the run would report success + * — the same class of failure `entrypoint.sh` grew its unknown-mode guard for, + * one level down. So anything unrecognised is refused with the usage. */ -const DEFAULT_EMAIL = "satvik@tenurework.com" - -/** Refused before any write. Not a flag. Not overridable. */ -const FORBIDDEN_SLUGS = new Set(["simon-ose"]) - -// ── Argument parsing ──────────────────────────────────────────────────────── - function parseArgs(argv) { - const args = { email: null, slug: DEFAULT_SLUG, reset: false, dryRun: false } + const args = { verify: false, email: null } for (let i = 0; i < argv.length; i++) { const a = argv[i] - if (a === "--email") args.email = argv[++i] - else if (a === "--institution-slug") args.slug = argv[++i] - else if (a === "--reset") args.reset = true - else if (a === "--dry-run") args.dryRun = true - else if (a.startsWith("--")) throw new Error(`Unknown option: ${a}`) + if (a === "--verify") args.verify = true + else if (a === "--email") args.email = argv[++i] + else { + console.error(`❌ Unknown option: ${a}`) + console.error(" Usage: node scripts/seed-preview-world.mjs [--verify] [--email
]") + process.exit(64) // EX_USAGE, the code entrypoint.sh uses for the same thing + } } return args } +const ARGS = parseArgs(process.argv.slice(2)) + /** - * The SAME normalisation the gate matches on. A seeder that lowercases - * differently from `restricted-registry.ts` writes rows the gate can never - * match — which is not a cosmetic difference: it sealed a registry that then - * refused all 82 people. Deliberately NOT doing Gmail-style dot-stripping or - * plus-tag removal; those are provider conventions and applying them to an - * institutional domain merges addresses the University considers distinct. + * Tenants this script will not touch, checked before any write and with no flag + * to override it. + * + * The tenant is a constant rather than an argument here, so this guard bites on + * an EDIT to that constant rather than on an operator's typo — which is the + * remaining way this script could ever be pointed at the pilot. It is cheap and + * it is not vacuous: `PREVIEW_INSTITUTION_SLUG` is one character away from + * `simon-ose`, the two now share a prefix, and `reset()` below deletes an + * entire institution. */ -function normalizeEmail(value) { - return String(value ?? "").trim().toLowerCase() -} - -// ── The synthetic world ───────────────────────────────────────────────────── - -const SEATS = [ - { name: "President", scope: "PRESIDENT", order: 1 }, - { name: "VP Finance & Operations", scope: "FUNCTIONAL", order: 2 }, - { name: "VP Events & Partnerships", scope: "FUNCTIONAL", order: 3 }, - { name: "VP Marketing & Communications", scope: "FUNCTIONAL", order: 4 }, - { name: "Member", scope: "MEMBER", order: 5 }, -] +const FORBIDDEN_SLUGS = new Set(["simon-ose"]) /** - * Two clubs, so club-switching is visible rather than theoretical. Names are - * obviously synthetic — a preview that looks like real Simon clubs invites - * somebody to mistake it for production data. + * Slugs a PREVIOUS version of this seeder gave the same tenant, cleared on the + * way to rebuilding it. + * + * `main` shipped a preview seeder on the same day as this branch (#130) and + * called the tenant `simon-ose-preview`, with a generated id. This branch + * renamed it — `fork-prevention.test.ts` refuses a `[Ss]imon` literal in a NEW + * file under `src`, and `src/lib/preview/personas.ts` is where the name now + * lives — so after the merge NEITHER the id nor the slug below matches the row + * that is already in the pilot's database. + * + * Without this list the rename leaves that row behind: a THIRD institution, and + * with it the operator's `InstitutionMembership` and the one `RestrictedIdentity` + * the old seeder wrote. Both are exactly what this design is built not to have. + * The membership is the worse half — `resolveTenantScope` derives the acting + * tenant from memberships, so the account the chooser assumes holds nothing + * would instead resolve into a stale, half-empty preview, which is the ordering + * hazard that made "and no other institution" a rule in the first place. + * + * Naming it here rather than in `src` is deliberate and is what the fork gate + * asks for: `scripts/` is outside its scan, and this is a migration detail of + * one seeder rather than a fact the application knows. + * + * Deleting these is safe for the reason the whole file is: `simon-ose-preview` + * is a synthetic tenant this project created five hours earlier, it is not + * `simon-ose`, and `refuseRealTenants` reads THIS list too — so a real cohort + * cannot be added to it by an edit that looks like housekeeping. */ -const CLUBS = [ - { - slug: "preview-consulting-club", - name: "Preview Consulting Club", - acronym: "PCC", - category: "PROFESSIONAL", - holders: ["Ada Preview", "Bo Sample", "Cy Fixture", "Di Mock", "Eli Sample"], - }, - { - slug: "preview-social-club", - name: "Preview Social Club", - acronym: "PSC", - category: "SOCIAL", - holders: ["Fay Preview", "Gus Sample", "Hal Fixture", "Ivy Mock", "Jo Sample"], - }, -] +const LEGACY_PREVIEW_SLUGS = ["simon-ose-preview"] -function personEmail(name, slug) { - const local = name.toLowerCase().replace(/[^a-z]+/g, ".") - return `${local}@${slug}.preview.invalid` +function refuseRealTenants() { + for (const value of [PREVIEW_INSTITUTION_ID, PREVIEW_INSTITUTION_SLUG, ...LEGACY_PREVIEW_SLUGS]) { + if (FORBIDDEN_SLUGS.has(value)) { + console.error(`\n❌ REFUSED. "${value}" is a real tenant.`) + console.error(" This script deletes and rebuilds the institution it names, so pointing") + console.error(" it at the pilot would reset live data. There is deliberately no override.\n") + process.exit(2) + } + } } -const DAY = 24 * 60 * 60 * 1000 +/** + * The address the production workflow provisions, named here so the contract + * test in `src/lib/__tests__/preview-access-contract.test.ts` can read one + * literal out of this file and compare it with the workflow's `PREVIEW_EMAIL`. + */ +const DEFAULT_OPERATOR_EMAIL = "satvik@tenurework.com" /** - * Deliverables spread across the states a real board actually sees: one overdue, - * two upcoming at different distances, one already past with no urgency. A list - * that is uniformly "due next week" shows none of the states the UI renders. + * `--email ` asserts that address can actually sign in, and refuses if it + * cannot. + * + * NOT folded into the allowlist, and the distinction is the whole point. The + * gate that admits this address is `decidePreviewAccess`, which reads + * `MASTER_ACCESS_EMAILS` from the RUNNING SERVICE's environment — a seeder that + * treated `--email` as a grant would write a `User` row, print success, and + * leave `gateOnEligibility` refusing the sign-in the workflow is about to set a + * password for. That is precisely the half-build this script already refuses to + * produce, arriving through an argument instead of through a missing variable. + * + * This seeder writes NO `RestrictedIdentity` and NO `RestrictedRegistrySeal` + * row — see ADR-0020 §2, and the three constraints named in the comment in + * `auth/restricted-registry.ts`, which are properties of this file. So + * `MASTER_ACCESS_EMAILS` is the only door, and it is a deployment decision. */ -function deliverablesFor(now) { - return [ - { key: "preview-budget-submission", title: "Autumn budget submission", days: -6, seat: "VP_FINANCE" }, - { key: "preview-roster-confirm", title: "Confirm the board roster", days: 3, seat: "PRESIDENT" }, - { key: "preview-event-plan", title: "Signature event plan", days: 21, seat: "VP_EVENTS" }, - { key: "preview-brand-assets", title: "Refresh club brand assets", days: 45, seat: "VP_MARKETING" }, - { key: "preview-charter-review", title: "Annual charter review", days: 90, seat: "ALL" }, - ].map((d) => ({ ...d, dueAt: new Date(now.getTime() + d.days * DAY) })) +function assertOperatorCanGetIn(masters) { + if (!ARGS.email) return true + const wanted = String(ARGS.email).trim().toLowerCase() + if (masters.includes(wanted)) return true + + console.error(`\n❌ REFUSED. ${wanted} is not on MASTER_ACCESS_EMAILS.`) + console.error("") + console.error(" The preview account is admitted by a separate door that reads that variable") + console.error(" from the RUNNING SERVICE (lib/preview/access.ts, ADR-0020 §2). This seeder") + console.error(" writes no RestrictedIdentity and no seal row, so seeding the world would") + console.error(" leave an address the sign-in gate still refuses — a half-build that reports") + console.error(" success. Set it on the service and re-run:") + console.error("") + console.error(` MASTER_ACCESS_EMAILS=${wanted}`) + console.error("") + console.error(" In the pilot that is a change to `environment` in") + console.error(" infrastructure/terraform/ecs.tf followed by a rollout.") + process.exitCode = 1 + return false +} + +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.", } /** - * Memory cards, including a LESSON — the type whose schema comment reads - * "Hard-won insight for the successor". A vault surface with no LESSON in it - * fails to show the thing the vault exists for. + * 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 MEMORY = [ - { - type: "LESSON", - title: "Book the atrium before the term calendar is published", - seat: "VP Events & Partnerships", - content: { body: "Rooms are allocated in the last week of the prior term. Every year somebody discovers this in week two and every year the signature event moves to a worse room." }, - }, +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" }, { - type: "PLAYBOOK", - title: "Running the autumn kickoff", - seat: "President", - content: { body: "Six weeks out: confirm the room. Four: open sign-ups. Two: order catering against the approved budget line, not a personal card." }, + name: "Prof. Alan Beckett", + email: "preview.clubadvisor@tenure.invalid", + kind: "ADVISOR", + affiliation: "Faculty — Strategy", }, { - type: "VENDOR", - title: "Caterer with an existing university agreement", - seat: "VP Finance & Operations", - content: { body: "Already on the approved supplier list, so no separate procurement step. Ask for the student-organisation rate; it is not advertised." }, + 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. { - type: "THREAD", - title: "Open: sponsorship conversation carried over", - seat: null, - content: { body: "Started last term and not concluded. The contact moved teams; the successor should re-open rather than restart." }, + email: "preview.tferreira@tenure.invalid", + name: "Tomas Ferreira", + club: PREVIEW_CLUB_A_SLUG, + seatName: "VP Marketing & Communications", + status: "SHADOW", + startDate: days(120), }, ] -// ── Reporting ─────────────────────────────────────────────────────────────── +// ─── Reset ──────────────────────────────────────────────────────────────────── -const tally = { created: 0, present: 0, skipped: 0 } -function say(kind, what) { - if (kind === "created") tally.created++ - else if (kind === "present") tally.present++ - else tally.skipped++ - const mark = kind === "created" ? "+" : kind === "present" ? "=" : "·" - console.log(` ${mark} ${what}`) +/** + * 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() { + // By id OR slug, and by EVERY slug this tenant has ever had — deliberately, + // and `findMany` rather than `findFirst` for the same reason. + // + // The version of this script that shipped on `main` (#130) upserted the + // preview institution BY SLUG, let Prisma generate the id, and called it + // `simon-ose-preview`. So in a cell where that ran there is a row this branch + // matches on NEITHER field. Looking for one row and stopping would rebuild the + // new tenant beside the old one and report success: a third institution, the + // operator holding a membership in the stale one, and the `RestrictedIdentity` + // the old seeder wrote still sitting in a table the sign-in gate reads without + // an institution filter. + const existing = await db.institution.findMany({ + where: { + OR: [ + { id: PREVIEW_INSTITUTION_ID }, + { slug: { in: [PREVIEW_INSTITUTION_SLUG, ...LEGACY_PREVIEW_SLUGS] } }, + ], + }, + select: { id: true }, + }) + if (existing.length === 0) 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) } } }) + } + + const ids = existing.map((i) => i.id) + await db.auditEvent.deleteMany({ where: { institutionId: { in: ids } } }) + await db.seatMeterEvent.deleteMany({ where: { institutionId: { in: ids } } }) + await db.institution.deleteMany({ where: { id: { in: ids } } }) + return { removed: true } } -// ── Main ──────────────────────────────────────────────────────────────────── +// ─── Build ──────────────────────────────────────────────────────────────────── -async function main() { - const args = parseArgs(process.argv.slice(2)) - const email = normalizeEmail(args.email ?? DEFAULT_EMAIL) - const slug = String(args.slug ?? "").trim() +async function build() { + refuseRealTenants() - if (!email || !email.includes("@")) { - throw new Error(`--email must be an address. Got: ${JSON.stringify(args.email)}`) + 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=${DEFAULT_OPERATOR_EMAIL} 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 } - // Invariant 3, before anything else touches the database. - if (FORBIDDEN_SLUGS.has(slug)) { - console.error( - `\n❌ REFUSED. "${slug}" is a real tenant.\n` + - ` This seeder builds a synthetic preview world and would overwrite live data.\n` + - ` There is deliberately no flag to override this.\n`, + if (!assertOperatorCanGetIn(masters)) 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" }, + }), ) - process.exit(2) } - if (!slug) throw new Error("--institution-slug must not be empty") - console.log(`\nSeeding the rollout-preview tenant`) - console.log(` institution : ${slug}`) - console.log(` operator : ${email}`) - console.log(` mode : ${args.dryRun ? "DRY RUN (no writes)" : args.reset ? "RESET then seed" : "seed"}\n`) + // ── 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}`, + }, + }) + } - if (args.dryRun) { - console.log(" (dry run — nothing was written)\n") - return + // ── 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. + // + // `functionKeys` is DERIVED, never omitted and never hand-typed. It is what + // audience routing and finance authority key off — not the seat's name, which + // is an editable label — and the column has a `[]` default, so a writer that + // leaves it out stores a seat that is silently inert: "VP Finance & + // Operations" renders, its holder cannot write a budget line, and the + // resources board and the deadline reminders skip the seat. Nothing fails. + // + // That is worse here than anywhere else it could happen, because this world + // exists so somebody can see what users will see before rolling out, and a + // preview that understates the product is the one failure this seeder must + // not have. The defect arrived on `main` in the seeder this file replaces + // (via `suggestSeatFunctions`) and would have arrived a third time through + // this merge; `roles/functions.test.ts` reads every `.mjs` seat writer as + // text and is what makes deriving it non-optional. + 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, + functionKeys: suggestSeatFunctions(seat.name), + }, + }) + } } - const now = new Date() + // ── 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, + }, + }) + } - const institution = await db.institution.upsert({ - where: { slug }, - update: { name: "Simon OSE — Rollout Preview" }, - create: { - slug, - name: "Simon OSE — Rollout Preview", - // No `domain`. The domain is used for verified-email matching, and the - // preview must never claim simon.rochester.edu — that would put it in the - // path of real address checks. - timeZone: "America/New_York", - }, - }) - say("created", `institution ${slug}`) - - if (args.reset) { - // Scoped to THIS institution by id, never a bare deleteMany. The seeder that - // can clear rows it did not write is the one that clears somebody else's. - const orgs = await db.organization.findMany({ where: { institutionId: institution.id }, select: { id: true } }) - const orgIds = orgs.map((o) => o.id) - if (orgIds.length) { - await db.memoryRecord.deleteMany({ where: { organizationId: { in: orgIds } } }) - await db.roleAssignment.deleteMany({ where: { institutionId: institution.id } }) - await db.role.deleteMany({ where: { institutionId: institution.id } }) - await db.organization.deleteMany({ where: { institutionId: institution.id } }) + // ── 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, + }, + }) } - await db.deliverable.deleteMany({ where: { institutionId: institution.id } }) - say("created", `reset: cleared the previous preview world (${orgIds.length} clubs)`) } - // ── The operator ────────────────────────────────────────────────────────── - // Lowercased, because `resolveCognitoIdentity` matches exactly on the - // lowercase form — a mixed-case row reads as `no-tenure-account`. - const operator = await db.user.upsert({ - where: { email }, - update: {}, - create: { email, name: "Preview Operator" }, - }) - say("created", `operator user ${email}`) - - // MEMBERSHIP IN THE PREVIEW INSTITUTION ONLY. A second membership would make - // resolveTenantScope return two candidates and pick by ordering — a coin flip - // on whether the preview is looking at synthetic data or the real cohort. - const existingMemberships = await db.institutionMembership.findMany({ - where: { userId: operator.id }, - select: { institutionId: true }, - }) - const foreign = existingMemberships.filter((m) => m.institutionId !== institution.id) - if (foreign.length) { - console.error( - `\n❌ REFUSED. ${email} already holds membership in ${foreign.length} other institution(s).\n` + - ` resolveTenantScope would then return several candidates and pick by ordering,\n` + - ` so the preview could silently scope to the real tenant. Remove those first.\n`, - ) - process.exit(3) + // 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), + }, + }) } - await db.institutionMembership.upsert({ - where: { - userId_institutionId: { userId: operator.id, institutionId: institution.id }, + // ── 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.", }, - update: { role: "OSE_DIRECTOR" }, - create: { userId: operator.id, institutionId: institution.id, role: "OSE_DIRECTOR" }, }) - say("created", `membership OSE_DIRECTOR in ${slug} (and in no other institution)`) - // ── Invariant 2: EXACTLY ONE registry row, and it is the operator's ─────── - // This is the only `restrictedIdentity` write in this file, by design and by - // a contract test. Provenance is set here rather than left null because a row - // without addedBy/addedVia/sourceVersion cannot be sealed, and an unsealed - // registry stops enforcing for everybody. + // ── Budget, lines, and a ledger the actuals are the SUM of ──────────────── // - // Since #127 this row admits the operator only while NO institution is sealed - // — see the warning printed below, and invariant 2 in the header. - await db.restrictedIdentity.upsert({ - where: { - institutionId_emailNormalized: { institutionId: institution.id, emailNormalized: email }, - }, - update: { status: "ACTIVE" }, - create: { + // `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, - emailNormalized: email, - cohort: "PREVIEW_OPERATOR", - source: "seed-preview-world.mjs", - sourceVersion: `preview-${slug}`, - addedBy: "seed-preview-world", - addedVia: "VIA_PREVIEW_SEED", - status: "ACTIVE", + 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.", }, }) - say("created", `registry row for the operator — the ONLY one this seeder writes`) - // The row above is not, on its own, a sign-in. Since #127 the gate pairs a - // roster row with a seal in the SAME institution, and this tenant has no seal - // and never will (invariant 1). So while ANY institution is sealed the - // boundary is in force, and the operator is admitted only if some SEALED - // institution holds an ACTIVE row for them. + 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 ───────────────────────────── // - // Printed rather than left to be found. A preview that seeds cleanly and then - // refuses the person it was seeded for is the kind of failure somebody debugs - // in the wrong table for an hour, and this seeder is the last thing that runs - // before they try it. + // 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 ────────────────────────────────────────────────────────── // - // Both reads are the GATE'S OWN, deliberately: a seal ANYWHERE is what puts - // the boundary in force, and the second is #127's pairing predicate — an - // ACTIVE row for this address in an institution that is itself sealed. A - // first draft asked instead about the roster of the ONE institution a - // `findFirst` over seals happened to return, which with two sealed - // institutions announces a refusal for somebody the gate admits. + // Institution-wide OSE deadlines, published to every club at once. One is + // overdue, several are ahead, and one is behind us and settled. // - // Unconditional and concurrent, so there is no branch here for a regression - // to fall out of. What the two facts MEAN is `previewSignInWarning`'s, which - // is a pure function with a test that enumerates all four combinations — - // review found two defects in this decision while it was inline, which is - // twice more than a decision this size gets to be wrong before it becomes - // somebody's unit. - const [anySeal, sealedRosterRow] = await Promise.all([ - db.restrictedRegistrySeal.findFirst({ select: { id: true } }), - db.restrictedIdentity.findFirst({ - where: { - status: "ACTIVE", - emailNormalized: email, - institution: { registrySeal: { isNot: null } }, + // `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)", }, - select: { institutionId: true }, - }), - ]) - const gateWarning = previewSignInWarning({ - email, - anySeal: Boolean(anySeal), - onSealedRoster: Boolean(sealedRosterRow), + }) + } + + // ── 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.", + }, + }, }) - if (gateWarning) console.warn(gateWarning) - // Invariant 1 is enforced by absence: there is no seal write anywhere in this - // file, and a contract test asserts that. + // ── 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), + }, + }) + } - // ── The clubs, their seats, and the people holding them ────────────────── - for (const club of CLUBS) { - const org = await db.organization.upsert({ - where: { slug: club.slug }, - update: { name: club.name }, - create: { - slug: club.slug, - name: club.name, - acronym: club.acronym, - category: club.category, + // ── 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, - description: "A synthetic club that exists only in the rollout preview.", + organizationId: clubA.id, + title: doc.title, + objectKey: `${institution.id}/${clubA.id}/${doc.key}`, + mimeType: doc.mime, + sizeBytes: doc.bytes, + isArchived: doc.archived, }, }) - say("created", `club ${club.name}`) - - for (const [i, seat] of SEATS.entries()) { - const holderName = club.holders[i] - const positionCode = `${club.acronym}-PREVIEW-${seat.order}` - - // `functionKeys` is what audience routing and finance authority key off — - // NOT the seat's name, which is an editable label. A seat written without - // it is silently inert: the column defaults to `[]`, so nothing fails, - // and "VP Finance & Operations" simply renders in a preview whose holder - // cannot write a budget line and whose seat is skipped by the resources - // board and the deadline reminders. - // - // That is worse here than anywhere else it could happen. The preview - // world exists so somebody can see what users will see before rolling - // out; a preview that understates the product is the one failure this - // seeder must not have. Derived, like every other seat writer, by - // `suggestSeatFunctions` — and re-derived on update so re-running the - // seeder repairs a row written before this fix. - const role = await db.role.upsert({ - where: { positionCode }, - update: { name: seat.name, functionKeys: suggestSeatFunctions(seat.name) }, - create: { - organizationId: org.id, - institutionId: institution.id, - name: seat.name, - scope: seat.scope, - positionCode, - seatOrder: seat.order, - functionKeys: suggestSeatFunctions(seat.name), - }, - }) + } - // Synthetic holders are CONTENT, never identities. They get a User row so - // the People surface renders a name instead of a blank, and they get NO - // registry row — see invariant 2. `.preview.invalid` is reserved by - // RFC 2606 and can never be a deliverable address. - const holder = await db.user.upsert({ - where: { email: personEmail(holderName, club.slug) }, - update: {}, - create: { email: personEmail(holderName, club.slug), name: holderName }, - }) + // ── 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 existing = await db.roleAssignment.findFirst({ - where: { userId: holder.id, roleId: role.id, institutionId: institution.id }, - select: { id: true }, - }) - if (existing) { - say("present", `${club.acronym} ${seat.name} — ${holderName}`) - } else { - await db.roleAssignment.create({ - data: { - userId: holder.id, - roleId: role.id, - institutionId: institution.id, + 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`, + ) + + await reportTheOtherDoor(masters) + return true +} + +/** + * What the ROSTER would do to these addresses, printed after a good run. + * + * Carried over from the seeder on `main`, which printed it because #127 made a + * seeded row stop being a sign-in: a preview can seed perfectly and then refuse + * the person it was seeded for, and that is an hour spent in the wrong table. + * Printed rather than left to be found, for that reason. + * The decision itself is `previewSignInWarning`'s — a pure function with a test + * that enumerates all four combinations, because review found two defects in it + * while it was four lines inline. Both reads below are the GATE'S OWN: a seal + * ANYWHERE is what puts the boundary in force, and the second is #127's pairing + * predicate, an ACTIVE row for this address in an institution that is itself + * sealed. + * + * What changed here is not the question but WHOSE it is. This seeder writes no + * roster row (invariant 2), and `decidePreviewAccess` admits these addresses + * ahead of the roster, so the roster is not the door today and a bare "WILL BE + * REFUSED" would be false every time it fired. It is the door the moment + * `MASTER_ACCESS_EMAILS` is unset on the running service — which is a rollout + * away, is the documented way to CLOSE this feature, and is the state in which + * somebody will be debugging a refusal. So the warning is printed under the + * condition that makes it true rather than dropped for being false under the + * other one. + * + * Best-effort by construction. This runs after the world is built and reports + * on a door it does not own, so a read that fails must not turn a good seed + * into a bad exit code — the account and its world exist either way. + */ +async function reportTheOtherDoor(masters) { + try { + for (const email of masters) { + const [anySeal, sealedRosterRow] = await Promise.all([ + db.restrictedRegistrySeal.findFirst({ select: { id: true } }), + db.restrictedIdentity.findFirst({ + where: { status: "ACTIVE", - startDate: new Date(now.getTime() - 60 * DAY), + emailNormalized: email, + institution: { registrySeal: { isNot: null } }, }, - }) - say("created", `${club.acronym} ${seat.name} — ${holderName}`) - } + select: { institutionId: true }, + }), + ]) + const warning = previewSignInWarning({ + email, + anySeal: Boolean(anySeal), + onSealedRoster: Boolean(sealedRosterRow), + }) + if (!warning) continue + console.warn( + `\n MASTER_ACCESS_EMAILS is what admits ${email} (ADR-0020 §2). If it is ever` + + `\n unset on the running service, this address falls back to the restricted` + + `\n roster — and the roster's answer today is:`, + ) + console.warn(warning) } + } catch (error) { + console.warn(`\n⚠️ Could not read the restricted gate's state: ${error?.message ?? error}`) + console.warn(" The preview world was still built. This note is about the other door only.\n") + } +} - // Memory cards, attached to the seat that holds them where there is one — - // which is the whole point of the vault: knowledge lives in the seat, not - // in the person. - for (const card of MEMORY) { - const role = card.seat - ? await db.role.findFirst({ - where: { organizationId: org.id, name: card.seat }, - select: { id: true }, - }) - : null - const already = await db.memoryRecord.findFirst({ - where: { organizationId: org.id, title: card.title }, - select: { id: true }, - }) - if (already) { - say("present", `${club.acronym} memory: ${card.title}`) - } else { - await db.memoryRecord.create({ - data: { - institutionId: institution.id, - organizationId: org.id, - roleId: role?.id ?? null, - title: card.title, - type: card.type, - content: card.content, - authorId: operator.id, - }, - }) - say("created", `${club.acronym} memory (${card.type}): ${card.title}`) - } +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`) } - // ── Deliverables, across the states a real board sees ──────────────────── - for (const d of deliverablesFor(now)) { - await db.deliverable.upsert({ - where: { key: d.key }, - update: { dueAt: d.dueAt, title: d.title }, - create: { - key: d.key, - institutionId: institution.id, - title: d.title, - dueAt: d.dueAt, - seat: d.seat, - source: "seed-preview-world.mjs", - }, + // 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`) + } + + // ── The two invariants the REAL pilot's access gate depends on ──────────── + // + // `auth/restricted-registry.ts` decides admission BEFORE a session exists, so + // neither of its lookups can be scoped to an institution: the identity read + // is `findMany({ where: { status: "ACTIVE", emailNormalized } })` with no + // institution, and the seal read is `findFirst` with no `where` AT ALL. The + // consequence is that rows written HERE are rows the pilot's gate would act + // on, and the containment is entirely a property of what this seeder writes. + // + // Until now those were sentences in a comment on the file that reads them. + // `preview-access-contract.test.ts` (merged from main) proves the ABSENCE of + // the two writes in this file's SOURCE, which is the right check for a + // regression in this script and the wrong one for a database — a row put + // there by hand, or left behind by the version of this seeder that shipped on + // `main` and did write one, is invisible to it. These two are the same + // invariants asked of the data. + // + // Over EVERY generation of this tenant, not just the current one. `--verify` + // is the half that reads the DATABASE rather than this file's source, so it + // has to be able to see a row the PREVIOUS seeder left in the PREVIOUS + // institution — which is exactly the state a cell is in after the rename and + // before `build()` has run once. Scoped to the current id alone it reported + // "populated and isolated" over a registry row nobody had cleared. + const strayIdentities = await db.restrictedIdentity.count({ + where: { + OR: [ + { institutionId: PREVIEW_INSTITUTION_ID }, + { institution: { slug: { in: [PREVIEW_INSTITUTION_SLUG, ...LEGACY_PREVIEW_SLUGS] } } }, + ], + }, + }) + if (strayIdentities > 0) { + problems.push( + `${strayIdentities} RestrictedIdentity row(s) exist for the preview tenant — the sign-in ` + + `gate's lookup is NOT institution-scoped, so every one is an address the REAL pilot's ` + + `gate would admit. The preview is admitted by MASTER_ACCESS_EMAILS instead (ADR-0020 §2)`, + ) + } + + const straySeals = await db.restrictedRegistrySeal.count({ + where: { + OR: [ + { institutionId: PREVIEW_INSTITUTION_ID }, + { institution: { slug: { in: [PREVIEW_INSTITUTION_SLUG, ...LEGACY_PREVIEW_SLUGS] } } }, + ], + }, + }) + if (straySeals > 0) { + problems.push( + `${straySeals} RestrictedRegistrySeal row(s) exist for the preview tenant — the seal lookup ` + + `has no where clause, so ANY seal row arms the restricted gate for EVERY institution. It ` + + `is inert only while simon-ose is itself sealed, and would silently survive an unseal`, + ) + } + + // 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 when = d.days < 0 ? `${-d.days}d overdue` : `due in ${d.days}d` - say("created", `deliverable ${d.title} (${when})`) + const sum = agg._sum.amountCents ?? 0 + if (sum !== line.actualCents) { + problems.push(`budget line "${line.category}": actual ${line.actualCents} != ledger sum ${sum}`) + } } console.log( - `\n created ${tally.created} · already present ${tally.present} · skipped ${tally.skipped}\n`, + `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}`, ) - console.log(` Sign in at https://simon-ose.tenurework.com/signin as ${email}.`) - console.log(` Tenancy resolves from membership, so that address lands in ${slug}.\n`) + + 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.") } -main() - .then(async () => { - await db.$disconnect() - process.exit(0) - }) - .catch(async (err) => { - console.error(`\n❌ Preview seed FAILED: ${err?.message ?? err}\n`) - if (err?.stack) console.error(err.stack) - await db.$disconnect() - // Non-zero, so the workflow's exit-code read reports a failed seed as failed - // rather than moving on to publish a password for a tenant that is not there. - process.exit(1) +const mode = ARGS.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. */}