From 1c967c416c9759602248fa4d1979bd152ff9a77e Mon Sep 17 00:00:00 2001 From: Isaac S Date: Tue, 22 Sep 2026 15:58:16 -0400 Subject: [PATCH 1/4] Phases 3 and 4: writes on Postgres, accounts on database sessions Phase 3 moves the last writes off Notion. Tickets land in maintenance_logs, corrections in feedback, project submissions in projects and project_tools. POST /api/uploads replaces the Notion upload with Vercel Blob plus an attachments row, and /api/cron/daily does the backup, the blob retention prune and the orphaned-attachment sweep. The Phase 2 notion-ids bridge has no callers left. Phase 4 gives the app real accounts. Better Auth moves onto the Drizzle adapter with database sessions and the admin plugin; the stateless makerlab.identity cookie is gone. Roles live in the users table instead of the AUTH_STAFF_EMAILS and AUTH_ADMIN_EMAILS env lists, with AUTH_SUPER_ADMIN_EMAILS kept as the bootstrap floor. /admin/users changes a role, audit_events records that it happened, and submitting a project now requires sign-in while browsing stays anonymous. All of it runs with no credentials: PGlite serves the writes, an unset blob token makes uploads refuse rather than pretend, and an unset CRON_SECRET makes the cron route refuse. 96 files, 1331 unit tests, 49 E2E on a production build, spec:coverage 0 undocumented. Three major review findings are outstanding and fixed in the next commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE --- docs/deploy.md | 48 +- docs/handover.md | 59 +- .../2026-09-14-v5-data-platform-design.md | 118 + v5/.env.example | 82 +- v5/AGENTS.md | 148 +- v5/e2e/admin-users.spec.ts | 172 ++ v5/e2e/auth.spec.ts | 139 +- v5/e2e/corrections.spec.ts | 39 +- v5/e2e/projects.spec.ts | 156 +- v5/e2e/utils/session.ts | 42 + v5/messages/ar.json | 2 + v5/messages/en.json | 60 +- v5/messages/es.json | 2 + v5/messages/fr.json | 2 + v5/messages/he.json | 2 + v5/messages/hi.json | 2 + v5/messages/ja.json | 2 + v5/messages/ko.json | 2 + v5/messages/pt-BR.json | 2 + v5/messages/ru.json | 2 + v5/messages/tr.json | 2 + v5/messages/zh-CN.json | 2 + v5/playwright.config.ts | 20 + v5/src/app/admin/layout.tsx | 62 + v5/src/app/admin/page.tsx | 44 + v5/src/app/admin/users/action-result.ts | 40 + v5/src/app/admin/users/actions.test.ts | 467 ++++ v5/src/app/admin/users/actions.ts | 266 ++ v5/src/app/admin/users/page.tsx | 58 + v5/src/app/api/admin/backup/route.ts | 11 + v5/src/app/api/admin/revalidate/route.test.ts | 56 +- v5/src/app/api/admin/revalidate/route.ts | 16 +- v5/src/app/api/auth/[...all]/route.test.ts | 111 +- v5/src/app/api/auth/[...all]/route.ts | 100 +- v5/src/app/api/chat/rate-limit.route.test.ts | 42 +- v5/src/app/api/chat/route.test.ts | 217 +- v5/src/app/api/chat/route.ts | 39 +- v5/src/app/api/cron/daily/route.test.ts | 231 ++ v5/src/app/api/cron/daily/route.ts | 124 + v5/src/app/api/flags/route.test.ts | 194 +- v5/src/app/api/flags/route.ts | 22 +- v5/src/app/api/identity/route.test.ts | 101 +- v5/src/app/api/projects/route.test.ts | 653 ++--- v5/src/app/api/projects/route.ts | 162 +- v5/src/app/api/upload-notion/route.ts | 10 + v5/src/app/api/uploads/route.test.ts | 403 +++ v5/src/app/api/uploads/route.ts | 248 ++ v5/src/components/AdminLink.test.tsx | 53 + v5/src/components/AdminLink.tsx | 36 + v5/src/components/ChatFab.test.tsx | 65 +- v5/src/components/ChatFab.tsx | 26 +- v5/src/components/PrimaryNav.test.tsx | 34 +- v5/src/components/PrimaryNav.tsx | 13 +- v5/src/components/ProjectSubmitForm.test.tsx | 186 +- v5/src/components/ProjectSubmitForm.tsx | 83 +- .../components/RefreshCatalogButton.test.tsx | 28 +- v5/src/components/RefreshCatalogButton.tsx | 9 +- v5/src/components/admin/AdminNotice.tsx | 35 + v5/src/components/admin/BanToggle.test.tsx | 123 + v5/src/components/admin/BanToggle.tsx | 114 + v5/src/components/admin/RoleSelect.test.tsx | 122 + v5/src/components/admin/RoleSelect.tsx | 121 + v5/src/components/admin/UsersTable.test.tsx | 151 ++ v5/src/components/admin/UsersTable.tsx | 132 + v5/src/lib/auth/config.test.ts | 179 +- v5/src/lib/auth/config.ts | Bin 5748 -> 8150 bytes v5/src/lib/auth/floor-role.test.ts | 115 + v5/src/lib/auth/floor-role.ts | 92 + v5/src/lib/auth/identity.test.ts | 282 +- v5/src/lib/auth/identity.ts | 204 +- v5/src/lib/auth/permissions.test.ts | 233 ++ v5/src/lib/auth/permissions.ts | 184 ++ v5/src/lib/auth/roles.test.ts | 86 +- v5/src/lib/auth/roles.ts | 91 +- v5/src/lib/auth/session-cookie.ts | 12 + v5/src/lib/auth/sign-in-client.test.ts | 8 +- v5/src/lib/auth/super-admins.test.ts | 56 + v5/src/lib/auth/super-admins.ts | 42 + v5/src/lib/blob.test.ts | 74 + v5/src/lib/blob.ts | 74 +- v5/src/lib/capabilities/access.test.ts | 109 +- v5/src/lib/capabilities/access.ts | 68 +- v5/src/lib/capabilities/flags.test.ts | 303 +-- v5/src/lib/capabilities/flags.ts | 221 +- v5/src/lib/capabilities/index.ts | 13 +- v5/src/lib/capabilities/intake.test.ts | 77 +- v5/src/lib/capabilities/intake.ts | 43 +- v5/src/lib/capabilities/maintenance.test.ts | 298 ++- v5/src/lib/capabilities/maintenance.ts | 170 +- v5/src/lib/capabilities/types.ts | 49 +- v5/src/lib/chat/photo-parts.test.ts | 5 +- v5/src/lib/chat/photo-parts.ts | 6 +- v5/src/lib/cron/backup-policy.test.ts | 80 + v5/src/lib/cron/backup-policy.ts | 83 + v5/src/lib/cron/backup.test.ts | 211 ++ v5/src/lib/cron/backup.ts | 167 ++ v5/src/lib/cron/cleanup.test.ts | 134 + v5/src/lib/cron/cleanup.ts | 76 + v5/src/lib/data/attachments.test.ts | 263 ++ v5/src/lib/data/attachments.ts | 209 ++ v5/src/lib/data/audit.test.ts | 155 ++ v5/src/lib/data/audit.ts | 138 + v5/src/lib/data/feedback.test.ts | 126 + v5/src/lib/data/feedback.ts | 75 + v5/src/lib/data/maintenance.test.ts | 205 +- v5/src/lib/data/maintenance.ts | 167 +- v5/src/lib/data/notion-ids.ts | 8 +- v5/src/lib/data/projects.test.ts | 203 +- v5/src/lib/data/projects.ts | 166 +- v5/src/lib/data/users.test.ts | 127 + v5/src/lib/data/users.ts | 122 + v5/src/lib/db/demo-seed.test.ts | 37 +- v5/src/lib/db/demo-seed.ts | 101 +- v5/src/lib/db/migrations/0003_better_auth.sql | 88 + .../lib/db/migrations/meta/0003_snapshot.json | 2279 +++++++++++++++++ v5/src/lib/db/migrations/meta/_journal.json | 9 +- v5/src/lib/db/schema/audit.ts | 6 +- v5/src/lib/db/schema/auth.test.ts | 136 + v5/src/lib/db/schema/auth.ts | 131 + v5/src/lib/db/schema/checks.ts | 32 + v5/src/lib/db/schema/helpers.ts | 40 +- v5/src/lib/db/schema/index.ts | 9 +- v5/src/lib/lab-time.test.ts | 66 + v5/src/lib/lab-time.ts | 65 + v5/src/lib/rate-limit.test.ts | 22 +- v5/src/lib/rate-limit.ts | 17 +- v5/src/styles/globals.css | 202 ++ v5/test/README.md | 143 +- v5/test/mocks/next-cache.ts | 1 + v5/test/mocks/next-headers.ts | 37 + v5/test/utils/better-auth-cookie.ts | 41 + v5/test/utils/session.test.ts | 91 + v5/test/utils/session.ts | 176 ++ v5/vercel.json | 2 +- 134 files changed, 13578 insertions(+), 2075 deletions(-) create mode 100644 v5/e2e/admin-users.spec.ts create mode 100644 v5/e2e/utils/session.ts create mode 100644 v5/src/app/admin/layout.tsx create mode 100644 v5/src/app/admin/page.tsx create mode 100644 v5/src/app/admin/users/action-result.ts create mode 100644 v5/src/app/admin/users/actions.test.ts create mode 100644 v5/src/app/admin/users/actions.ts create mode 100644 v5/src/app/admin/users/page.tsx create mode 100644 v5/src/app/api/cron/daily/route.test.ts create mode 100644 v5/src/app/api/cron/daily/route.ts create mode 100644 v5/src/app/api/uploads/route.test.ts create mode 100644 v5/src/app/api/uploads/route.ts create mode 100644 v5/src/components/AdminLink.test.tsx create mode 100644 v5/src/components/AdminLink.tsx create mode 100644 v5/src/components/admin/AdminNotice.tsx create mode 100644 v5/src/components/admin/BanToggle.test.tsx create mode 100644 v5/src/components/admin/BanToggle.tsx create mode 100644 v5/src/components/admin/RoleSelect.test.tsx create mode 100644 v5/src/components/admin/RoleSelect.tsx create mode 100644 v5/src/components/admin/UsersTable.test.tsx create mode 100644 v5/src/components/admin/UsersTable.tsx create mode 100644 v5/src/lib/auth/floor-role.test.ts create mode 100644 v5/src/lib/auth/floor-role.ts create mode 100644 v5/src/lib/auth/permissions.test.ts create mode 100644 v5/src/lib/auth/permissions.ts create mode 100644 v5/src/lib/auth/super-admins.test.ts create mode 100644 v5/src/lib/auth/super-admins.ts create mode 100644 v5/src/lib/cron/backup-policy.test.ts create mode 100644 v5/src/lib/cron/backup-policy.ts create mode 100644 v5/src/lib/cron/backup.test.ts create mode 100644 v5/src/lib/cron/backup.ts create mode 100644 v5/src/lib/cron/cleanup.test.ts create mode 100644 v5/src/lib/cron/cleanup.ts create mode 100644 v5/src/lib/data/attachments.test.ts create mode 100644 v5/src/lib/data/attachments.ts create mode 100644 v5/src/lib/data/audit.test.ts create mode 100644 v5/src/lib/data/audit.ts create mode 100644 v5/src/lib/data/feedback.test.ts create mode 100644 v5/src/lib/data/feedback.ts create mode 100644 v5/src/lib/data/users.test.ts create mode 100644 v5/src/lib/data/users.ts create mode 100644 v5/src/lib/db/migrations/0003_better_auth.sql create mode 100644 v5/src/lib/db/migrations/meta/0003_snapshot.json create mode 100644 v5/src/lib/db/schema/auth.test.ts create mode 100644 v5/src/lib/db/schema/auth.ts create mode 100644 v5/src/lib/db/schema/checks.ts create mode 100644 v5/src/lib/lab-time.test.ts create mode 100644 v5/src/lib/lab-time.ts create mode 100644 v5/test/mocks/next-headers.ts create mode 100644 v5/test/utils/better-auth-cookie.ts create mode 100644 v5/test/utils/session.test.ts create mode 100644 v5/test/utils/session.ts diff --git a/docs/deploy.md b/docs/deploy.md index 7d9fcba..9be372b 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -105,21 +105,43 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= AUTH_BASE_URL=http://localhost:3000 AUTH_ALLOWED_EMAIL_DOMAIN=cornell.edu -AUTH_STAFF_EMAILS=you@cornell.edu # gives you the Refresh catalogue button +AUTH_SUPER_ADMIN_EMAILS=you@cornell.edu # the floor — see below ``` +`AUTH_SECRET` alone is enough for sessions; the two `GOOGLE_*` variables are +what make *starting* one possible. With them unset, `/api/auth/sign-in/social` +answers 503 and the header says sign-in is not set up here. + +`AUTH_SUPER_ADMIN_EMAILS` is a **floor, not a roster**. Everyone else's role is +the `user.role` column, changed on `/admin/users`; an address listed here is +created as `super_admin` on first sign-in and stays one whatever its row says. +It is the only way the first super admin comes to exist (no user row exists +until somebody signs in) and the reason the lab cannot lock itself out. +`AUTH_STAFF_EMAILS` and `AUTH_ADMIN_EMAILS` were removed in Phase 4 — nothing +reads them, so delete them from the deployment's environment rather than +leaving a list that grants nothing. + Tickets and projects now record the **verified session** instead of a typed name. Anonymous browsing and chat keep working — sign-in unlocks, it does not gate the front door. -## Stage 4 · Backups locally (optional) +## Stage 4 · The nightly job locally (optional) Cron does not run locally. Trigger it by hand: ```bash curl -H "x-admin-secret: $ADMIN_REVALIDATE_SECRET" \ - http://localhost:3000/api/admin/backup + http://localhost:3000/api/cron/daily ``` +It exports every Postgres table to a private blob and sweeps photos that were uploaded but +never attached to anything. It needs `BLOB_READ_WRITE_TOKEN` and refuses without it — the +old `/api/admin/backup` route it replaces dumped Notion and is no longer scheduled. + +Two tables are held out of the file on purpose: `session` and `verification` are sign-in +credentials, not records, and the Google tokens on `account` are blanked. A backup is +something you might email to yourself at 2am; it must not double as a way to sign in as +somebody. People, roles and bans are all still in there. + --- # Part 2 — On Vercel @@ -145,8 +167,17 @@ ADMIN_REVALIDATE_SECRET cache invalidation **Sign-in** — same as Stage 3, but `AUTH_BASE_URL=https://` and the Google redirect URI updated to match. -**Backups** — link a Vercel Blob store (sets `BLOB_READ_WRITE_TOKEN`), then set -`CRON_SECRET`. The cron itself is already in `vercel.json`, nightly at 07:17. +**Blob store** — link one (it sets `BLOB_READ_WRITE_TOKEN`). It carries **both** jobs now: +every photo a student uploads through the chat or the project form, and the nightly backup. +Without it the site still runs — uploads say so and the catalogue is unaffected — but +nothing is backed up and no photo can be attached. + +**Backups** — with the store linked, set `CRON_SECRET`. The cron is already in +`vercel.json`, nightly at 07:17, pointing at `/api/cron/daily`. + +**`LAB_TIMEZONE`** — optional, defaults to `America/New_York`. It decides the date on a +maintenance ticket; a function running in UTC would otherwise date an evening report +tomorrow. Set it before the first ticket is filed, or leave it to the default. **Optional** — `NOTION_DB_PROJECTS`, `UPSTASH_REDIS_REST_*` (rate limits enforced across instances rather than per-process), `MCP_TOKEN` (also the switch that exposes write tools @@ -188,10 +219,9 @@ Send alerts to a shared address, never one person. | | Blocks | |---|---| | Google OAuth client | Sign-in anywhere | -| `AUTH_STAFF_EMAILS` / `AUTH_ADMIN_EMAILS` | Staff features — **these two lists are the entire role system**; there is no user database | -| Notion: Flags `status` → `New` option | Corrections, silently | -| Notion: Projects DB + `published` checkbox | The gallery | -| Vercel Blob + `CRON_SECRET` | Backups — **there is currently no backup at all** | +| `AUTH_SUPER_ADMIN_EMAILS` | The first super admin, and therefore **every role**: nobody can be promoted until somebody can reach `/admin/users`. Roles live in `user.role` now; `AUTH_STAFF_EMAILS` / `AUTH_ADMIN_EMAILS` are retired and should be deleted from the environment | +| Vercel Blob store | **Photo uploads** (chat, maintenance, projects) and backups. Without it uploads refuse with a translated message rather than failing silently | +| Vercel Blob + `CRON_SECRET` | The nightly backup — **there is currently no backup at all** | | Inference spend limit | Nothing, until it does | | Uptime monitor | Nothing, until something breaks quietly | diff --git a/docs/handover.md b/docs/handover.md index a96b648..363a4a6 100644 --- a/docs/handover.md +++ b/docs/handover.md @@ -128,9 +128,12 @@ for what remains to verify before production traffic goes through it. ### Handle a maintenance ticket -Tickets from the assistant land in **Maintenance_Logs**, with photos if the student attached -any. Work them in Notion: set `status` to `In Progress`, then `Resolved`, and fill in -`resolution`. +Tickets from the assistant land in the **`maintenance_logs`** table in Postgres, with photos +if the student attached any. Corrections students report land in **`feedback`**, and project +submissions in **`projects`** (unpublished until staff publish them). None of the three go +to Notion any more, and the admin queues that work them are a later phase — until then, +working a ticket (`status` → `in_progress` → `resolved`, plus `resolution`) needs a +developer **[dev]**. Nothing in the app enforces this. **A ticket queue nobody reads is worse than no ticket queue** — students stop reporting after a couple of unanswered reports. Decide who checks @@ -157,21 +160,26 @@ Environment variables in Vercel, no code change: `NEXT_PUBLIC_SITE_NAME`, ### Check the nightly backup is still running -**Every night at 07:17 UTC (about 03:17 New York) the site backs up Notion.** Vercel Cron -calls `/api/admin/backup`, which reads every Notion database and writes one file to private -Vercel Blob storage as `backups/YYYY-MM-DD.json`. Files older than **30 days** are deleted -by the same job, so the store holds roughly a month at any time. +**Every night at 07:17 UTC (about 03:17 New York) the site backs itself up.** Vercel Cron +calls `/api/cron/daily`, which exports **every Postgres table** and writes one file to +private Vercel Blob storage as `backups/YYYY-MM-DD.json`. Files older than **30 days** are +deleted by the same job, so the store holds roughly a month at any time. The same run also +deletes photos that were uploaded but never attached to anything within 24 hours. -**This is the only copy of the Notion data outside Notion.** Before it existed, one deleted -database meant ~100 machines of staff work was gone for good. +**This is the only copy of the data outside Neon.** Before it existed, one deleted database +meant ~100 machines of staff work was gone for good. + +> The job used to dump Notion at `/api/admin/backup`. Postgres is the source of truth now, +> so the file holds database rows and its `source` field reads `postgres`. A file written +> before September 2026 holds raw Notion pages instead. Three settings in Vercel make it work, and it does nothing without all three: | Setting | Where | What it is | |---|---|---| -| A **Blob store** linked to the project | Vercel → Storage | Sets `BLOB_READ_WRITE_TOKEN` automatically | +| A **Blob store** linked to the project | Vercel → Storage | Sets `BLOB_READ_WRITE_TOKEN` automatically. **Also required for photo uploads** — with no store, the chat and the project form say photo uploads are unavailable instead of failing silently | | `CRON_SECRET` | Vercel env vars | Vercel sends it so the route knows the nightly call is genuine | -| `ADMIN_REVALIDATE_SECRET` | Vercel env vars | Lets a person trigger a backup by hand (same secret as §4) | +| `ADMIN_REVALIDATE_SECRET` | Vercel env vars | Lets a person trigger the job by hand (same secret as §4) | **How to check it, once a month:** Vercel dashboard → your project → **Cron Jobs**. A green run means a file was written. **A red run means the backup did not happen** — the route @@ -181,21 +189,28 @@ is discovered on the day you need it. The failure reason is in the run's log. To run one by hand, or to confirm it works after changing anything: ``` -GET https:///api/admin/backup +GET https:///api/cron/daily Header: x-admin-secret: ``` -It answers with the file it wrote, how many rows came from each database, and which old -files it deleted. Anything other than `200` is a real failure. +It answers with the file it wrote, how many rows came from each table, which old files it +deleted, and what the orphaned-photo sweep removed. Anything other than `200` is a real +failure, and the body names which stage broke. > [!WARNING] -> **The backup contains student names and email addresses** from Maintenance_Logs. It is -> written to *private* blob storage and must stay that way — never make the store public, -> never share a download link, and list it in whatever data inventory the university keeps. - -**To restore:** download the file from Vercel → Storage → Blob, and re-import the affected -database. The file holds the raw Notion rows, so a person can read it and rebuild from it. -There is no automated restore, on purpose — it is far more work than the failure justifies. +> **The backup contains student names and email addresses** from `maintenance_logs` and +> `feedback`. It is written to *private* blob storage and must stay that way — never make +> the store public, never share a download link, and list it in whatever data inventory the +> university keeps. +> +> It deliberately contains **no sign-in credentials**: the `session` and `verification` +> tables are skipped and the Google tokens on `account` are blanked, so somebody holding a +> backup file cannot use it to sign in as anybody. People, their roles and their bans *are* +> in it, because that is the state a restore most needs to get right. + +**To restore:** download the file from Vercel → Storage → Blob. The file holds the table +rows as JSON, so a person can read it and rebuild from it. There is no automated restore, on +purpose — it is far more work than the failure justifies. **[dev]** for anything beyond reading the file. --- @@ -225,7 +240,7 @@ effect. | Vercel logs | `DbUnavailableError` (Postgres unreachable) | Whenever the catalogue looks odd | | Vercel → Storage | The Neon database is reachable | Whenever the catalogue looks odd | | Vercel → Cron Jobs | The nightly backup ran green | Monthly — see §3 | -| Notion: Maintenance_Logs | Open tickets | Per §3 | +| Postgres: `maintenance_logs` | Open tickets | Per §3 | **The one alert that matters: an Anthropic spend threshold.** Everything else is recoverable; an unbounded bill is not. diff --git a/docs/specs/2026-09-14-v5-data-platform-design.md b/docs/specs/2026-09-14-v5-data-platform-design.md index c9cfa90..d05c28c 100644 --- a/docs/specs/2026-09-14-v5-data-platform-design.md +++ b/docs/specs/2026-09-14-v5-data-platform-design.md @@ -1029,3 +1029,121 @@ Appended per [`DRIFT.md`](DRIFT.md). Original text above is never edited — the - **The demo seed has one published sample project** (a laser-cut lamp built with both demo tools, with two bundled photos under `public/sample-projects/`, materials and an outside link) and gives the Trotec a photo attachment, since its bundled image file is not named after the tool. The gallery, the project page and "built with this" are therefore exercised end to end without a database. **Status.** Accepted. The bridge and the prerender change are the two a reader should know about before Phase 3. + +### 2026-09-21 — Phase 3 built, with as-built details + +**What changed.** Phase 3 (§9) is implemented: the last three student-facing writes are on Postgres (`report_issue` → `maintenance_logs`, `report_correction` → `feedback`, `POST /api/projects` → `projects` + `project_tools`), `POST /api/uploads` replaces `POST /api/upload-notion` (Vercel Blob plus an `attachments` row), and `GET /api/cron/daily` replaces `GET /api/admin/backup` (a JSON export of every Postgres table, a 30-day prune, and an orphaned-upload sweep). The whole phase runs with every environment variable unset. Details the spec did not name: + +- **The upload id is a cross-phase contract.** `UploadedImage.file_upload_id` became `attachmentId`, and the chat hint became `[Attached photos: attachment_id= name=; …]`. `report_issue`'s input is `photo_attachment_ids: string[]`. The ripple the spec does not mention is intake: §9 leaves `create_tool` on Notion until Phase 6, and a Postgres uuid is not a Notion `file_upload_id` — Notion rejects the *whole page* when it does not recognise one. So `create_tool` now sends **no** images and pushes a warning saying the photos stayed in the app, and the intake prompt tells the model to relay that rather than claim the photo is on the listing. Losing the picture beats losing the listing (Article 4). +- **`previewUrl` is `string | null`, not the unconditional string §3.3 implies.** A private blob has no URL an unauthenticated viewer can follow, so a maintenance photo comes back with `previewUrl: null`; both clients already hold a local `URL.createObjectURL` preview, so nothing is lost. +- **Chat uploads are private, which §3.3's table does not cover.** The table lists maintenance photos as private and tool/resource/project photos as public, but a photo attached in chat is not yet either — it may become a ticket photo (which may show a person) or a pending-tool photo. It is stored `private`, the conservative half, since nothing renders a chat photo on a public page. Phase 6 decides what happens when such a photo is approved onto a tool. +- **`src/lib/blob.ts` gained a second write verb rather than a parameter.** Its doc comment said `access: "private"` and `addRandomSuffix: false` were "not a caller's decision"; both are wrong for uploads. `put` still writes backups privately at the exact pathname it is given, because that pathname *is* the retention key. `putUpload` writes at a random pathname (so an unpublished tool image is unguessable, and two `IMG_0001.jpg`s do not collide) with the caller's access. The filename is sanitised before it becomes part of a pathname. +- **`/api/cron/daily` kept both of `/api/admin/backup`'s accepted callers.** §3.10 folds the backup into the cron without saying whether the documented hand-trigger survives. It does: the cron bearer *and* `ADMIN_REVALIDATE_SECRET`, because `docs/deploy.md` tells an operator to run the job by hand after the first deploy. "Unconfigured" therefore means neither secret is set, which is the credential-free default. +- **The backup discovers its tables rather than listing them.** `backupTables()` filters the schema module for Drizzle tables, the same reasoning the old route used when it derived its targets from the env contract: Phase 4's four Better Auth tables and Phase 6's `pending_tools` are backed up because they exist, not because somebody remembered. The file is `version: 2, source: "postgres"`; version 1 held raw Notion pages, and a restore has to be able to tell them apart. Its date is UTC, not `LAB_TIMEZONE` — the retention window reads that date back, and it names a file rather than a ticket. +- **Cleanup deletes the blob before the row.** A row without a blob is a broken image on a page; a blob without a row is invisible and gets swept next run. Of the two half-failures the second is preferable, so the order is deliberate and tested. Pending-tool expiry (§4.10) is deliberately out of scope: that table has no writer until Phase 6. +- **Uploads clean up after themselves.** A blob write that succeeds and whose `attachments` insert then fails deletes the blob before answering 502 — the cron only sweeps files that *have* a row, so those bytes would otherwise be unreachable forever. +- **Phase 3 adds no `revalidateTag`.** §3.9 says writes invalidate their tags, but none of these writes touches a cached read: tickets and corrections are not cached, and a submitted project is unpublished so it cannot appear in `getPublishedProjects()`. The Phase 2 amendment deferred the `tool:` tag to "the write paths that would invalidate it" — that turns out to be Phase 5's publish. +- **No migration was needed.** Phase 1 created every column and CHECK these writes use, including `attachments.owner_type`'s list. +- **Retired but not deleted** (constitution working agreements — each deletion is proposed separately): `src/lib/data/notion-ids.ts` and its test, now importer-less; `src/app/api/upload-notion/` and `src/app/api/admin/backup/`, both superseded and no longer scheduled or linked. Each file carries a header saying so. `src/lib/notion.ts` itself cannot be retired — intake still uses `createTool`/`createUnit`/`createResource`/`findOrCreateCategory`/`findOrCreateLocation`, and `src/lib/import/source.ts` still uses `hasProjectsEnv`/`fetchAllProjects` — but `createMaintenanceLog`, `createProject` and `ProjectWriteFields` are now dead exports. +- **E2E got more real.** `corrections.spec.ts` and `projects.spec.ts` each gained one uncaptured, end-to-end submission against the E2E server's own PGlite, which was impossible while the write needed a Notion credential. The project one asserts that the submitted project does **not** appear in the gallery — Article 5's draft-by-default, made visible. `playwright.config.ts` now blanks `BLOB_READ_WRITE_TOKEN` and `CRON_SECRET` too, so a developer with a real store linked gets the same run as CI and no test can put bytes in it. + +**Status.** Accepted. The upload-id rename and intake's photo warning are the two a reader should know about before Phase 4. + +### 2026-09-21 — Phase 4 part 1 built (accounts and permissions), with as-built details + +**What changed.** The first half of Phase 4 (§9) is implemented: Better Auth on the Drizzle adapter with **database sessions** and the admin plugin (§3.4), the access-control declaration and `can()` (§3.5), `requiredPermission` on capabilities, `role` in `/api/identity` from the database, and `AUTH_STAFF_EMAILS` / `AUTH_ADMIN_EMAILS` retired in favour of `user.role` with `AUTH_SUPER_ADMIN_EMAILS` kept as the floor. Migration `0003_better_auth.sql` adds `user` / `session` / `account` / `verification`, the `user_role_check` CHECK, and the `created_by` / `updated_by` / `audit_events.actor_user_id` foreign keys Phase 1 deferred. The whole suite — 1208 unit/integration tests and 39 E2E — runs with every environment variable unset. Still to come in part 2: `/admin/users` and its server actions, `src/lib/data/audit.ts` and `users.ts`, and the sign-in requirement on project submission. + +Details the spec did not name, or named differently: + +- **`hasAuthEnv()` had to split.** It required `AUTH_SECRET` *and* both `GOOGLE_*`, which after Phase 4 would mean no database sessions without a Google client — and §10's E2E needs exactly that. It is now `hasSessionEnv()` (the secret alone) and `hasGoogleEnv()`. The Google gate moved to the route: `POST /api/auth/sign-in/social` answers 503 when the client is unconfigured, which is the `"unconfigured"` notice `sign-in-client.ts` already renders. +- **`getAuth()` is async.** `drizzleAdapter` needs the handle synchronously and `getDb()` is a promise (PGlite migrates and seeds on first use), so `getAuth(): Promise` and its memo key is the env fingerprint **plus `dataSubstrate()`** — a memo keyed only on env hands back an instance pointed at the previous database. +- **`resolveIdentity` is memoized with a `WeakMap>`, not React `cache()`.** `cache()` only memoizes inside a React render scope; in a Route Handler and under Vitest it silently does nothing, turning one session lookup per request into one per caller. A `resolveIdentityFromHeaders()` variant over `next/headers` exists for the server components part 2 needs; it is deliberately not memoized, because `next/headers` returns a fresh object per call. +- **`resolveIdentity` gained a `DbUnavailableError` branch.** Neon configured but unreachable logs and resolves anonymous rather than 500-ing a public page (Article 4). +- **The four tables are hand-written from the library, not from `npx @better-auth/cli generate`.** That CLI is not a dependency and wants the network. The authoritative offline source is `@better-auth/core/dist/db/get-tables.mjs` plus `better-auth/dist/plugins/admin/schema.d.mts`, and the constraint that actually matters is that each Drizzle table's *property keys* are Better Auth's camelCase field names (`@better-auth/drizzle-adapter` resolves a field by property name); the SQL column names stay snake_case like the rest of the schema. No `updated_at` trigger is attached — the library writes `updatedAt` itself, and a table it owns should not have a second writer. +- **`statement` includes the admin plugin's own `user` and `session` resources.** §3.5's sketch lists only the app's resources, but the plugin authorizes `set-role` against `{ user: ["set-role"] }`, so a declaration that omitted them would make every admin endpoint refuse everybody, super admins included. `super_admin` holds `user: [list, get, set-role, ban, update]` and `session: [list, revoke]`; impersonation, create, delete, set-password and set-email are granted to **nobody**, because v5 never signs in as somebody else and accounts come from Google and nowhere else. +- **The schema has a new leaf module, `src/lib/db/schema/checks.ts`.** `helpers.ts` now imports `auth.ts` (for the `user.id` reference on `actorColumns()`), and `auth.ts` needs a CHECK for `user.role`. Moving `inList` / `inListCheck` into a leaf keeps the pair acyclic rather than relying on ESM's tolerance for a cycle; `helpers.ts` re-exports both names, so every existing import still resolves. +- **`created_by` / `updated_by` are `on delete set null`, not `cascade`.** Deleting a person must never delete the catalogue they built. The same applies to `audit_events.actor_user_id` — deleting somebody must not delete the record that their role was changed. +- **`roles.ts` kept the domain rule and lost everything else.** `roleRank`, `isAtLeast`, `staffEmails`, `adminEmails` and `roleForEmail` are gone; `IDENTITY_ROLES` is `["anonymous", ...ROLES]` built from `db/schema/vocabulary.ts`, so the type and the CHECK cannot drift. Because `admin` means different things in the two vocabularies, every fixture was rewritten rather than renamed: `student` → `user`, `staff` → `admin`, old `admin` → `super_admin`. +- **`isSuperAdminFloor` applies the domain rule too.** An address outside `AUTH_ALLOWED_EMAIL_DOMAIN` can never be a row, so honouring a floor entry for one would grant the highest role to an account that cannot otherwise exist. A typo in the env list fails closed. +- **A Better Auth session cookie can be minted by hand, and that is what makes roles testable without Google.** `better-call/dist/crypto.mjs` signs a cookie as `encodeURIComponent(value + "." + btoa(HMAC-SHA256(secret, value)))` under `better-auth.session_token` (unprefixed on http). `test/utils/session.ts` does that against seeded rows, and `test/utils/session.test.ts` proves the format against a real `auth.api.getSession()` — the one assertion that licenses every other test to trust the helper. No backdoor route exists in the app. +- **The demo seed ships one account per role** (`DEMO_ACCOUNTS`) with constant session tokens, and `playwright.config.ts` boots with a test-only `AUTH_SECRET` and blank `GOOGLE_*`. `e2e/auth.spec.ts` no longer stubs `/api/identity`: it sets a genuinely signed cookie and the real route reads the real row. The E2E therefore proves the property the phase exists for — same cookie kind, different row, different controls. Those rows exist only in the PGlite substrate; a deployment with `DATABASE_URL` set never sees them, and the tokens are worthless without the secret that signs them. +- **`AUTH_STAFF_EMAILS` / `AUTH_ADMIN_EMAILS` cannot "seed the first admin rows" as §3.11 says**, because no `user` row exists until somebody signs in. In practice the floor is the only bootstrap: the first super admin signs in, then promotes people on `/admin/users`. Open question 3 has no automatic implementation — it is a manual step at the SuperMaker session. +- **Retired but not deleted** (constitution working agreements — each deletion is proposed separately): `src/lib/auth/session-cookie.ts` and its test. Nothing in the application imports them; the file carries a header saying so. +- **Unchanged on purpose:** `/api/mcp` still has no role gate — `MCP_TOKEN` is MCP's whole trust model and an MCP caller has no role — and `/api/admin/backup` (already superseded by `/api/cron/daily`) remains secret-gated rather than role-gated. + +**Status.** Accepted. The `getAuth()` async signature, the `WeakMap` memoization, and the fact that `created_by` now has a foreign key are the three a reader should know before touching part 2. + +### 2026-09-21 — Phase 4 part 2 built (the admin surface and the audit trail), with as-built details + +**What changed.** Phase 4 is complete. `/admin/users` (§5.2, §6) lists everyone who has signed in and changes their role or bans them, through two server actions — the first in the app. `src/lib/data/audit.ts` writes `role.changed` and `user.banned` into `audit_events` (§4.11). `POST /api/projects` now requires sign-in (§5.5) and takes the byline from the session; `/projects/new` shows "Sign in to share your project" to an anonymous visitor. `AdminLink` in the header opens `/admin` for anyone holding an admin-surface permission. The whole suite — 1290 unit/integration tests and 49 E2E — still runs with every environment variable unset. + +Details the spec did not name, or named differently: + +- **`/admin` has an index page the spec put in Phase 5.** §6 gives the header's `AdminLink` to anyone with *any* admin permission and points it at `/admin`, but `/admin` was to be `AdminHome` with counts and queues that do not exist yet. Linking a SuperMaker to a 404 is worse than a short page, so `/admin/page.tsx` lists the surfaces the viewer can actually open — one, so far — and says the rest arrive later. Phase 5 replaces its body, not its route. +- **The gate is in two places on purpose.** The layout answers the coarse question (signed in? any admin-surface permission?) so every page under it can check only what it needs. A SuperMaker therefore gets *through* the layout and is refused on `/admin/users`, which is the honest answer — `users.manage` is a director's, and saying so beats a 404. `canReachAdmin` / `ADMIN_SURFACE_PERMISSIONS` were added to `auth/permissions.ts` so the header and the layout cannot disagree about what "an admin surface" means. +- **Refusals are return values, not exceptions.** A thrown error in a server action reaches the browser as a digest and an error boundary, which is the wrong shape for "you cannot demote the floor address, and here is why". Each action answers `{ ok: false, error: }` and the island renders the matching `admin.errors.` string. The codes and `ADMIN_USERS_PATH` live in `action-result.ts`, because a `"use server"` module may export **only async functions** — every export becomes a callable endpoint. +- **Server actions travel to the islands as props.** `RoleSelect` and `BanToggle` take the action rather than importing it. A client component importing `actions.ts` would pull `next/headers`, the rate limiter and `server-only` into its graph and make it untestable without a Next runtime; the page that renders them is a server component that already holds the action. `UsersTable` is a server component with no `async` for the same reason — everything it needs is a prop, so RTL can mount it. +- **There are two lock-out guards, not one.** §8 names the floor; §10 separately names "the last super admin demotes themselves", and a deployment with `AUTH_SUPER_ADMIN_EMAILS` unset — a preview, a fork, the E2E server — can genuinely do it. So a demotion is refused when the target is on the floor (`protected_floor`) *or* when no unbanned `super_admin` would remain (`last_super_admin`). Banned super admins are not counted: they resolve to anonymous and can undo nothing. +- **Banning has no "last super admin" guard, deliberately.** Reaching that code means the *caller* holds `users.manage`, so banning somebody else cannot leave the lab without a director; the only self-directed case is refused earlier as `self_ban`. Better Auth refuses a self-ban too, but with an error code the page would have to translate. +- **The islands do not use `useTransition`, and that was a bug fix rather than a style choice.** A transition's pending state covers the action *and* the `revalidatePath` re-render it triggers, so the select sat on "Saving…" until a whole page had been rendered again — seconds under load, and the E2E caught it as a save that looked stuck for twenty seconds. The confirmation now comes from the awaited result; the revalidation still happens, it just no longer holds the confirmation hostage. Anything Phase 5 builds on this pattern should do the same. +- **`AUDIT_ACTIONS` has no `user.unbanned`**, so lifting a ban is recorded as `user.banned` with `detail.banned: false`. Adding a vocabulary term the spec does not list would be the worse drift. +- **`audit.ts` exports no mutator, and a test asserts it.** Append-only is only as good as the guarantee that nobody rewrote a row; with the app and the migrations sharing one connection string, a Postgres privilege is not available, so the enforcement is that the update does not exist in the codebase — checked by asserting the module's export shape. +- **A new `test/mocks/next-headers.ts`**, matching `next-cache.ts`: `nextHeadersMock()` plus a `setMockHeaders()` whose state lives in the mock module, because `vi.mock`'s factory is hoisted above the test's imports and may not close over anything. `next-cache.ts` gained `revalidatePath`. +- **The demo seed gained a fourth account, `DEMO_ACCOUNTS.promotable`.** E2E files run in parallel against one server, so the test that *changes* a role must change a row nobody else asserts on — promoting `DEMO_ACCOUNTS.user` would race `auth.spec.ts`'s "an ordinary signed-in user gets no admin controls". `e2e/utils/session.ts` now holds the `signIn` helper all three specs share. +- **`POST /api/projects` stopped reading `payload.author` entirely.** With the field gone from the form, leaving the fallback in would mean a request that simply omitted its cookie could choose its own byline. A signed-in account with no display name writes a null byline, which the gallery already renders as "Anonymous" — an account we know but cannot name. +- **A database outage now answers 401 on that route, not 502.** `resolveIdentity` treats an unreachable database as "nobody is signed in" so public pages keep serving (Article 4), and the sign-in check runs before the write — so a signed-in student sees "sign in to share a project" during an outage. It leaks nothing and writes nothing, but it is a worse sentence than it could be. Both branches are asserted in `route.test.ts`; a Phase 5 improvement would be to distinguish them with a `pingDb()`. +- **The roster shows email addresses.** The one surface in the app that does, because telling two accounts apart is its whole job. It goes no further — not into a prompt, not into the mirror, not into a log line (§8). +- **Dates in the roster are ISO, not localized.** `createdAt.toISOString().slice(0, 10)` in the mono treatment the design system gives every timestamp: locale-neutral, and it cannot render differently on the server and the client. +- **English-only strings, as Article 6 (amended) allows.** About 40 keys under `admin.*` plus the project sign-in prompt went into `messages/en.json`; the other 11 locales fall back to English until Phase 9. +- **Still pre-existing and unrelated:** `e2e/theme-i18n.spec.ts`'s two language-switch tests are flaky under a loaded machine (the server action plus `router.refresh()` occasionally exceeds the 5s expect timeout). They pass on retry and pass outright when run alone. + +**Status.** Accepted. What a reader should know before Phase 5: the two-gate pattern in `/admin/layout.tsx`, that server actions arrive at client islands as props, and that `"use server"` modules may export only async functions. + +### 2026-09-21 — Phases 3 and 4 integrated, with one seam closed + +**What changed.** The four implementation passes were run together as one branch and the +whole gate observed green with every environment variable unset: `npm run lint` (0 errors, +3 pre-existing warnings), `npm run typecheck` (clean), `npx vitest run` (**95 files, 1303 +tests**), `npx playwright test` (**49 passed**, no retries), `npm run spec:coverage` +(73 surface items · 0 undocumented) and `npm run build` (succeeds with no database). One +defect was found between the phases and fixed. + +- **The nightly export was about to archive live credentials, and now does not.** Phase 3 + built `backupTables()` to discover tables from the schema module rather than list them, + so that a table added later is backed up because it exists — and wrote a note asking + whoever landed Better Auth to decide about its tables. Phase 4 landed them. Nothing + connected the two, so `select *` over `session` would have written bearer tokens, and + over `account` Google's refresh tokens, into a private file kept for **thirty days**. + Anyone holding one backup could have signed in as anybody. + + The fix is `src/lib/cron/backup-policy.ts`, and it keeps discovery's virtue intact: the + default is still "back it up", and the exceptions are named and argued. `session` and + `verification` are skipped whole — a session row *is* a bearer token and a verification + row is a half-finished handshake; neither is worth restoring, and restoring them would + revive sign-ins that should have ended with the outage. `account` is kept with its four + secret columns blanked, because the row that matters is the link (this person is this + Google `sub`), which is what a restore needs, while the tokens are reissued on the next + sign-in. `user` is kept whole and deliberately so: `role` and `banned` are the state a + restore would most need to get right, and the row carries no secret. Everything is named + through the table objects, so renaming a column fails the typecheck rather than quietly + un-redacting it. `backup.test.ts` asserts the strong form — the demo seed's session token + does not appear in the written bytes at all. + +- **Nothing else needed fixing.** The seams that were looked for and were not there: no two + modules invalidate the same cache tag (`createProjectSubmission` deliberately invalidates + nothing and says why; the only `revalidateTag` is still `/api/admin/revalidate`), no + capability is registered twice, `claimAttachments` and the four functions added around it + live in one `attachments.ts` with no duplicate, the upload-id contract reads + `attachmentId` / `attachment_id` everywhere the four files that must move together, and + the E2E signing secret has one definition (`e2e/utils/session.ts`) that + `playwright.config.ts` documents itself as having to match. + +- **Two live routes still write `backups/YYYY-MM-DD.json`.** `/api/admin/backup` is + superseded and unscheduled but still reachable with a secret, and it writes a version-1 + Notion dump to the *same pathname* the nightly Postgres export uses — so a hand-trigger + of the retired route would overwrite that day's real backup. Deleting the route removes + the hazard and is already on the deletion-approval list; it was left in place under the + working agreement rather than half-fixed. + +**Status.** Accepted. Phases 3 and 4 are integrated and green; what remains before +production is credentials, which no test can stand in for. diff --git a/v5/.env.example b/v5/.env.example index f514281..a8a05e1 100644 --- a/v5/.env.example +++ b/v5/.env.example @@ -28,23 +28,31 @@ NEXT_PUBLIC_LOGO="/makerlab-logo-transparent.png" NEXT_PUBLIC_COLOR_PRIMARY="#ff6b35" NEXT_PUBLIC_COLOR_PRIMARY_DARK="#cc4f1f" -# ── Notion data layer ──────────────────────────────────────────────── +# ── Notion data layer — IMPORT ONLY ────────────────────────────────── +# Postgres is the source of truth. Nothing the site serves reads Notion, and +# as of Phase 3 nothing the site writes goes to Notion either, except intake's +# `create_tool` (which uses NOTION_DB_TOOLS/CATEGORIES/LOCATIONS/UNITS/ +# RESOURCES until Phase 6). Everything below is needed by +# `npm run import:notion`; MAINTENANCE_LOGS, FLAGS and PROJECTS are needed by +# NOTHING ELSE and can be removed from the deployment once the import has run. +# # Internal integration token from https://www.notion.so/my-integrations NOTION_API_KEY=ntn_YourTokenHere -# Notion database IDs (required — share each database with the integration) +# Notion database IDs (required by the import — share each with the integration) NOTION_DB_TOOLS=YourDatabaseIdHere NOTION_DB_CATEGORIES=YourDatabaseIdHere NOTION_DB_LOCATIONS=YourDatabaseIdHere NOTION_DB_UNITS=YourDatabaseIdHere NOTION_DB_RESOURCES=YourDatabaseIdHere +# Import only — tickets are written to Postgres `maintenance_logs` now. NOTION_DB_MAINTENANCE_LOGS=YourDatabaseIdHere +# Import only — corrections are written to Postgres `feedback` now. NOTION_DB_FLAGS=YourDatabaseIdHere -# Optional, and separate from the seven above: the Student Projects gallery. -# Unset, /projects renders an empty state and submission returns a clear error; -# the rest of the catalog is unaffected. The database needs a `published` -# checkbox — that checkbox is the entire moderation gate. +# Optional, and import only: the Student Projects gallery's source database. +# Submissions are written to Postgres `projects` (unpublished until staff +# publish them), so the gallery works with this unset. NOTION_DB_PROJECTS= # ── AI APIs ────────────────────────────────────────────────────────── @@ -72,21 +80,32 @@ ANTHROPIC_API_KEY=sk-ant-api03-YourKeyHere # ── Admin ──────────────────────────────────────────────────────────── # Shared secret guarding POST /api/admin/revalidate. Also accepted by -# GET /api/admin/backup, so a person can trigger a backup by hand. +# GET /api/cron/daily, so a person can trigger the nightly job by hand. ADMIN_REVALIDATE_SECRET=YourSecretHere -# ── Nightly Notion backup ──────────────────────────────────────────── -# Both are set in Vercel, not locally. Without them GET /api/admin/backup -# answers 503 — deliberately, so a missing backup is never silent. +# ── Files and the nightly job ──────────────────────────────────────── +# Set in Vercel, not locally. # # Sent by Vercel Cron as `Authorization: Bearer $CRON_SECRET`, which is how -# the backup route knows the nightly call is genuine. +# GET /api/cron/daily knows the nightly call is genuine. Unset, that route +# answers 503 — deliberately, so a missing backup is never silent. CRON_SECRET=YourCronSecretHere # Injected automatically when a Blob store is linked to the Vercel project. -# The backup file contains student names and emails, so the store must stay -# PRIVATE. +# It gates TWO things now: +# 1. Every photo upload (POST /api/uploads). Unset, the route answers 503 +# `blob_not_configured` and the chat and project form say photo uploads +# are unavailable — they never hand back an id for a file nobody stored. +# 2. The nightly Postgres export written by GET /api/cron/daily. +# Uploads are stored public or private per kind; maintenance photos and the +# backup file contain student names and emails and are always PRIVATE. BLOB_READ_WRITE_TOKEN=vercel_blob_rw_YourTokenHere +# The lab's timezone, used for the date on a maintenance ticket. Defaults to +# America/New_York. A Vercel function runs in UTC, so without this a report +# filed at 9pm in New York would be dated tomorrow. A value Intl does not +# recognise falls back to UTC with a warning rather than losing the report. +LAB_TIMEZONE=America/New_York + # ── Database (Neon Postgres) ───────────────────────────────────────── # Injected automatically when Neon is installed from the Vercel Marketplace. # With it unset the app, the tests and `npm run import:notion -- --dry-run` @@ -97,13 +116,19 @@ DATABASE_URL= # ── Sign-in (Google Workspace) ─────────────────────────────────────── # Optional. With these unset the app runs exactly as before: everyone is # anonymous, the catalog and the assistant still work, and /api/auth returns -# 503. Setting all three turns sign-in on. +# 503. AUTH_SECRET alone gives you sessions; add the two GOOGLE_* variables to +# make signing in possible. # -# Signing key for the stateless session cookie. Generate with: +# Signing key for the session cookie. Generate with: # openssl rand -base64 32 -# ROTATING THIS SIGNS EVERYONE OUT — there is no server-side session -# revocation, so rotation is the emergency lever. Also salts the hashed IPs -# the rate limiter stores, so rotating resets anonymous allowances too. +# ROTATING THIS SIGNS EVERYONE OUT: every existing cookie stops verifying. +# (Individual revocation no longer needs it — sessions are rows since Phase 4, +# so a ban takes effect on the person's next request.) Also salts the hashed +# IPs the rate limiter stores, so rotating resets anonymous allowances too. +# +# This alone is enough for sessions. Without the two GOOGLE_* variables below +# there is simply no way to start one, and the header says sign-in is not set +# up here — which is how the E2E suite and a pre-OAuth deployment both run. AUTH_SECRET= # Google OAuth client (Web application) from console.cloud.google.com. @@ -121,11 +146,22 @@ AUTH_BASE_URL=http://localhost:3000 # also sent to Google as the `hd` hint, which only narrows the account picker. AUTH_ALLOWED_EMAIL_DOMAIN=cornell.edu -# Role rosters — comma-separated addresses. Anyone else who signs in with an -# address on the allowed domain is a "student". There is no user database; -# these lists are the whole role assignment mechanism. -AUTH_STAFF_EMAILS= -AUTH_ADMIN_EMAILS= +# The super-admin FLOOR — comma-separated addresses. Not a roster: everyone +# else's role is the `user.role` column, changed on /admin/users. +# +# An address listed here is created as `super_admin` on first sign-in and +# resolves as `super_admin` whatever its row says, and cannot be demoted or +# banned. Two reasons, both structural: +# 1. Bootstrap — no user row exists until somebody signs in, so there is no +# admin to promote the first one. This is how the first one comes to be. +# 2. Lock-out — a super admin who demotes themselves would otherwise leave +# nobody able to undo it and no UI to fix it with. +# Use a permanent address. The Cornell Tech deployment uses ies22@cornell.edu. +# +# AUTH_STAFF_EMAILS and AUTH_ADMIN_EMAILS were removed in Phase 4. Nothing +# reads them; delete them from the deployment's environment, because a list +# that grants nothing is a misleading roster. +AUTH_SUPER_ADMIN_EMAILS= # ── Rate limiting (optional) ───────────────────────────────────────── # Upstash Redis backs the API rate limiter. When both are set, limits are diff --git a/v5/AGENTS.md b/v5/AGENTS.md index fe0b927..ba3fe33 100644 --- a/v5/AGENTS.md +++ b/v5/AGENTS.md @@ -49,35 +49,145 @@ variable list. - **Notion is read only by the one-time import** (`npm run import:notion`). No request path reads Notion. A one-way mirror (app → an admin's own Notion workspace) is a later phase, not built yet. -- **The three writes still on Notion address pages, not rows.** A correction, - a maintenance ticket and a project submission create Notion pages whose - `relation` properties need *Notion page ids*, while everything the app hands - around is now a Postgres uuid. `src/lib/data/notion-ids.ts` translates - through `notion_page_id`, and a row that has none is written without the - relation rather than with an id Notion would reject — a ticket a human has - to link by hand beats a ticket that never arrived (Article 4). It goes away - with those writes in Phase 3. -- **Files** (tool images, manuals, project photos) live in **Vercel Blob**, - not Notion attachments — see `next.config.ts`'s `images.remotePatterns`. +- **Every student-facing write is on Postgres** as of Phase 3. A correction + goes to `feedback`, a maintenance ticket to `maintenance_logs`, a project + submission to `projects` + `project_tools` — see `src/lib/data/*.ts`. + `src/lib/data/notion-ids.ts` (the Phase-2 page-id bridge) has no importers + left and is awaiting deletion approval, as are `/api/upload-notion` and + `/api/admin/backup`. +- **The one write still on Notion is intake's `create_tool`** (`capabilities/ + intake.ts`), which stays there until Phase 6. Its photos no longer travel + with it: an upload id is a Postgres uuid now, Notion would reject the page, + so the tool is created without pictures and says so in its `warnings[]`. +- **Files** (tool images, manuals, project photos, maintenance photos) live in + **Vercel Blob**, recorded row-by-row in `attachments` — see + `next.config.ts`'s `images.remotePatterns`. `POST /api/uploads` is the one + upload route; it writes the blob, inserts an **unowned** `attachments` row and + returns `{ attachmentId, previewUrl }`. The write that follows *claims* those + ids (`claimAttachments`), and `/api/cron/daily` deletes anything still + unclaimed after 24 hours. With no `BLOB_READ_WRITE_TOKEN` the route answers + 503 `{ code: "blob_not_configured" }` and both clients show a translated + "photo uploads are unavailable" — never a fabricated id (Article 4). - **Failing toward stale, not wrong (Article 4).** `DATABASE_URL` unset serves the PGlite demo seed with `DemoDataBanner` shown. `DATABASE_URL` set but unreachable never falls back to demo or invented data — cached pages keep serving and an uncached read renders the error state. +## Accounts, roles and permissions (Phase 4) + +**Better Auth runs the way it is meant to be run**, on the Drizzle adapter over +the same `getDb()` handle everything else uses. It owns four tables — `user`, +`session`, `account`, `verification` (`src/lib/db/schema/auth.ts`, migration +`0003`). The stateless `makerlab.identity` cookie is **retired**: +`src/lib/auth/session-cookie.ts` has no importers and is awaiting deletion +approval. Do not mint one. + +- **Sessions are rows.** The cookie carries only a token; `resolveIdentity` + looks the session and its user up on every request. That is why a role change + lands on the person's next request and a ban bites immediately. Better Auth's + cookie cache is deliberately off. +- **Roles** are `anonymous` (never a row) plus the stored `user | admin | + super_admin` (`src/lib/db/schema/vocabulary.ts`, which also backs the + `user_role_check` constraint). `student` and `staff` are gone; today's `admin` + is the old `staff`, and today's `super_admin` is the old `admin`. +- **What each role may do is declared in code**, not in a table: + `src/lib/auth/permissions.ts` (`statement` / `ac` / `roles` / `can()`), the + same declaration the admin plugin is configured with. **One check, + everywhere:** routes, server actions and capability composition call + `can(identity, "tools.add")`; client components call it with the role + `/api/identity` reports, to hide a control. Hiding is presentation; the + server check is the control. +- **Capabilities declare `requiredPermission`**, enforced once by + `capabilitiesForIdentity` in `src/lib/capabilities/access.ts` — never inside a + tool's `run()`. +- **`AUTH_STAFF_EMAILS` / `AUTH_ADMIN_EMAILS` are retired.** Nothing reads them. + The one env list left is **`AUTH_SUPER_ADMIN_EMAILS`, a floor, not a roster** + (`src/lib/auth/super-admins.ts`): a listed address is created as + `super_admin` and resolves as `super_admin` whatever its row says. It is the + bootstrap (no user row exists until somebody signs in) and the lock-out + guarantee. +- **Everything stays optional.** `AUTH_SECRET` alone gives database sessions; + the two `GOOGLE_*` variables are what make *starting* one possible, and + without them `/api/auth/sign-in/social` answers 503 and the header says + sign-in is not set up. With neither, nobody is signed in and the catalogue and + chat are unchanged. **Sign-in unlocks; it never gates the front door.** +- **Testing a role needs no Google.** `test/utils/session.ts` seeds a `user` and + a `session` row and mints the cookie Better Auth would have set; the demo seed + ships one account per role for E2E. See `test/README.md`. +- **`created_by` / `updated_by` now reference `user.id`** (`on delete set null`), + the foreign keys Phase 1 deferred. A write whose author is not a row is + refused — correct, because in production that id comes from a session. + +## The admin surface (`/admin`) + +`/admin/users` is the first admin page and the first server action in the app; +Phase 5 extends both. The shape it sets: + +- **Two gates, coarse then exact.** `src/app/admin/layout.tsx` resolves the + identity from headers and answers "may this person see an admin surface at + all" (`canReachAdmin`, any admin-surface permission). Each page then checks + the permission it actually needs — `/admin/users` on `users.manage`, which + only `super_admin` holds. A SuperMaker gets past the layout and is refused on + the page, **and is told so**: `AdminNotice` renders "not signed in" or "not + permitted", never a 404 and never an error boundary (Article 4). +- **Server actions check themselves.** A server action is a POST endpoint with + a generated name, reachable without the page that offers it, so + `src/app/admin/users/actions.ts` resolves the identity, rate-limits + (`ADMIN_ACTION_TIER`, 120/min per person), and re-checks `users.manage` — it + trusts nothing from the page that rendered the control (spec §8). Refusals are + **values** (`{ ok: false, error }`), not exceptions, so the island can render + the reason; every code has an `admin.errors.` string. +- **Two things cannot be undone, so they cannot be done.** An address in + `AUTH_SUPER_ADMIN_EMAILS` cannot be demoted or banned, and the last + unbanned `super_admin` cannot be demoted. The table disables those rows with + the reason showing; the action derives both again before it writes. +- **Reads go straight to Postgres, writes go through the plugin.** + `src/lib/data/users.ts` selects the roster; `auth.api.setRole` / `banUser` / + `unbanUser` perform the change, because a ban there also deletes the person's + sessions. +- **Every security-relevant change is recorded.** `src/lib/data/audit.ts` is + insert-and-select only — there is deliberately no update or delete export, + and a test asserts the module's shape. `AUDIT_ACTIONS` has no + `user.unbanned`, so lifting a ban is `user.banned` with `detail.banned: + false`. +- **Server actions pass down as props.** `RoleSelect` and `BanToggle` take the + action rather than importing it, which keeps `next/headers`, the limiter and + `server-only` out of a client component's graph and makes both testable with + a `vi.fn`. +- **Submitting a project needs an account** (spec §5.5) — the one place in the + app where sign-in is required. `POST /api/projects` answers 401 + `sign_in_required` to an anonymous caller, the byline comes from the session + and the typed-name field is gone. Browsing, chatting and reporting a problem + are all still anonymous. + ## Key files | Path | Purpose | |---|---| | `src/lib/site-config.ts` | White-label branding (env-driven, all have defaults) | | `src/lib/db/client.ts` | `getDb()`, `dataSubstrate()`, `pingDb()` — the one entry point to Postgres/PGlite | -| `src/lib/notion.ts` | Notion API client — write paths still on Notion until Phase 3 (tickets, corrections, project submission); no request path reads it | +| `src/lib/notion.ts` | Notion API client — read by the one-time import and by intake's `create_tool` (Phase 6); no other write and no request path reads it | +| `src/lib/data/attachments.ts` | `attachments` rows: create, claim onto an owner, list orphans, delete | +| `src/lib/blob.ts` | The Blob seam — `put` (private backups, fixed pathname) and `putUpload` (random pathname, caller's access) | +| `src/lib/cron/backup.ts`, `src/lib/cron/cleanup.ts` | The nightly Postgres export and the orphaned-upload sweep | +| `src/lib/cron/backup-policy.ts` | What the nightly export holds back — `session` / `verification` skipped, `account` tokens blanked. A backup is data, not credentials | | `src/lib/catalog.ts` | Catalog orchestration + cache, reading Postgres | -| `src/lib/rate-limit.ts` | In-memory (or Upstash) sliding-window limiter | +| `src/lib/rate-limit.ts` | In-memory (or Upstash) sliding-window limiter, tiered by role | +| `src/lib/auth/config.ts` | The Better Auth instance: Drizzle adapter, database sessions, admin plugin, domain enforcement | +| `src/lib/auth/identity.ts` | `resolveIdentity(req)` — the one way to learn who is calling. Never throws | +| `src/lib/auth/permissions.ts` | `statement` / `ac` / `roles` / `can()` — what each role may do | +| `src/lib/auth/super-admins.ts` | `AUTH_SUPER_ADMIN_EMAILS`, the lock-out floor | +| `src/app/admin/layout.tsx` | The `/admin` front door — signed in? holds an admin permission? | +| `src/app/admin/users/actions.ts` | `setUserRole` / `setUserBanned` — the app's first server actions | +| `src/lib/data/users.ts` | The `/admin/users` roster, read straight from Postgres | +| `src/lib/data/audit.ts` | `audit_events` — insert and select, never update or delete | +| `src/lib/db/schema/auth.ts` | Better Auth's four tables; property keys are its field names | | `src/lib/types.ts` / `src/components/catalog-types.ts` | Notion record types / resolved view types | | `src/app/api/chat/route.ts` | Claude chat: streaming, tools (`get_unit_details`, `report_issue`, `web_fetch`), PDF manual attach | | `src/app/api/mcp/route.ts` | MCP JSON-RPC server (5 tools), bearer-token auth | -| `src/app/api/upload-notion/route.ts` | Image upload proxy → Notion file_uploads | -| `src/app/api/admin/revalidate/route.ts` | Cache invalidation (`x-admin-secret`) | +| `src/app/api/uploads/route.ts` | The one upload route → Vercel Blob + an `attachments` row | +| `src/app/api/cron/daily/route.ts` | The single nightly cron (`vercel.json`): backup, then orphaned-upload cleanup | +| `src/app/api/admin/revalidate/route.ts` | Cache invalidation (`tools.edit`, or `x-admin-secret` for session-less callers) | | `src/components/ChatFab.tsx` | Chat UI (`useChat`, citations stripped, photo upload) | | `src/app/page.tsx`, `tools/[id]/page.tsx` | Gallery + tool detail | @@ -88,7 +198,8 @@ variable list. - Server-only modules import `"server-only"` (e.g. `rate-limit.ts`). - Theme/brand via **CSS variables** (`--primary`, `--background`, …) — `[data-theme="light|dark"]` on ``, never hardcoded colors. - All branding strings come from `siteConfig` (`@/lib/site-config`). -- Every API route is **rate-limited by IP** before expensive work. +- Every API route is **rate-limited by identity** before expensive work — user id when signed in, hashed IP when not. +- Authorization is **always** `can(subject, permission)` from `src/lib/auth/permissions.ts`. Never compare role names, and never gate inside a capability tool's `run()`. - Maintenance tickets are always written in **English** even when the chat replies in another locale. ## Testing @@ -105,9 +216,10 @@ Or individually: `npm test` (Vitest: unit + integration + component), first), `npm run test:coverage`. - **Vitest** (jsdom) + React Testing Library + MSW; **Playwright** for E2E. -- Catalogue reads run against an in-process PGlite database seeded with demo - data (`getCatalogTools()` needs no Notion env at all); write paths still on - Notion (§ above) are covered with `vi.stubEnv` + MSW as before. +- **The whole suite runs with every environment variable unset.** Reads *and* + writes go to an in-process PGlite database seeded with demo data; Vercel Blob + is stubbed at the `src/lib/blob.ts` seam (`vi.mock`), never called for real. + The only MSW-stubbed Notion left is intake's `create_tool`. - E2E boots its own server on **port 3100** with `DATABASE_URL` unset (PGlite demo catalog) and intercepts `/api/chat` — it never touches your `:3000` dev server or real services. - Tests are colocated (`*.test.ts(x)` next to source); shared harness in `test/`. - **Read these before writing tests:** `TESTING.md` (runbook), `test/README.md` (harness internals + the `streamText`-capture and env-stubbing patterns), and `docs/specs/2026-05-29-v5-test-suite-design.md` (design + coverage matrix). The harness deps/scripts are already wired — don't hand-edit `package.json` for them. diff --git a/v5/e2e/admin-users.spec.ts b/v5/e2e/admin-users.spec.ts new file mode 100644 index 0000000..2ee214d --- /dev/null +++ b/v5/e2e/admin-users.spec.ts @@ -0,0 +1,172 @@ +import { test, expect } from "@playwright/test"; + +import { DEMO_ACCOUNTS } from "../src/lib/db/demo-seed"; +import { signIn } from "./utils/session"; + +/** + * `/admin/users`, end to end (data platform design spec §5.2, §10 scenario 6). + * + * The scenario the spec names is one sentence long and is the whole reason + * Phase 4 exists: **a super admin changes a user to admin, and that person's + * Add button appears on their next page load.** Nothing is cached across + * requests, nothing is carried in a cookie, and the only thing that changed is + * a column. + * + * The account it promotes is `DEMO_ACCOUNTS.promotable`, a spare that exists + * for exactly this: the suite runs its files in parallel against one server, + * so a test that mutates a shared row must mutate one nobody else asserts on. + * + * Sign-in is still unconfigured on this server (`GOOGLE_*` blank), and no test + * here touches Google. Being somebody is a signed cookie for a seeded session + * row — see `e2e/utils/session.ts`. + */ + +const PLACEHOLDER_LEAK = "{institution}"; + +/** The tests below run in order: one promotes, the next reads the result. */ +test.describe.configure({ mode: "serial" }); + +/** + * Headroom for a save. It normally lands in a couple of hundred milliseconds — + * `RoleSelect` confirms from the action's result rather than waiting out the + * revalidation that follows it — but this runs beside thirteen other workers + * against one PGlite database, and five seconds has not always been enough. + */ +const SAVE_TIMEOUT = 15_000; + +test.describe("/admin/users — who may open it", () => { + test("an anonymous visitor is told to sign in, not 404ed", async ({ page }) => { + await page.goto("/admin/users"); + + // A 404 would lie about the page existing; an error boundary would say + // something went wrong when nothing did (§6). + await expect( + page.getByRole("heading", { name: "You are not signed in", level: 1 }) + ).toBeVisible(); + await expect(page.getByRole("table")).toHaveCount(0); + await expect(page.locator("body")).not.toContainText(PLACEHOLDER_LEAK); + }); + + test("an ordinary student is refused, and told why", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.user, baseURL); + await page.goto("/admin/users"); + + await expect( + page.getByRole("heading", { name: /do not have access/i, level: 1 }) + ).toBeVisible(); + await expect(page.getByRole("table")).toHaveCount(0); + }); + + test("a SuperMaker reaches /admin but not the people page", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + + // `tools.edit` gets them through the layout… + await page.goto("/admin"); + await expect(page.getByRole("heading", { name: "Admin surfaces" })).toBeVisible(); + await expect(page.getByRole("link", { name: "People" })).toHaveCount(0); + + // …and `users.manage`, which only a director holds, stops them here. + await page.goto("/admin/users"); + await expect( + page.getByRole("heading", { name: /do not have access/i, level: 1 }) + ).toBeVisible(); + }); + + test("the header offers the admin link only to those who can use it", async ({ + page, + context, + baseURL, + }) => { + const nav = page.getByRole("navigation", { name: "Primary navigation" }); + + await page.goto("/"); + await expect(nav.getByRole("link", { name: "ADMIN" })).toHaveCount(0); + + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + await page.reload(); + await expect(nav.getByRole("link", { name: "ADMIN" })).toBeVisible(); + }); +}); + +test.describe("/admin/users — changing a role", () => { + test("a director sees the roster, with the last director's own row locked", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.superAdmin, baseURL); + await page.goto("/admin/users"); + + await expect(page.getByRole("heading", { name: "People" })).toBeVisible(); + + const ownRow = page.getByRole("row", { name: new RegExp(DEMO_ACCOUNTS.superAdmin.name) }); + await expect(ownRow.getByText("you", { exact: true })).toBeVisible(); + // This server boots with AUTH_SUPER_ADMIN_EMAILS blank, so there is no + // floor — the only thing standing between the lab and a lock-out is the + // "last director" guard, and it is visible rather than a surprise on save. + await expect( + ownRow.getByRole("combobox", { name: new RegExp(DEMO_ACCOUNTS.superAdmin.name) }) + ).toBeDisabled(); + await expect(ownRow.getByText(/last director/i)).toBeVisible(); + + await expect(page.locator("body")).not.toContainText(PLACEHOLDER_LEAK); + }); + + test("a director promotes a student, and the change is in the database", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.superAdmin, baseURL); + await page.goto("/admin/users"); + + const row = page.getByRole("row", { name: new RegExp(DEMO_ACCOUNTS.promotable.name) }); + const select = row.getByRole("combobox", { + name: new RegExp(DEMO_ACCOUNTS.promotable.name), + }); + + // This test changes a row and the suite retries a failed test once, so it + // puts the account back where it expects to find it first. Without this a + // retry starts from the half-applied state the first attempt left. + if ((await select.inputValue()) !== "user") { + await select.selectOption("user"); + await expect(select).toBeEnabled({ timeout: SAVE_TIMEOUT }); + } + await expect(select).toHaveValue("user"); + + await select.selectOption("admin"); + await expect(row.getByText("Saved")).toBeVisible({ timeout: SAVE_TIMEOUT }); + + // A reload, not the optimistic state: the row is what the server holds. + await page.reload(); + await expect( + page + .getByRole("row", { name: new RegExp(DEMO_ACCOUNTS.promotable.name) }) + .getByRole("combobox", { name: new RegExp(DEMO_ACCOUNTS.promotable.name) }) + ).toHaveValue("admin"); + }); + + test("and that person's Add button appears on their next page load", async ({ + page, + context, + baseURL, + }) => { + // Spec §10 scenario 6, the half that matters. Same cookie they had before + // the promotion — sessions are rows, the role is read per request, and + // nothing had to expire. + await signIn(context, DEMO_ACCOUNTS.promotable, baseURL); + await page.goto("/"); + + const nav = page.getByRole("navigation", { name: "Primary navigation" }); + await expect(nav.getByRole("button", { name: /Add new equipment/i })).toBeVisible(); + await expect(nav.getByRole("link", { name: "ADMIN" })).toBeVisible(); + }); +}); diff --git a/v5/e2e/auth.spec.ts b/v5/e2e/auth.spec.ts index 59e6a90..d696e2d 100644 --- a/v5/e2e/auth.spec.ts +++ b/v5/e2e/auth.spec.ts @@ -1,59 +1,39 @@ import { test, expect } from "@playwright/test"; -// Sign-in (auth design spec 2026-07-29 §5, §6, §10). Two properties are worth an +import { DEMO_ACCOUNTS } from "../src/lib/db/demo-seed"; +import { + BETTER_AUTH_SESSION_COOKIE, + E2E_AUTH_SECRET, + signCookieValue, + signIn, +} from "./utils/session"; + +// Sign-in (data platform design spec §3.4, §10). Two properties are worth an // E2E each: signing in never gates the front door, and the header reflects who // the server says you are. // -// **No real Google OAuth.** Driving it in CI is neither possible nor desirable -// (spec §10), and the E2E server boots with no credentials at all. +// **No real Google OAuth.** Driving it in CI is neither possible nor desirable, +// and the E2E server boots with GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET blank +// — so `/api/auth/sign-in/social` answers 503 and the header says sign-in is +// not set up here. // -// Which means a *genuinely* signed cookie cannot be minted here either: the -// session cookie is an HMAC over `AUTH_SECRET`, and the E2E server has none, so -// anything the browser sends resolves to anonymous by design. The header's whole -// view of the session is `GET /api/identity` (see the spec's `/api/identity` -// amendment), so the stub below sits at that boundary and answers *from the -// cookie the browser actually sent*. The cookie is still what flips the header; -// the stub stands in only for the signature check the server cannot perform. - -/** Mirrors SESSION_COOKIE_NAME in src/lib/auth/session-cookie.ts. */ -const SESSION_COOKIE = "makerlab.identity"; - -/** Opaque: nothing verifies it. Shaped like a real token so it is not mistaken for one. */ -const STUB_TOKEN = "e2e-stub-session.not-a-real-signature"; - -const USER_NAME = "Casey Rivera"; +// It is nonetheless a *genuine* session that is asserted below, not a stub. +// Sessions are database rows since Phase 4, so the demo seed ships one account +// per role with a known session token (`DEMO_ACCOUNTS`), the Playwright server +// boots with a test-only `AUTH_SECRET`, and the cookie below is signed with it +// exactly the way Better Auth signs one. Nothing is intercepted: the real +// `/api/identity` reads the real session row and reports the real role. + +const SIGNED_IN = DEMO_ACCOUNTS.user; /** PrimaryNav shows the first name only (spec §6). */ -const USER_FIRST_NAME = "Casey"; - -/** - * Answer `/api/identity` as a server holding `AUTH_SECRET` would: signed in when - * the request carries the session cookie, anonymous when it does not. - */ -async function stubIdentityFromCookie(page: import("@playwright/test").Page) { - await page.route("**/api/identity", async (route) => { - const headers = await route.request().allHeaders(); - const signedIn = (headers["cookie"] || "").includes(`${SESSION_COOKIE}=`); - await route.fulfill({ - status: 200, - headers: { - "content-type": "application/json", - "cache-control": "no-store, private", - }, - body: JSON.stringify( - signedIn - ? { role: "student", name: USER_NAME } - : { role: "anonymous", name: null } - ), - }); - }); -} +const USER_FIRST_NAME = SIGNED_IN.name.split(" ")[0]; test.describe("Sign-in", () => { test("anonymous visitors browse the catalog and open a tool page", async ({ page, }) => { - // No stub and no cookie: this is the real /api/identity answering anonymous, - // which is what an ISAM attendee who never creates an account will get. + // No cookie: this is the real /api/identity answering anonymous, which is + // what an ISAM attendee who never creates an account will get. await page.goto("/"); await expect( @@ -89,32 +69,79 @@ test.describe("Sign-in", () => { await expect(nav.getByRole("button", { name: /sign out/i })).toHaveCount(0); }); - test("with a stubbed session cookie the header shows the user's name", async ({ + test("a real signed session cookie shows the user's name in the header", async ({ page, context, baseURL, }) => { - await stubIdentityFromCookie(page); - const nav = page.getByRole("navigation", { name: "Primary navigation" }); - // Same stub, no cookie: the header must still offer sign-in. Asserting both - // halves is what makes the cookie — rather than the stub — the thing under - // test. + // No cookie first: the header must offer sign-in. Asserting both halves is + // what makes the session — rather than the page — the thing under test. await page.goto("/"); await expect(nav.getByRole("button", { name: /sign in/i })).toBeVisible(); + await signIn(context, SIGNED_IN, baseURL); + await page.reload(); + + await expect(nav.getByText(USER_FIRST_NAME, { exact: true })).toBeVisible(); + await expect(nav.getByRole("button", { name: /sign out/i })).toBeVisible(); + await expect(nav.getByRole("button", { name: /sign in/i })).toHaveCount(0); + }); + + test("an ordinary signed-in user gets no admin controls", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.user, baseURL); + await page.goto("/"); + + const nav = page.getByRole("navigation", { name: "Primary navigation" }); + await expect(nav.getByRole("button", { name: /sign out/i })).toBeVisible(); + + // `tools.add` and `tools.edit` are not granted to `user` (auth/permissions). + await expect( + nav.getByRole("button", { name: /Add new equipment/i }) + ).toHaveCount(0); + await expect(nav.getByRole("button", { name: /Refresh the/i })).toHaveCount(0); + }); + + test("an admin's role comes from their row, and unlocks the admin controls", async ({ + page, + context, + baseURL, + }) => { + // The end-to-end proof that the role is read per request from the database + // rather than carried in the cookie: the same kind of cookie, a different + // row, a different set of controls. + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + await page.goto("/"); + + const nav = page.getByRole("navigation", { name: "Primary navigation" }); + await expect(nav.getByRole("button", { name: /Add new equipment/i })).toBeVisible(); + await expect(nav.getByRole("button", { name: /Refresh the/i })).toBeVisible(); + }); + + test("a cookie signed with the wrong secret is nobody", async ({ + page, + context, + baseURL, + }) => { await context.addCookies([ { - name: SESSION_COOKIE, - value: STUB_TOKEN, + name: BETTER_AUTH_SESSION_COOKIE, + // Same real session token, signed with anything but the server's key. + value: await signCookieValue( + SIGNED_IN.sessionToken, + `${E2E_AUTH_SECRET}-but-wrong` + ), url: baseURL ?? "http://localhost:3100", }, ]); - await page.reload(); + await page.goto("/"); - await expect(nav.getByText(USER_FIRST_NAME, { exact: true })).toBeVisible(); - await expect(nav.getByRole("button", { name: /sign out/i })).toBeVisible(); - await expect(nav.getByRole("button", { name: /sign in/i })).toHaveCount(0); + const nav = page.getByRole("navigation", { name: "Primary navigation" }); + await expect(nav.getByRole("button", { name: /sign in/i })).toBeVisible(); }); }); diff --git a/v5/e2e/corrections.spec.ts b/v5/e2e/corrections.spec.ts index dadd94c..c82e05e 100644 --- a/v5/e2e/corrections.spec.ts +++ b/v5/e2e/corrections.spec.ts @@ -5,10 +5,11 @@ import { test, expect } from "@playwright/test"; // description; the confirmation replaces the form in place rather than firing a // toast. // -// The E2E server boots with NOTION_DB_FLAGS unset (see playwright.config.ts), so -// the real POST /api/flags would answer 503 `not_configured`. The submit test -// intercepts that request with page.route() instead — the same technique -// chat.spec.ts uses — so nothing leaves the machine. +// The write is local as of Phase 3: POST /api/flags inserts into the E2E +// server's PGlite database with no credential at all, so one test submits for +// real end to end. The other submit test keeps its page.route() interception — +// not because the route would refuse, but because asserting the exact request +// body is the cheapest way to prove the field travels. // // Demo catalogue: "Form 4" is slug `form-4`; its id is the Postgres uuid the // seed assigned (src/lib/db/demo-seed.ts). Strings are `flag.*` in @@ -111,3 +112,33 @@ test.describe("Report a correction", () => { }); }); }); + +// One genuinely end-to-end submission. Nothing is intercepted: the browser +// posts to the real route, which writes a `feedback` row into the E2E server's +// PGlite database. This is only possible because the write no longer needs a +// Notion credential (data platform spec §3.10). +test.describe("Report a correction — the real write path", () => { + test("a report submitted with no interception reaches the route and confirms", async ({ + page, + }) => { + const posted: number[] = []; + page.on("response", (res) => { + if (res.url().includes("/api/flags")) posted.push(res.status()); + }); + + await page.goto("/tools/form-4"); + await page.getByRole("button", { name: "Report a correction" }).click(); + + const dialog = page.getByRole("dialog"); + await dialog + .getByRole("textbox", { name: /what.s wrong/i }) + .fill("This lives in the Resin Bench, not the Wood Shop."); + await dialog.getByRole("button", { name: "Send report" }).click(); + + await expect( + dialog.getByRole("heading", { name: "Report sent" }) + ).toBeVisible(); + // A 503 here would mean the route still thinks it needs a credential. + expect(posted).toEqual([201]); + }); +}); diff --git a/v5/e2e/projects.spec.ts b/v5/e2e/projects.spec.ts index c5a09ba..dd984d2 100644 --- a/v5/e2e/projects.spec.ts +++ b/v5/e2e/projects.spec.ts @@ -1,14 +1,19 @@ import { test, expect } from "@playwright/test"; +import { DEMO_ACCOUNTS } from "../src/lib/db/demo-seed"; +import { signIn } from "./utils/session"; + // The app boots with no DATABASE_URL and NOTION_* unset (see // playwright.config.ts webServer.env), so the gallery reads the PGlite demo // seed — one published sample project, "Laser-cut plywood lamp" -// (src/lib/db/demo-seed.ts) — and `POST /api/projects`, still a Notion write -// until Phase 3, would answer 503 "not configured". +// (src/lib/db/demo-seed.ts). As of Phase 3 `POST /api/projects` writes into +// that same database and needs no credential, so one test below submits for +// real, end to end. // -// The submit path is therefore exercised with `page.route("**/api/projects")` -// standing in for the route handler, exactly as chat.spec.ts stands in for -// /api/chat. Nothing in this file reaches a real service. +// The other submit tests keep `page.route("**/api/projects")` standing in for +// the route handler — not because the route would refuse, but because the +// exact request body and the failure branch are cheaper to assert that way. +// Nothing in this file reaches a real service either way. // // Strings come from messages/en.json (`projects.*`, `projectForm.*`). Branding // is deliberately NOT asserted literally — `siteConfig.institution` is @@ -18,15 +23,15 @@ import { test, expect } from "@playwright/test"; const PLACEHOLDER_LEAK = "{institution}"; -/** Fills the three fields the form requires before it will POST. */ +/** Fills the two fields the form requires before it will POST. */ async function fillRequiredFields(page: import("@playwright/test").Page) { await page .getByRole("textbox", { name: "Project title" }) .fill("Parametric stool"); - await page.getByRole("textbox", { name: "Your name" }).fill("Ada Lovelace"); await page .getByRole("textbox", { name: /Write-up/ }) .fill("Cut on the Trotec, assembled with wedged tenons."); + // No name field since Phase 4: the byline is the session's (spec §5.5). } test.describe("Projects gallery", () => { @@ -82,6 +87,13 @@ test.describe("Projects gallery", () => { }); test.describe("Project submission form", () => { + // Submitting requires an account since Phase 4 (spec §5.5). Every test in + // this block is a signed-in student; the anonymous path is its own block + // below, and browsing the gallery above never needed a cookie. + test.beforeEach(async ({ context, baseURL }) => { + await signIn(context, DEMO_ACCOUNTS.user, baseURL); + }); + test("/projects/new renders every field a submission needs", async ({ page, }) => { @@ -100,11 +112,12 @@ test.describe("Project submission form", () => { await expect( page.getByRole("textbox", { name: "Project title" }) ).toBeVisible(); - // Anonymous in E2E (no auth env, /api/identity answers role "anonymous"), - // so the byline is typed rather than pre-filled and read-only. - const author = page.getByRole("textbox", { name: "Your name" }); - await expect(author).toBeVisible(); - await expect(author).toBeEditable(); + // The byline is stated, not asked for: it is the session's display name + // and the server writes it whatever the request says. + await expect( + page.getByText(`Your project will be credited to ${DEMO_ACCOUNTS.user.name}`) + ).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Your name" })).toHaveCount(0); await expect(page.getByRole("textbox", { name: /Write-up/ })).toBeVisible(); await expect( page.getByRole("textbox", { name: "Link (optional)" }) @@ -175,11 +188,10 @@ test.describe("Project submission form", () => { .fill("Half-filled submission"); await page.getByRole("button", { name: "Submit project" }).click(); - // Validation moves to the next missing field rather than letting a - // half-filled submission through. - await expect( - page.getByRole("textbox", { name: "Your name" }) - ).toBeFocused(); + // Validation moves to the next missing field — the write-up, now that the + // byline is not a field — rather than letting a half-filled submission + // through. + await expect(page.getByRole("textbox", { name: /Write-up/ })).toBeFocused(); expect(posts).toBe(0); }); @@ -227,7 +239,9 @@ test.describe("Project submission form", () => { expect(bodies).toHaveLength(1); const payload = bodies[0] as Record; expect(payload.title).toBe("Parametric stool"); - expect(payload.author).toBe("Ada Lovelace"); + // No author in the body since Phase 4: the byline is the session's, and + // the form sends nothing the server would ignore (spec §5.5). + expect(payload).not.toHaveProperty("author"); // The chosen tool travels as its database id (a uuid), not its slug. expect(payload.tools).toEqual([ expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), @@ -268,3 +282,109 @@ test.describe("Project submission form", () => { ).toBeEnabled(); }); }); + +// A genuinely end-to-end submission, possible for the first time in Phase 3. +// What it proves is Article 5 made visible: a submitted project is a draft, so +// the gallery does not show it until staff publish it. +test.describe("Project submission — the real write path", () => { + test.beforeEach(async ({ context, baseURL }) => { + await signIn(context, DEMO_ACCOUNTS.user, baseURL); + }); + + test("a submission with no interception is accepted and does NOT appear in the gallery", async ({ + page, + }) => { + const statuses: number[] = []; + page.on("response", (res) => { + if (res.url().includes("/api/projects")) statuses.push(res.status()); + }); + + await page.goto("/projects/new"); + await page + .getByRole("textbox", { name: "Project title" }) + .fill("Unpublished by design"); + await page + .getByRole("textbox", { name: /Write-up/ }) + .fill("Submitted end to end against the real route."); + await page.getByRole("button", { name: "Submit project" }).click(); + + await expect( + page.getByRole("heading", { name: /pending review/i, level: 1 }) + ).toBeVisible(); + // A 503 here would mean the route still thinks it needs a credential. + expect(statuses).toEqual([201]); + + // The whole point of `published: false`: it is stored, and invisible. + await page.goto("/projects"); + await expect(page.getByText("Unpublished by design")).toHaveCount(0); + // The published sample is still there, so the assertion above is about + // publication rather than an empty gallery. + await expect( + page.getByRole("link").filter({ hasText: "Laser-cut plywood lamp" }).first() + ).toBeVisible(); + }); + + test("the photo control says uploads are unavailable with no blob store", async ({ + page, + }) => { + // The E2E server runs with BLOB_READ_WRITE_TOKEN unset, which is the whole + // credential-free premise. The honest degradation is worth asserting: the + // route refuses, the message is translated, and the form still submits. + await page.goto("/projects/new"); + + await page + .locator('input[type="file"]') + .setInputFiles({ + name: "lamp.png", + mimeType: "image/png", + buffer: Buffer.from( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489", + "hex" + ), + }); + + // `.project-form-error` rather than role=alert: Next's route announcer is + // also a live region, and two matches is a strict-mode violation. + await expect(page.locator("p.project-form-error")).toContainText( + "Photo uploads are unavailable" + ); + await expect( + page.getByRole("button", { name: "Submit project" }) + ).toBeEnabled(); + }); +}); + +// Spec §10 scenario 1's last clause: an anonymous visitor browses, opens a +// tool, and *cannot submit a project*. +test.describe("Project submission — anonymous visitors", () => { + test("/projects/new offers the sign-in prompt instead of the form", async ({ + page, + }) => { + // No cookie. The real /api/identity answers anonymous, which is what an + // ISAM attendee who never creates an account gets. + await page.goto("/projects/new"); + + await expect( + page.getByRole("heading", { name: "Sign in to share your project", level: 1 }) + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Submit project" })).toHaveCount(0); + await expect(page.getByRole("textbox", { name: "Project title" })).toHaveCount(0); + + // Not a dead end and not a redirect: browsing stays open to them. + await expect(page.getByRole("link", { name: "Browse projects" })).toBeVisible(); + await expect(page).toHaveURL(/\/projects\/new$/); + await expect(page.locator("body")).not.toContainText(PLACEHOLDER_LEAK); + }); + + test("the route itself refuses an anonymous POST, not only the form", async ({ + request, + }) => { + // The form's prompt is presentation; this is the control (§8). + const res = await request.post("/api/projects", { + data: { title: "Sneaked in", body: "Posted without a session." }, + }); + + expect(res.status()).toBe(401); + expect(await res.json()).toMatchObject({ code: "sign_in_required" }); + }); +}); diff --git a/v5/e2e/utils/session.ts b/v5/e2e/utils/session.ts new file mode 100644 index 0000000..4abbebe --- /dev/null +++ b/v5/e2e/utils/session.ts @@ -0,0 +1,42 @@ +import type { BrowserContext } from "@playwright/test"; + +import { + BETTER_AUTH_SESSION_COOKIE, + signCookieValue, +} from "../../test/utils/better-auth-cookie"; + +/** + * Being somebody in an E2E run, without Google. + * + * Sessions are database rows since Phase 4, so the demo seed ships an account + * per role with a known session token (`src/lib/db/demo-seed.ts`), the + * Playwright server boots with a test-only `AUTH_SECRET`, and this signs a + * cookie for one of them exactly the way Better Auth would. Nothing is + * intercepted: the real `/api/identity` reads the real session row. + * + * It imports only `test/utils/better-auth-cookie.ts`, which has no imports of + * its own — deliberately, so an E2E spec never drags a `server-only` module + * into the Playwright process. + */ + +/** Must match `AUTH_SECRET` in playwright.config.ts's `webServer.env`. */ +export const E2E_AUTH_SECRET = "e2e-only-secret-not-used-anywhere-else"; + +export const E2E_BASE_URL = "http://localhost:3100"; + +/** Put a properly signed session cookie for a demo account in the browser. */ +export async function signIn( + context: BrowserContext, + account: { sessionToken: string }, + baseURL?: string +): Promise { + await context.addCookies([ + { + name: BETTER_AUTH_SESSION_COOKIE, + value: await signCookieValue(account.sessionToken, E2E_AUTH_SECRET), + url: baseURL ?? E2E_BASE_URL, + }, + ]); +} + +export { BETTER_AUTH_SESSION_COOKIE, signCookieValue }; diff --git a/v5/messages/ar.json b/v5/messages/ar.json index 6b77e51..62173e7 100644 --- a/v5/messages/ar.json +++ b/v5/messages/ar.json @@ -135,6 +135,7 @@ "cancel": "إلغاء", "onlyImages": "يتم دعم ملفات الصور فقط.", "uploadFailed": "فشل رفع الصورة.", + "uploadsUnavailable": "رفع الصور غير متاح حاليًا. لا يزال بإمكانك إرسال مشروعك بدون صور.", "requiredError": "العنوان واسمك والوصف حقول مطلوبة.", "submitError": "حدث خطأ ما. يرجى المحاولة مرة أخرى.", "thanksTitle": "شكرًا — مشروعك قيد المراجعة", @@ -175,6 +176,7 @@ "uploadingAria": "جارٍ رفع الصورة", "onlyImages": "يُسمح بملفات الصور فقط.", "uploadFailed": "فشل رفع الصورة", + "uploadsUnavailable": "رفع الصور غير متاح حاليًا. لا يزال بإمكانك إرسال رسالتك بدون صورة.", "toolRunningAria": "الأداة قيد التشغيل", "typingAria": "المساعد يكتب", "readingManualsAria": "جارٍ قراءة الأدلة", diff --git a/v5/messages/en.json b/v5/messages/en.json index ed11fa2..fb3c799 100644 --- a/v5/messages/en.json +++ b/v5/messages/en.json @@ -21,7 +21,8 @@ "signInUnconfigured": "Sign-in isn't set up on this deployment yet.", "signInFailed": "Couldn't start sign-in. Try again.", "signOut": "SIGN OUT", - "signedInAria": "Signed in as {name}" + "signedInAria": "Signed in as {name}", + "admin": "ADMIN" }, "catalogRefresh": { "action": "REFRESH", @@ -137,11 +138,16 @@ "cancel": "Cancel", "onlyImages": "Only image files are supported.", "uploadFailed": "Photo upload failed.", - "requiredError": "Title, your name, and a write-up are required.", + "uploadsUnavailable": "Photo uploads are unavailable right now. You can still submit your project without photos.", + "requiredError": "A title and a write-up are required.", "submitError": "Something went wrong. Please try again.", "thanksTitle": "Thanks — your project is pending review", "thanksBody": "A staff member will review your submission and publish it to the gallery soon.", - "backToGallery": "Back to gallery" + "backToGallery": "Back to gallery", + "signInTitle": "Sign in to share your project", + "signInBody": "Project write-ups are credited to your {institution} account, so you need to be signed in to submit one. Use the Sign in control in the header.", + "signInBrowse": "Browse projects", + "authorNote": "Your project will be credited to {name}." }, "about": { "eyebrow": "About", @@ -177,6 +183,7 @@ "uploadingAria": "Uploading photo", "onlyImages": "Only image files are supported.", "uploadFailed": "Photo upload failed", + "uploadsUnavailable": "Photo uploads are unavailable right now. You can still send your message without a photo.", "toolRunningAria": "Tool running", "typingAria": "Assistant is typing", "readingManualsAria": "Reading manuals", @@ -265,5 +272,52 @@ "demoBanner": { "label": "Demo data", "body": "This catalogue is built-in sample data, not {institution}'s real inventory. Notion is not configured." + }, + "admin": { + "eyebrow": "Admin", + "title": "Admin", + "loading": "Checking who you are…", + "backToCatalog": "Back to the catalog", + "signedOutTitle": "You are not signed in", + "signedOutBody": "Admin pages need an account. Use the Sign in control in the header, then come back.", + "forbiddenTitle": "You do not have access to this page", + "forbiddenBody": "Your account is signed in, but it does not hold the permission this page needs. Ask a super admin if you think it should.", + "indexTitle": "Admin surfaces", + "indexNothingYet": "Nothing here is open to your account yet.", + "indexMoreComing": "Inventory, intake, maintenance and corrections arrive in later phases.", + "usersTitle": "People", + "usersLede": "Everyone who has signed in. A role change takes effect on that person's next request.", + "tableLabel": "People and their roles", + "columnPerson": "Person", + "columnRole": "Role", + "columnAccess": "Access", + "columnJoined": "First signed in", + "noUsers": "Nobody has signed in yet. The first person to sign in appears here.", + "you": "you", + "roleForPerson": "Role for {name}", + "saving": "Saving…", + "saved": "Saved", + "banned": "Banned", + "bannedWithReason": "Banned — {reason}", + "banFor": "Ban", + "liftBanFor": "Lift ban", + "banReasonPlaceholder": "Reason (optional)", + "banReasonFor": "Reason for banning {name}", + "roles": { + "user": "Student", + "admin": "SuperMaker", + "super_admin": "Director" + }, + "errors": { + "not_signed_in": "You are not signed in any more. Sign in and try again.", + "not_permitted": "Your account does not hold the permission this needs.", + "rate_limited": "Too many changes at once. Wait a moment and try again.", + "unknown_user": "That account no longer exists.", + "invalid_role": "That is not a role this app knows.", + "protected_floor": "This address is protected in the deployment's settings and cannot be demoted or banned.", + "last_super_admin": "This is the last director. Promote somebody else first, or nobody could undo it.", + "self_ban": "You cannot ban yourself.", + "failed": "That did not save. Nothing was changed." + } } } diff --git a/v5/messages/es.json b/v5/messages/es.json index aeb4250..e5419e3 100644 --- a/v5/messages/es.json +++ b/v5/messages/es.json @@ -135,6 +135,7 @@ "cancel": "Cancelar", "onlyImages": "Solo se admiten archivos de imagen.", "uploadFailed": "Error al subir la foto.", + "uploadsUnavailable": "La subida de fotos no está disponible en este momento. Puedes enviar tu proyecto sin fotos.", "requiredError": "El título, tu nombre y una descripción son obligatorios.", "submitError": "Algo salió mal. Inténtalo de nuevo.", "thanksTitle": "Gracias: tu proyecto está pendiente de revisión", @@ -175,6 +176,7 @@ "uploadingAria": "Subiendo foto", "onlyImages": "Solo se admiten archivos de imagen.", "uploadFailed": "Error al subir la foto", + "uploadsUnavailable": "La subida de fotos no está disponible en este momento. Puedes enviar tu mensaje sin foto.", "toolRunningAria": "Herramienta en ejecución", "typingAria": "El asistente está escribiendo", "readingManualsAria": "Leyendo manuales", diff --git a/v5/messages/fr.json b/v5/messages/fr.json index 2302fd0..4dbe57d 100644 --- a/v5/messages/fr.json +++ b/v5/messages/fr.json @@ -135,6 +135,7 @@ "cancel": "Annuler", "onlyImages": "Seuls les fichiers image sont pris en charge.", "uploadFailed": "Échec du téléversement de la photo.", + "uploadsUnavailable": "L'envoi de photos est indisponible pour le moment. Vous pouvez soumettre votre projet sans photos.", "requiredError": "Le titre, votre nom et une description sont obligatoires.", "submitError": "Une erreur est survenue. Veuillez réessayer.", "thanksTitle": "Merci — votre projet est en attente de validation", @@ -175,6 +176,7 @@ "uploadingAria": "Téléversement de la photo", "onlyImages": "Seuls les fichiers image sont pris en charge.", "uploadFailed": "Échec du téléversement de la photo", + "uploadsUnavailable": "L'envoi de photos est indisponible pour le moment. Vous pouvez envoyer votre message sans photo.", "toolRunningAria": "Outil en cours d'exécution", "typingAria": "L'assistant est en train d'écrire", "readingManualsAria": "Lecture des manuels", diff --git a/v5/messages/he.json b/v5/messages/he.json index abb3e72..786f619 100644 --- a/v5/messages/he.json +++ b/v5/messages/he.json @@ -135,6 +135,7 @@ "cancel": "ביטול", "onlyImages": "נתמכים קבצי תמונה בלבד.", "uploadFailed": "העלאת התמונה נכשלה.", + "uploadsUnavailable": "העלאת תמונות אינה זמינה כרגע. עדיין אפשר להגיש את הפרויקט בלי תמונות.", "requiredError": "שם, שמכם ותיאור הם שדות חובה.", "submitError": "משהו השתבש. נסו שוב.", "thanksTitle": "תודה — הפרויקט שלכם ממתין לבדיקה", @@ -175,6 +176,7 @@ "uploadingAria": "מעלה תמונה", "onlyImages": "נתמכים קבצי תמונה בלבד.", "uploadFailed": "העלאת התמונה נכשלה", + "uploadsUnavailable": "העלאת תמונות אינה זמינה כרגע. עדיין אפשר לשלוח את ההודעה בלי תמונה.", "toolRunningAria": "הכלי פועל", "typingAria": "העוזר מקליד", "readingManualsAria": "קורא מדריכים", diff --git a/v5/messages/hi.json b/v5/messages/hi.json index 2dfe5ca..ce3ac87 100644 --- a/v5/messages/hi.json +++ b/v5/messages/hi.json @@ -135,6 +135,7 @@ "cancel": "रद्द करें", "onlyImages": "केवल छवि फ़ाइलें समर्थित हैं।", "uploadFailed": "तस्वीर अपलोड विफल।", + "uploadsUnavailable": "फ़ोटो अपलोड अभी उपलब्ध नहीं है। आप बिना फ़ोटो के भी अपना प्रोजेक्ट जमा कर सकते हैं।", "requiredError": "शीर्षक, आपका नाम और विवरण आवश्यक हैं।", "submitError": "कुछ गलत हुआ। कृपया पुनः प्रयास करें।", "thanksTitle": "धन्यवाद — आपकी परियोजना समीक्षा हेतु लंबित है", @@ -175,6 +176,7 @@ "uploadingAria": "फ़ोटो अपलोड हो रही है", "onlyImages": "केवल छवि फ़ाइलें समर्थित हैं।", "uploadFailed": "फ़ोटो अपलोड विफल", + "uploadsUnavailable": "फ़ोटो अपलोड अभी उपलब्ध नहीं है। आप बिना फ़ोटो के भी अपना संदेश भेज सकते हैं।", "toolRunningAria": "उपकरण चल रहा है", "typingAria": "सहायक टाइप कर रहा है", "readingManualsAria": "मैनुअल पढ़ रहा है", diff --git a/v5/messages/ja.json b/v5/messages/ja.json index 51fbbed..ed432bd 100644 --- a/v5/messages/ja.json +++ b/v5/messages/ja.json @@ -135,6 +135,7 @@ "cancel": "キャンセル", "onlyImages": "画像ファイルのみ対応しています。", "uploadFailed": "写真のアップロードに失敗しました。", + "uploadsUnavailable": "写真のアップロードは現在利用できません。写真なしでもプロジェクトを投稿できます。", "requiredError": "タイトル、お名前、説明は必須です。", "submitError": "問題が発生しました。もう一度お試しください。", "thanksTitle": "ありがとうございます — プロジェクトは審査待ちです", @@ -175,6 +176,7 @@ "uploadingAria": "写真をアップロード中", "onlyImages": "画像ファイルのみ対応しています。", "uploadFailed": "写真のアップロードに失敗しました", + "uploadsUnavailable": "写真のアップロードは現在利用できません。写真なしでもメッセージを送信できます。", "toolRunningAria": "ツール実行中", "typingAria": "アシスタントが入力中です", "readingManualsAria": "マニュアルを読み込み中", diff --git a/v5/messages/ko.json b/v5/messages/ko.json index 1d38b4e..0b14239 100644 --- a/v5/messages/ko.json +++ b/v5/messages/ko.json @@ -135,6 +135,7 @@ "cancel": "취소", "onlyImages": "이미지 파일만 지원됩니다.", "uploadFailed": "사진 업로드에 실패했습니다.", + "uploadsUnavailable": "사진 업로드를 현재 사용할 수 없습니다. 사진 없이도 프로젝트를 제출할 수 있습니다.", "requiredError": "제목, 이름, 설명은 필수입니다.", "submitError": "문제가 발생했습니다. 다시 시도해 주세요.", "thanksTitle": "감사합니다 — 프로젝트가 검토 대기 중입니다", @@ -175,6 +176,7 @@ "uploadingAria": "사진 업로드 중", "onlyImages": "이미지 파일만 지원됩니다.", "uploadFailed": "사진 업로드 실패", + "uploadsUnavailable": "사진 업로드를 현재 사용할 수 없습니다. 사진 없이도 메시지를 보낼 수 있습니다.", "toolRunningAria": "도구 실행 중", "typingAria": "어시스턴트가 입력 중입니다", "readingManualsAria": "매뉴얼 읽는 중", diff --git a/v5/messages/pt-BR.json b/v5/messages/pt-BR.json index d2c9bcb..fdc17fc 100644 --- a/v5/messages/pt-BR.json +++ b/v5/messages/pt-BR.json @@ -135,6 +135,7 @@ "cancel": "Cancelar", "onlyImages": "Apenas arquivos de imagem são suportados.", "uploadFailed": "Falha ao enviar a foto.", + "uploadsUnavailable": "O envio de fotos está indisponível no momento. Você ainda pode enviar seu projeto sem fotos.", "requiredError": "Título, seu nome e uma descrição são obrigatórios.", "submitError": "Algo deu errado. Tente novamente.", "thanksTitle": "Obrigado — seu projeto está aguardando revisão", @@ -175,6 +176,7 @@ "uploadingAria": "Enviando foto", "onlyImages": "Somente arquivos de imagem são aceitos.", "uploadFailed": "Falha ao enviar a foto", + "uploadsUnavailable": "O envio de fotos está indisponível no momento. Você ainda pode enviar sua mensagem sem foto.", "toolRunningAria": "Ferramenta em execução", "typingAria": "O assistente está digitando", "readingManualsAria": "Lendo manuais", diff --git a/v5/messages/ru.json b/v5/messages/ru.json index ba642d7..2cabca7 100644 --- a/v5/messages/ru.json +++ b/v5/messages/ru.json @@ -135,6 +135,7 @@ "cancel": "Отмена", "onlyImages": "Поддерживаются только файлы изображений.", "uploadFailed": "Не удалось загрузить фото.", + "uploadsUnavailable": "Загрузка фото сейчас недоступна. Вы можете отправить проект без фотографий.", "requiredError": "Название, ваше имя и описание обязательны.", "submitError": "Что-то пошло не так. Попробуйте снова.", "thanksTitle": "Спасибо — ваш проект ожидает проверки", @@ -175,6 +176,7 @@ "uploadingAria": "Загрузка фото", "onlyImages": "Поддерживаются только файлы изображений.", "uploadFailed": "Не удалось загрузить фото", + "uploadsUnavailable": "Загрузка фото сейчас недоступна. Вы можете отправить сообщение без фото.", "toolRunningAria": "Инструмент выполняется", "typingAria": "Ассистент печатает", "readingManualsAria": "Чтение руководств", diff --git a/v5/messages/tr.json b/v5/messages/tr.json index 68d2053..e7e5874 100644 --- a/v5/messages/tr.json +++ b/v5/messages/tr.json @@ -135,6 +135,7 @@ "cancel": "İptal", "onlyImages": "Yalnızca resim dosyaları desteklenir.", "uploadFailed": "Fotoğraf yüklenemedi.", + "uploadsUnavailable": "Fotoğraf yükleme şu anda kullanılamıyor. Projenizi fotoğrafsız da gönderebilirsiniz.", "requiredError": "Başlık, adın ve bir açıklama gereklidir.", "submitError": "Bir şeyler ters gitti. Lütfen tekrar deneyin.", "thanksTitle": "Teşekkürler — projen inceleme bekliyor", @@ -175,6 +176,7 @@ "uploadingAria": "Fotoğraf yükleniyor", "onlyImages": "Yalnızca görsel dosyaları desteklenir.", "uploadFailed": "Fotoğraf yüklenemedi", + "uploadsUnavailable": "Fotoğraf yükleme şu anda kullanılamıyor. Mesajınızı fotoğrafsız da gönderebilirsiniz.", "toolRunningAria": "Araç çalışıyor", "typingAria": "Asistan yazıyor", "readingManualsAria": "Kılavuzlar okunuyor", diff --git a/v5/messages/zh-CN.json b/v5/messages/zh-CN.json index 804200e..c1ab976 100644 --- a/v5/messages/zh-CN.json +++ b/v5/messages/zh-CN.json @@ -135,6 +135,7 @@ "cancel": "取消", "onlyImages": "仅支持图片文件。", "uploadFailed": "照片上传失败。", + "uploadsUnavailable": "照片上传当前不可用。您仍可以不带照片提交项目。", "requiredError": "标题、你的姓名和说明为必填项。", "submitError": "出现问题,请重试。", "thanksTitle": "谢谢——你的项目正在等待审核", @@ -175,6 +176,7 @@ "uploadingAria": "正在上传照片", "onlyImages": "仅支持图片文件。", "uploadFailed": "照片上传失败", + "uploadsUnavailable": "照片上传当前不可用。您仍可以不带照片发送消息。", "toolRunningAria": "工具运行中", "typingAria": "助手正在输入", "readingManualsAria": "正在阅读手册", diff --git a/v5/playwright.config.ts b/v5/playwright.config.ts index d31f250..b9855dd 100644 --- a/v5/playwright.config.ts +++ b/v5/playwright.config.ts @@ -64,6 +64,26 @@ export default defineConfig({ NOTION_DB_RESOURCES: "", NOTION_DB_MAINTENANCE_LOGS: "", NOTION_DB_FLAGS: "", + // Blanked so the run is the same on a machine that happens to have a + // Blob store linked: uploads must take the "unavailable" branch, which + // is itself asserted in projects.spec.ts, and no test may put bytes in + // somebody's real store. + BLOB_READ_WRITE_TOKEN: "", + // Same reasoning for the nightly job: no E2E test should be able to + // trigger a real backup. + CRON_SECRET: "", + // A test-only signing key, so sessions are real rows and a spec can be + // somebody by presenting a properly signed cookie for one of the demo + // accounts (src/lib/db/demo-seed.ts). GOOGLE_* stay blank below, so + // *starting* a session is still impossible here: /api/auth/sign-in/social + // answers 503 and the header says sign-in is not set up. That is the + // state a deployment is in before its OAuth client exists, and it is the + // state the anonymous specs assert against. + AUTH_SECRET: "e2e-only-secret-not-used-anywhere-else", + AUTH_BASE_URL: "http://localhost:3100", + GOOGLE_CLIENT_ID: "", + GOOGLE_CLIENT_SECRET: "", + AUTH_SUPER_ADMIN_EMAILS: "", }, }, }); diff --git a/v5/src/app/admin/layout.tsx b/v5/src/app/admin/layout.tsx new file mode 100644 index 0000000..8823761 --- /dev/null +++ b/v5/src/app/admin/layout.tsx @@ -0,0 +1,62 @@ +import { Suspense } from "react"; +import { getTranslations } from "next-intl/server"; +import { AdminNotice } from "../../components/admin/AdminNotice"; +import { resolveIdentityFromHeaders } from "../../lib/auth/identity"; +import { canReachAdmin } from "../../lib/auth/permissions"; +import { siteConfig } from "../../lib/site-config"; + +/** + * The `/admin` shell and its front door (spec §6, §8). + * + * **The gate is here, once, and each page checks again.** This layout answers + * the coarse question — may this person see an admin surface at all — so that + * every page under it can assume a signed-in someone and check only the + * permission it actually needs. `/admin/users` gates on `users.manage`; a + * SuperMaker gets past this layout and is refused there, which is the honest + * answer rather than a 404. + * + * **It refuses, it never throws.** No `notFound()` and no redirect: a 404 would + * lie about the page existing and a redirect to sign-in would lose where they + * were going. `AdminNotice` says which of the two situations this is. + * + * The identity read lives in its own `Suspense` boundary because + * `cacheComponents` is enabled: reading request headers marks this subtree + * dynamic, and the boundary is what lets the rest of the shell stay static. + */ + +export const metadata = { + title: `Admin — ${siteConfig.name}`, +}; + +export default async function AdminLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const t = await getTranslations("admin"); + + return ( +
+
+
+ +

{t("title")}

+
+
+ + {t("loading")}

}> + {children} +
+
+ ); +} + +/** Resolves who is asking, and renders the children only if they may be here. */ +async function AdminGate({ children }: { children: React.ReactNode }) { + const identity = await resolveIdentityFromHeaders(); + + if (identity.role === "anonymous") return ; + if (!canReachAdmin(identity)) return ; + + return <>{children}; +} diff --git a/v5/src/app/admin/page.tsx b/v5/src/app/admin/page.tsx new file mode 100644 index 0000000..b121104 --- /dev/null +++ b/v5/src/app/admin/page.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { resolveIdentityFromHeaders } from "../../lib/auth/identity"; +import { can } from "../../lib/auth/permissions"; + +/** + * `/admin` — the index the header's `AdminLink` points at. + * + * Phase 4 builds one surface, so this is a short list rather than the + * `AdminHome` of spec §6 (counts, the intake queue, open tickets, mirror + * status) — those need the tables and queues later phases add. What it must do + * now is be honest: a SuperMaker who holds `tools.edit` but not `users.manage` + * reaches this page, sees nothing they can open yet, and is told that, instead + * of following a link into a refusal. + * + * The layout above has already established that this person may see an admin + * surface at all. + */ + +export default async function AdminHomePage() { + const t = await getTranslations("admin"); + const identity = await resolveIdentityFromHeaders(); + const manageUsers = can(identity, "users.manage"); + + return ( +
+

{t("eyebrow")}

+

{t("indexTitle")}

+ + {manageUsers ? ( +
    +
  • + {t("usersTitle")} + {t("usersLede")} +
  • +
+ ) : ( +

{t("indexNothingYet")}

+ )} + +

{t("indexMoreComing")}

+
+ ); +} diff --git a/v5/src/app/admin/users/action-result.ts b/v5/src/app/admin/users/action-result.ts new file mode 100644 index 0000000..0b399ac --- /dev/null +++ b/v5/src/app/admin/users/action-result.ts @@ -0,0 +1,40 @@ +import type { Role } from "../../../lib/db/schema/vocabulary"; + +/** + * What a `/admin/users` server action answers, and where it lives. + * + * Its own module because `actions.ts` carries `"use server"`, and a module with + * that directive may export **only async functions** — every export becomes a + * callable endpoint. A shared constant and a result type therefore cannot live + * there, and a client island that only needs the shape should not have to + * import the endpoints to get it. + */ + +/** The page these actions belong to, and the path they invalidate. */ +export const ADMIN_USERS_PATH = "/admin/users"; + +/** + * Why an action did nothing. Every code has an `admin.errors.` message in + * `messages/en.json`; the union is what keeps the two in step. + * + * - `not_signed_in` / `not_permitted` — told apart on purpose: one is + * actionable and the other is not. + * - `protected_floor` — the address is in `AUTH_SUPER_ADMIN_EMAILS`. + * - `last_super_admin` — the change would leave nobody holding `users.manage`. + * - `self_ban` — banning yourself; the plugin refuses it too. + * - `failed` — the write did not land. Deliberately opaque to the browser. + */ +export type AdminActionError = + | "not_signed_in" + | "not_permitted" + | "rate_limited" + | "unknown_user" + | "invalid_role" + | "protected_floor" + | "last_super_admin" + | "self_ban" + | "failed"; + +export type AdminActionResult = + | { ok: true; role?: Role; banned?: boolean } + | { ok: false; error: AdminActionError }; diff --git a/v5/src/app/admin/users/actions.test.ts b/v5/src/app/admin/users/actions.test.ts new file mode 100644 index 0000000..a9f80d6 --- /dev/null +++ b/v5/src/app/admin/users/actions.test.ts @@ -0,0 +1,467 @@ +// @vitest-environment node +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); +vi.mock("next/headers", () => nextHeadersMock()); + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { resetAuthForTests } from "../../../lib/auth/config"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { auditEvents, session, user } from "../../../lib/db/schema/index"; +import { listAuditEvents } from "../../../lib/data/audit"; +import { findUserById } from "../../../lib/data/users"; +import { seedUser, signInAs, signInAsNew } from "../../../../test/utils/session"; +import { setUserBanned, setUserRole } from "./actions"; + +/** + * The two `/admin/users` writes, end to end over PGlite: a real Better Auth + * instance, real session rows, the real admin plugin, the real audit table. + * The only things stubbed are the two Next modules that need a request scope — + * `next/headers` (fed a cookie `test/utils/session.ts` minted) and + * `next/cache`. No network, no Google, no `DATABASE_URL` (Article 3). + * + * `resetAuthForTests()` runs in both hooks: `getAuth()` memoizes on the env + * fingerprint *and* the substrate, so a test that stubs `AUTH_SECRET` and one + * that resets the database must not share an instance. + */ + +const AUTH_SECRET = "admin-users-actions-test-secret"; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + resetAuthForTests(); + vi.mocked(revalidatePath).mockClear(); + + // The demo seed ships one account per role; these tests build their own + // cast, so the table starts empty and a count of super admins means what it + // says. Sessions and audit rows go with them (cascade / explicit). + const db = await getDb(); + await db.delete(auditEvents); + await db.delete(session); + await db.delete(user); +}); + +afterEach(() => { + resetAuthForTests(); + resetDbForTests(); +}); + +/** Sign in as a director and point `next/headers` at their cookie. */ +async function asDirector(email = "director@cornell.edu") { + const signedIn = await signInAsNew({ email, role: "super_admin", name: "Dee Rector" }); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; +} + +async function roleOf(userId: string) { + return (await findUserById(userId))?.role; +} + +// ── Who may call these at all (§8) ────────────────────────────────── + +describe("the permission gate", () => { + it("refuses an anonymous caller and changes nothing", async () => { + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + setMockHeaders(); + + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect(await roleOf(target.id)).toBe("user"); + expect(await listAuditEvents()).toEqual([]); + }); + + it("refuses an ordinary signed-in user", async () => { + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + const caller = await signInAsNew({ email: "someone@cornell.edu", role: "user" }); + setMockHeaders({ cookie: caller.cookie }); + + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: false, + error: "not_permitted", + }); + expect(await roleOf(target.id)).toBe("user"); + }); + + it("refuses a SuperMaker — `users.manage` belongs to the director alone", async () => { + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + const caller = await signInAsNew({ email: "maker@cornell.edu", role: "admin" }); + setMockHeaders({ cookie: caller.cookie }); + + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: false, + error: "not_permitted", + }); + expect(await listAuditEvents()).toEqual([]); + }); + + it("refuses a forged cookie rather than trusting it", async () => { + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + const director = await signInAsNew({ + email: "director@cornell.edu", + role: "super_admin", + }); + setMockHeaders({ cookie: director.cookie.replace(/.$/, (c) => (c === "A" ? "B" : "A")) }); + + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: false, + error: "not_signed_in", + }); + }); + + it("refuses a banned director — a ban bites on the next request", async () => { + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + const banned = await seedUser({ + email: "expired@cornell.edu", + role: "super_admin", + banned: true, + }); + const signedIn = await signInAs(banned); + setMockHeaders({ cookie: signedIn.cookie }); + + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: false, + error: "not_signed_in", + }); + }); + + it("is bounded before it checks anything (Article 4, §8: 120/min)", async () => { + // The limiter is a per-process singleton and an anonymous caller is keyed + // by a hash of their IP, so this test needs an address no other test in + // the file shares — otherwise it inherits their spent allowance. + setMockHeaders({ "x-forwarded-for": "198.51.100.7" }); + + // Anonymous, so every call is refused on the permission check — until the + // 121st, which is refused *before* it, which is the ordering under test. + const results: string[] = []; + for (let i = 0; i < 121; i += 1) { + const result = await setUserRole({ userId: "whoever", role: "admin" }); + if (!result.ok) results.push(result.error); + } + + expect(results.slice(0, 120).every((error) => error === "not_signed_in")).toBe(true); + expect(results[120]).toBe("rate_limited"); + }); +}); + +// ── Changing a role (§5.2) ────────────────────────────────────────── + +describe("setUserRole", () => { + it("promotes a student to SuperMaker and writes exactly one audit event", async () => { + const director = await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: true, + role: "admin", + }); + expect(await roleOf(target.id)).toBe("admin"); + + const events = await listAuditEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + actorUserId: director.user.id, + action: "role.changed", + subjectType: "user", + subjectId: target.id, + // Both halves: "became an admin" is unanswerable later without the from. + detail: { from: "user", to: "admin" }, + }); + }); + + it("revalidates the page so the roster shows the change", async () => { + await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + await setUserRole({ userId: target.id, role: "admin" }); + + expect(vi.mocked(revalidatePath)).toHaveBeenCalledWith("/admin/users"); + }); + + it("treats a change to the role they already hold as a no-op with no event", async () => { + await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + expect(await setUserRole({ userId: target.id, role: "user" })).toEqual({ + ok: true, + role: "user", + }); + expect(await listAuditEvents()).toEqual([]); + }); + + it("refuses a role outside the vocabulary", async () => { + await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + expect(await setUserRole({ userId: target.id, role: "staff" })).toEqual({ + ok: false, + error: "invalid_role", + }); + expect(await roleOf(target.id)).toBe("user"); + }); + + it("refuses an id that names nobody", async () => { + await asDirector(); + + expect(await setUserRole({ userId: "nobody-here", role: "admin" })).toEqual({ + ok: false, + error: "unknown_user", + }); + }); + + it("refuses to demote an address on the super-admin floor, and says why", async () => { + await asDirector(); + const floor = await seedUser({ email: "founder@cornell.edu", role: "super_admin" }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + expect(await setUserRole({ userId: floor.id, role: "user" })).toEqual({ + ok: false, + error: "protected_floor", + }); + // The row is untouched, which is the half that matters: a refusal that + // half-applied would be worse than no refusal at all. + expect(await roleOf(floor.id)).toBe("super_admin"); + expect(await listAuditEvents()).toEqual([]); + }); + + it("still allows *promoting* a floor address — the floor is a floor, not a freeze", async () => { + await asDirector(); + const floor = await seedUser({ email: "founder@cornell.edu", role: "super_admin" }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + expect(await setUserRole({ userId: floor.id, role: "super_admin" })).toEqual({ + ok: true, + role: "super_admin", + }); + }); + + it("refuses the last director demoting themselves (spec §10)", async () => { + const director = await asDirector(); + + expect(await setUserRole({ userId: director.user.id, role: "user" })).toEqual({ + ok: false, + error: "last_super_admin", + }); + expect(await roleOf(director.user.id)).toBe("super_admin"); + }); + + it("allows a director to step down once somebody else holds the role", async () => { + const director = await asDirector(); + await seedUser({ email: "successor@cornell.edu", role: "super_admin" }); + + expect(await setUserRole({ userId: director.user.id, role: "admin" })).toEqual({ + ok: true, + role: "admin", + }); + expect(await roleOf(director.user.id)).toBe("admin"); + }); +}); + +// ── Banning (§5.2) ────────────────────────────────────────────────── + +describe("setUserBanned", () => { + it("bans with a reason, records it, and deletes the person's sessions", async () => { + const director = await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + await signInAs(target); + + expect( + await setUserBanned({ userId: target.id, banned: true, reason: "Ignored the rules" }) + ).toEqual({ ok: true, banned: true }); + + const stored = await findUserById(target.id); + expect(stored).toMatchObject({ banned: true, banReason: "Ignored the rules" }); + + // The plugin deletes their sessions, so the ban bites mid-visit rather + // than at expiry — and `resolveIdentity` would refuse them regardless. + const db = await getDb(); + const rows = await db.select().from(session).where(eq(session.userId, target.id)); + expect(rows).toEqual([]); + + const events = await listAuditEvents(); + expect(events[0]).toMatchObject({ + actorUserId: director.user.id, + action: "user.banned", + subjectId: target.id, + detail: { banned: true, reason: "Ignored the rules" }, + }); + }); + + it("lifts a ban, recorded as the same action with `banned: false`", async () => { + await asDirector(); + const target = await seedUser({ + email: "student@cornell.edu", + role: "user", + banned: true, + }); + + expect(await setUserBanned({ userId: target.id, banned: false })).toEqual({ + ok: true, + banned: false, + }); + expect((await findUserById(target.id))?.banned).toBe(false); + + const events = await listAuditEvents(); + expect(events[0]).toMatchObject({ action: "user.banned", detail: { banned: false } }); + }); + + it("refuses to ban a floor address", async () => { + await asDirector(); + const floor = await seedUser({ email: "founder@cornell.edu", role: "super_admin" }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + expect(await setUserBanned({ userId: floor.id, banned: true })).toEqual({ + ok: false, + error: "protected_floor", + }); + expect((await findUserById(floor.id))?.banned).toBe(false); + }); + + it("refuses to ban yourself", async () => { + const director = await asDirector(); + await seedUser({ email: "successor@cornell.edu", role: "super_admin" }); + + expect(await setUserBanned({ userId: director.user.id, banned: true })).toEqual({ + ok: false, + error: "self_ban", + }); + expect((await findUserById(director.user.id))?.banned).toBe(false); + }); + + it("allows banning another director — the caller is still one", async () => { + // There is deliberately no "last director" guard on the ban path: reaching + // it means the *caller* holds `users.manage`, so the lab keeps one. + await asDirector(); + const other = await seedUser({ email: "other@cornell.edu", role: "super_admin" }); + + expect(await setUserBanned({ userId: other.id, banned: true })).toEqual({ + ok: true, + banned: true, + }); + expect((await findUserById(other.id))?.banned).toBe(true); + }); + + it("is a no-op when the account is already in that state", async () => { + await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + expect(await setUserBanned({ userId: target.id, banned: false })).toEqual({ + ok: true, + banned: false, + }); + expect(await listAuditEvents()).toEqual([]); + }); +}); + +// ── The floor has to reach the plugin, not just `can()` (§3.4) ────── + +describe("a floor address whose row has not caught up", () => { + /** + * The regression this guards. `AUTH_SUPER_ADMIN_EMAILS` is applied in two + * places — `databaseHooks.user.create.before`, which only runs at first + * sign-in, and `identityFromSession`, which overrides the resolved role. The + * *plugin* reads `session.user.role` off the row and sees neither. So a floor + * address added after that person had already signed in, or a `super_admin` + * demoted by a restored backup, reached `/admin/users` with every control + * live (the app says they are a director) and every save returning `failed` + * (the plugin says they are a `user`) — the exact lock-out the floor exists + * to undo, with no hint in the message about why. + */ + async function asFloorAddressStoredAs(role: "user" | "admin") { + const signedIn = await signInAsNew({ + email: "founder@cornell.edu", + role, + name: "Fran Ounder", + }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; + } + + it("can still change a role, and the row catches up", async () => { + const caller = await asFloorAddressStoredAs("user"); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: true, + role: "admin", + }); + expect(await roleOf(target.id)).toBe("admin"); + // Reconciled rather than special-cased: the table now shows the role the + // app has been reporting all along. + expect(await roleOf(caller.user.id)).toBe("super_admin"); + }); + + it("can still ban somebody", async () => { + await asFloorAddressStoredAs("admin"); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + expect(await setUserBanned({ userId: target.id, banned: true })).toEqual({ + ok: true, + banned: true, + }); + expect((await findUserById(target.id))?.banned).toBe(true); + }); + + it("records the reconciliation as the role change it is", async () => { + const caller = await asFloorAddressStoredAs("user"); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + await setUserRole({ userId: target.id, role: "admin" }); + + const events = await listAuditEvents(); + const reconciliation = events.find((event) => event.subjectId === caller.user.id); + // Null actor: the environment did this, not a person who clicked something. + expect(reconciliation).toMatchObject({ + action: "role.changed", + actorUserId: null, + detail: { from: "user", to: "super_admin", reason: "super_admin_floor" }, + }); + }); + + it("reconciles once, not on every action", async () => { + const caller = await asFloorAddressStoredAs("user"); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + await setUserRole({ userId: target.id, role: "admin" }); + await setUserRole({ userId: target.id, role: "user" }); + + const forCaller = (await listAuditEvents()).filter( + (event) => event.subjectId === caller.user.id + ); + expect(forCaller).toHaveLength(1); + }); + + it("leaves a director who is not on the floor exactly as they were", async () => { + const director = await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + await setUserRole({ userId: target.id, role: "admin" }); + + // One event, for the target. Nothing was written about the caller. + expect((await listAuditEvents()).map((event) => event.subjectId)).toEqual([ + target.id, + ]); + expect(await roleOf(director.user.id)).toBe("super_admin"); + }); + + it("does not promote an ordinary user who merely shares a prefix with the floor", async () => { + // `isSuperAdminFloor` matches the whole normalized address; a reconciler + // that matched loosely would be a privilege escalation, not a repair. + const caller = await signInAsNew({ email: "founder2@cornell.edu", role: "user" }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + setMockHeaders({ cookie: caller.cookie }); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: false, + error: "not_permitted", + }); + expect(await roleOf(caller.user.id)).toBe("user"); + }); +}); diff --git a/v5/src/app/admin/users/actions.ts b/v5/src/app/admin/users/actions.ts new file mode 100644 index 0000000..563ec39 --- /dev/null +++ b/v5/src/app/admin/users/actions.ts @@ -0,0 +1,266 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { getAuth } from "../../../lib/auth/config"; +import { reconcileSuperAdminFloor } from "../../../lib/auth/floor-role"; +import { resolveIdentityFromHeaders, type Identity } from "../../../lib/auth/identity"; +import { can } from "../../../lib/auth/permissions"; +import { isSuperAdminFloor } from "../../../lib/auth/super-admins"; +import { recordAuditEvent } from "../../../lib/data/audit"; +import { countUsersWithRole, findUserById, type UserRecord } from "../../../lib/data/users"; +import { isOneOf, ROLES, type Role } from "../../../lib/db/schema/vocabulary"; +import { ADMIN_ACTION_TIER, rateLimitAsync } from "../../../lib/rate-limit"; +import { + ADMIN_USERS_PATH, + type AdminActionError, + type AdminActionResult, +} from "./action-result"; + +/** + * The two writes `/admin/users` performs (data platform design spec §5.2, §8). + * + * **Each one checks its own permission.** A server action is a POST endpoint + * with a generated name: it is reachable without ever rendering the page that + * offers it, so nothing it receives — and nothing about the page that rendered + * the control — is evidence of anything (§8). The identity is resolved here, + * `users.manage` is checked here, and the limiter runs here. + * + * **The write goes through the admin plugin, the reads do not.** `set-role` + * and `ban-user` carry behaviour worth having (a ban deletes the person's + * sessions, so they are signed out mid-visit rather than on expiry), and they + * authorize against the same declaration `can()` does. The roster itself is + * read straight from Postgres — see `src/lib/data/users.ts`. + * + * **Refusals are values, not exceptions.** Each action answers + * `{ ok: false, error: }` and the client island renders the matching + * `next-intl` string. A thrown error in a server action reaches the browser as + * a digest and an error boundary, which is the wrong shape for "you cannot + * demote the floor address, and here is why" (§5.2). + */ + +/** + * Change one person's role. + * + * Refuses, in this order: an anonymous caller, the rate ceiling, a caller + * without `users.manage`, a role outside the vocabulary, an unknown target, the + * super-admin floor, and a demotion that would leave nobody holding + * `super_admin` at all (spec §10, "the last super admin demotes themselves"). + * + * A change to the role the person already has is a no-op that reports success + * and writes no audit event — the trail records changes, and "admin → admin" + * is not one. + */ +export async function setUserRole(input: { + userId: string; + role: string; +}): Promise { + const gate = await authorize(); + if (!gate.ok) return gate; + const { identity } = gate; + + if (!isOneOf(ROLES, input.role)) return { ok: false, error: "invalid_role" }; + const role: Role = input.role; + + const target = await findUserById(input.userId); + if (!target) return { ok: false, error: "unknown_user" }; + if (target.role === role) return { ok: true, role }; + + const protection = await demotionProtection(target, role); + if (protection) return { ok: false, error: protection }; + + try { + const auth = await getAuth(); + if (!auth) return { ok: false, error: "failed" }; + await auth.api.setRole({ + body: { userId: target.id, role }, + headers: await requestHeaders(), + }); + } catch (err) { + // The plugin refuses with an `APIError`; anything else is a database or + // configuration problem. Either way the row did not change, and the caller + // is told that rather than being shown a success they did not get. + console.error("[admin/users] set-role failed", err); + return { ok: false, error: "failed" }; + } + + await recordAuditEvent({ + actorUserId: identity.userId, + action: "role.changed", + subjectType: "user", + subjectId: target.id, + // Both halves: "became an admin" is not answerable later without the + // "from", and that is the question an audit trail exists to answer. + detail: { from: target.role, to: role }, + }); + + revalidatePath(ADMIN_USERS_PATH); + return { ok: true, role }; +} + +/** + * Ban or unban one person. + * + * A ban deletes their sessions, so it bites immediately rather than on the next + * page load — and `resolveIdentity` refuses a banned user anyway, so even a + * cookie that outlived the sweep resolves to anonymous. + * + * The floor address cannot be banned, and neither can the last super admin; + * banning yourself is refused here so the message is ours (the plugin refuses + * it too, with an error code the page would have to translate). + */ +export async function setUserBanned(input: { + userId: string; + banned: boolean; + reason?: string; +}): Promise { + const gate = await authorize(); + if (!gate.ok) return gate; + const { identity } = gate; + + const target = await findUserById(input.userId); + if (!target) return { ok: false, error: "unknown_user" }; + if (target.banned === input.banned) return { ok: true, banned: input.banned }; + + if (input.banned) { + // Self-ban first: the plugin refuses it too, but with an error code the + // page would have to translate, and this way the message is ours. + if (target.id === identity.userId) return { ok: false, error: "self_ban" }; + // No "last super admin" check here, deliberately. Reaching this line means + // somebody *else* holds `users.manage` — the caller — so banning this + // account cannot leave the lab without one. + if (isSuperAdminFloor(target.email)) return { ok: false, error: "protected_floor" }; + } + + const reason = (input.reason ?? "").trim() || undefined; + + try { + const auth = await getAuth(); + if (!auth) return { ok: false, error: "failed" }; + const requestedHeaders = await requestHeaders(); + if (input.banned) { + await auth.api.banUser({ + body: { userId: target.id, ...(reason ? { banReason: reason } : {}) }, + headers: requestedHeaders, + }); + } else { + await auth.api.unbanUser({ + body: { userId: target.id }, + headers: requestedHeaders, + }); + } + } catch (err) { + console.error("[admin/users] ban-user failed", err); + return { ok: false, error: "failed" }; + } + + await recordAuditEvent({ + actorUserId: identity.userId, + action: "user.banned", + subjectType: "user", + subjectId: target.id, + // `AUDIT_ACTIONS` has no `user.unbanned` (spec §4.11), so lifting a ban is + // the same action with `banned: false`. The alternative is a vocabulary + // that drifts from the spec, which is worse than a flag in the detail. + detail: { banned: input.banned, ...(reason ? { reason } : {}) }, + }); + + revalidatePath(ADMIN_USERS_PATH); + return { ok: true, banned: input.banned }; +} + +// ── The shared preamble ───────────────────────────────────────────── + +type Gate = { ok: true; identity: Identity } | { ok: false; error: AdminActionError }; + +/** + * Resolve the caller, bound their attempts, and check `users.manage`. + * + * Bounded *before* the permission check and the queries behind it (Article 4, + * §8: 120/min per user), and keyed on `rateLimitKey` — the user id when signed + * in, a hashed IP when not — so an anonymous prodder cannot spend an admin's + * allowance. + * + * The last step is the one that is not a refusal: the floor is written onto the + * caller's own row before either action calls the plugin. `can()` honours the + * floor and the plugin does not — see `lib/auth/floor-role.ts` — so without + * this a floor address whose row says `user` reaches the page with every + * control live and every save failing. It runs after the permission check, so + * only somebody the app already treats as a director can trigger it, and it is + * a no-op for everyone whose row already agrees. + */ +async function authorize(): Promise { + const identity = await resolveIdentityFromHeaders(); + + const { allowed } = await rateLimitAsync( + `admin-action:${identity.rateLimitKey}`, + ADMIN_ACTION_TIER + ); + if (!allowed) return { ok: false, error: "rate_limited" }; + + // Told apart on purpose: "sign in" is actionable and "you are not permitted" + // is not, and showing the wrong one of those is how a page feels broken. + if (identity.role === "anonymous") return { ok: false, error: "not_signed_in" }; + if (!can(identity, "users.manage")) return { ok: false, error: "not_permitted" }; + + try { + await reconcileSuperAdminFloor(identity); + } catch (err) { + // The write that follows depends on this having landed, so reporting + // "failed" is the honest answer — better than letting the plugin refuse + // for a reason the page cannot explain. + console.error("[admin/users] super-admin floor reconciliation failed", err); + return { ok: false, error: "failed" }; + } + + return { ok: true, identity }; +} + +/** + * Why `target` may not be moved to `nextRole`, or null when they may. + * + * Only demotions are protected — promoting anybody, including a floor address + * that already holds the role, is always fine. Two guarantees, and they are + * not the same one: + * + * - **The floor.** An address in `AUTH_SUPER_ADMIN_EMAILS` resolves + * `super_admin` whatever its row says, so demoting it in the database would + * produce a row that disagrees with the running app — confusing rather than + * dangerous, and worth refusing plainly. + * - **The last super admin.** A deployment with no floor configured — a + * preview, a fork — can genuinely lock itself out, and this is the case spec + * §10 names. Banned super admins do not count towards "somebody is left": + * they resolve to anonymous and can undo nothing. + */ +async function demotionProtection( + target: UserRecord, + nextRole: Role +): Promise { + if (nextRole === "super_admin") return null; + + if (isSuperAdminFloor(target.email)) return "protected_floor"; + + if (target.role === "super_admin") { + const remaining = await countUsersWithRole("super_admin", { + excludeUserId: target.id, + }); + if (remaining === 0) return "last_super_admin"; + } + + return null; +} + +/** + * The incoming request's headers as a real `Headers`. + * + * Better Auth iterates what it is given and `next/headers` returns a read-only + * look-alike, so this copies rather than casts. Only the cookie is needed: + * `set-role` is `requireHeaders: true` and authenticates from the session. + */ +async function requestHeaders(): Promise { + const incoming = await headers(); + const copy = new Headers(); + const cookie = incoming.get("cookie"); + if (cookie) copy.set("cookie", cookie); + return copy; +} diff --git a/v5/src/app/admin/users/page.tsx b/v5/src/app/admin/users/page.tsx new file mode 100644 index 0000000..1ca50ee --- /dev/null +++ b/v5/src/app/admin/users/page.tsx @@ -0,0 +1,58 @@ +import { getTranslations } from "next-intl/server"; +import { AdminNotice } from "../../../components/admin/AdminNotice"; +import { UsersTable } from "../../../components/admin/UsersTable"; +import { resolveIdentityFromHeaders } from "../../../lib/auth/identity"; +import { can } from "../../../lib/auth/permissions"; +import { listUsers } from "../../../lib/data/users"; +import { siteConfig } from "../../../lib/site-config"; +import { setUserBanned, setUserRole } from "./actions"; + +/** + * `/admin/users` — who is who, and how to change it (spec §5.2, §6). + * + * Super admin only: `users.manage` belongs to that role alone (§8). The layout + * above let a SuperMaker in — they hold other admin permissions — so the + * refusal for them happens here, and it says so rather than 404ing. + * + * Nothing on this page is cached. The roster is read per request, because the + * point of the whole phase is that a role change is visible immediately; a + * cached roster would show the change to everyone except the person who made + * it. The actions call `revalidatePath` for the same reason. + * + * The two server actions are imported here and handed to the table, which + * hands them to its client islands. That is what keeps `RoleSelect` free of + * `next/headers` and the rate limiter — see its own note. + */ + +export const metadata = { + title: `People — ${siteConfig.name}`, +}; + +export default async function AdminUsersPage() { + const t = await getTranslations("admin"); + const identity = await resolveIdentityFromHeaders(); + + if (!can(identity, "users.manage")) return ; + + const users = await listUsers(); + + return ( +
+
+

{t("eyebrow")}

+

{t("usersTitle")}

+ {/* No placeholder in this string: `/admin/page.tsx` renders the same + key without arguments, and a next-intl placeholder with no argument + renders literally (Article 6 — this has been a real bug here). */} +

{t("usersLede")}

+
+ + +
+ ); +} diff --git a/v5/src/app/api/admin/backup/route.ts b/v5/src/app/api/admin/backup/route.ts index ae4f170..0a6685f 100644 --- a/v5/src/app/api/admin/backup/route.ts +++ b/v5/src/app/api/admin/backup/route.ts @@ -1,3 +1,14 @@ +/** + * **SUPERSEDED — retired in Phase 3, kept on disk pending deletion approval.** + * + * `GET /api/cron/daily` replaces this route (data platform design spec §3.9): + * the source of truth is Postgres now, so the nightly job exports tables rather + * than dumping Notion, and `vercel.json`'s single cron entry points there. Both + * of this route's accepted callers (the cron bearer and the + * `ADMIN_REVALIDATE_SECRET` hand-trigger) were carried across. Nothing schedules + * or calls this handler any more. + */ + import { getBlobStore, isBlobConfigured } from "../../../../lib/blob"; import { getNotionEnvContract } from "../../../../lib/notion"; import { rateLimitAsync } from "../../../../lib/rate-limit"; diff --git a/v5/src/app/api/admin/revalidate/route.test.ts b/v5/src/app/api/admin/revalidate/route.test.ts index bfff88e..2870524 100644 --- a/v5/src/app/api/admin/revalidate/route.test.ts +++ b/v5/src/app/api/admin/revalidate/route.test.ts @@ -1,13 +1,12 @@ +// @vitest-environment node import { nextCacheMock } from "../../../../../test/mocks/next-cache"; vi.mock("next/cache", () => nextCacheMock()); import { revalidateTag } from "next/cache"; -import { - SESSION_COOKIE_NAME, - createSessionPayload, - signSession, -} from "../../../../lib/auth/session-cookie"; +import { resetAuthForTests } from "../../../../lib/auth/config"; +import { resetDbForTests } from "../../../../lib/db/client"; +import { signInAsNew } from "../../../../../test/utils/session"; import { POST } from "./route"; function makeRequest(headers: Record = {}) { @@ -85,34 +84,32 @@ function uniqueIp() { return `198.51.100.${counter}`; } -async function cookieFor(sub: string, email: string) { - const token = await signSession( - createSessionPayload({ sub, email, name: "Test Person" }), - AUTH_SECRET - ); - return `${SESSION_COOKIE_NAME}=${token}`; -} - function sessionRequest(cookie?: string, ip = uniqueIp()) { const headers: Record = { "x-forwarded-for": ip }; if (cookie) headers.cookie = cookie; return makeRequest(headers); } -describe("POST /api/admin/revalidate — staff session", () => { +describe("POST /api/admin/revalidate — a signed-in session", () => { beforeEach(() => { // The `next/cache` mock is a module-factory mock, so `restoreAllMocks` // leaves its call log alone; each test needs a clean one. vi.mocked(revalidateTag).mockClear(); + vi.stubEnv("DATABASE_URL", ""); vi.stubEnv("AUTH_SECRET", AUTH_SECRET); - // Unset on purpose: a staff session must not depend on the shared secret - // being configured, because the browser can never send it. + // Unset on purpose: a signed-in session must not depend on the shared + // secret being configured, because the browser can never send it. vi.stubEnv("ADMIN_REVALIDATE_SECRET", ""); + resetAuthForTests(); + }); + + afterEach(() => { + resetAuthForTests(); + resetDbForTests(); }); - it("accepts a staff session with no secret header at all", async () => { - vi.stubEnv("AUTH_STAFF_EMAILS", "niti@cornell.edu"); - const cookie = await cookieFor("sub-staff", "niti@cornell.edu"); + it("accepts an admin session with no secret header at all", async () => { + const { cookie } = await signInAsNew({ role: "admin" }); const res = await POST(sessionRequest(cookie)); @@ -122,16 +119,15 @@ describe("POST /api/admin/revalidate — staff session", () => { expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith("projects", "minutes"); }); - it("accepts an admin session", async () => { - vi.stubEnv("AUTH_ADMIN_EMAILS", "isaac@cornell.edu"); - const cookie = await cookieFor("sub-admin", "isaac@cornell.edu"); + it("accepts a super admin session", async () => { + const { cookie } = await signInAsNew({ role: "super_admin" }); expect((await POST(sessionRequest(cookie))).status).toBe(200); expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(2); }); - it("refuses a signed-in student — signing in is not staff", async () => { - const cookie = await cookieFor("sub-student", "ada@cornell.edu"); + it("refuses an ordinary signed-in user — signing in grants no tools.edit", async () => { + const { cookie } = await signInAsNew({ role: "user" }); const res = await POST(sessionRequest(cookie)); @@ -148,12 +144,9 @@ describe("POST /api/admin/revalidate — staff session", () => { expect(vi.mocked(revalidateTag)).not.toHaveBeenCalled(); }); - it("refuses a tampered session cookie rather than trusting its claims", async () => { - vi.stubEnv("AUTH_STAFF_EMAILS", "niti@cornell.edu"); - const cookie = await cookieFor("sub-forge", "niti@cornell.edu"); - const [name, token] = cookie.split("="); - const [payload, signature] = token.split("."); - const forged = `${name}=${payload}.${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; + it("refuses a tampered session cookie rather than trusting it", async () => { + const { cookie } = await signInAsNew({ role: "admin" }); + const forged = cookie.replace(/.$/, (c) => (c === "A" ? "B" : "A")); const res = await POST(sessionRequest(forged)); @@ -176,8 +169,7 @@ describe("POST /api/admin/revalidate — staff session", () => { }); it("is bounded: the ceiling refuses with Retry-After", async () => { - vi.stubEnv("AUTH_STAFF_EMAILS", "niti@cornell.edu"); - const cookie = await cookieFor("sub-flood", "niti@cornell.edu"); + const { cookie } = await signInAsNew({ role: "admin" }); const ip = uniqueIp(); for (let i = 0; i < 30; i += 1) { diff --git a/v5/src/app/api/admin/revalidate/route.ts b/v5/src/app/api/admin/revalidate/route.ts index 11cfad6..d419955 100644 --- a/v5/src/app/api/admin/revalidate/route.ts +++ b/v5/src/app/api/admin/revalidate/route.ts @@ -1,6 +1,6 @@ import { revalidateTag } from "next/cache"; import { resolveIdentity } from "../../../../lib/auth/identity"; -import { isAtLeast } from "../../../../lib/auth/roles"; +import { can } from "../../../../lib/auth/permissions"; import { rateLimitAsync } from "../../../../lib/rate-limit"; /** @@ -10,9 +10,9 @@ import { rateLimitAsync } from "../../../../lib/rate-limit"; * The catalog caches for a day now, so freshness comes from invalidation rather * than polling and *something has to invalidate*. Two callers do: * - * - **A signed-in `staff` or `admin` session**, which is what the Refresh - * control in the header uses. A browser cannot hold the shared secret, so - * without this branch the button could not exist. + * - **A signed-in session holding `tools.edit`** — an admin or a super admin — + * which is what the Refresh control in the header uses. A browser cannot hold + * the shared secret, so without this branch the button could not exist. * - **The `x-admin-secret` header**, unchanged, for the callers that have no * session: a Notion automation webhook, a cron job, `curl` during an incident. * @@ -22,10 +22,10 @@ import { rateLimitAsync } from "../../../../lib/rate-limit"; /** * Invalidation is cheap here but expensive on the next request: it forces a - * full Notion re-read. Bounded before that happens (Article 4), keyed per + * full catalogue re-read. Bounded before that happens (Article 4), keyed per * identity so one caller cannot spend another's allowance. Generous enough that - * a staff member correcting a run of rows never notices, and a webhook firing - * per row edit only sheds refreshes it would have made redundant anyway. + * an admin correcting a run of rows never notices, and a webhook firing per row + * edit only sheds refreshes it would have made redundant anyway. */ const REVALIDATE_TIER = { limit: 30, windowMs: 60_000 }; @@ -49,7 +49,7 @@ export async function POST(req: Request) { ); } - if (!isAtLeast(identity.role, "staff")) { + if (!can(identity, "tools.edit")) { const presented = req.headers.get("x-admin-secret"); // No session and no secret offered: nothing to check, and nothing about the // deployment's configuration is worth telling this caller. diff --git a/v5/src/app/api/auth/[...all]/route.test.ts b/v5/src/app/api/auth/[...all]/route.test.ts index e87298d..55edd54 100644 --- a/v5/src/app/api/auth/[...all]/route.test.ts +++ b/v5/src/app/api/auth/[...all]/route.test.ts @@ -7,14 +7,20 @@ */ import { GET, POST } from "@/app/api/auth/[...all]/route"; import { resetAuthForTests } from "@/lib/auth/config"; +import { resetDbForTests } from "@/lib/db/client"; const ORIGIN = "http://localhost:3000"; -function stubAuthEnv() { +function stubSessionEnv() { + vi.stubEnv("DATABASE_URL", ""); vi.stubEnv("AUTH_SECRET", "route-test-secret"); + vi.stubEnv("AUTH_BASE_URL", ORIGIN); +} + +function stubAuthEnv() { + stubSessionEnv(); vi.stubEnv("GOOGLE_CLIENT_ID", "client-id.apps.googleusercontent.com"); vi.stubEnv("GOOGLE_CLIENT_SECRET", "client-secret"); - vi.stubEnv("AUTH_BASE_URL", ORIGIN); } // The limiter is a per-process singleton keyed by hashed IP — give each test @@ -39,10 +45,16 @@ function authRequest( } beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); resetAuthForTests(); }); -describe("GET|POST /api/auth/* — not configured", () => { +afterEach(() => { + resetAuthForTests(); + resetDbForTests(); +}); + +describe("GET|POST /api/auth/* — no AUTH_SECRET", () => { it("answers 503 rather than throwing, so the rest of the app still works", async () => { const res = await GET(authRequest("/get-session")); expect(res.status).toBe(503); @@ -57,6 +69,28 @@ describe("GET|POST /api/auth/* — not configured", () => { }); }); +describe("GET|POST /api/auth/* — sessions but no Google", () => { + // How the E2E suite runs, and how a deployment looks before the OAuth client + // exists: real sessions, no way to start one through Google. + it("still answers get-session, so a seeded cookie works", async () => { + stubSessionEnv(); + const res = await GET(authRequest("/get-session")); + expect(res.status).toBe(200); + }); + + it("refuses social sign-in with the 503 the header renders as unconfigured", async () => { + stubSessionEnv(); + const res = await POST( + authRequest("/sign-in/social", { + method: "POST", + body: { provider: "google", callbackURL: "/" }, + }) + ); + expect(res.status).toBe(503); + expect((await res.json()).error).toMatch(/not configured/i); + }); +}); + describe("GET|POST /api/auth/* — configured", () => { it("hands the request to Better Auth", async () => { stubAuthEnv(); @@ -80,6 +114,77 @@ describe("GET|POST /api/auth/* — configured", () => { }); }); +describe("the admin plugin's own endpoints are not exposed", () => { + /** + * The regression this guards: the catch-all mounts every endpoint the admin + * plugin registers, so `/api/auth/admin/set-role` was a second way to change + * a role — one that writes no `audit_events` row, consults no super-admin + * floor, has no "last super admin" guard and is outside `ADMIN_ACTION_TIER`. + * Anyone holding a director's session cookie could have used it from the + * browser console on the site's own origin. + */ + async function seedDirectorCookie() { + const { signInAsNew } = await import("../../../../../test/utils/session"); + return signInAsNew({ email: "director@cornell.edu", role: "super_admin" }); + } + + it("refuses set-role even when a real super admin asks", async () => { + stubAuthEnv(); + const director = await seedDirectorCookie(); + const { seedUser } = await import("../../../../../test/utils/session"); + const target = await seedUser({ email: "student-plugin@cornell.edu", role: "user" }); + + const req = new Request(`${ORIGIN}/api/auth/admin/set-role`, { + method: "POST", + headers: { + "x-forwarded-for": uniqueIp(), + origin: ORIGIN, + "content-type": "application/json", + cookie: director.cookie, + }, + body: JSON.stringify({ userId: target.id, role: "super_admin" }), + }); + const res = await POST(req); + + expect(res.status).toBe(403); + expect((await res.json()).code).toBe("admin_api_not_exposed"); + + // The row is the assertion that matters: a 403 that still wrote would be + // worse than no check at all. + const { findUserById } = await import("@/lib/data/users"); + expect((await findUserById(target.id))?.role).toBe("user"); + }); + + it("refuses every other plugin path too, however it is spelled", async () => { + stubAuthEnv(); + for (const path of [ + "/admin/ban-user", + "/admin/update-user", + "/admin/remove-user", + "/admin/list-users", + "/admin/impersonate-user", + "/ADMIN/set-role", + "/%61dmin/set-role", + ]) { + const res = await POST(authRequest(path, { method: "POST", body: {} })); + expect(res.status, path).toBe(403); + } + }); + + it("refuses before asking whether auth is configured at all", async () => { + // No AUTH_SECRET: the answer is still "not exposed", not "not configured". + const res = await POST( + authRequest("/admin/set-role", { method: "POST", body: {} }) + ); + expect(res.status).toBe(403); + }); + + it("leaves the ordinary endpoints alone", async () => { + stubSessionEnv(); + expect((await GET(authRequest("/get-session"))).status).toBe(200); + }); +}); + describe("rate limiting", () => { it("refuses past the per-IP ceiling before ever calling Google", async () => { stubAuthEnv(); diff --git a/v5/src/app/api/auth/[...all]/route.ts b/v5/src/app/api/auth/[...all]/route.ts index db30953..5e6e708 100644 --- a/v5/src/app/api/auth/[...all]/route.ts +++ b/v5/src/app/api/auth/[...all]/route.ts @@ -1,23 +1,40 @@ -import { getAuth } from "../../../../lib/auth/config"; +import { AUTH_BASE_PATH, getAuth, hasGoogleEnv } from "../../../../lib/auth/config"; import { anonymousIdentity } from "../../../../lib/auth/identity"; import { checkRateLimit } from "../../../../lib/rate-limit"; /** - * `GET|POST /api/auth/*` — the Better Auth handler (auth design spec §3.1). + * `GET|POST /api/auth/*` — the Better Auth handler (data platform design spec + * §3.4). * * The route decides nothing. It rate-limits, then hands the request to the - * configured instance; the domain check, the session cookie, and the + * configured instance; the domain check, the session row, and the * rejected-domain redirect all live in `lib/auth/config.ts`. * - * When sign-in is not configured the endpoint answers 503 rather than throwing, - * so a deployment without Google credentials still serves the catalog and the - * assistant — sign-in unlocks, it does not gate the front door. + * Two things can be unconfigured, and they are now separate. Without + * `AUTH_SECRET` there is no instance at all. With a secret but no Google client + * there *is* one — the E2E suite runs exactly that way, with seeded sessions + * and no OAuth — but the social sign-in endpoint has nothing to start, so it + * answers 503 rather than a library error. 503 is what `sign-in-client.ts` + * reads as "this deployment has no sign-in set up" and renders as such. + * + * Either way the deployment still serves the catalogue and the assistant: + * sign-in unlocks, it does not gate the front door. + * + * **The one thing the route does decide is that the admin plugin's own HTTP + * endpoints are not part of the public surface.** See {@link isPluginAdminPath}. */ // `runtime` cannot be set when nextConfig.cacheComponents is enabled. // Default Node.js runtime is used. export const maxDuration = 15; +const NOT_CONFIGURED = { error: "Sign-in is not configured." }; + +const ADMIN_NOT_EXPOSED = { + error: "Account administration is not available over this API. Use /admin/users.", + code: "admin_api_not_exposed", +}; + async function handle(req: Request): Promise { // Always by IP: the whole point of these endpoints is that the caller has no // session yet (Article 4 — limit inbound before any outbound to Google). @@ -33,13 +50,78 @@ async function handle(req: Request): Promise { ); } - const auth = getAuth(); - if (!auth) { - return Response.json({ error: "Sign-in is not configured." }, { status: 503 }); + // Before the instance is even asked for: this refusal does not depend on + // configuration, and an unconfigured deployment should answer it the same way. + if (isPluginAdminPath(req)) { + return Response.json(ADMIN_NOT_EXPOSED, { status: 403 }); + } + + const auth = await getAuth(); + if (!auth) return Response.json(NOT_CONFIGURED, { status: 503 }); + if (isSocialSignIn(req) && !hasGoogleEnv()) { + return Response.json(NOT_CONFIGURED, { status: 503 }); } return auth.handler(req); } +/** True for `POST /api/auth/sign-in/social`, the one endpoint Google gates. */ +function isSocialSignIn(req: Request): boolean { + return normalizedPath(req) === `${AUTH_BASE_PATH}/sign-in/social`; +} + +/** + * True for anything the admin plugin mounts — `/api/auth/admin/set-role`, + * `/admin/ban-user`, `/admin/update-user`, and the rest. + * + * **Those endpoints are a second, weaker way to do everything `/admin/users` + * does.** The plugin authorizes them against the *stored* `user.role` and + * nothing else: no audit event is written (`recordAuditEvent` has exactly two + * call sites, both in `app/admin/users/actions.ts`), the super-admin floor is + * not consulted, the "last super admin" guard does not exist, and they are + * outside `ADMIN_ACTION_TIER`. A director's session cookie — borrowed laptop, + * XSS, an exfiltrated cookie — would therefore be able to change roles from the + * browser console leaving no trace in `audit_events`, which is the one hole an + * audit trail must not have (spec §4.11). + * + * So the app does not expose them. v5 calls `auth.api.setRole` / `banUser` / + * `unbanUser` **in process**, from the server actions that carry the guarantees; + * `auth.api.*` never travels through this handler, so refusing the HTTP paths + * costs the application nothing. Nothing in the app or the E2E suite calls + * `/api/auth/admin/*` — there is no Better Auth *client* in the codebase at all. + * + * 403 rather than 404 on purpose (Article 4): the endpoint is real and the + * caller is told why it will not answer, instead of being told a comfortable + * lie about what exists. + */ +function isPluginAdminPath(req: Request): boolean { + const prefix = `${AUTH_BASE_PATH}/admin`; + // Decoded and lower-cased before comparing, so `%61dmin` and `/ADMIN/` are + // the same path to this check as they may be to the router underneath. There + // is nothing else under `/api/auth` named `admin`, so matching loosely here + // can only ever over-refuse an endpoint that does not exist. + return normalizedPath(req).startsWith(prefix); +} + +/** + * The request's pathname, percent-decoded and lower-cased. `""` for a URL that + * will not parse or will not decode — neither can reach a real endpoint, and a + * path this function cannot read is not one to wave through. + */ +function normalizedPath(req: Request): string { + let pathname: string; + try { + pathname = new URL(req.url).pathname; + } catch { + return ""; + } + try { + pathname = decodeURIComponent(pathname); + } catch { + return ""; + } + return pathname.toLowerCase(); +} + export const GET = handle; export const POST = handle; diff --git a/v5/src/app/api/chat/rate-limit.route.test.ts b/v5/src/app/api/chat/rate-limit.route.test.ts index 5266036..b875b63 100644 --- a/v5/src/app/api/chat/rate-limit.route.test.ts +++ b/v5/src/app/api/chat/rate-limit.route.test.ts @@ -4,18 +4,15 @@ * * Unlike `route.test.ts`, this suite uses the **real** rate limiter and the * **real** `resolveIdentity`, so it exercises the thing that actually matters: - * an anonymous visitor and a signed-in student hitting the same endpoint from - * the same IP get different allowances, and the refusal offers a way forward. + * an anonymous visitor and a signed-in user hitting the same endpoint from the + * same IP get different allowances, and the refusal offers a way forward. * * The model is still stubbed at the `streamText` boundary, and the catalogue * comes from the demo-seeded PGlite database — no network (Art. 3). */ -import { - SESSION_COOKIE_NAME, - createSessionPayload, - signSession, -} from "@/lib/auth/session-cookie"; +import { resetAuthForTests } from "@/lib/auth/config"; import { resetDbForTests } from "@/lib/db/client"; +import { signInAsNew } from "../../../../test/utils/session"; const AUTH_SECRET = "chat-ceiling-test-secret"; @@ -65,12 +62,10 @@ function uniqueIp() { return `192.0.2.${counter}`; } -async function studentCookie(sub: string, email = "student@cornell.edu") { - const token = await signSession( - createSessionPayload({ sub, email, name: "Ada" }), - AUTH_SECRET - ); - return `${SESSION_COOKIE_NAME}=${token}`; +/** A seeded `user` session — a real row and the cookie that addresses it. */ +async function userCookie(): Promise { + const { cookie } = await signInAsNew({ role: "user", name: "Ada" }); + return cookie; } function chatRequest({ ip, cookie }: { ip: string; cookie?: string }): Request { @@ -91,6 +86,11 @@ function chatRequest({ ip, cookie }: { ip: string; cookie?: string }): Request { beforeEach(() => { vi.stubEnv("AUTH_SECRET", AUTH_SECRET); vi.stubEnv("DATABASE_URL", ""); + resetAuthForTests(); +}); + +afterEach(() => { + resetAuthForTests(); }); afterAll(() => { @@ -154,19 +154,19 @@ describe("POST /api/chat — anonymous ceiling", () => { }); describe("POST /api/chat — signed in", () => { - it("gets the higher student ceiling from the same IP that was exhausted", async () => { + it("gets the higher signed-in ceiling from the same IP that was exhausted", async () => { const ip = uniqueIp(); for (let i = 0; i < 8; i += 1) await POST(chatRequest({ ip })); expect((await POST(chatRequest({ ip }))).status).toBe(429); - const cookie = await studentCookie("sub-signed-in-1"); + const cookie = await userCookie(); for (let i = 0; i < 20; i += 1) { expect((await POST(chatRequest({ ip, cookie }))).status).toBe(200); } }); it("keys on the user, so changing IP neither resets nor escapes the ceiling", async () => { - const cookie = await studentCookie("sub-roaming"); + const cookie = await userCookie(); const first = await POST(chatRequest({ ip: uniqueIp(), cookie })); expect(first.status).toBe(200); @@ -174,12 +174,12 @@ describe("POST /api/chat — signed in", () => { for (let i = 0; i < 59; i += 1) { expect((await POST(chatRequest({ ip: uniqueIp(), cookie }))).status).toBe(200); } - // 61st message overall: the student ceiling, reached despite the IP churn. + // 61st message overall: the signed-in ceiling, despite the IP churn. expect((await POST(chatRequest({ ip: uniqueIp(), cookie }))).status).toBe(429); }); it("refuses without offering sign-in to someone already signed in", async () => { - const cookie = await studentCookie("sub-at-ceiling"); + const cookie = await userCookie(); for (let i = 0; i < 60; i += 1) await POST(chatRequest({ ip: uniqueIp(), cookie })); const res = await POST(chatRequest({ ip: uniqueIp(), cookie })); @@ -191,10 +191,8 @@ describe("POST /api/chat — signed in", () => { }); it("falls back to the anonymous ceiling when the cookie is tampered with", async () => { - const cookie = await studentCookie("sub-tampered"); - const [name, token] = cookie.split("="); - const [payload, sig] = token.split("."); - const forged = `${name}=${payload}.${sig.startsWith("A") ? "B" : "A"}${sig.slice(1)}`; + const cookie = await userCookie(); + const forged = cookie.replace(/.$/, (c) => (c === "A" ? "B" : "A")); const ip = uniqueIp(); const statuses: number[] = []; diff --git a/v5/src/app/api/chat/route.test.ts b/v5/src/app/api/chat/route.test.ts index 9cab52f..65762b7 100644 --- a/v5/src/app/api/chat/route.test.ts +++ b/v5/src/app/api/chat/route.test.ts @@ -46,9 +46,8 @@ vi.mock("ai", async (importOriginal) => { const mocks = vi.hoisted(() => ({ rateLimitAsync: vi.fn(), checkRateLimit: vi.fn(), - createMaintenanceLog: vi.fn(), })); -const { checkRateLimit, createMaintenanceLog } = mocks; +const { checkRateLimit } = mocks; // ── Mock the rate limiter (default: allowed, set in beforeEach) ────── // The route calls the identity-keyed `checkRateLimit`; the rest of the module @@ -64,14 +63,9 @@ vi.mock("@/lib/rate-limit", async (importOriginal) => { }; }); -// ── Mock the one Notion call left in the route's tools ─────────────── -// `report_issue` still files its ticket in Notion (spec §9, Phase 3). Every -// other export stays real: nothing else in this request path reads Notion, and -// a full replacement would have to track every import in the capability layer. -vi.mock("@/lib/notion", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, createMaintenanceLog: mocks.createMaintenanceLog }; -}); +// No Notion mock: since Phase 3 nothing in this request path reads or writes +// Notion. `report_issue` files its ticket into the demo-seeded PGlite database +// and the assertions below read the row back out. // next/cache is imported by catalog.ts ("use cache" / cacheTag / cacheLife). vi.mock("next/cache", () => ({ @@ -83,7 +77,6 @@ vi.mock("next/cache", () => ({ import { eq, inArray } from "drizzle-orm"; import { POST } from "@/app/api/chat/route"; import { getDb, resetDbForTests } from "@/lib/db/client"; -import { DEMO_FORM_4_UNIT_NOTION_PAGE_ID } from "@/lib/db/demo-seed"; import { attachments, maintenanceLogs, @@ -91,11 +84,8 @@ import { tools as toolsTable, units, } from "@/lib/db/schema/index"; -import { - SESSION_COOKIE_NAME, - createSessionPayload, - signSession, -} from "@/lib/auth/session-cookie"; +import { resetAuthForTests } from "@/lib/auth/config"; +import { signInAsNew } from "../../../../test/utils/session"; /** * The catalogue, the units and the resources all come from the demo-seeded @@ -175,6 +165,7 @@ const userMessage = (text: string) => ({ beforeEach(() => { captured.args = undefined; vi.stubEnv("DATABASE_URL", ""); + resetAuthForTests(); // Undo any `vi.stubGlobal("fetch", …)` from a prior PDF test (the shared // setup file does not call vi.unstubAllGlobals). vi.unstubAllGlobals(); @@ -184,9 +175,8 @@ beforeEach(() => { limit: 60, windowMs: 60 * 60_000, retryAfterSeconds: 3600, - role: "student", + role: "user", }); - createMaintenanceLog.mockReset(); }); afterEach(async () => { @@ -338,14 +328,17 @@ describe("report_issue.execute", () => { return captured.args.tools; } - it("returns { success:true, ticket_id } when createMaintenanceLog resolves", async () => { - createMaintenanceLog.mockResolvedValueOnce({ - id: "created-page-1", - createdTime: "2024-09-01T10:00:00.000Z", - lastEditedTime: "2024-09-01T10:00:00.000Z", - fields: { title: "Bed not leveling" }, - }); + /** The ticket the tool call actually wrote. */ + async function ticket(id: string) { + const db = await getDb(); + const [row] = await db + .select() + .from(maintenanceLogs) + .where(eq(maintenanceLogs.id, id)); + return row; + } + it("writes an open issue_report and returns its id", async () => { const tools = await getTools(); const result = await tools.report_issue.execute({ title: "Bed not leveling", @@ -354,25 +347,16 @@ describe("report_issue.execute", () => { }); expect(result.success).toBe(true); - expect(result.ticket_id).toBe("created-page-1"); - expect(createMaintenanceLog).toHaveBeenCalledTimes(1); - expect(createMaintenanceLog).toHaveBeenCalledWith( - expect.objectContaining({ - title: "Bed not leveling", - type: "Issue Report", - status: "Open", - }) - ); + const row = await ticket(result.ticket_id); + expect(row).toMatchObject({ + title: "Bed not leveling", + type: "issue_report", + priority: "medium", + status: "open", + }); }); it("links the resolved unit when a known unit_label is supplied", async () => { - createMaintenanceLog.mockResolvedValueOnce({ - id: "created-page-2", - createdTime: "2024-09-01T10:00:00.000Z", - lastEditedTime: "2024-09-01T10:00:00.000Z", - fields: { title: "x" }, - }); - const tools = await getTools(); const result = await tools.report_issue.execute({ title: "Resin leak", @@ -381,86 +365,68 @@ describe("report_issue.execute", () => { priority: "High", }); - // The caller is told the Postgres uuid the catalogue resolved; the ticket, - // still filed in Notion, carries the unit's imported Notion page id — the - // only id that database's `unit` relation can address. + // The catalogue id and the ticket's `unit_id` are the same Postgres uuid — + // there is no translation left between them. const form4A = await unitId("Form 4 // A"); expect(result.success).toBe(true); expect(result.unit_resolved).toEqual({ id: form4A, label: "Form 4 // A" }); - expect(createMaintenanceLog).toHaveBeenCalledWith( - expect.objectContaining({ unit: [DEMO_FORM_4_UNIT_NOTION_PAGE_ID] }) - ); + expect((await ticket(result.ticket_id)).unitId).toBe(form4A); }); - it("records the verified session name and email for a signed-in student", async () => { - // The end-to-end proof of the pass-through: a real signed cookie on the - // request, through resolveIdentity, onto the CapabilityCtx, into the write. + it("records the verified session name and email for a signed-in user", async () => { + // The end-to-end proof of the pass-through: a real session row and the + // cookie that addresses it, through resolveIdentity, onto the + // CapabilityCtx, into the write. vi.stubEnv("AUTH_SECRET", "chat-route-test-secret"); - const token = await signSession( - createSessionPayload({ - sub: "google-sub-1", - email: "ada@cornell.edu", - name: "Ada Lovelace", - }), - "chat-route-test-secret" - ); - createMaintenanceLog.mockResolvedValueOnce({ - id: "created-page-3", - createdTime: "2024-09-01T10:00:00.000Z", - lastEditedTime: "2024-09-01T10:00:00.000Z", - fields: { title: "x" }, + resetAuthForTests(); + const reporter = await signInAsNew({ + email: "ada@cornell.edu", + name: "Ada Lovelace", }); await POST( - chatRequest( - { messages: [userMessage("hi")] }, - { cookie: `${SESSION_COOKIE_NAME}=${token}` } - ) + chatRequest({ messages: [userMessage("hi")] }, { cookie: reporter.cookie }) ); // The assistant is told who it is talking to, and told not to ask. expect(captured.args.system).toContain("Ada Lovelace"); expect(captured.args.system).not.toContain("ada@cornell.edu"); - await captured.args.tools.report_issue.execute({ + const result = await captured.args.tools.report_issue.execute({ title: "Resin leak", description: "Leaking resin", priority: "High", reported_by: "Somebody Else", }); - expect(createMaintenanceLog).toHaveBeenCalledWith( - expect.objectContaining({ - reported_by: "Ada Lovelace", - reporter_email: "ada@cornell.edu", - }) - ); + expect(await ticket(result.ticket_id)).toMatchObject({ + reportedByName: "Ada Lovelace", + reportedByEmail: "ada@cornell.edu", + reportedByUserId: reporter.user.id, + }); }); it("records no email and the supplied name for an anonymous reporter", async () => { - createMaintenanceLog.mockResolvedValueOnce({ - id: "created-page-4", - createdTime: "2024-09-01T10:00:00.000Z", - lastEditedTime: "2024-09-01T10:00:00.000Z", - fields: { title: "x" }, - }); - const tools = await getTools(); - await tools.report_issue.execute({ + const result = await tools.report_issue.execute({ title: "Resin leak", description: "Leaking resin", priority: "High", reported_by: "Grace Hopper", }); - const fields = createMaintenanceLog.mock.calls[0][0]; - expect(fields.reported_by).toBe("Grace Hopper"); - expect(fields.reporter_email).toBeUndefined(); + const row = await ticket(result.ticket_id); + expect(row.reportedByName).toBe("Grace Hopper"); + expect(row.reportedByEmail).toBeNull(); }); - it("returns { success:false, error } when createMaintenanceLog rejects", async () => { - createMaintenanceLog.mockRejectedValueOnce(new Error("Notion is down")); - + it("returns { success:false, error } when the write fails, and files nothing", async () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); const tools = await getTools(); + // A configured database nobody can reach — the case that must never come + // back as "logged your ticket". + vi.stubEnv("DATABASE_URL", "postgres://user:hunter2@127.0.0.1:1/none"); + resetDbForTests(); + const result = await tools.report_issue.execute({ title: "Broken", description: "It is broken", @@ -468,7 +434,14 @@ describe("report_issue.execute", () => { }); expect(result.success).toBe(false); - expect(result.error).toBe("Notion is down"); + expect(result.ticket_id).toBeUndefined(); + expect(result.error).not.toMatch(/hunter2|postgres|ECONNREFUSED/i); + expect(logged).toHaveBeenCalled(); + + // Put the demo substrate back before the file's own cleanup runs: the + // stub is still in force until vitest's global afterEach clears it. + vi.stubEnv("DATABASE_URL", ""); + resetDbForTests(); }); }); @@ -617,24 +590,18 @@ describe("PDF manual collection (focused tool)", () => { }); }); -// ── Adding equipment is staff-only (auth spec amendment 2026-09-14) ── +// ── Adding equipment needs `tools.add` (spec §3.5) ─────────────────── describe("POST /api/chat — who may add equipment", () => { const INTAKE_TOOLS = ["research_tool", "propose_listing", "create_tool"]; const SECRET = "chat-route-test-secret"; const ASK = "I'd like to add new equipment to the inventory."; - async function postAs(email: string, name: string) { + /** Post as a seeded session in `role`. No Google, no env roster. */ + async function postAs(role: "user" | "admin" | "super_admin", name: string) { vi.stubEnv("AUTH_SECRET", SECRET); - const token = await signSession( - createSessionPayload({ sub: `sub-${email}`, email, name }), - SECRET - ); - await POST( - chatRequest( - { messages: [userMessage(ASK)] }, - { cookie: `${SESSION_COOKIE_NAME}=${token}` } - ) - ); + resetAuthForTests(); + const { cookie } = await signInAsNew({ role, name }); + await POST(chatRequest({ messages: [userMessage(ASK)] }, { cookie })); } it("gives an anonymous visitor no intake tools, and tells the assistant why", async () => { @@ -647,8 +614,8 @@ describe("POST /api/chat — who may add equipment", () => { expect(captured.args.system).not.toContain("act as an intake agent"); }); - it("gives a signed-in student no intake tools either", async () => { - await postAs("ada@cornell.edu", "Ada Lovelace"); + it("gives an ordinary signed-in user no intake tools either", async () => { + await postAs("user", "Ada Lovelace"); for (const name of INTAKE_TOOLS) { expect(captured.args.tools).not.toHaveProperty(name); @@ -656,9 +623,9 @@ describe("POST /api/chat — who may add equipment", () => { expect(captured.args.system).toContain("limited to lab staff"); }); - it("gives staff the intake tools and the full intake instructions", async () => { - vi.stubEnv("AUTH_STAFF_EMAILS", "niti@cornell.edu"); - await postAs("niti@cornell.edu", "Niti Parikh"); + it("gives an admin the intake tools and the full intake instructions", async () => { + // The role comes from the `user` row. `AUTH_STAFF_EMAILS` is retired. + await postAs("admin", "Niti Parikh"); for (const name of INTAKE_TOOLS) { expect(captured.args.tools).toHaveProperty(name); @@ -685,7 +652,7 @@ describe("POST /api/chat — photos reach the model", () => { parts: [ { type: "text", - text: "what printer is this?\n\n[Attached photos: file_upload_id=fu_1 name=plate.jpg]", + text: "what printer is this?\n\n[Attached photos: attachment_id=3f2504e0-4f89-41d3-9a0c-0305e82c3301 name=plate.jpg]", }, { type: "file", @@ -710,4 +677,40 @@ describe("POST /api/chat — photos reach the model", () => { ]) ); }); + + it("still shows the model a photo whose hint entry is malformed", async () => { + // The hint is assembled by the client and re-sent verbatim on every turn, + // so a truncated one must degrade to "no id" rather than throwing the + // request away — the model can still look at the picture. + const res = await POST( + chatRequest({ + messages: [ + { + id: "1", + role: "user", + parts: [ + { + type: "text", + text: "what is this?\n\n[Attached photos: name=plate.jpg; attachment_id=]", + }, + { + type: "file", + mediaType: "image/jpeg", + filename: "plate.jpg", + url: "data:image/jpeg;base64,AAAA", + }, + ], + }, + ], + }) + ); + + expect(res.status).toBe(200); + const user = captured.args.messages.find((m: any) => m.role === "user"); + expect(user.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "file", mediaType: "image/jpeg" }), + ]) + ); + }); }); diff --git a/v5/src/app/api/chat/route.ts b/v5/src/app/api/chat/route.ts index d0735d7..4ca2ca4 100644 --- a/v5/src/app/api/chat/route.ts +++ b/v5/src/app/api/chat/route.ts @@ -24,7 +24,7 @@ import { siteConfig } from "../../../lib/site-config"; import { chatModel } from "../../../lib/model"; import { CAPABILITIES, - capabilitiesForRole, + capabilitiesForIdentity, composeChat, } from "../../../lib/capabilities"; import type { @@ -85,8 +85,8 @@ export async function POST(req: Request) { // Convert the UI messages, attach any server-fetched manuals, and surface the // uploaded photos for this turn both to the model (image bytes — design spec - // §6.1) and to the capability layer (file_upload ids the intake `create_tool` - // re-uses to attach the same photo to the new Notion page). + // §6.1) and to the capability layer (`attachments.id`s a write such as + // `report_issue` claims onto the row it creates). const baseMessages = await convertToModelMessages(messages); const attachments = collectAttachments(baseMessages); if (attachments.length > 0) { @@ -125,10 +125,10 @@ export async function POST(req: Request) { // them). web_fetch keeps the focused-tool domain allow-list. // // The registry is composed as this caller may use it: a capability whose - // minimum role they do not meet contributes no tools, only a note on why - // (auth spec amendment 2026-09-14). + // required permission they do not hold contributes no tools, only a note + // on why (spec §3.5). const { tools: capabilityTools, system } = composeChat( - capabilitiesForRole(CAPABILITIES, identity.role), + capabilitiesForIdentity(CAPABILITIES, identity), ctx, { tools, focusedTool: focused, locale } ); @@ -231,12 +231,13 @@ function describeChatError(error: unknown): string { /** * Reconstruct the uploaded photos for this turn into {@link UploadedImage}s. * - * The chat client uploads each photo to Notion and appends a text hint - * (`[Attached photos: file_upload_id= name=; ...]`) to the user - * message; for vision it also includes the image bytes as image/file parts so - * Claude can see them. We pair the hint entries (which carry the durable Notion - * `file_upload_id` the intake `create_tool` re-uses) with the inline image bytes - * (the `dataUrl` the model sees) from the latest user message, in order. + * The chat client uploads each photo to Blob through `POST /api/uploads` and + * appends a text hint (`[Attached photos: attachment_id= name=; + * ...]`) to the user message; for vision it also includes the image bytes as + * image/file parts so Claude can see them. We pair the hint entries (which + * carry the durable `attachments.id` a write later claims) with the inline + * image bytes (the `dataUrl` the model sees) from the latest user message, in + * order. */ function collectAttachments(messages: ModelMessage[]): UploadedImage[] { const lastUser = [...messages] @@ -250,7 +251,7 @@ function collectAttachments(messages: ModelMessage[]): UploadedImage[] { if (hints.length === 0 && images.length === 0) return []; - // Pair hints (file_upload_id + name) with inline image bytes by position. Some + // Pair hints (attachment_id + name) with inline image bytes by position. Some // entries may have only one side: a hint without bytes still feeds the intake // layer; bytes without a hint still let the model see the photo. const count = Math.max(hints.length, images.length); @@ -259,7 +260,7 @@ function collectAttachments(messages: ModelMessage[]): UploadedImage[] { const hint = hints[i]; const image = images[i]; attachments.push({ - file_upload_id: hint?.file_upload_id ?? "", + attachmentId: hint?.attachmentId ?? "", name: hint?.name ?? image?.name ?? `photo-${i + 1}`, contentType: image?.contentType ?? "image/jpeg", dataUrl: image?.dataUrl, @@ -348,11 +349,11 @@ function imageDataToUrl( const PHOTO_HINT_RE = /\[Attached photos:\s*([^\]]+)\]/i; interface PhotoHint { - file_upload_id: string; + attachmentId: string; name: string; } -/** Parse the `[Attached photos: file_upload_id=… name=…; …]` hint into entries. */ +/** Parse the `[Attached photos: attachment_id=… name=…; …]` hint into entries. */ function parsePhotoHints(text: string): PhotoHint[] { const block = text.match(PHOTO_HINT_RE); if (!block) return []; @@ -361,11 +362,11 @@ function parsePhotoHints(text: string): PhotoHint[] { .map((entry) => entry.trim()) .filter(Boolean) .map((entry) => { - const id = entry.match(/file_upload_id=(\S+)/)?.[1] ?? ""; + const id = entry.match(/attachment_id=(\S+)/)?.[1] ?? ""; const name = entry.match(/name=([^;]+?)\s*$/)?.[1]?.trim() ?? ""; - return { file_upload_id: id, name }; + return { attachmentId: id, name }; }) - .filter((hint) => hint.file_upload_id || hint.name); + .filter((hint) => hint.attachmentId || hint.name); } // ── Helpers (focused tool / manuals) ─────────────────────────────── diff --git a/v5/src/app/api/cron/daily/route.test.ts b/v5/src/app/api/cron/daily/route.test.ts new file mode 100644 index 0000000..a4314d9 --- /dev/null +++ b/v5/src/app/api/cron/daily/route.test.ts @@ -0,0 +1,231 @@ +// @vitest-environment node + +// Vercel Blob is the one service MSW cannot stand in for, so it is mocked at +// the seam `blob.ts` exists to provide. `vi.hoisted` because the `vi.mock` +// factory runs before module scope exists. +const blob = vi.hoisted(() => ({ + configured: { value: true }, + put: vi.fn(), + putUpload: vi.fn(), + list: vi.fn(), + del: vi.fn(), +})); + +vi.mock("../../../../lib/blob", () => ({ + isBlobConfigured: () => blob.configured.value, + getBlobStore: () => ({ + put: blob.put, + putUpload: blob.putUpload, + list: blob.list, + del: blob.del, + }), +})); + +import { getDb, resetDbForTests } from "@/lib/db/client"; +import { attachments, tools } from "@/lib/db/schema/index"; +import { GET } from "./route"; + +/** + * The daily cron against the demo-seeded PGlite database. Nothing leaves the + * process and no credential is real. + */ + +const CRON_SECRET = "cron-s3cret"; +const ADMIN_SECRET = "admin-s3cret"; +const HOUR = 60 * 60 * 1000; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("CRON_SECRET", CRON_SECRET); + vi.stubEnv("ADMIN_REVALIDATE_SECRET", ""); + vi.stubEnv("BLOB_READ_WRITE_TOKEN", "vercel_blob_rw_test"); + + blob.configured.value = true; + blob.put.mockReset().mockResolvedValue({ pathname: "written" }); + blob.list.mockReset().mockResolvedValue([]); + blob.del.mockReset().mockResolvedValue(undefined); + + const db = await getDb(); + await db.delete(attachments); +}); + +afterAll(() => { + resetDbForTests(); +}); + +// The in-memory limiter is a per-process singleton keyed by IP. +let ipCounter = 0; +function uniqueIp() { + ipCounter += 1; + return `203.0.113.${ipCounter}`; +} + +function cronRequest(headers: Record = {}) { + return new Request("http://localhost/api/cron/daily", { + method: "GET", + headers: { "x-forwarded-for": uniqueIp(), ...headers }, + }); +} + +function authorized() { + return cronRequest({ authorization: `Bearer ${CRON_SECRET}` }); +} + +/** The body handed to `store.put`, parsed. */ +function writtenFile() { + return JSON.parse(blob.put.mock.calls[0][1] as string); +} + +describe("GET /api/cron/daily — authorization", () => { + it("refuses with 503 when no secret is configured at all", async () => { + vi.stubEnv("CRON_SECRET", ""); + vi.stubEnv("ADMIN_REVALIDATE_SECRET", ""); + + const res = await GET(cronRequest()); + + expect(res.status).toBe(503); + expect((await res.json()).error).toContain("CRON_SECRET"); + // Unconfigured is not open: nothing is read and nothing is written. + expect(blob.put).not.toHaveBeenCalled(); + }); + + it("returns 403 when the bearer is missing", async () => { + const res = await GET(cronRequest()); + expect(res.status).toBe(403); + expect(blob.put).not.toHaveBeenCalled(); + }); + + it("returns 403 when the bearer is wrong", async () => { + const res = await GET(cronRequest({ authorization: "Bearer nope" })); + expect(res.status).toBe(403); + }); + + it("accepts a Vercel Cron request carrying Authorization: Bearer $CRON_SECRET", async () => { + const res = await GET(authorized()); + expect(res.status).toBe(200); + }); + + it("still accepts the documented hand-trigger header", async () => { + // `docs/deploy.md` tells an operator to run the backup once by hand after + // the first deploy; folding the job into the cron route must not quietly + // remove that affordance. + vi.stubEnv("ADMIN_REVALIDATE_SECRET", ADMIN_SECRET); + + const res = await GET(cronRequest({ "x-admin-secret": ADMIN_SECRET })); + + expect(res.status).toBe(200); + }); +}); + +describe("GET /api/cron/daily — the backup stage", () => { + it("writes one JSON export of Postgres, whose tool count matches the seed", async () => { + const res = await GET(authorized()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.backup.pathname).toMatch(/^backups\/\d{4}-\d{2}-\d{2}\.json$/); + + const db = await getDb(); + const seeded = await db.select().from(tools); + expect(writtenFile().tables.tools.rowCount).toBe(seeded.length); + expect(body.backup.tables.tools).toBe(seeded.length); + }); + + it("prunes an over-retention backup and leaves an unrecognised pathname alone", async () => { + blob.list.mockResolvedValue([ + { pathname: "backups/2020-01-01.json", uploadedAt: "" }, + { pathname: "uploads/project/lamp-Xa9k2.png", uploadedAt: "" }, + ]); + + const body = await (await GET(authorized())).json(); + + expect(body.backup.pruned).toEqual(["backups/2020-01-01.json"]); + }); + + it("returns 500 naming the stage when the backup write fails", async () => { + blob.put.mockRejectedValueOnce(new Error("blob down")); + + const res = await GET(authorized()); + const body = await res.json(); + + // A backup that fails quietly is worse than no backup — this has to land + // in the cron log as a failed invocation. + expect(res.status).toBe(500); + expect(body.stage).toBe("backup"); + expect(body.error).toContain("blob down"); + }); +}); + +describe("GET /api/cron/daily — the cleanup stage", () => { + async function upload( + ageHours: number, + overrides: Partial = {} + ) { + const db = await getDb(); + const [row] = await db + .insert(attachments) + .values({ + blobPathname: `uploads/chat/${crypto.randomUUID()}.png`, + access: "private", + createdAt: new Date(Date.now() - ageHours * HOUR), + ...overrides, + }) + .returning({ id: attachments.id }); + return row.id; + } + + it("sweeps an unclaimed upload older than 24 hours and reports what it did", async () => { + await upload(25); + + const body = await (await GET(authorized())).json(); + + expect(body.cleanup).toMatchObject({ + orphans: 1, + blobsDeleted: 1, + rowsDeleted: 1, + }); + }); + + it("leaves a fresh upload and a claimed one alone", async () => { + const db = await getDb(); + const [tool] = await db.select({ id: tools.id }).from(tools).limit(1); + await upload(1); + // `owner_id` is what marks a file as claimed; an old one still survives. + await upload(48, { ownerType: "tool", ownerId: tool.id }); + + const body = await (await GET(authorized())).json(); + + expect(body.cleanup.orphans).toBe(0); + expect(await db.select().from(attachments)).toHaveLength(2); + }); + + it("returns 500 naming the stage, but reports the backup that did land", async () => { + await upload(25); + // The backup's own `del` (the prune) succeeds; the cleanup's fails. + blob.del.mockResolvedValueOnce(undefined).mockRejectedValueOnce( + new Error("delete failed") + ); + + const res = await GET(authorized()); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body.stage).toBe("cleanup"); + // Today's data is safe; only the sweep needs attention. Whoever reads the + // log has to be able to tell those apart. + expect(body.backup.pathname).toMatch(/^backups\//); + }); +}); + +describe("GET /api/cron/daily — blob not configured", () => { + it("refuses with 503 naming the variable rather than running half the job", async () => { + blob.configured.value = false; + + const res = await GET(authorized()); + + expect(res.status).toBe(503); + expect((await res.json()).error).toContain("BLOB_READ_WRITE_TOKEN"); + expect(blob.put).not.toHaveBeenCalled(); + }); +}); diff --git a/v5/src/app/api/cron/daily/route.ts b/v5/src/app/api/cron/daily/route.ts new file mode 100644 index 0000000..406e8c8 --- /dev/null +++ b/v5/src/app/api/cron/daily/route.ts @@ -0,0 +1,124 @@ +import { getBlobStore, isBlobConfigured } from "../../../../lib/blob"; +import { runBackup } from "../../../../lib/cron/backup"; +import { runCleanup } from "../../../../lib/cron/cleanup"; +import { rateLimitAsync } from "../../../../lib/rate-limit"; +import { resolveIdentity } from "../../../../lib/auth/identity"; + +/** + * `GET /api/cron/daily` — the one scheduled job (data platform design spec + * §3.9). + * + * It replaces `GET /api/admin/backup`, which dumped Notion. Hobby allows a + * cron at most once a day, so everything nightly shares this one entry in + * `vercel.json`: + * + * 1. **Backup** — a JSON export of every Postgres table to a private blob, + * kept 30 days. + * 2. **Cleanup** — uploads nobody claimed within 24 hours, removed from Blob + * and from `attachments`. + * + * Mirror pushes (§3.8) and pending-tool expiry (§4.10) join this list in later + * phases; neither has a writer yet. + * + * **Nothing here fails quietly.** Both stages report, and either one failing + * makes the whole invocation non-200 so it shows in Vercel's cron log as + * failed. A backup that silently stopped running is the thing this route was + * built to prevent. + */ + +// `runtime` cannot be set when nextConfig.cacheComponents is enabled. +// Default Node.js runtime is used. +export const maxDuration = 60; + +/** + * Two accepted callers, carried over from `/api/admin/backup` rather than + * quietly dropped: Vercel Cron, which sends `Authorization: Bearer + * $CRON_SECRET`, and a person holding `ADMIN_REVALIDATE_SECRET` — the + * hand-trigger `docs/deploy.md` documents for the first run after a deploy. + * + * With neither secret set the route is **unconfigured, not open**. An + * unauthenticated endpoint that dumps every student email is not a state to + * degrade into, so it refuses and says which variable is missing (Article 4). + */ +type AuthResult = "ok" | "unconfigured" | "forbidden"; + +function authorize(req: Request): AuthResult { + const cronSecret = process.env.CRON_SECRET; + const adminSecret = process.env.ADMIN_REVALIDATE_SECRET; + if (!cronSecret && !adminSecret) return "unconfigured"; + + if (cronSecret && req.headers.get("authorization") === `Bearer ${cronSecret}`) { + return "ok"; + } + if (adminSecret && req.headers.get("x-admin-secret") === adminSecret) { + return "ok"; + } + return "forbidden"; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : "unknown error"; +} + +export async function GET(req: Request) { + const auth = authorize(req); + if (auth === "unconfigured") { + return Response.json( + { ok: false, error: "cron is not configured: CRON_SECRET is not set" }, + { status: 503 } + ); + } + if (auth === "forbidden") { + return Response.json({ ok: false, error: "forbidden" }, { status: 403 }); + } + + // Article 4: bound the expensive work before doing any of it. The route is + // secret-gated, so this is the second line — a leaked secret in a loop would + // otherwise be one full database dump per request. Generous enough that a + // daily cron plus a few manual retries never trips it. + const identity = await resolveIdentity(req); + const { allowed } = await rateLimitAsync(`cron:${identity.rateLimitKey}`, { + limit: 10, + windowMs: 60 * 60_000, + }); + if (!allowed) { + return Response.json( + { ok: false, error: "Too many requests. Please slow down." }, + { status: 429, headers: { "Retry-After": "3600" } } + ); + } + + if (!isBlobConfigured()) { + return Response.json( + { ok: false, error: "BLOB_READ_WRITE_TOKEN is not set" }, + { status: 503 } + ); + } + + const store = getBlobStore(); + + let backup: Awaited>; + try { + backup = await runBackup(store); + } catch (error) { + console.error("[cron] backup failed:", error); + return Response.json( + { ok: false, stage: "backup", error: message(error) }, + { status: 500 } + ); + } + + // Cleanup runs second and reports separately: today's data is already safe, + // so a sweep that fails is worth a failed invocation but not a lost backup — + // whoever reads the log needs to be able to tell those apart. + try { + const cleanup = await runCleanup(store); + return Response.json({ ok: true, backup, cleanup }); + } catch (error) { + console.error("[cron] cleanup failed:", error); + return Response.json( + { ok: false, stage: "cleanup", backup, error: message(error) }, + { status: 500 } + ); + } +} diff --git a/v5/src/app/api/flags/route.test.ts b/v5/src/app/api/flags/route.test.ts index 4ce6754..ad661e2 100644 --- a/v5/src/app/api/flags/route.test.ts +++ b/v5/src/app/api/flags/route.test.ts @@ -1,28 +1,33 @@ // @vitest-environment node -import { http, HttpResponse } from "msw"; -import { server } from "../../../../test/msw/server"; -import { DB_IDS } from "../../../../test/msw/handlers"; import { nextCacheMock } from "../../../../test/mocks/next-cache"; // The route reaches the catalog (for tool resolution) which imports next/cache. vi.mock("next/cache", () => nextCacheMock()); import { getCatalogTools } from "@/lib/catalog"; -import { resetDbForTests } from "@/lib/db/client"; -import { DEMO_FORM_4_NOTION_PAGE_ID } from "@/lib/db/demo-seed"; +import { getDb, resetDbForTests } from "@/lib/db/client"; +import { feedback, tools as toolsTable } from "@/lib/db/schema/index"; +import { resetAuthForTests } from "@/lib/auth/config"; +import { signInAsNew } from "../../../../test/utils/session"; import { POST } from "./route"; -const NOTION = "https://api.notion.com/v1"; +/** + * `POST /api/flags` against the demo-seeded PGlite database, with **no Notion + * environment stubbed anywhere** — the write is local now, so there is no + * credential this route could be missing (Article 3). + */ + +const AUTH_SECRET = "flags-route-test-secret"; -// The route resolves the flagged tool against the demo-seeded PGlite database -// (`DATABASE_URL` unset). Its id is a Postgres uuid minted at seed time, so it -// is looked up rather than hard-coded. let FORM_4_ID = ""; beforeEach(async () => { vi.stubEnv("DATABASE_URL", ""); + resetAuthForTests(); const tools = await getCatalogTools(); FORM_4_ID = tools.find((tool) => tool.slug === "form-4")?.id ?? ""; + const db = await getDb(); + await db.delete(feedback); }); afterAll(() => { @@ -37,15 +42,15 @@ function uniqueIp() { return `198.51.100.${ipCounter}`; } -function stubFlagsEnv() { - vi.stubEnv("NOTION_API_KEY", "secret_test"); - vi.stubEnv("NOTION_DB_FLAGS", DB_IDS.flags); -} - -function flagRequest(body: unknown, ip = uniqueIp()) { +function flagRequest(body: unknown, ip = uniqueIp(), cookie?: string) { + const headers: Record = { + "content-type": "application/json", + "x-forwarded-for": ip, + }; + if (cookie) headers.cookie = cookie; return new Request("http://localhost/api/flags", { method: "POST", - headers: { "content-type": "application/json", "x-forwarded-for": ip }, + headers, body: typeof body === "string" ? body : JSON.stringify(body), }) as never; } @@ -59,108 +64,117 @@ function validPayload(overrides: Record = {}) { }; } -/** Record every Notion write the route provokes, per database. */ -function captureNotionWrites() { - const creates: Array<{ parent: { database_id: string }; properties: Record }> = - []; - const patches: string[] = []; - server.use( - http.post(`${NOTION}/pages`, async ({ request }) => { - const body = (await request.json()) as (typeof creates)[number]; - creates.push(body); - return HttpResponse.json({ - object: "page", - id: "flag-page-1", - created_time: "2026-07-29T10:00:00.000Z", - last_edited_time: "2026-07-29T10:00:00.000Z", - properties: body.properties, - }); - }), - http.patch(`${NOTION}/pages/:id`, ({ params }) => { - patches.push(params.id as string); - return HttpResponse.json({ object: "page", id: params.id }); - }) - ); - return { creates, patches }; +/** + * A seeded session — a `user` row, a `session` row and the cookie Better Auth + * would have set. The only way to reach the route as a signed-in person + * without a network call (Article 3). + */ +async function signedIn(email: string, name: string) { + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + resetAuthForTests(); + return signInAsNew({ email, name }); } -describe("POST /api/flags", () => { - it("creates a Flags row with status New and returns 201", async () => { - stubFlagsEnv(); - const { creates } = captureNotionWrites(); +/** Every correction currently in the table. */ +async function storedRows() { + const db = await getDb(); + return db.select().from(feedback); +} +describe("POST /api/flags", () => { + it("inserts a feedback row with status `new` and returns 201", async () => { const res = await POST(flagRequest(validPayload({ reporter: "Ada" }))); expect(res.status).toBe(201); - expect(await res.json()).toEqual({ id: "flag-page-1" }); - expect(creates).toHaveLength(1); - expect(creates[0].properties).toMatchObject({ - status: { select: { name: "New" } }, - field_flagged: { select: { name: "location" } }, - // The relation addresses the tool's Notion page, not its Postgres id. - tool: { relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }] }, + const rows = await storedRows(); + expect(rows).toHaveLength(1); + expect(await res.json()).toEqual({ id: rows[0].id }); + expect(rows[0]).toMatchObject({ + toolId: FORM_4_ID, + fieldFlagged: "location", + status: "new", + reporterName: "Ada", + issueDescription: "This lives in the Resin Bench, not the Wood Shop.", }); }); // The assertion that matters (spec §10): a flag is inert. - it("never writes to the Tools database", async () => { - stubFlagsEnv(); - const { creates, patches } = captureNotionWrites(); + it("never writes to the tools table", async () => { + const db = await getDb(); + const before = await db.select().from(toolsTable); await POST(flagRequest(validPayload())); - expect(creates).toHaveLength(1); - expect(creates[0].parent.database_id).toBe(DB_IDS.flags); - expect( - creates.some((create) => create.parent.database_id === DB_IDS.tools) - ).toBe(false); - // Nor does it edit the tool page it refers to. - expect(patches).toEqual([]); + expect(await db.select().from(toolsTable)).toEqual(before); }); - it("rejects an invalid field_flagged with 400 and writes nothing", async () => { - stubFlagsEnv(); - const { creates } = captureNotionWrites(); + it("resolves the tool by slug as well as by uuid", async () => { + const res = await POST(flagRequest(validPayload({ tool_id: "form-4" }))); + + expect(res.status).toBe(201); + expect((await storedRows())[0].toolId).toBe(FORM_4_ID); + }); + + it("records the reporter's email and id from a session, never from the body", async () => { + const reporter = await signedIn("ada@cornell.edu", "Ada Lovelace"); + + const res = await POST( + flagRequest( + validPayload({ reporter_email: "dean@cornell.edu" }), + uniqueIp(), + reporter.cookie + ) + ); + expect(res.status).toBe(201); + const [row] = await storedRows(); + expect(row.reporterEmail).toBe("ada@cornell.edu"); + expect(row.reporterUserId).toBe(reporter.user.id); + expect(JSON.stringify(row)).not.toContain("dean@cornell.edu"); + }); + + it("files an anonymous correction with no email at all", async () => { + const res = await POST( + flagRequest(validPayload({ reporter_email: "dean@cornell.edu" })) + ); + + // Anonymous reporting stays the intended default (§8), and a client may + // not assert who it is. + expect(res.status).toBe(201); + const [row] = await storedRows(); + expect(row.reporterEmail).toBeNull(); + expect(row.reporterUserId).toBeNull(); + }); + + it("rejects an invalid field_flagged with 400 and writes nothing", async () => { const res = await POST(flagRequest(validPayload({ field_flagged: "price" }))); expect(res.status).toBe(400); expect(await res.json()).toEqual({ code: "invalid_input" }); - expect(creates).toHaveLength(0); + expect(await storedRows()).toHaveLength(0); }); it("rejects an empty description with 400", async () => { - stubFlagsEnv(); const res = await POST(flagRequest(validPayload({ issue_description: " " }))); expect(res.status).toBe(400); expect(await res.json()).toEqual({ code: "invalid_input" }); + expect(await storedRows()).toHaveLength(0); }); it("rejects a body that is not JSON with 400", async () => { - stubFlagsEnv(); const res = await POST(flagRequest("not json at all")); expect(res.status).toBe(400); expect(await res.json()).toEqual({ code: "invalid_input" }); }); it("returns 404 for a tool that is not in the catalog", async () => { - stubFlagsEnv(); const res = await POST(flagRequest(validPayload({ tool_id: "tool-nope" }))); expect(res.status).toBe(404); expect(await res.json()).toEqual({ code: "unknown_tool" }); - }); - - it("returns 503 when the Flags database is not configured", async () => { - vi.stubEnv("NOTION_API_KEY", ""); - vi.stubEnv("NOTION_DB_FLAGS", ""); - const res = await POST(flagRequest(validPayload())); - expect(res.status).toBe(503); - expect(await res.json()).toEqual({ code: "not_configured" }); + expect(await storedRows()).toHaveLength(0); }); it("returns 429 after the fifth report from one IP", async () => { - stubFlagsEnv(); - captureNotionWrites(); const ip = uniqueIp(); for (let attempt = 1; attempt <= 5; attempt += 1) { @@ -172,31 +186,27 @@ describe("POST /api/flags", () => { expect(limited.status).toBe(429); expect(limited.headers.get("Retry-After")).toBe("3600"); expect(await limited.json()).toEqual({ code: "rate_limited" }); + // Rate limiting happens before any database work (Article 4). + expect(await storedRows()).toHaveLength(5); }); - it("returns 502 on a Notion failure without leaking the Notion error", async () => { - stubFlagsEnv(); + it("returns 502 on a database failure without leaking the driver's error", async () => { const logged = vi.spyOn(console, "error").mockImplementation(() => {}); - server.use( - http.post(`${NOTION}/pages`, () => - HttpResponse.json( - { - object: "error", - code: "validation_error", - message: "property 'field_flagged' does not exist", - }, - { status: 400 } - ) - ) - ); + vi.stubEnv("DATABASE_URL", "postgres://user:hunter2@127.0.0.1:1/none"); + resetDbForTests(); const res = await POST(flagRequest(validPayload())); expect(res.status).toBe(502); const body = await res.text(); expect(body).toBe(JSON.stringify({ code: "write_failed" })); - expect(body).not.toMatch(/validation_error|does not exist|property/i); + expect(body).not.toMatch(/hunter2|postgres|ECONNREFUSED/i); // The detail is still visible to operators in the server log (Art. 4). expect(logged).toHaveBeenCalled(); + + // Put the demo substrate back before the file's own cleanup runs: the + // stub is still in force until vitest's global afterEach clears it. + vi.stubEnv("DATABASE_URL", ""); + resetDbForTests(); }); }); diff --git a/v5/src/app/api/flags/route.ts b/v5/src/app/api/flags/route.ts index 05072f8..3d9ff71 100644 --- a/v5/src/app/api/flags/route.ts +++ b/v5/src/app/api/flags/route.ts @@ -9,8 +9,8 @@ import { resolveIdentity } from "../../../lib/auth/identity"; /** * `POST /api/flags` — the form path for "report a correction" (design spec - * 2026-07-29 §3, §9.2). It owns nothing: validation and the Notion write both - * live in the `flags` capability, so this route and the assistant's + * 2026-07-29 §3, §9.2). It owns nothing: validation and the write both live in + * the `flags` capability, so this route and the assistant's * `report_correction` tool share one code path (constitution Art. 2). * * Responses carry a machine-readable `code` rather than a prose message — @@ -25,6 +25,10 @@ export const maxDuration = 15; /** 5 per hour per IP — tighter than chat, looser than project submission (§8). */ const RATE_LIMIT = { limit: 5, windowMs: 60 * 60_000 }; +// `not_configured` is unreachable since the write moved to Postgres — there is +// no credential left that could be missing — but the code stays declared here +// and in `FlagButton`'s message map rather than being retired across three +// files for nothing. const STATUS_BY_CODE: Record = { invalid_input: 400, unknown_tool: 404, @@ -61,14 +65,18 @@ export async function POST(req: NextRequest) { const parsed = parseCorrectionReport(payload); if (!parsed.ok) return fail(parsed.code); - // `reporter_email` is only ever written from the server-resolved session - // above — a client may not assert its own identity. Anonymous reporting stays - // the intended default (§8): an unauthenticated caller simply has no email, - // and the report is filed without one. + // `reporter_email` and `reporter_user_id` are only ever written from the + // server-resolved session above — a client may not assert its own identity. + // Anonymous reporting stays the intended default (§8): an unauthenticated + // caller simply has no email, and the report is filed without one. const result = await submitCorrection( parsed.report, identity.email - ? { name: identity.name ?? undefined, email: identity.email } + ? { + name: identity.name ?? undefined, + email: identity.email, + userId: identity.userId ?? undefined, + } : undefined ); if (!result.ok) return fail(result.code); diff --git a/v5/src/app/api/identity/route.test.ts b/v5/src/app/api/identity/route.test.ts index 71d7af2..e76f028 100644 --- a/v5/src/app/api/identity/route.test.ts +++ b/v5/src/app/api/identity/route.test.ts @@ -1,16 +1,20 @@ +// @vitest-environment node /** - * `GET /api/identity` — the projection the header reads (auth design spec §6). + * `GET /api/identity` — the projection the header reads (spec §3.5, auth spec §6). * - * Uses the real `resolveIdentity` and the real limiter; the only thing minted - * here is a session cookie, signed exactly the way the callback signs one. No - * network, no OAuth (Article 3). + * Uses the real `resolveIdentity` and the real limiter against real session + * rows in PGlite. No network, no OAuth (Article 3): `signInAsNew` seeds the + * row and mints the cookie Better Auth would have set. */ -import { - SESSION_COOKIE_NAME, - createSessionPayload, - signSession, -} from "@/lib/auth/session-cookie"; import { GET } from "@/app/api/identity/route"; +import { resetAuthForTests } from "@/lib/auth/config"; +import { resetDbForTests } from "@/lib/db/client"; +import { + BETTER_AUTH_SESSION_COOKIE, + seedUser, + signInAs, + signInAsNew, +} from "../../../../test/utils/session"; const AUTH_SECRET = "identity-route-test-secret"; @@ -22,14 +26,6 @@ function uniqueIp() { return `203.0.113.${counter}`; } -async function cookieFor(sub: string, email: string, name: string | null) { - const token = await signSession( - createSessionPayload({ sub, email, name }), - AUTH_SECRET - ); - return `${SESSION_COOKIE_NAME}=${token}`; -} - function identityRequest({ ip = uniqueIp(), cookie }: { ip?: string; cookie?: string } = {}) { const headers: Record = { "x-forwarded-for": ip }; if (cookie) headers.cookie = cookie; @@ -37,7 +33,14 @@ function identityRequest({ ip = uniqueIp(), cookie }: { ip?: string; cookie?: st } beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + resetAuthForTests(); +}); + +afterEach(() => { + resetAuthForTests(); + resetDbForTests(); }); describe("GET /api/identity", () => { @@ -48,24 +51,33 @@ describe("GET /api/identity", () => { expect(await res.json()).toEqual({ role: "anonymous", name: null }); }); - it("returns the role and display name of a signed-in student", async () => { - const cookie = await cookieFor("sub-1", "ada@cornell.edu", "Ada Lovelace"); + it("returns the role and display name of a signed-in user", async () => { + const { cookie } = await signInAsNew({ + email: "ada@cornell.edu", + name: "Ada Lovelace", + }); const res = await GET(identityRequest({ cookie })); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ role: "student", name: "Ada Lovelace" }); + expect(await res.json()).toEqual({ role: "user", name: "Ada Lovelace" }); }); - it("reflects the staff role from AUTH_STAFF_EMAILS", async () => { - vi.stubEnv("AUTH_STAFF_EMAILS", "niti@cornell.edu"); - const cookie = await cookieFor("sub-2", "niti@cornell.edu", "Niti"); - - expect((await (await GET(identityRequest({ cookie }))).json()).role).toBe("staff"); + it("reports the role from the database, for every stored role", async () => { + // What the header reads to decide whether to show Add and Refresh. The + // env lists that used to answer this question are gone. + for (const role of ["user", "admin", "super_admin"] as const) { + const { cookie } = await signInAsNew({ role }); + const body = await (await GET(identityRequest({ cookie }))).json(); + expect(body.role).toBe(role); + } }); it("never returns the email address — the header only needs a name", async () => { - const cookie = await cookieFor("sub-3", "ada@cornell.edu", "Ada Lovelace"); + const { cookie } = await signInAsNew({ + email: "ada@cornell.edu", + name: "Ada Lovelace", + }); const body = await (await GET(identityRequest({ cookie }))).json(); @@ -75,10 +87,8 @@ describe("GET /api/identity", () => { }); it("degrades to anonymous on a tampered cookie rather than throwing", async () => { - const cookie = await cookieFor("sub-4", "ada@cornell.edu", "Ada"); - const [name, token] = cookie.split("="); - const [payload, signature] = token.split("."); - const forged = `${name}=${payload}.${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; + const { cookie } = await signInAsNew(); + const forged = cookie.replace(/.$/, (c) => (c === "A" ? "B" : "A")); const res = await GET(identityRequest({ cookie: forged })); @@ -86,24 +96,31 @@ describe("GET /api/identity", () => { expect((await res.json()).role).toBe("anonymous"); }); - it("degrades to anonymous on an expired cookie", async () => { - const token = await signSession( - createSessionPayload( - { sub: "sub-5", email: "ada@cornell.edu", name: "Ada" }, - Date.now() - 60_000, - 1 // one-second lifetime, already spent - ), - AUTH_SECRET - ); + it("degrades to anonymous on an expired session", async () => { + const { cookie } = await signInAsNew({}, { expiresInSeconds: -60 }); - const res = await GET( - identityRequest({ cookie: `${SESSION_COOKIE_NAME}=${token}` }) - ); + const res = await GET(identityRequest({ cookie })); expect(res.status).toBe(200); expect((await res.json()).role).toBe("anonymous"); }); + it("degrades to anonymous for a banned user", async () => { + const banned = await seedUser({ banned: true, banReason: "spam" }); + const { cookie } = await signInAs(banned); + + expect((await (await GET(identityRequest({ cookie }))).json()).role).toBe( + "anonymous" + ); + }); + + it("degrades to anonymous when the cookie names no session at all", async () => { + const cookie = `${BETTER_AUTH_SESSION_COOKIE}=not-a-real-token`; + expect((await (await GET(identityRequest({ cookie }))).json()).role).toBe( + "anonymous" + ); + }); + it("is never cached — it is per-request and per-person", async () => { const res = await GET(identityRequest()); expect(res.headers.get("cache-control")).toContain("no-store"); diff --git a/v5/src/app/api/projects/route.test.ts b/v5/src/app/api/projects/route.test.ts index c9a79a6..2d3357b 100644 --- a/v5/src/app/api/projects/route.test.ts +++ b/v5/src/app/api/projects/route.test.ts @@ -1,38 +1,63 @@ // @vitest-environment node -import { http, HttpResponse } from "msw"; - -import { server } from "../../../../test/msw/server"; -import { - SESSION_COOKIE_NAME, - createSessionPayload, - signSession, -} from "@/lib/auth/session-cookie"; +import { eq } from "drizzle-orm"; + +import { resetAuthForTests } from "@/lib/auth/config"; +import { signInAsNew } from "../../../../test/utils/session"; import { nextCacheMock } from "../../../../test/mocks/next-cache"; -// The route translates catalogue ids to Notion page ids through Postgres, and -// that module's neighbours import cacheTag/cacheLife. +// The route's neighbours in `src/lib/data` pull in modules that import +// cacheTag/cacheLife. vi.mock("next/cache", () => nextCacheMock()); import { getCatalogTools } from "@/lib/catalog"; -import { resetDbForTests } from "@/lib/db/client"; -import { DEMO_FORM_4_NOTION_PAGE_ID } from "@/lib/db/demo-seed"; +import { getPublishedProjects } from "@/lib/projects"; +import { getDb, resetDbForTests } from "@/lib/db/client"; +import { attachments, projectTools, projects } from "@/lib/db/schema/index"; + +/** + * `POST /api/projects` against the demo-seeded PGlite database, with **no + * environment variables stubbed** — the submission is a Postgres row now, so + * there is no credential this route could be missing (Article 3). + */ -const NOTION = "https://api.notion.com/v1"; -const PROJECTS_DB = "db-projects"; const AUTH_SECRET = "projects-route-test-secret"; -// `tools_used` is a Notion relation, but the form now submits catalogue ids — -// Postgres uuids minted at seed time. The demo seed is the substrate here -// (`DATABASE_URL` unset): the Form 4 came from Notion and has a page id, the -// Trotec did not and has none. +// The form submits catalogue ids — Postgres uuids minted at seed time — so +// they are resolved rather than hard-coded. let FORM_4_ID = ""; let TROTEC_ID = ""; +// The signed-in student every test submits as unless it says otherwise. +let defaultSession: Awaited>; +let sessionCounter = 0; + beforeEach(async () => { vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + resetAuthForTests(); const tools = await getCatalogTools(); FORM_4_ID = tools.find((tool) => tool.slug === "form-4")?.id ?? ""; TROTEC_ID = tools.find((tool) => tool.slug === "trotec-speedy-400")?.id ?? ""; + + const db = await getDb(); + // The demo seed ships one published sample project; clearing the table keeps + // each test's assertions about "the submission" unambiguous. + await db.delete(attachments); + await db.delete(projects); + + // Since Phase 4 this route requires sign-in (spec §5.5), so the *default* + // caller is a signed-in student. A fresh account per test on purpose: the + // limiter keys a signed-in caller by user id, so one test's ten requests + // must not be another's, the way `uniqueIp()` already isolates anonymous ones. + sessionCounter += 1; + defaultSession = await signInAsNew({ + email: `ada-${sessionCounter}@cornell.edu`, + name: "Ada Lovelace", + }); +}); + +afterEach(() => { + resetAuthForTests(); }); afterAll(() => { @@ -50,21 +75,20 @@ function uniqueIp() { return `10.1.0.${ipCounter}`; } -function stubProjectsEnv() { - vi.stubEnv("NOTION_API_KEY", "secret_test"); - vi.stubEnv("NOTION_DB_PROJECTS", PROJECTS_DB); -} - interface SubmitOptions { ip?: string; raw?: string; - cookie?: string; + /** A different session, or `null` to submit as an anonymous visitor. */ + cookie?: string | null; } // The route only uses `getClientIp(req)` (headers), the session cookie, and // `req.json()`, so a plain Request is enough; it's cast at the call site because // the signature asks for a NextRequest. -function submitRequest(payload: unknown, { ip, raw, cookie }: SubmitOptions = {}) { +function submitRequest(payload: unknown, options: SubmitOptions = {}) { + const { ip, raw } = options; + const cookie = + "cookie" in options ? options.cookie : defaultSession.cookie; const headers: Record = { "content-type": "application/json", "x-forwarded-for": ip ?? uniqueIp(), @@ -78,23 +102,17 @@ function submitRequest(payload: unknown, { ip, raw, cookie }: SubmitOptions = {} } /** - * A session cookie signed exactly the way the OAuth callback signs one — the - * only way to reach the route as a signed-in student without a network call - * (Article 3). + * A seeded session — a `user` row, a `session` row and the cookie Better Auth + * would have set. The only way to reach the route as a signed-in person + * without a network call (Article 3). */ -async function cookieFor(email: string, name: string | null) { - vi.stubEnv("AUTH_SECRET", AUTH_SECRET); - const token = await signSession( - createSessionPayload({ sub: `sub-${email}`, email, name }), - AUTH_SECRET - ); - return `${SESSION_COOKIE_NAME}=${token}`; +async function signedIn(email: string, name: string) { + return signInAsNew({ email, name }); } function validPayload(overrides: Record = {}) { return { title: "Lamp from scrap plywood", - author: "Ada Lovelace", body: "Cut on the laser, glued, sanded.", tools: [FORM_4_ID], materials: ["Plywood"], @@ -103,34 +121,6 @@ function validPayload(overrides: Record = {}) { }; } -type NotionCreateBody = { - parent?: { database_id?: string }; - properties?: Record; -}; - -/** - * Capture the body of the Notion page-create call. Returns a getter that is - * `undefined` when the route never reached Notion — which is itself the - * assertion for every rejected payload. - */ -function captureNotionCreate() { - const calls: NotionCreateBody[] = []; - server.use( - http.post(`${NOTION}/pages`, async ({ request }) => { - const body = (await request.json()) as NotionCreateBody; - calls.push(body); - return HttpResponse.json({ - object: "page", - id: "created-project-1", - created_time: "2024-09-01T10:00:00.000Z", - last_edited_time: "2024-09-01T10:00:00.000Z", - properties: body.properties ?? {}, - }); - }) - ); - return calls; -} - async function loadRoute() { return import("./route"); } @@ -140,41 +130,42 @@ async function post(req: Request) { return POST(req as never); } -// ── Configuration ─────────────────────────────────────────────────── - -describe("POST /api/projects (not configured)", () => { - it("returns 503 with a clear message and never calls Notion", async () => { - vi.stubEnv("NOTION_API_KEY", ""); - vi.stubEnv("NOTION_DB_PROJECTS", ""); - - const res = await post(submitRequest(validPayload())); +/** Every project row currently in the table. */ +async function storedProjects() { + const db = await getDb(); + return db.select().from(projects); +} - expect(res.status).toBe(503); - const body = await res.json(); - expect(body.error).toMatch(/not configured/i); - }); -}); +/** An uploaded-but-unattached file, as `POST /api/uploads` will leave one. */ +async function upload(): Promise { + const db = await getDb(); + const [row] = await db + .insert(attachments) + .values({ + blobPathname: `uploads/${crypto.randomUUID()}.png`, + access: "public", + publicUrl: `https://blob.test/${crypto.randomUUID()}.png`, + contentType: "image/png", + }) + .returning({ id: attachments.id }); + return row.id; +} // ── The moderation gate (Article 5) ───────────────────────────────── describe("POST /api/projects (drafts by default)", () => { - it("creates the page with published explicitly false", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - + it("records one unpublished row and answers with its id and slug", async () => { const res = await post(submitRequest(validPayload())); expect(res.status).toBe(201); - expect(await res.json()).toEqual({ id: "created-project-1" }); - expect(calls).toHaveLength(1); - expect(calls[0].parent?.database_id).toBe(PROJECTS_DB); - expect(calls[0].properties?.published).toEqual({ checkbox: false }); + const rows = await storedProjects(); + expect(rows).toHaveLength(1); + expect(await res.json()).toEqual({ id: rows[0].id, slug: rows[0].slug }); + expect(rows[0].published).toBe(false); + expect(rows[0].slug).toBe("lamp-from-scrap-plywood"); }); it("IGNORES published:true from the client — the submission stays a draft", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const res = await post( submitRequest( validPayload({ published: true, Published: true, fields: { published: true } }) @@ -183,17 +174,22 @@ describe("POST /api/projects (drafts by default)", () => { expect(res.status).toBe(201); // The single most important assertion in this feature: nothing a client - // sends may publish a project. Staff tick the box in Notion, or it stays + // sends may publish a project. Staff publish it in the app, or it stays // invisible. - expect(calls[0].properties?.published).toEqual({ checkbox: false }); - expect(JSON.stringify(calls[0])).not.toContain('"checkbox":true'); + const [row] = await storedProjects(); + expect(row.published).toBe(false); + expect(row.publishedAt).toBeNull(); + expect(row.publishedBy).toBeNull(); }); - it("writes the submitted fields through to Notion", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); + it("does not appear in the gallery, which is what 'draft' actually means", async () => { + await post(submitRequest(validPayload())); - await post( + expect(await getPublishedProjects()).toEqual([]); + }); + + it("writes the submitted fields through to the row", async () => { + const res = await post( submitRequest( validPayload({ link: "https://example.com/lamp", @@ -202,175 +198,171 @@ describe("POST /api/projects (drafts by default)", () => { ) ); - const properties = calls[0].properties as Record; - expect(properties.title).toEqual({ - title: [{ text: { content: "Lamp from scrap plywood" } }], - }); - expect(properties.author).toEqual({ - rich_text: [{ text: { content: "Ada Lovelace" } }], - }); - expect(properties.link).toEqual({ url: "https://example.com/lamp" }); - // Each catalogue id is translated to the tool's Notion page; the Trotec has - // no imported page, so it drops out rather than failing the whole write. - expect(properties.tools_used).toEqual({ - relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }], - }); - expect(properties.materials).toEqual({ multi_select: [{ name: "Plywood" }] }); - // Photos go in as file_upload references from /api/upload-notion. - expect(properties.photos).toEqual({ - files: [ - { - type: "file_upload", - file_upload: { id: "file-upload-1" }, - name: "cover.png", - }, - ], - }); - }); -}); - -// ── Verified authorship (spec §4, §5) ─────────────────────────────── - -describe("POST /api/projects (author identity)", () => { - it("records author_email from the session of a signed-in student", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const cookie = await cookieFor("ada@cornell.edu", "Ada Lovelace"); - - const res = await post(submitRequest(validPayload(), { cookie })); - expect(res.status).toBe(201); - const properties = calls[0].properties as Record; - expect(properties.author_email).toEqual({ email: "ada@cornell.edu" }); - // The byline comes from the session too — the form makes the field - // read-only, and this is what makes that guarantee real. - expect(properties.author).toEqual({ - rich_text: [{ text: { content: "Ada Lovelace" } }], - }); + const [row] = await storedProjects(); + expect(row.title).toBe("Lamp from scrap plywood"); + expect(row.authorName).toBe("Ada Lovelace"); + expect(row.body).toBe("Cut on the laser, glued, sanded."); + expect(row.link).toBe("https://example.com/lamp"); + expect(row.materials).toEqual(["Plywood"]); + + const db = await getDb(); + const links = await db + .select() + .from(projectTools) + .where(eq(projectTools.projectId, row.id)); + expect(links.map((link) => link.toolId).sort()).toEqual([FORM_4_ID, TROTEC_ID].sort()); }); - it("uses the session name even when the body claims a different author", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const cookie = await cookieFor("ada@cornell.edu", "Ada Lovelace"); + it("claims the uploaded photos onto the project, cover first", async () => { + const cover = await upload(); + const second = await upload(); const res = await post( - submitRequest(validPayload({ author: "Somebody Else" }), { cookie }) + submitRequest( + validPayload({ + photos: [ + { id: cover, name: "cover.png" }, + { id: second, name: "detail.png" }, + ], + }) + ) ); expect(res.status).toBe(201); - expect((calls[0].properties as Record).author).toEqual({ - rich_text: [{ text: { content: "Ada Lovelace" } }], - }); + const [row] = await storedProjects(); + const db = await getDb(); + const photos = await db + .select() + .from(attachments) + .where(eq(attachments.ownerId, row.id)) + .orderBy(attachments.position); + expect(photos.map((photo) => photo.id)).toEqual([cover, second]); + expect(photos.every((photo) => photo.ownerType === "project")).toBe(true); }); - it("records no author_email for an anonymous submission, which still succeeds", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); + it("records the submission even when no photo id matches an upload", async () => { + // The write-up is worth more than the photos (Article 4) — and until + // `/api/uploads` lands, the ids the form holds are Notion file_upload ids + // that no `attachments` row answers to. + const res = await post(submitRequest(validPayload())); + + expect(res.status).toBe(201); + expect(await storedProjects()).toHaveLength(1); + }); + it("gives a second submission with the same title its own slug", async () => { + await post(submitRequest(validPayload())); const res = await post(submitRequest(validPayload())); - // Anonymous submission is deliberate (spec §5) — the ISAM demo and any - // student who has not signed in must still be able to contribute. expect(res.status).toBe(201); - const properties = calls[0].properties as Record; - expect(properties).not.toHaveProperty("author_email"); - expect(JSON.stringify(calls[0])).not.toContain("author_email"); - expect(properties.author).toEqual({ - rich_text: [{ text: { content: "Ada Lovelace" } }], + expect((await res.json()).slug).toBe("lamp-from-scrap-plywood-2"); + }); +}); + +// ── Signing in is the gate (spec §5.5) ────────────────────────────── + +describe("POST /api/projects (sign-in required)", () => { + it("answers 401 to an anonymous submission and writes nothing", async () => { + const res = await post(submitRequest(validPayload(), { cookie: null })); + + expect(res.status).toBe(401); + // 401, not 403: signing in is something the visitor can actually do, and + // the browser tells them so. + expect(await res.json()).toMatchObject({ code: "sign_in_required" }); + expect(await storedProjects()).toHaveLength(0); + }); + + it("answers 401 to a forged cookie rather than trusting it", async () => { + const forged = defaultSession.cookie.replace(/.$/, (c) => (c === "A" ? "B" : "A")); + + const res = await post(submitRequest(validPayload(), { cookie: forged })); + + expect(res.status).toBe(401); + expect(await storedProjects()).toHaveLength(0); + }); + + it("answers 401 to a banned account — a ban resolves to anonymous", async () => { + const banned = await signInAsNew({ + email: "banned@cornell.edu", + name: "Ben Banned", + banned: true, }); + + const res = await post(submitRequest(validPayload(), { cookie: banned.cookie })); + + expect(res.status).toBe(401); + expect(await storedProjects()).toHaveLength(0); }); - it("IGNORES author_email supplied in the body by an anonymous caller", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); + it("accepts a signed-in student — `projects.submit` is what signing in grants", async () => { + const res = await post(submitRequest(validPayload())); + expect(res.status).toBe(201); + }); - const res = await post( - submitRequest( - validPayload({ - author_email: "dean@cornell.edu", - authorEmail: "dean@cornell.edu", - }) - ) - ); + it("accepts a SuperMaker and a director too", async () => { + for (const role of ["admin", "super_admin"] as const) { + const { cookie } = await signInAsNew({ + email: `${role}@cornell.edu`, + name: "Staff Person", + role, + }); + const res = await post(submitRequest(validPayload(), { cookie })); + expect(res.status).toBe(201); + } + }); +}); + +// ── Verified authorship (spec §4, §5) ─────────────────────────────── + +describe("POST /api/projects (author identity)", () => { + it("records the author id from the session", async () => { + const author = await signedIn("author@cornell.edu", "Ada Lovelace"); + + const res = await post(submitRequest(validPayload(), { cookie: author.cookie })); expect(res.status).toBe(201); - // A client may not assert who it is: no session, no verified author. - expect(JSON.stringify(calls[0])).not.toContain("dean@cornell.edu"); - expect(calls[0].properties).not.toHaveProperty("author_email"); + const [row] = await storedProjects(); + expect(row.authorUserId).toBe(author.user.id); + expect(row.createdBy).toBe(author.user.id); + // The byline comes from the session too — the form stopped offering the + // field, and this is what makes that guarantee real rather than cosmetic. + expect(row.authorName).toBe("Ada Lovelace"); }); - it("IGNORES author_email in the body when a session says something else", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const cookie = await cookieFor("ada@cornell.edu", "Ada Lovelace"); + it("IGNORES an author supplied in the body", async () => { + const { cookie } = await signedIn("author@cornell.edu", "Ada Lovelace"); const res = await post( - submitRequest(validPayload({ author_email: "dean@cornell.edu" }), { cookie }) + submitRequest(validPayload({ author: "Somebody Else" }), { cookie }) ); expect(res.status).toBe(201); - expect((calls[0].properties as Record).author_email).toEqual({ - email: "ada@cornell.edu", - }); - expect(JSON.stringify(calls[0])).not.toContain("dean@cornell.edu"); + expect((await storedProjects())[0].authorName).toBe("Ada Lovelace"); }); - it("still refuses published:true from a signed-in client", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const cookie = await cookieFor("ada@cornell.edu", "Ada Lovelace"); - + it("IGNORES author_email supplied in the body", async () => { const res = await post( - submitRequest(validPayload({ published: true }), { cookie }) + submitRequest( + validPayload({ + author_email: "dean@cornell.edu", + authorEmail: "dean@cornell.edu", + }) + ) ); expect(res.status).toBe(201); - // Signing in verifies who submitted; it does not publish anything. - expect(calls[0].properties?.published).toEqual({ checkbox: false }); - expect(JSON.stringify(calls[0])).not.toContain('"checkbox":true'); - }); - - it("records the submission without the email when Notion has no author_email property", async () => { - stubProjectsEnv(); - const cookie = await cookieFor("ada@cornell.edu", "Ada Lovelace"); - const bodies: NotionCreateBody[] = []; - server.use( - http.post(`${NOTION}/pages`, async ({ request }) => { - const body = (await request.json()) as NotionCreateBody; - bodies.push(body); - if (body.properties?.author_email) { - return HttpResponse.json( - { - object: "error", - status: 400, - code: "validation_error", - message: "author_email is not a property that exists", - }, - { status: 400 } - ); - } - return HttpResponse.json({ - object: "page", - id: "created-project-1", - created_time: "2024-09-01T10:00:00.000Z", - last_edited_time: "2024-09-01T10:00:00.000Z", - properties: body.properties ?? {}, - }); - }) - ); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + // A client may not assert who it is, and `projects` has no email column to + // put one in even if it could. + expect(JSON.stringify(await storedProjects())).not.toContain("dean@cornell.edu"); + }); - const res = await post(submitRequest(validPayload(), { cookie })); + it("still refuses published:true from a signed-in client", async () => { + const res = await post(submitRequest(validPayload({ published: true }))); - // A column a person has not added yet must not cost a student their - // write-up (Article 4 — fail toward stale, not toward wrong). expect(res.status).toBe(201); - expect(bodies).toHaveLength(2); - expect(bodies[1].properties).not.toHaveProperty("author_email"); - expect(bodies[1].properties?.published).toEqual({ checkbox: false }); - // And the misconfiguration is loud in the logs. - expect(warn).toHaveBeenCalled(); + // Signing in verifies who submitted; it does not publish anything. + expect((await storedProjects())[0].published).toBe(false); }); }); @@ -378,58 +370,44 @@ describe("POST /api/projects (author identity)", () => { describe("POST /api/projects (validation)", () => { it("rejects malformed JSON with 400", async () => { - stubProjectsEnv(); - captureNotionCreate(); - const res = await post(submitRequest(null, { raw: "{not json" })); expect(res.status).toBe(400); expect((await res.json()).error).toMatch(/invalid json/i); + expect(await storedProjects()).toHaveLength(0); }); it.each([ ["title", { title: "" }], - ["author", { author: " " }], ["body", { body: "" }], ])("rejects a submission missing %s with 400", async (_field, overrides) => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const res = await post(submitRequest(validPayload(overrides))); expect(res.status).toBe(400); expect((await res.json()).error).toMatch(/required/i); - expect(calls).toHaveLength(0); + expect(await storedProjects()).toHaveLength(0); }); it("rejects an oversized write-up with 400", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const res = await post( submitRequest(validPayload({ body: "x".repeat(20_001) })) ); expect(res.status).toBe(400); expect((await res.json()).error).toMatch(/too long/i); - expect(calls).toHaveLength(0); + expect(await storedProjects()).toHaveLength(0); }); it("accepts a write-up right at the size limit", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const res = await post( submitRequest(validPayload({ body: "x".repeat(20_000) })) ); expect(res.status).toBe(201); - expect(calls).toHaveLength(1); + expect(await storedProjects()).toHaveLength(1); }); it("rejects more than 8 photos with 400 rather than silently dropping them", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); const photos = Array.from({ length: 9 }, (_, i) => ({ id: `file-upload-${i}`, name: `photo-${i}.png`, @@ -439,55 +417,52 @@ describe("POST /api/projects (validation)", () => { expect(res.status).toBe(400); expect((await res.json()).error).toMatch(/8 photos/i); - expect(calls).toHaveLength(0); + expect(await storedProjects()).toHaveLength(0); }); it("accepts exactly 8 photos", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const photos = Array.from({ length: 8 }, (_, i) => ({ - id: `file-upload-${i}`, - name: `photo-${i}.png`, - })); + const ids = await Promise.all(Array.from({ length: 8 }, () => upload())); + const photos = ids.map((id, i) => ({ id, name: `photo-${i}.png` })); const res = await post(submitRequest(validPayload({ photos }))); expect(res.status).toBe(201); - expect(calls[0].properties?.photos).toHaveProperty("files"); - expect((calls[0].properties?.photos as { files: unknown[] }).files).toHaveLength(8); + const [row] = await storedProjects(); + const db = await getDb(); + const owned = await db + .select() + .from(attachments) + .where(eq(attachments.ownerId, row.id)); + expect(owned).toHaveLength(8); }); it("rejects more than 20 tools with 400", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); const tools = Array.from({ length: 21 }, (_, i) => `tool-${i}`); const res = await post(submitRequest(validPayload({ tools }))); expect(res.status).toBe(400); expect((await res.json()).error).toMatch(/20 tools/i); - expect(calls).toHaveLength(0); + expect(await storedProjects()).toHaveLength(0); }); it("accepts exactly 20 tools", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); const tools = Array.from({ length: 20 }, () => crypto.randomUUID()); const res = await post(submitRequest(validPayload({ tools }))); // The cap is what is under test: twenty is not one too many. None of these - // ids is a real tool, so nothing survives the Notion-page translation and - // the submission is filed without a relation rather than refused. + // ids names a real tool, so the submission is filed with no links rather + // than refused. expect(res.status).toBe(201); - expect(calls).toHaveLength(1); - expect(calls[0].properties?.tools_used).toBeUndefined(); + const [row] = await storedProjects(); + const db = await getDb(); + expect( + await db.select().from(projectTools).where(eq(projectTools.projectId, row.id)) + ).toEqual([]); }); it("ignores non-string entries in tools and materials", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const res = await post( submitRequest( validPayload({ tools: [FORM_4_ID, 7, null], materials: [{}, "Plywood"] }) @@ -495,12 +470,14 @@ describe("POST /api/projects (validation)", () => { ); expect(res.status).toBe(201); - expect(calls[0].properties?.tools_used).toEqual({ - relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }], - }); - expect(calls[0].properties?.materials).toEqual({ - multi_select: [{ name: "Plywood" }], - }); + const [row] = await storedProjects(); + expect(row.materials).toEqual(["Plywood"]); + const db = await getDb(); + const links = await db + .select() + .from(projectTools) + .where(eq(projectTools.projectId, row.id)); + expect(links.map((link) => link.toolId)).toEqual([FORM_4_ID]); }); }); @@ -513,27 +490,21 @@ describe("POST /api/projects (link validation)", () => { "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", "file:///etc/passwd", "not a url at all", - ])("rejects %s with 400 and never reaches Notion", async (link) => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - + ])("rejects %s with 400 and writes nothing", async (link) => { const res = await post(submitRequest(validPayload({ link }))); expect(res.status).toBe(400); expect((await res.json()).error).toMatch(/http/i); - expect(calls).toHaveLength(0); + expect(await storedProjects()).toHaveLength(0); }); it.each(["https://example.com/lamp", "http://example.com/lamp"])( "accepts %s", async (link) => { - stubProjectsEnv(); - const calls = captureNotionCreate(); - const res = await post(submitRequest(validPayload({ link }))); expect(res.status).toBe(201); - expect(calls[0].properties?.link).toEqual({ url: link }); + expect((await storedProjects())[0].link).toBe(link); } ); }); @@ -542,8 +513,6 @@ describe("POST /api/projects (link validation)", () => { describe("POST /api/projects (rate limiting)", () => { it("returns 429 with Retry-After once the limiter says no", async () => { - stubProjectsEnv(); - const calls = captureNotionCreate(); vi.resetModules(); vi.doMock("@/lib/rate-limit", async () => { const actual = await vi.importActual( @@ -561,21 +530,17 @@ describe("POST /api/projects (rate limiting)", () => { expect(res.status).toBe(429); expect(res.headers.get("Retry-After")).toBe("60"); expect((await res.json()).error).toMatch(/too many requests/i); - // Rate limiting happens before the expensive outbound call (Article 4). - expect(calls).toHaveLength(0); + // Rate limiting happens before any database work (Article 4). + expect(await storedProjects()).toHaveLength(0); vi.doUnmock("@/lib/rate-limit"); vi.resetModules(); }); - it("starts refusing the same IP once its window is exhausted", async () => { - stubProjectsEnv(); - captureNotionCreate(); - const ip = uniqueIp(); - + it("starts refusing the same person once their window is exhausted", async () => { const statuses: number[] = []; for (let i = 0; i < 12; i += 1) { - const res = await post(submitRequest(validPayload(), { ip })); + const res = await post(submitRequest(validPayload())); statuses.push(res.status); } @@ -583,47 +548,101 @@ describe("POST /api/projects (rate limiting)", () => { expect(statuses).toContain(429); // Once refused, it stays refused for the rest of the window. expect(statuses.at(-1)).toBe(429); + expect(await storedProjects()).toHaveLength(10); + }); + + it("keys a signed-in caller by who they are, not where they are", async () => { + // Every request below comes from a different address. A signed-in caller + // is keyed on their user id, so moving networks does not buy a fresh + // allowance — and, the other way round, sharing a campus NAT does not + // spend somebody else's. + const statuses: number[] = []; + for (let i = 0; i < 12; i += 1) { + const res = await post(submitRequest(validPayload(), { ip: uniqueIp() })); + statuses.push(res.status); + } + + expect(statuses.at(-1)).toBe(429); }); - it("does not penalize a different IP", async () => { - stubProjectsEnv(); - captureNotionCreate(); - const noisy = uniqueIp(); + it("does not penalize a different person", async () => { for (let i = 0; i < 12; i += 1) { - await post(submitRequest(validPayload(), { ip: noisy })); + await post(submitRequest(validPayload())); } - const res = await post(submitRequest(validPayload(), { ip: uniqueIp() })); + const other = await signInAsNew({ email: "quiet@cornell.edu", name: "Quiet Person" }); + const res = await post(submitRequest(validPayload(), { cookie: other.cookie })); expect(res.status).toBe(201); }); + + it("bounds an anonymous caller by IP, before telling them to sign in", async () => { + const ip = uniqueIp(); + + const statuses: number[] = []; + for (let i = 0; i < 12; i += 1) { + const res = await post(submitRequest(validPayload(), { ip, cookie: null })); + statuses.push(res.status); + } + + // The ordering is the point (Article 4): the limiter runs before the + // sign-in check, so an unauthenticated flood is shed rather than answered. + expect(statuses[0]).toBe(401); + expect(statuses.at(-1)).toBe(429); + }); }); -// ── Notion failure ────────────────────────────────────────────────── - -describe("POST /api/projects (Notion failure)", () => { - it("returns 502 and does not leak the raw Notion error", async () => { - stubProjectsEnv(); - const notionMessage = - "validation_error: body.properties.tools_used[0].id is not a valid uuid (db-projects, token secret_test)"; - server.use( - http.post(`${NOTION}/pages`, () => - HttpResponse.json( - { object: "error", status: 400, code: "validation_error", message: notionMessage }, - { status: 400 } - ) - ) - ); +// ── Database failure ──────────────────────────────────────────────── + +describe("POST /api/projects (database failure)", () => { + it("returns 502 and does not leak the driver's error when the write fails", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.resetModules(); + vi.doMock("@/lib/data/projects", async () => { + const actual = + await vi.importActual("@/lib/data/projects"); + return { + ...actual, + createProjectSubmission: vi.fn(async () => { + throw new Error("connect ECONNREFUSED postgres://user:hunter2@127.0.0.1:1/none"); + }), + }; + }); + const { POST } = await import("./route"); - const res = await post(submitRequest(validPayload())); + const res = await POST(submitRequest(validPayload()) as never); expect(res.status).toBe(502); const raw = await res.text(); - expect(raw).not.toContain(notionMessage); - expect(raw).not.toContain("validation_error"); - expect(raw).not.toContain("secret_test"); + expect(raw).not.toMatch(/hunter2|postgres|ECONNREFUSED/i); expect(JSON.parse(raw).error).toMatch(/try again/i); // The detail is still logged server-side. expect(error).toHaveBeenCalled(); + + vi.doUnmock("@/lib/data/projects"); + vi.resetModules(); + }); + + it("degrades to 401 when the session store itself is unreachable", async () => { + // Documented consequence of Phase 4's ordering, not an accident: + // `resolveIdentity` treats an unreachable database as "nobody is signed + // in" so that public pages keep serving (Article 4), and this route checks + // sign-in before it writes. A signed-in student therefore sees "sign in to + // share a project" during an outage rather than "submission failed" — a + // worse sentence than it could be, but it still leaks nothing and still + // writes nothing. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubEnv("DATABASE_URL", "postgres://user:hunter2@127.0.0.1:1/none"); + resetDbForTests(); + resetAuthForTests(); + + const res = await post(submitRequest(validPayload())); + + expect(res.status).toBe(401); + expect(await res.text()).not.toMatch(/hunter2|ECONNREFUSED/i); + + warn.mockRestore(); + vi.stubEnv("DATABASE_URL", ""); + resetDbForTests(); + resetAuthForTests(); }); }); diff --git a/v5/src/app/api/projects/route.ts b/v5/src/app/api/projects/route.ts index 1aefd9a..16bf34f 100644 --- a/v5/src/app/api/projects/route.ts +++ b/v5/src/app/api/projects/route.ts @@ -1,13 +1,30 @@ import { NextRequest } from "next/server"; -import { - createProject, - hasProjectsEnv, - type ProjectWriteFields, -} from "../../../lib/notion"; -import { notionPageIdsForTools } from "../../../lib/data/notion-ids"; -import type { ProjectRecord } from "../../../lib/types"; +import { createProjectSubmission } from "../../../lib/data/projects"; import { rateLimitAsync } from "../../../lib/rate-limit"; import { resolveIdentity } from "../../../lib/auth/identity"; +import { can } from "../../../lib/auth/permissions"; + +/** + * `POST /api/projects` — a student's project write-up. + * + * Since Phase 3 the submission is a `projects` row plus its `project_tools` + * links (data platform spec §3.10, §4.10), written by + * `src/lib/data/projects.ts` in one transaction. Nothing here decides anything + * about publication: `createProjectSubmission` takes no `published` argument, + * so there is no field a client could send that would put a write-up in the + * gallery (Article 5). + * + * **Phase 4 made sign-in a requirement here** (spec §5.5) — the one place in + * the app where it is. Browsing, searching, chatting and reporting a problem + * are all still anonymous; submitting is not, because a project carries a + * byline into a public gallery and "who wrote this" has to be something the + * server knows rather than something the request claimed. `projects.submit` is + * held by every signed-in role, so the gate is sign-in, not seniority. + * + * There is no "not configured" state any more. The database is always there — + * Neon in production, PGlite when `DATABASE_URL` is unset — so a submission + * either lands or reports that it did not. + */ // `runtime` cannot be set when nextConfig.cacheComponents is enabled. // Default Node.js runtime is used. @@ -19,12 +36,13 @@ const MAX_TOOLS = 20; const MAX_MATERIALS = 20; /** - * What a submission may send. There is deliberately no `author_email` here: - * the verified author comes from the session and nowhere else. + * What a submission may send. There is deliberately no `author` and no + * `author_email`: since Phase 4 the byline and the author id both come from the + * session, and a field the server ignores is a field somebody will eventually + * believe in. */ interface ProjectPayload { title?: unknown; - author?: unknown; body?: unknown; link?: unknown; tools?: unknown; @@ -32,11 +50,6 @@ interface ProjectPayload { photos?: unknown; } -interface PhotoUpload { - id: string; - name: string; -} - function asString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } @@ -50,16 +63,21 @@ function asStringArray(value: unknown, max: number): string[] { .slice(0, max); } -function asPhotoUploads(value: unknown): PhotoUpload[] { +/** + * The upload ids out of the `photos` array. The `name` each entry also carries + * is the client's own label for its preview; the filename staff see comes from + * the `attachments` row the upload wrote, not from the request body. + */ +function asPhotoIds(value: unknown): string[] { if (!Array.isArray(value)) return []; return value .filter( - (item): item is { id: string; name?: string } => + (item): item is { id: string } => typeof item === "object" && item !== null && typeof (item as { id?: unknown }).id === "string" ) - .map((item) => ({ id: item.id, name: asString(item.name) || "upload" })) + .map((item) => item.id) .slice(0, MAX_PHOTOS); } @@ -73,7 +91,7 @@ function isValidUrl(value: string): boolean { } export async function POST(req: NextRequest) { - // Rate limit before any expensive work (Notion page create). + // Rate limit before any expensive work (Article 4). const identity = await resolveIdentity(req); const { allowed } = await rateLimitAsync(`projects:${identity.rateLimitKey}`, { limit: 10, @@ -86,10 +104,20 @@ export async function POST(req: NextRequest) { ); } - if (!hasProjectsEnv()) { + // Told apart on purpose (spec §5.5). 401 means "sign in and this will work", + // which is true for everybody with an institutional address; 403 would mean + // "signing in will not help", which is only true for a banned account — and + // a banned account resolves to anonymous, so it lands on the 401 too. + if (identity.role === "anonymous") { return Response.json( - { error: "Project submissions are not configured yet." }, - { status: 503 } + { error: "Sign in to share a project.", code: "sign_in_required" }, + { status: 401 } + ); + } + if (!can(identity, "projects.submit")) { + return Response.json( + { error: "Your account cannot submit projects.", code: "forbidden" }, + { status: 403 } ); } @@ -101,20 +129,22 @@ export async function POST(req: NextRequest) { } const title = asString(payload.title); - // The session wins over whatever the client typed: when the server knows who - // is submitting, the byline is theirs. The form makes the field read-only for - // a signed-in student, and this is what makes that guarantee real rather than - // cosmetic. Anonymous submission keeps working — the typed name is used. - const author = asString(identity.name) || asString(payload.author); + // **The byline is the session's, full stop.** `payload.author` is no longer + // read at all: the form stopped offering the field (spec §5.5), and leaving + // the fallback in would mean a request that simply omitted its cookie could + // still choose its own byline. A signed-in account with no display name gets + // a null byline, which the gallery renders as "Anonymous" — an account we + // know but cannot name, which is the truth. + const author = asString(identity.name); const body = asString(payload.body); const link = asString(payload.link); const tools = asStringArray(payload.tools, MAX_TOOLS); const materials = asStringArray(payload.materials, MAX_MATERIALS); - const photoUploads = asPhotoUploads(payload.photos); + const photoIds = asPhotoIds(payload.photos); - if (!title || !author || !body) { + if (!title || !body) { return Response.json( - { error: "Title, author, and a write-up are required." }, + { error: "A title and a write-up are required." }, { status: 400 } ); } @@ -145,28 +175,27 @@ export async function POST(req: NextRequest) { ); } - // `tools` arrives as catalogue ids, which are Postgres uuids since the read - // path moved (spec §3.10), while `tools_used` is a Notion relation. Translate - // through `tools.notion_page_id` and drop what does not resolve: Notion - // rejects the whole page for one unknown relation id, and losing a student's - // whole write-up over a tool link is the wrong way to fail (Article 4). - const toolPageIds = await notionPageIdsForTools(tools); - try { - const record = await submitProject({ + const record = await createProjectSubmission({ title, - author, body, - // Server-resolved only (spec §4). `payload.author_email` is never read — - // a client may not assert who it is. Anonymous stays a first-class path: - // no session, no email, submission still succeeds. - author_email: identity.email || undefined, - link: link || undefined, - tools_used: toolPageIds, + authorName: author || null, + // Server-resolved only (spec §4). Nothing in the request body reaches + // either of these — a client may not assert who it is — and after the + // gate above `identity.userId` is always a real `user.id`, which the + // `created_by` foreign key now requires anyway. + authorUserId: identity.userId, + link: link || null, materials, - photo_uploads: photoUploads, + // Unknown ids are dropped inside the write rather than refused here: a + // stale catalogue id in a form that has been open a while must not cost + // a student their write-up (Article 4). + toolIds: tools, + photoAttachmentIds: photoIds, }); - return Response.json({ id: record.id }, { status: 201 }); + // The id is what the form has always been handed back; the slug rides + // along for the admin page that will publish it. + return Response.json({ id: record.id, slug: record.slug }, { status: 201 }); } catch (err) { console.error("Project submission failed", err); return Response.json( @@ -175,42 +204,3 @@ export async function POST(req: NextRequest) { ); } } - -/** - * Create the project, retrying once without `author_email` if Notion refuses - * that property. - * - * `author_email` is a new Email column a person has to add to the Projects - * database by hand (spec §4) — Notion has no migrations and rejects any write - * naming a property that does not exist. Losing a student's write-up to a - * missing column is the wrong way to fail: record the project, drop the email, - * and make the misconfiguration loud in the logs (Article 4 — fail toward - * stale, not toward wrong). Mirrors `report_issue`'s `reporter_email` fallback. - */ -async function submitProject(fields: ProjectWriteFields): Promise { - try { - return await createProject(fields); - } catch (err) { - if (!fields.author_email || !isUnknownPropertyError(err, "author_email")) { - throw err; - } - console.warn( - "[projects] Notion rejected `author_email` — recording the submission without it. Add the Email property to the Projects database (projects spec §4).", - err - ); - const withoutEmail = { ...fields }; - delete withoutEmail.author_email; - return createProject(withoutEmail); - } -} - -/** - * Does this look like Notion refusing an unknown property? `projectsRequest` - * throws `Notion API : `, and a schema mismatch is a 400 whose - * body names the offending property. Narrow on both so a 401 or a network blip - * still surfaces as the failure it is. - */ -function isUnknownPropertyError(err: unknown, property: string): boolean { - const message = err instanceof Error ? err.message : String(err); - return message.includes("400") && message.includes(property); -} diff --git a/v5/src/app/api/upload-notion/route.ts b/v5/src/app/api/upload-notion/route.ts index 68fc9bf..795a880 100644 --- a/v5/src/app/api/upload-notion/route.ts +++ b/v5/src/app/api/upload-notion/route.ts @@ -1,3 +1,13 @@ +/** + * **SUPERSEDED — retired in Phase 3, kept on disk pending deletion approval.** + * + * `POST /api/uploads` replaces this route (data platform design spec §3.3). + * Uploads go to Vercel Blob and are recorded in `attachments`; nothing in the + * app calls this handler any more. It is left in place only because deletions + * are approved separately — do not wire anything new to it, and do not treat + * its `file_upload_id` response as a live contract. + */ + import { NextRequest } from "next/server"; import { rateLimitAsync } from "../../../lib/rate-limit"; import { resolveIdentity } from "../../../lib/auth/identity"; diff --git a/v5/src/app/api/uploads/route.test.ts b/v5/src/app/api/uploads/route.test.ts new file mode 100644 index 0000000..b1d0b18 --- /dev/null +++ b/v5/src/app/api/uploads/route.test.ts @@ -0,0 +1,403 @@ +// @vitest-environment node + +// Vercel Blob is the one service here MSW cannot stand in for (the SDK talks to +// a signed API and would need a real token), so it is mocked at the seam +// `blob.ts` exists to provide — the same pattern the backup route test uses. +// `vi.hoisted` because the `vi.mock` factory runs before module scope exists. +const blob = vi.hoisted(() => ({ + configured: { value: true }, + putUpload: vi.fn(), + put: vi.fn(), + list: vi.fn(), + del: vi.fn(), +})); + +vi.mock("../../../lib/blob", () => ({ + isBlobConfigured: () => blob.configured.value, + getBlobStore: () => ({ + put: blob.put, + putUpload: blob.putUpload, + list: blob.list, + del: blob.del, + }), +})); + +import { resetAuthForTests } from "@/lib/auth/config"; +import { getDb, resetDbForTests } from "@/lib/db/client"; +import { attachments } from "@/lib/db/schema/index"; +import { signInAsNew } from "../../../../test/utils/session"; +import { POST } from "./route"; + +/** + * `POST /api/uploads` against the demo-seeded PGlite database with the Blob + * seam stubbed. No environment variable is set beyond the token flag and a + * test-only `AUTH_SECRET`, and no request leaves the process (Article 3). + */ + +const AUTH_SECRET = "uploads-route-test-secret"; + +const STORED = { + pathname: "uploads/project/lamp-Xa9k2.png", + url: "https://store.public.blob.vercel-storage.com/uploads/project/lamp-Xa9k2.png", +}; + +// A public `kind` needs a permission now, so the default caller for those tests +// is a signed-in student. Fresh per test: the limiter keys a signed-in caller +// by user id, so one test's fifteen uploads must not be another's — the same +// isolation `uniqueIp()` gives the anonymous ones. +let student: Awaited>; +let studentCounter = 0; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("BLOB_READ_WRITE_TOKEN", "vercel_blob_rw_test"); + resetAuthForTests(); + blob.configured.value = true; + blob.putUpload.mockReset().mockResolvedValue(STORED); + blob.del.mockReset().mockResolvedValue(undefined); + + const db = await getDb(); + await db.delete(attachments); + + studentCounter += 1; + student = await signInAsNew({ email: `ada-${studentCounter}@cornell.edu` }); +}); + +afterEach(() => { + resetAuthForTests(); +}); + +afterAll(() => { + resetDbForTests(); +}); + +// The in-memory limiter is a per-process singleton keyed by IP, so each test +// gets its own IP rather than inheriting a spent window. +let ipCounter = 0; +function uniqueIp() { + ipCounter += 1; + return `192.0.2.${ipCounter}`; +} + +function imageFile(bytes = 3, name = "lamp.png", type = "image/png") { + return new File([new Uint8Array(bytes)], name, { type }); +} + +interface UploadOptions { + kind?: string; + ip?: string; + /** A different session, or `null` to upload as an anonymous visitor. */ + cookie?: string | null; +} + +function uploadRequest(file: File | null, options: UploadOptions = {}) { + const form = new FormData(); + if (file) form.append("file", file); + if (options.kind) form.append("kind", options.kind); + // Signed in as the default student unless the test says otherwise: a public + // `kind` requires a permission, and most of these tests use one. + const cookie = "cookie" in options ? options.cookie : student.cookie; + const headers: Record = { + "x-forwarded-for": options.ip ?? uniqueIp(), + }; + if (cookie) headers.cookie = cookie; + return new Request("http://localhost/api/uploads", { + method: "POST", + headers, + body: form, + }) as never; +} + +async function rows() { + const db = await getDb(); + return db.select().from(attachments); +} + +/** A signed-in SuperMaker — the role that holds `tools.add` and `tools.edit`. */ +async function asSuperMaker() { + studentCounter += 1; + return signInAsNew({ email: `maker-${studentCounter}@cornell.edu`, role: "admin" }); +} + +describe("POST /api/uploads — degrading honestly", () => { + it("refuses with a machine-readable code when BLOB_READ_WRITE_TOKEN is unset", async () => { + blob.configured.value = false; + + const res = await POST(uploadRequest(imageFile())); + + expect(res.status).toBe(503); + // The client translates the code; the prose is for a log, not a student. + expect((await res.json()).code).toBe("blob_not_configured"); + }); + + it("never invents an attachment or touches the store when it cannot save", async () => { + blob.configured.value = false; + + const res = await POST(uploadRequest(imageFile())); + const body = await res.json(); + + // The failure mode this route exists to avoid: an id handed back for a file + // that was never stored, which a later claim would silently drop. + expect(body.attachmentId).toBeUndefined(); + expect(blob.putUpload).not.toHaveBeenCalled(); + expect(await rows()).toHaveLength(0); + }); +}); + +describe("POST /api/uploads — validation", () => { + it("rejects a request with no file", async () => { + const res = await POST(uploadRequest(null)); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("Missing file"); + }); + + it("rejects an empty file", async () => { + const res = await POST(uploadRequest(imageFile(0))); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("Empty file"); + }); + + it("rejects a non-image", async () => { + const res = await POST( + uploadRequest(imageFile(3, "notes.txt", "text/plain")) + ); + expect(res.status).toBe(400); + expect(blob.putUpload).not.toHaveBeenCalled(); + }); + + it("rejects an image over 18 MB", async () => { + const res = await POST(uploadRequest(imageFile(19 * 1024 * 1024))); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain("18MB"); + }); + + it("accepts a PDF only for a resource upload", async () => { + const pdf = () => imageFile(3, "manual.pdf", "application/pdf"); + const maker = await asSuperMaker(); + + const rejected = await POST(uploadRequest(pdf(), { kind: "project" })); + expect(rejected.status).toBe(400); + + const accepted = await POST( + uploadRequest(pdf(), { kind: "resource", cookie: maker.cookie }) + ); + expect(accepted.status).toBe(200); + }); + + it("rejects a resource PDF over 20 MB", async () => { + const maker = await asSuperMaker(); + const res = await POST( + uploadRequest( + imageFile(21 * 1024 * 1024, "manual.pdf", "application/pdf"), + { kind: "resource", cookie: maker.cookie } + ) + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain("20MB"); + }); +}); + +describe("POST /api/uploads — the happy path", () => { + it("stores the file and records an UNOWNED attachments row", async () => { + const res = await POST(uploadRequest(imageFile(), { kind: "project" })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.attachmentId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + + const [row] = await rows(); + // Unowned on purpose: the project this photo belongs to does not exist yet. + expect(row).toMatchObject({ + id: body.attachmentId, + ownerType: null, + ownerId: null, + blobPathname: STORED.pathname, + access: "public", + contentType: "image/png", + originalFilename: "lamp.png", + }); + }); + + it("records the pathname the store chose, not the one it was asked for", async () => { + await POST(uploadRequest(imageFile(), { kind: "project" })); + + const [prefix] = blob.putUpload.mock.calls[0]; + expect(prefix).toBe("uploads/project/"); + const [row] = await rows(); + expect(row.blobPathname).toBe(STORED.pathname); + }); + + it("returns a preview URL for a public upload", async () => { + const res = await POST(uploadRequest(imageFile(), { kind: "project" })); + expect((await res.json()).previewUrl).toBe(STORED.url); + }); + + it("stores a maintenance photo PRIVATELY and returns no preview URL", async () => { + const res = await POST(uploadRequest(imageFile(), { kind: "maintenance" })); + + // §3.3: maintenance photos may show people. A private blob has no URL an + // unauthenticated viewer can follow, so the client keeps showing its own + // object-URL preview instead. + expect(blob.putUpload.mock.calls[0][2]).toBe("private"); + expect((await res.json()).previewUrl).toBeNull(); + expect((await rows())[0]).toMatchObject({ access: "private", publicUrl: null }); + }); + + it("treats an unrecognised kind as a chat upload rather than failing", async () => { + const res = await POST(uploadRequest(imageFile(), { kind: "nonsense" })); + + expect(res.status).toBe(200); + expect(blob.putUpload.mock.calls[0][0]).toBe("uploads/chat/"); + }); + + it("uploads anonymously — no sign-in is required to report a broken machine", async () => { + await POST(uploadRequest(imageFile(), { kind: "maintenance", cookie: null })); + expect((await rows())[0].uploadedBy).toBeNull(); + }); + + it("records the uploader when there is one", async () => { + await POST(uploadRequest(imageFile(), { kind: "project" })); + expect((await rows())[0].uploadedBy).toBe(student.user.id); + }); +}); + +describe("POST /api/uploads — who may ask for a public URL", () => { + /** + * The regression this guards: `kind` came straight off the request body and + * was the only thing deciding public vs private, so + * `curl -F kind=resource -F file=@anything.pdf .../api/uploads` with no + * cookie got back a permanent, world-readable Blob URL for a 20 MB PDF the + * lab never agreed to host — claimed by nothing, and swept only if still + * unclaimed 24 hours later. Every surface that *consumes* a public kind + * already requires sign-in, so no legitimate flow ever needed this. + */ + const publicKinds = ["project", "tool", "resource"] as const; + + it.each(publicKinds)("refuses an anonymous caller asking for kind=%s", async (kind) => { + const res = await POST(uploadRequest(imageFile(), { kind, cookie: null })); + + expect(res.status).toBe(401); + expect((await res.json()).code).toBe("sign_in_required"); + // Nothing was written: no blob, no row, no id handed back. + expect(blob.putUpload).not.toHaveBeenCalled(); + expect(await rows()).toHaveLength(0); + }); + + it("refuses a signed-in student asking for a catalogue image", async () => { + // 403, not 401: signing in is what they already did, and it did not help. + const res = await POST(uploadRequest(imageFile(), { kind: "tool" })); + + expect(res.status).toBe(403); + expect((await res.json()).code).toBe("forbidden"); + expect(blob.putUpload).not.toHaveBeenCalled(); + }); + + it("lets a SuperMaker upload a catalogue image and a manual", async () => { + const maker = await asSuperMaker(); + + for (const kind of ["tool", "resource"] as const) { + const res = await POST( + uploadRequest(imageFile(), { kind, cookie: maker.cookie }) + ); + expect(res.status, kind).toBe(200); + } + }); + + it("still lets an anonymous student send a chat or maintenance photo", async () => { + for (const kind of ["chat", "maintenance"] as const) { + const res = await POST( + uploadRequest(imageFile(), { kind, cookie: null }) + ); + expect(res.status, kind).toBe(200); + // Private, so there is no public URL to hand out in the first place — + // which is what makes leaving these two open safe. + expect((await res.json()).previewUrl).toBeNull(); + } + }); + + it("refuses before reading the file, so the refusal costs nothing", async () => { + const res = await POST( + uploadRequest(imageFile(19 * 1024 * 1024), { kind: "resource", cookie: null }) + ); + // 401 rather than the 400 the oversize check would give: the caller is + // told they may not ask, without the route reading 19 MB to find out. + expect(res.status).toBe(401); + }); + + it("does not let an unrecognised kind smuggle in a public upload", async () => { + // `nonsense` falls back to `chat`, which is private and open — so the + // fallback cannot be used to reach a public URL without a permission. + const res = await POST( + uploadRequest(imageFile(), { kind: "nonsense", cookie: null }) + ); + + expect(res.status).toBe(200); + expect(blob.putUpload.mock.calls[0][2]).toBe("private"); + expect((await res.json()).previewUrl).toBeNull(); + }); +}); + +describe("POST /api/uploads — failures leave nothing behind", () => { + it("answers 502 when the blob write fails, and records nothing", async () => { + blob.putUpload.mockRejectedValueOnce(new Error("network down")); + + const res = await POST(uploadRequest(imageFile())); + + expect(res.status).toBe(502); + expect(await rows()).toHaveLength(0); + }); + + it("deletes the stored blob when the attachments insert fails", async () => { + // Staged by pointing the row's `access` at a value the CHECK constraint + // rejects, so the insert fails the way a real constraint violation would. + blob.putUpload.mockResolvedValueOnce({ + pathname: "uploads/project/x.png", + url: "x", + }); + const db = await getDb(); + const insert = vi + .spyOn(db, "insert") + .mockImplementation(() => { + throw new Error("insert failed"); + }); + + try { + const res = await POST(uploadRequest(imageFile(), { kind: "project" })); + + expect(res.status).toBe(502); + // Otherwise the bytes sit in the store forever: the cron only sweeps + // files that have a row to find them by. + expect(blob.del).toHaveBeenCalledWith(["uploads/project/x.png"]); + } finally { + insert.mockRestore(); + } + }); +}); + +describe("POST /api/uploads — rate limiting", () => { + it("answers 429 after 15 uploads in a minute from one caller", async () => { + const ip = uniqueIp(); + for (let i = 0; i < 15; i += 1) { + const ok = await POST(uploadRequest(imageFile(), { kind: "project", ip })); + expect(ok.status).toBe(200); + } + + const res = await POST(uploadRequest(imageFile(), { kind: "project", ip })); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("60"); + }); + + it("refuses before reading the body, so a flood costs no bytes", async () => { + const ip = uniqueIp(); + for (let i = 0; i < 15; i += 1) { + await POST(uploadRequest(imageFile(), { kind: "project", ip })); + } + blob.putUpload.mockClear(); + + await POST(uploadRequest(imageFile(), { kind: "project", ip })); + expect(blob.putUpload).not.toHaveBeenCalled(); + }); +}); diff --git a/v5/src/app/api/uploads/route.ts b/v5/src/app/api/uploads/route.ts new file mode 100644 index 0000000..c2cdbb8 --- /dev/null +++ b/v5/src/app/api/uploads/route.ts @@ -0,0 +1,248 @@ +import { NextRequest } from "next/server"; +import { getBlobStore, isBlobConfigured, type BlobAccess } from "../../../lib/blob"; +import { createAttachment } from "../../../lib/data/attachments"; +import { rateLimitAsync } from "../../../lib/rate-limit"; +import { resolveIdentity, type Identity } from "../../../lib/auth/identity"; +import { can, type Permission } from "../../../lib/auth/permissions"; + +/** + * `POST /api/uploads` — the one upload route (data platform design spec §3.3, + * §4.7). It replaces `/api/upload-notion`, which pushed bytes into a Notion + * `file_upload` session and handed back a Notion handle. + * + * What a caller gets back is now an `attachmentId`: a row in `attachments` + * owned by nothing yet. The write that follows — a ticket, a project + * submission — *claims* those ids onto itself, and the daily cron deletes + * anything still unclaimed after 24 hours. That two-step exists because at + * upload time the record the photo belongs to has not been written: the student + * is still typing it. + * + * **Access is decided here, not by the client.** A maintenance photo may show a + * person and is written privately, so it comes back with `previewUrl: null` and + * the client shows the local `URL.createObjectURL` preview it already holds. A + * project or tool photo is about to appear on a public page, so it is public + * and its URL comes back. + * + * **And so is who may ask for which.** The client still picks `kind`, so `kind` + * alone cannot be the whole of the decision: see {@link KIND_POLICY}. + * + * **With no `BLOB_READ_WRITE_TOKEN` this route refuses and says so** (Article + * 4). It does not invent an id, and it does not pretend the file was stored: + * a student who is told their photo is attached, and whose photo is not, is + * worse off than one who is told photos are unavailable today. + */ + +// `runtime` cannot be set when nextConfig.cacheComponents is enabled. +// Default Node.js runtime is used. +export const maxDuration = 30; + +/** + * Kept identical to `/api/upload-notion`'s: the same anonymous students file + * the same maintenance photos through it, and tightening the limit as a side + * effect of changing the storage backend would be a behaviour change nobody + * asked for. + */ +const RATE_LIMIT = { limit: 15, windowMs: 60_000 }; + +/** What the upload is for. Decides both the access and where it is filed. */ +const KINDS = ["chat", "maintenance", "project", "tool", "resource"] as const; +type UploadKind = (typeof KINDS)[number]; +const DEFAULT_KIND: UploadKind = "chat"; + +const MAX_IMAGE_BYTES = 18 * 1024 * 1024; +const MAX_PDF_BYTES = 20 * 1024 * 1024; + +/** + * What each kind costs the caller: the blob's access, and the permission the + * surface that consumes it requires. + * + * **A public `kind` is a request for a permanent, world-readable URL**, and the + * client chooses `kind`. So a route that read `kind` and nothing else handed an + * unauthenticated caller a place to host arbitrary images — and, with + * `kind=resource`, 20 MB PDFs — on the lab's Blob account, claimed by nothing + * and swept only if still unclaimed 24 hours later. The route's own comment + * said access was decided here; it was in fact decided by the request body. + * + * The rule now is that the permission matches the surface the file is destined + * for, and it is the permission that surface already enforces: + * + * - **`project`** — `projects.submit`. `POST /api/projects` has required sign-in + * since Phase 4 (spec §5.5), so an anonymous project photo belongs to no + * submission anybody can make. + * - **`tool`** / **`resource`** — `tools.add` and `tools.edit`. Catalogue images + * and manuals come from intake and the admin surfaces, both of which are + * already gated on exactly these. + * - **`chat`** and **`maintenance`** stay open, because they are the two the lab + * deliberately lets an anonymous student use — photograph a machine to ask + * what it is, photograph a broken one to report it (§3.3). Both are written + * **private**: no public URL exists to hand back, and the row is the only way + * to reach the bytes. That is what makes leaving them open safe, and it is why + * the two lists are the same list. + * + * Maintenance photos are the private case §3.3 names — they may show people and + * are admin-only to read. Chat photos follow them for the same reason. + */ +const KIND_POLICY: Record< + UploadKind, + { access: BlobAccess; permission: Permission | null } +> = { + chat: { access: "private", permission: null }, + maintenance: { access: "private", permission: null }, + project: { access: "public", permission: "projects.submit" }, + tool: { access: "public", permission: "tools.add" }, + resource: { access: "public", permission: "tools.edit" }, +}; + +function isKind(value: string): value is UploadKind { + return (KINDS as readonly string[]).includes(value); +} + +/** + * Why this caller may not upload this kind, or null when they may. + * + * 401 and 403 are told apart the way `POST /api/projects` tells them apart: + * 401 means "sign in and this will work", which is true of every institutional + * address for a project photo; 403 means signing in will not help, which is the + * honest answer for a student asking to write a catalogue image. + */ +function uploadRefusal( + identity: Identity, + kind: UploadKind +): { status: number; code: string; error: string } | null { + const { permission } = KIND_POLICY[kind]; + if (!permission) return null; + + if (identity.role === "anonymous") { + return { + status: 401, + code: "sign_in_required", + error: "Sign in to upload this kind of file.", + }; + } + if (!can(identity, permission)) { + return { + status: 403, + code: "forbidden", + error: "Your account cannot upload this kind of file.", + }; + } + return null; +} + +export async function POST(req: NextRequest) { + // Rate limit before reading a multipart body or touching Blob (Article 4). + const identity = await resolveIdentity(req); + const { allowed } = await rateLimitAsync(`upload:${identity.rateLimitKey}`, RATE_LIMIT); + if (!allowed) { + return Response.json( + { error: "Too many requests. Please slow down." }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + // Checked before the body is read: with no store there is nowhere for the + // bytes to go, and reading 18 MB to then refuse is work for nothing. + if (!isBlobConfigured()) { + return Response.json( + { + code: "blob_not_configured", + error: "File uploads are unavailable: BLOB_READ_WRITE_TOKEN is not set.", + }, + { status: 503 } + ); + } + + let form: FormData; + try { + form = await req.formData(); + } catch { + return Response.json({ error: "Invalid form data" }, { status: 400 }); + } + + const rawKind = String(form.get("kind") ?? ""); + const kind: UploadKind = isKind(rawKind) ? rawKind : DEFAULT_KIND; + + // Before the file is looked at, let alone written: a caller who may not ask + // for this kind learns so without the lab storing anything on their behalf. + const refusal = uploadRefusal(identity, kind); + if (refusal) { + const { status, ...body } = refusal; + return Response.json(body, { status }); + } + + const file = form.get("file"); + if (!(file instanceof File)) { + return Response.json({ error: "Missing file" }, { status: 400 }); + } + if (file.size === 0) { + return Response.json({ error: "Empty file" }, { status: 400 }); + } + + const type = file.type || ""; + const isImage = type.startsWith("image/"); + // PDFs are for resources only — a manual. Accepting one on a chat or + // maintenance upload would put an arbitrary document behind a public URL for + // no feature that asks for it (§3.3). + const isResourcePdf = type === "application/pdf" && kind === "resource"; + + if (!isImage && !isResourcePdf) { + return Response.json( + { error: "Only image uploads are supported" }, + { status: 400 } + ); + } + + const maxBytes = isImage ? MAX_IMAGE_BYTES : MAX_PDF_BYTES; + if (file.size > maxBytes) { + return Response.json( + { error: `File too large (max ${Math.round(maxBytes / (1024 * 1024))}MB)` }, + { status: 400 } + ); + } + + const access = KIND_POLICY[kind].access; + const store = getBlobStore(); + + let stored: { pathname: string; url: string }; + try { + stored = await store.putUpload(`uploads/${kind}/`, file, access); + } catch (err) { + console.error("[uploads] blob write failed", err); + return Response.json({ error: "Upload failed" }, { status: 502 }); + } + + let attachmentId: string; + try { + const created = await createAttachment({ + blobPathname: stored.pathname, + access, + // A private blob has no URL an unauthenticated viewer can follow, so + // recording one would be a lie the catalogue would later render. + publicUrl: access === "public" ? stored.url : null, + contentType: type, + sizeBytes: file.size, + originalFilename: file.name || "upload", + uploadedBy: identity.userId, + }); + attachmentId = created.id; + } catch (err) { + console.error("[uploads] attachment insert failed", err); + // The bytes landed but nothing points at them, so they would sit in the + // store forever — the cron only sweeps files that *have* a row. Remove + // them here so a failed upload leaves nothing behind. + try { + await store.del([stored.pathname]); + } catch (cleanupErr) { + console.error("[uploads] orphaned blob cleanup failed", cleanupErr); + } + return Response.json({ error: "Upload failed" }, { status: 502 }); + } + + return Response.json({ + attachmentId, + previewUrl: access === "public" ? stored.url : null, + name: file.name || "upload", + contentType: type, + size: file.size, + }); +} diff --git a/v5/src/components/AdminLink.test.tsx b/v5/src/components/AdminLink.test.tsx new file mode 100644 index 0000000..143212a --- /dev/null +++ b/v5/src/components/AdminLink.test.tsx @@ -0,0 +1,53 @@ +import { ADMIN_HREF, AdminLink } from "./AdminLink"; +import { render, screen } from "../../test/utils/render"; + +vi.mock("next/link", () => ({ + __esModule: true, + default: ({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + }) => ( + + {children} + + ), +})); + +/** + * Who sees the way into `/admin`. The refusal behind it is the `/admin` layout + * (`canReachAdmin` again, server-side) — hiding this link is presentation. + */ + +// en.json: nav.admin = "ADMIN". +describe("AdminLink — who sees it", () => { + it("renders nothing while identity is still resolving", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing for an anonymous visitor", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing for an ordinary signed-in user", () => { + // Signing in unlocks submitting a project and nothing else — there is no + // admin surface a student holds a permission for. + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders for a SuperMaker, whose queues live behind the same link", () => { + render(); + expect(screen.getByRole("link", { name: "ADMIN" })).toHaveAttribute("href", ADMIN_HREF); + }); + + it("renders for a director", () => { + render(); + expect(screen.getByRole("link", { name: "ADMIN" })).toBeInTheDocument(); + }); +}); diff --git a/v5/src/components/AdminLink.tsx b/v5/src/components/AdminLink.tsx new file mode 100644 index 0000000..1cf40cd --- /dev/null +++ b/v5/src/components/AdminLink.tsx @@ -0,0 +1,36 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { canReachAdmin } from "../lib/auth/permissions"; +import type { Role } from "../lib/auth/roles"; + +/** + * The way into `/admin`, in the header (spec §6). + * + * Shown to anyone holding *any* admin-surface permission, not only a super + * admin: a SuperMaker's queues arrive in later phases and land behind this same + * link, and one entry point that grows is better than a link that appears when + * Phase 5 merges. + * + * **Hiding is presentation.** `/admin`'s layout resolves the identity itself + * and refuses anyone without one of these permissions; rendering `null` here + * only spares everyone else a link into a refusal. Same contract as + * `RefreshCatalogButton`, and the same reason `role` may be `undefined` — the + * header asks `/api/identity` after mount, and until it answers there is + * nothing to show. + */ + +export const ADMIN_HREF = "/admin"; + +export function AdminLink({ role }: { role: Role | undefined }) { + const t = useTranslations("nav"); + + if (!canReachAdmin({ role })) return null; + + return ( + + {t("admin")} + + ); +} diff --git a/v5/src/components/ChatFab.test.tsx b/v5/src/components/ChatFab.test.tsx index 9fb7d99..0bff1c1 100644 --- a/v5/src/components/ChatFab.test.tsx +++ b/v5/src/components/ChatFab.test.tsx @@ -487,9 +487,9 @@ describe("ChatFab — pending tool-call status", () => { // ── Photo upload + attachment hint ───────────────────────────────── // -// Selecting an image uploads it to /api/upload-notion, shows a removable -// preview, and on submit appends a parseable [Attached photos: …] hint that -// the chat route turns into report_issue photo_uploads. +// Selecting an image uploads it to /api/uploads, shows a removable preview, +// and on submit appends a parseable [Attached photos: …] hint that the chat +// route turns into report_issue photo_attachment_ids. describe("ChatFab — photo uploads", () => { let origCreate: typeof URL.createObjectURL; let origRevoke: typeof URL.revokeObjectURL; @@ -507,12 +507,16 @@ describe("ChatFab — photo uploads", () => { vi.unstubAllGlobals(); }); - it("uploads an image and includes its file_upload hint in the sent message", async () => { + it("uploads an image and includes its attachment hint in the sent message", async () => { const user = userEvent.setup(); const fetchMock = vi.fn( async () => new Response( - JSON.stringify({ file_upload_id: "fu_123", name: "broken.png" }), + JSON.stringify({ + attachmentId: "3f2504e0-4f89-41d3-9a0c-0305e82c3301", + previewUrl: null, + name: "broken.png", + }), { status: 200, headers: { "content-type": "application/json" } } ) ); @@ -536,7 +540,7 @@ describe("ChatFab — photo uploads", () => { await screen.findByRole("button", { name: "Remove broken.png" }) ).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith( - "/api/upload-notion", + "/api/uploads", expect.objectContaining({ method: "POST" }) ); @@ -548,7 +552,7 @@ describe("ChatFab — photo uploads", () => { const arg = sendMessage.mock.calls[0][0] as { text: string }; expect(arg.text).toContain("the printer is broken"); expect(arg.text).toContain( - "[Attached photos: file_upload_id=fu_123 name=broken.png]" + "[Attached photos: attachment_id=3f2504e0-4f89-41d3-9a0c-0305e82c3301 name=broken.png]" ); }); @@ -583,6 +587,41 @@ describe("ChatFab — photo uploads", () => { ).not.toBeInTheDocument(); }); + it("says photo uploads are unavailable when no blob store is configured", async () => { + const user = userEvent.setup(); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ code: "blob_not_configured" }), { + status: 503, + headers: { "content-type": "application/json" }, + }) + ) + ); + render(); + + await user.click( + screen.getByRole("button", { name: "Open MakerLab assistant" }) + ); + await user.upload( + document.querySelector('input[type="file"]') as HTMLInputElement, + new File([new Uint8Array([1])], "bed.png", { type: "image/png" }) + ); + + // Translated, not the route's English prose — a student never sees an + // English-only string (Article 6). + expect( + await screen.findByText( + "Photo uploads are unavailable right now. You can still send your message without a photo." + ) + ).toBeInTheDocument(); + // And the conversation is still usable without one. + expect( + screen.getByRole("textbox", { name: "Ask the lab console" }) + ).toBeEnabled(); + }); + async function attachPhotoAndSend( user: ReturnType, message: string @@ -592,7 +631,11 @@ describe("ChatFab — photo uploads", () => { vi.fn( async () => new Response( - JSON.stringify({ file_upload_id: "fu_123", name: "plate.jpg" }), + JSON.stringify({ + attachmentId: "3f2504e0-4f89-41d3-9a0c-0305e82c3302", + previewUrl: null, + name: "plate.jpg", + }), { status: 200, headers: { "content-type": "application/json" } } ) ) @@ -617,7 +660,7 @@ describe("ChatFab — photo uploads", () => { } it("sends the photo itself with the message, so the model can see it", async () => { - // Intake spec §6.1: the Notion upload is the record and the downscaled copy + // Intake spec §6.1: the stored upload is the record and the downscaled copy // is what the model looks at. Both go out on the same message. downscaleForVision.mockClear(); downscaleForVision.mockResolvedValue("data:image/jpeg;base64,SMALL"); @@ -632,7 +675,7 @@ describe("ChatFab — photo uploads", () => { files?: unknown[]; }; expect(arg.text).toContain( - "[Attached photos: file_upload_id=fu_123 name=plate.jpg]" + "[Attached photos: attachment_id=3f2504e0-4f89-41d3-9a0c-0305e82c3302 name=plate.jpg]" ); expect(arg.files).toEqual([ { @@ -652,7 +695,7 @@ describe("ChatFab — photo uploads", () => { expect(sendMessage).toHaveBeenCalledWith({ text: expect.stringContaining( - "[Attached photos: file_upload_id=fu_123 name=plate.jpg]" + "[Attached photos: attachment_id=3f2504e0-4f89-41d3-9a0c-0305e82c3302 name=plate.jpg]" ), }); }); diff --git a/v5/src/components/ChatFab.tsx b/v5/src/components/ChatFab.tsx index 96a8586..8ed5c81 100644 --- a/v5/src/components/ChatFab.tsx +++ b/v5/src/components/ChatFab.tsx @@ -221,7 +221,8 @@ const SPEECH_STORE = { interface PendingPhoto { key: string; - file_upload_id: string; + /** `attachments.id` from `POST /api/uploads` — a Postgres uuid. */ + attachmentId: string; name: string; previewUrl: string; /** Downscaled copy the model sees; absent when the browser could not encode it. */ @@ -351,16 +352,24 @@ export function ChatFab() { try { const form = new FormData(); form.append("file", file); - // The Notion upload is the record; the downscaled copy is what the + form.append("kind", "chat"); + // The stored upload is the record; the downscaled copy is what the // model looks at (intake spec §6.1). They run side by side, and a // photo the browser cannot encode still uploads. const [res, dataUrl] = await Promise.all([ - fetch("/api/upload-notion", { + fetch("/api/uploads", { method: "POST", body: form, }), downscaleForVision(file), ]); + if (res.status === 503) { + // No Blob store is configured, so there is nowhere to keep the + // photo. Say so in the visitor's language and let them send the + // message anyway — a report without a picture still beats no + // report (Article 4). + throw new Error(t("uploadsUnavailable")); + } if (!res.ok) { const body = (await res.json().catch(() => null)) as | { error?: string } @@ -368,15 +377,18 @@ export function ChatFab() { throw new Error(body?.error || "Upload failed"); } const data = (await res.json()) as { - file_upload_id: string; + attachmentId: string; name: string; }; setPendingPhotos((prev) => [ ...prev, { - key: `${data.file_upload_id}-${Date.now()}-${Math.random()}`, - file_upload_id: data.file_upload_id, + key: `${data.attachmentId}-${Date.now()}-${Math.random()}`, + attachmentId: data.attachmentId, name: data.name, + // A chat photo is stored privately (it may show a person), so + // the response carries no URL — the local object URL made above + // is the preview, and always was. previewUrl, dataUrl: dataUrl ?? undefined, }, @@ -545,7 +557,7 @@ export function ChatFab() { if (pendingPhotos.length > 0) { const hint = pendingPhotos .map( - (p) => `file_upload_id=${p.file_upload_id} name=${p.name}` + (p) => `attachment_id=${p.attachmentId} name=${p.name}` ) .join("; "); outgoing = `${text}\n\n[Attached photos: ${hint}]`; diff --git a/v5/src/components/PrimaryNav.test.tsx b/v5/src/components/PrimaryNav.test.tsx index 13209b9..308100f 100644 --- a/v5/src/components/PrimaryNav.test.tsx +++ b/v5/src/components/PrimaryNav.test.tsx @@ -156,7 +156,7 @@ describe("PrimaryNav — sign-in control", () => { }); it("shows the first name and a sign-out control once signed in", async () => { - fetchIdentity.mockResolvedValue({ role: "student", name: "Ada Lovelace" }); + fetchIdentity.mockResolvedValue({ role: "user", name: "Ada Lovelace" }); render(); expect(await screen.findByText("Ada")).toBeInTheDocument(); @@ -171,7 +171,7 @@ describe("PrimaryNav — sign-in control", () => { }); it("names the signed-in state for screen readers", async () => { - fetchIdentity.mockResolvedValue({ role: "staff", name: "Niti Parikh" }); + fetchIdentity.mockResolvedValue({ role: "admin", name: "Niti Parikh" }); render(); expect(await screen.findByLabelText("Signed in as Niti")).toHaveTextContent( @@ -180,7 +180,7 @@ describe("PrimaryNav — sign-in control", () => { }); it("renders no avatar image in either state (technical-schematic system)", async () => { - fetchIdentity.mockResolvedValue({ role: "student", name: "Ada Lovelace" }); + fetchIdentity.mockResolvedValue({ role: "user", name: "Ada Lovelace" }); const { container } = render(); await screen.findByText("Ada"); @@ -188,7 +188,7 @@ describe("PrimaryNav — sign-in control", () => { }); it("still offers sign-out when Google supplied no display name", async () => { - fetchIdentity.mockResolvedValue({ role: "student", name: null }); + fetchIdentity.mockResolvedValue({ role: "user", name: null }); render(); expect( @@ -208,7 +208,7 @@ describe("PrimaryNav — sign-in control", () => { it("signs out through the shared helper", async () => { const user = userEvent.setup(); - fetchIdentity.mockResolvedValue({ role: "student", name: "Ada Lovelace" }); + fetchIdentity.mockResolvedValue({ role: "user", name: "Ada Lovelace" }); render(); await user.click(await screen.findByRole("button", { name: "SIGN OUT" })); @@ -283,8 +283,8 @@ describe("PrimaryNav — staff refresh control", () => { fetchIdentity.mockResolvedValue(null); }); - it("offers the refresh control to staff", async () => { - fetchIdentity.mockResolvedValue({ role: "staff", name: "Niti Parikh" }); + it("offers the refresh control to an admin", async () => { + fetchIdentity.mockResolvedValue({ role: "admin", name: "Niti Parikh" }); render(); expect( @@ -292,8 +292,8 @@ describe("PrimaryNav — staff refresh control", () => { ).toBeInTheDocument(); }); - it("offers the refresh control to admins", async () => { - fetchIdentity.mockResolvedValue({ role: "admin", name: "Isaac Steinberg" }); + it("offers the refresh control to a super admin", async () => { + fetchIdentity.mockResolvedValue({ role: "super_admin", name: "Isaac Steinberg" }); render(); expect( @@ -301,8 +301,8 @@ describe("PrimaryNav — staff refresh control", () => { ).toBeInTheDocument(); }); - it("does not show it to a signed-in student", async () => { - fetchIdentity.mockResolvedValue({ role: "student", name: "Ada Lovelace" }); + it("does not show it to an ordinary signed-in user", async () => { + fetchIdentity.mockResolvedValue({ role: "user", name: "Ada Lovelace" }); render(); await screen.findByRole("button", { name: "SIGN OUT" }); @@ -322,8 +322,8 @@ describe("PrimaryNav — staff refresh control", () => { }); // en.json: nav.add = "ADD", nav.addAria = "Add new equipment to the inventory". -// Adding equipment is staff-only (auth spec amendment 2026-09-14); the chat -// enforces it server-side, so this only asserts the entry point's visibility. +// Adding equipment needs `tools.add` (spec §3.5); the chat enforces it +// server-side, so this only asserts the entry point's visibility. describe("PrimaryNav — add equipment", () => { const ADD = "Add new equipment to the inventory"; @@ -334,8 +334,8 @@ describe("PrimaryNav — add equipment", () => { }); it.each([ - ["staff", "Niti Parikh"], - ["admin", "Isaac Steinberg"], + ["admin", "Niti Parikh"], + ["super_admin", "Isaac Steinberg"], ] as const)("offers it to %s", async (role, name) => { fetchIdentity.mockResolvedValue({ role, name }); render(); @@ -345,8 +345,8 @@ describe("PrimaryNav — add equipment", () => { ); }); - it("does not offer it to a signed-in student", async () => { - fetchIdentity.mockResolvedValue({ role: "student", name: "Ada Lovelace" }); + it("does not offer it to an ordinary signed-in user", async () => { + fetchIdentity.mockResolvedValue({ role: "user", name: "Ada Lovelace" }); render(); await screen.findByRole("button", { name: "SIGN OUT" }); diff --git a/v5/src/components/PrimaryNav.tsx b/v5/src/components/PrimaryNav.tsx index 31d7562..3162246 100644 --- a/v5/src/components/PrimaryNav.tsx +++ b/v5/src/components/PrimaryNav.tsx @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { useChatLauncher } from "./ChatLauncherContext"; +import { AdminLink } from "./AdminLink"; import { RefreshCatalogButton } from "./RefreshCatalogButton"; import { canAddEquipment } from "../lib/capabilities/access"; import { siteConfig } from "../lib/site-config"; @@ -96,10 +97,14 @@ export function PrimaryNav({ noticeDurationMs = SIGN_IN_NOTICE_MS }: { noticeDur {t(link.key)} ))} - {/* Adding equipment is staff-only (auth spec amendment 2026-09-14), so the - entry point waits for an identity that may use it. The chat enforces - the same rule server-side; hiding the button is only presentation. */} - {canAddEquipment(identity?.role) ? ( + {/* Adding equipment needs `tools.add` (spec §3.5), so the entry point + waits for an identity that holds it. The chat enforces the same + declaration server-side; hiding the button is only presentation. */} + {/* The way into `/admin`, for anyone holding an admin-surface permission + (spec §6). Like every other control here it is presentation: the + layout behind it resolves the identity again and refuses. */} + + {canAddEquipment(identity) ? ( /* Same nav-action chrome as Report: an action, not a page. */ + + {pending ? t("saving") : null} + {!pending && note ? t(`errors.${note}`) : null} + + + ); +} diff --git a/v5/src/components/admin/RoleSelect.test.tsx b/v5/src/components/admin/RoleSelect.test.tsx new file mode 100644 index 0000000..3a7b5e6 --- /dev/null +++ b/v5/src/components/admin/RoleSelect.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, userEvent, waitFor } from "../../../test/utils/render"; +import { RoleSelect } from "./RoleSelect"; +import type { AdminActionResult } from "../../app/admin/users/action-result"; + +/** + * The presentation half of changing a role. The half that matters — + * who may actually do it — is `src/app/admin/users/actions.test.ts`. + * + * The server action is a prop, so there is nothing to mock: a `vi.fn` is a + * perfectly good `setUserRole` as far as this component is concerned, which is + * the point of passing it in rather than importing it. + */ + +function renderSelect( + overrides: Partial> = {} +) { + const action = vi.fn< + (input: { userId: string; role: string }) => Promise + >(async () => ({ ok: true })); + + render( + + ); + return { action }; +} + +function theSelect() { + return screen.getByRole("combobox", { name: /Ada Lovelace/ }); +} + +// en.json: admin.roles.user = "Student", admin = "SuperMaker", +// super_admin = "Director". +describe("RoleSelect — what it offers", () => { + it("offers every stored role, labelled in words rather than identifiers", () => { + renderSelect(); + + expect( + screen.getAllByRole("option").map((option) => option.textContent) + ).toEqual(["Student", "SuperMaker", "Director"]); + }); + + it("shows the role the person currently holds", () => { + renderSelect({ role: "admin" }); + expect(theSelect()).toHaveValue("admin"); + }); + + it("names the person in the control's accessible name", () => { + renderSelect(); + expect(theSelect()).toHaveAccessibleName("Role for Ada Lovelace"); + }); +}); + +describe("RoleSelect — changing it", () => { + it("calls the action with the chosen role and confirms", async () => { + const user = userEvent.setup(); + const { action } = renderSelect(); + + await user.selectOptions(theSelect(), "admin"); + + await waitFor(() => { + expect(action).toHaveBeenCalledWith({ userId: "u1", role: "admin" }); + }); + expect(await screen.findByText("Saved")).toBeInTheDocument(); + expect(theSelect()).toHaveValue("admin"); + }); + + it("puts the old role back and explains when the server refuses", async () => { + const user = userEvent.setup(); + const { action } = renderSelect({ role: "super_admin" }); + action.mockResolvedValue({ ok: false, error: "last_super_admin" }); + + await user.selectOptions(theSelect(), "user"); + + expect(await screen.findByText(/last director/i)).toBeInTheDocument(); + // The page must not be left asserting a change that did not happen. + await waitFor(() => expect(theSelect()).toHaveValue("super_admin")); + }); + + it("reports a refusal the page did not anticipate, rather than nothing", async () => { + const user = userEvent.setup(); + const { action } = renderSelect(); + action.mockResolvedValue({ ok: false, error: "rate_limited" }); + + await user.selectOptions(theSelect(), "admin"); + + expect(await screen.findByText(/Too many changes/i)).toBeInTheDocument(); + }); +}); + +describe("RoleSelect — rows that cannot change", () => { + it("disables the floor row and shows why, without waiting to be clicked", () => { + renderSelect({ role: "super_admin", disabledReason: "protected_floor" }); + + expect(theSelect()).toBeDisabled(); + expect(screen.getByText(/protected in the deployment's settings/i)).toBeInTheDocument(); + }); + + it("disables the last director's row with its own reason", () => { + renderSelect({ role: "super_admin", disabledReason: "last_super_admin" }); + + expect(theSelect()).toBeDisabled(); + expect(screen.getByText(/last director/i)).toBeInTheDocument(); + }); + + it("never calls the action for a disabled row", async () => { + const user = userEvent.setup(); + const { action } = renderSelect({ disabledReason: "protected_floor" }); + + await user.selectOptions(theSelect(), "admin").catch(() => { + // userEvent refuses to interact with a disabled control, which is the + // assertion — the catch only keeps the failure from being the test's. + }); + + expect(action).not.toHaveBeenCalled(); + }); +}); diff --git a/v5/src/components/admin/RoleSelect.tsx b/v5/src/components/admin/RoleSelect.tsx new file mode 100644 index 0000000..f46734b --- /dev/null +++ b/v5/src/components/admin/RoleSelect.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { ROLES, type Role } from "../../lib/db/schema/vocabulary"; +import type { AdminActionError, AdminActionResult } from "../../app/admin/users/action-result"; + +/** + * The one interactive control on `/admin/users`: pick a role, and the change + * lands on that person's next request (spec §5.2, §6). + * + * The action arrives as a **prop** rather than being imported here. Two reasons, + * and the second is the one that matters: a client component importing + * `actions.ts` would drag `next/headers`, the rate limiter and `server-only` + * into this module's graph, which makes it untestable without a Next runtime — + * and the page that renders this is a server component that already has the + * action to hand. `UsersTable` passes it down. + * + * **Disabling is presentation.** `disabledReason` explains a row the page + * already knows cannot change — the super-admin floor, the last super admin — + * but the server action checks both again, because a select that is disabled in + * the DOM is disabled for exactly as long as nobody opens the console (§8). + */ + +export interface RoleSelectProps { + userId: string; + /** Whose role this is — for the control's accessible name. */ + personName: string; + role: Role; + /** Why this row cannot change, or null when it can. */ + disabledReason?: AdminActionError | null; + /** The `setUserRole` server action, passed down by the page. */ + action: (input: { userId: string; role: string }) => Promise; +} + +export function RoleSelect({ + userId, + personName, + role, + disabledReason = null, + action, +}: RoleSelectProps) { + const t = useTranslations("admin"); + const [pending, setPending] = useState(false); + // The select is controlled from here rather than from the row's props, so it + // shows what was chosen while the action is in flight. On a refusal it snaps + // back to what the server still holds — never leaving the page asserting a + // change that did not happen (Article 4). + const [current, setCurrent] = useState(role); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + /** + * Deliberately **not** wrapped in `useTransition`. + * + * The action ends with `revalidatePath`, so a transition's pending state + * covers the write *and* the server re-render that follows it — which means + * the control sits on "Saving…" until a whole page has been rendered again. + * Under load that is seconds, and it made a genuine save look stuck. What + * the person needs confirmed is that the change landed, which is exactly + * what the awaited result says. The revalidation still happens; it just no + * longer holds the confirmation hostage. + */ + async function handleChange(next: string) { + const previous = current; + setCurrent(next as Role); + setError(null); + setSaved(false); + setPending(true); + + try { + const result = await action({ userId, role: next }); + if (result.ok) { + setSaved(true); + return; + } + setCurrent(previous); + setError(result.error); + } catch { + // A server action that never answered — a dropped connection, a redeploy + // mid-click. The row goes back to what the server last confirmed. + setCurrent(previous); + setError("failed"); + } finally { + setPending(false); + } + } + + const locked = Boolean(disabledReason); + const note = error ?? disabledReason; + + return ( +
+ + + {/* One live region for every outcome this control can have, so a screen + reader hears the refusal in the same place it heard the confirmation. */} + + {pending ? t("saving") : null} + {!pending && saved && !error ? t("saved") : null} + {!pending && note ? t(`errors.${note}`) : null} + +
+ ); +} diff --git a/v5/src/components/admin/UsersTable.test.tsx b/v5/src/components/admin/UsersTable.test.tsx new file mode 100644 index 0000000..65c74a2 --- /dev/null +++ b/v5/src/components/admin/UsersTable.test.tsx @@ -0,0 +1,151 @@ +import { render, screen, within } from "../../../test/utils/render"; +import { UsersTable } from "./UsersTable"; +import type { UserRecord } from "../../lib/data/users"; + +/** + * The roster's rendering, and the two rows it marks as unchangeable. + * + * `UsersTable` is a server component with no `async`, which is exactly why it + * can be mounted here: everything it needs is a prop, and the interactive cells + * are client islands with their own tests. + */ + +function person(overrides: Partial = {}): UserRecord { + return { + id: "u-ada", + email: "ada@cornell.edu", + name: "Ada Lovelace", + role: "user", + banned: false, + banReason: null, + createdAt: new Date("2026-03-04T10:00:00.000Z"), + ...overrides, + }; +} + +function renderTable(users: UserRecord[], currentUserId: string | null = null) { + const setRole = vi.fn(async () => ({ ok: true }) as const); + const setBanned = vi.fn(async () => ({ ok: true }) as const); + render( + + ); + return { setRole, setBanned }; +} + +function rowFor(name: string) { + return screen.getByRole("row", { name: new RegExp(name) }); +} + +describe("UsersTable — the roster", () => { + it("shows each person's name, address and role control", () => { + renderTable([person()]); + + const row = rowFor("Ada Lovelace"); + expect(within(row).getByText("ada@cornell.edu")).toBeInTheDocument(); + expect(within(row).getByRole("combobox", { name: /Ada Lovelace/ })).toHaveValue("user"); + }); + + it("renders the first sign-in as an ISO date", () => { + renderTable([person()]); + expect(screen.getByText("2026-03-04")).toBeInTheDocument(); + }); + + it("marks the viewer's own row", () => { + renderTable([person()], "u-ada"); + expect(within(rowFor("Ada Lovelace")).getByText("you")).toBeInTheDocument(); + }); + + it("shows a ban and its reason rather than hiding the account", () => { + renderTable([person({ banned: true, banReason: "Ignored the laser rules" })]); + + const row = rowFor("Ada Lovelace"); + expect(within(row).getByText(/Ignored the laser rules/)).toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "Lift ban" })).toBeInTheDocument(); + }); + + it("names what is missing when nobody has signed in", () => { + renderTable([]); + expect(screen.getByText(/Nobody has signed in yet/)).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); +}); + +describe("UsersTable — rows it will not let you change", () => { + it("locks a floor address, with the reason visible", () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + renderTable([ + person({ id: "u-founder", email: "founder@cornell.edu", name: "Fay Founder", role: "super_admin" }), + person({ id: "u-other", email: "other@cornell.edu", name: "Otto Other", role: "super_admin" }), + ]); + + const row = rowFor("Fay Founder"); + expect(within(row).getByRole("combobox", { name: /Fay Founder/ })).toBeDisabled(); + expect(within(row).getByRole("button", { name: "Ban" })).toBeDisabled(); + expect( + within(row).getAllByText(/protected in the deployment's settings/i).length + ).toBeGreaterThan(0); + }); + + it("locks the last director's role, worked out from the list it was given", () => { + renderTable([ + person({ id: "u-dee", email: "dee@cornell.edu", name: "Dee Rector", role: "super_admin" }), + person({ id: "u-ada", email: "ada@cornell.edu", name: "Ada Lovelace", role: "user" }), + ]); + + expect( + within(rowFor("Dee Rector")).getByRole("combobox", { name: /Dee Rector/ }) + ).toBeDisabled(); + expect( + within(rowFor("Ada Lovelace")).getByRole("combobox", { name: /Ada Lovelace/ }) + ).toBeEnabled(); + }); + + it("unlocks it once a second director exists", () => { + renderTable([ + person({ id: "u-dee", email: "dee@cornell.edu", name: "Dee Rector", role: "super_admin" }), + person({ id: "u-sam", email: "sam@cornell.edu", name: "Sam Second", role: "super_admin" }), + ]); + + expect( + within(rowFor("Dee Rector")).getByRole("combobox", { name: /Dee Rector/ }) + ).toBeEnabled(); + }); + + it("does not count a banned director as somebody who could undo it", () => { + renderTable([ + person({ id: "u-dee", email: "dee@cornell.edu", name: "Dee Rector", role: "super_admin" }), + person({ + id: "u-ban", + email: "banned@cornell.edu", + name: "Ben Banned", + role: "super_admin", + banned: true, + }), + ]); + + expect( + within(rowFor("Dee Rector")).getByRole("combobox", { name: /Dee Rector/ }) + ).toBeDisabled(); + }); + + it("will not let you ban yourself, but leaves your role alone", () => { + renderTable( + [ + person({ id: "u-ada", name: "Ada Lovelace", role: "admin" }), + person({ id: "u-dee", email: "dee@cornell.edu", name: "Dee Rector", role: "super_admin" }), + person({ id: "u-sam", email: "sam@cornell.edu", name: "Sam Second", role: "super_admin" }), + ], + "u-ada" + ); + + const row = rowFor("Ada Lovelace"); + expect(within(row).getByRole("button", { name: "Ban" })).toBeDisabled(); + expect(within(row).getByText("You cannot ban yourself.")).toBeInTheDocument(); + expect(within(row).getByRole("combobox", { name: /Ada Lovelace/ })).toBeEnabled(); + }); +}); diff --git a/v5/src/components/admin/UsersTable.tsx b/v5/src/components/admin/UsersTable.tsx new file mode 100644 index 0000000..2209511 --- /dev/null +++ b/v5/src/components/admin/UsersTable.tsx @@ -0,0 +1,132 @@ +import { useTranslations } from "next-intl"; +import { isSuperAdminFloor } from "../../lib/auth/super-admins"; +import type { UserRecord } from "../../lib/data/users"; +import type { AdminActionError, AdminActionResult } from "../../app/admin/users/action-result"; +import { BanToggle } from "./BanToggle"; +import { RoleSelect } from "./RoleSelect"; + +/** + * The roster on `/admin/users` (spec §5.2, §6). + * + * A server component, and deliberately not `async`: everything it needs is + * already in its props, so it renders synchronously and a component test can + * mount it with the ordinary i18n wrapper. The two interactive cells are client + * islands, and the server actions they call travel down as props — see + * `RoleSelect` for why. + * + * **It works out which rows cannot change, and says so.** The same two + * guarantees the server enforces (`actions.ts`): an address on the super-admin + * floor, and the last super admin standing. The count comes from the list this + * component was already handed rather than a second query, and the answer is + * only presentation — the action re-derives both before it writes. + */ + +export interface UsersTableProps { + users: UserRecord[]; + /** The viewer, so their own row can be marked and their ban refused. */ + currentUserId: string | null; + setRole: (input: { userId: string; role: string }) => Promise; + setBanned: (input: { + userId: string; + banned: boolean; + reason?: string; + }) => Promise; +} + +export function UsersTable({ users, currentUserId, setRole, setBanned }: UsersTableProps) { + const t = useTranslations("admin"); + + if (users.length === 0) { + // Spec §6: an empty state names what is missing and what would change it. + // Reaching this means nobody has ever signed in, which on a fresh + // deployment is the normal first state rather than a fault. + return

{t("noUsers")}

; + } + + // Who would still hold `super_admin` if a given row lost it. Banned super + // admins are not counted: they resolve to anonymous and can undo nothing. + const activeSuperAdmins = users.filter( + (person) => person.role === "super_admin" && !person.banned + ).length; + + return ( +
+ + + + + + + + + + + {users.map((person) => { + const floor = isSuperAdminFloor(person.email); + const lastSuperAdmin = + person.role === "super_admin" && !person.banned && activeSuperAdmins === 1; + const isSelf = Boolean(currentUserId) && person.id === currentUserId; + + const roleReason: AdminActionError | null = floor + ? "protected_floor" + : lastSuperAdmin + ? "last_super_admin" + : null; + // No "last super admin" here: whoever is reading this page holds + // `users.manage`, so banning somebody else cannot leave the lab + // without a director. `actions.ts` says the same in one comment. + const banReason: AdminActionError | null = floor + ? "protected_floor" + : isSelf + ? "self_ban" + : null; + + return ( + + + + + {/* ISO, in the mono treatment the design system gives every + other timestamp. Locale-neutral on purpose: a roster read by + one admin does not need a localized date, and a formatted + one would render differently on the server and the client. */} + + + ); + })} + +
{t("columnPerson")}{t("columnRole")}{t("columnAccess")}{t("columnJoined")}
+ + {person.name} + {isSelf ? {t("you")} : null} + + {/* The one surface in the app that shows an address: telling + two accounts apart is the whole job here (spec §8). */} + {person.email} + + + + {person.banned ? ( +

+ {person.banReason + ? t("bannedWithReason", { reason: person.banReason }) + : t("banned")} +

+ ) : null} + +
{person.createdAt.toISOString().slice(0, 10)}
+
+ ); +} diff --git a/v5/src/lib/auth/config.test.ts b/v5/src/lib/auth/config.test.ts index e889718..a7dce11 100644 --- a/v5/src/lib/auth/config.test.ts +++ b/v5/src/lib/auth/config.test.ts @@ -3,9 +3,10 @@ * * Node, not jsdom: Better Auth encrypts the provider's OAuth tokens through * `jose`, which checks `plaintext instanceof Uint8Array` — and jsdom's realm - * makes that check fail on a perfectly good Uint8Array. This route only ever - * runs on the server anyway. + * makes that check fail on a perfectly good Uint8Array. PGlite needs node too. + * This module only ever runs on the server anyway. */ +import { eq } from "drizzle-orm"; import { http, HttpResponse } from "msw"; import { server } from "../../../test/msw/server"; @@ -14,28 +15,33 @@ import { DOMAIN_REJECTED_PATH, createAuth, getAuth, - hasAuthEnv, + hasGoogleEnv, + hasSessionEnv, resetAuthForTests, } from "@/lib/auth/config"; -import { - SESSION_COOKIE_NAME, - verifySessionToken, -} from "@/lib/auth/session-cookie"; +import { getDb, resetDbForTests } from "@/lib/db/client"; +import { session, user } from "@/lib/db/schema/index"; // No live OAuth (Article 3). Google's token endpoint is mocked by MSW and the // id_token is a hand-built JWT — the Google provider decodes it rather than // verifying its signature on the authorization-code path, so a forged one is -// enough to drive the whole callback. +// enough to drive the whole callback. Every row lands in PGlite. const SECRET = "config-test-secret"; const ORIGIN = "http://localhost:3000"; const CLIENT_ID = "test-client-id.apps.googleusercontent.com"; -function stubAuthEnv(overrides: Record = {}) { +function stubSessionEnv(overrides: Record = {}) { + vi.stubEnv("DATABASE_URL", ""); vi.stubEnv("AUTH_SECRET", SECRET); + vi.stubEnv("AUTH_BASE_URL", ORIGIN); + for (const [key, value] of Object.entries(overrides)) vi.stubEnv(key, value); +} + +function stubAuthEnv(overrides: Record = {}) { + stubSessionEnv(); vi.stubEnv("GOOGLE_CLIENT_ID", CLIENT_ID); vi.stubEnv("GOOGLE_CLIENT_SECRET", "test-client-secret"); - vi.stubEnv("AUTH_BASE_URL", ORIGIN); for (const [key, value] of Object.entries(overrides)) vi.stubEnv(key, value); } @@ -86,13 +92,15 @@ function setCookieFor(res: Response, name: string): string | undefined { return res.headers.getSetCookie().find((c) => c.startsWith(`${name}=`)); } +let sub = 0; + /** * Run the full authorization-code flow and return the callback response. * Step 1 gets the state cookie + state param; step 2 is the callback Google * would redirect the browser to. */ async function signInThroughGoogle(email: string, name = "Ada Lovelace") { - const auth = createAuth(); + const auth = createAuth(await getDb()); const start = await auth.handler( new Request(`${ORIGIN}${AUTH_BASE_PATH}/sign-in/social`, { @@ -107,9 +115,10 @@ async function signInThroughGoogle(email: string, name = "Ada Lovelace") { const state = authorizeUrl.searchParams.get("state"); expect(state).toBeTruthy(); + sub += 1; mockGoogleToken( idToken({ - sub: "google-sub-42", + sub: `google-sub-${sub}`, email, name, hd: email.split("@")[1], @@ -127,35 +136,75 @@ async function signInThroughGoogle(email: string, name = "Ada Lovelace") { return { authorizeUrl, callback }; } +async function userRow(email: string) { + const db = await getDb(); + const [row] = await db.select().from(user).where(eq(user.email, email)); + return row; +} + beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); + resetAuthForTests(); +}); + +afterEach(() => { resetAuthForTests(); + resetDbForTests(); }); -describe("hasAuthEnv / getAuth", () => { - it("is false and yields no instance when Google is not configured", () => { - expect(hasAuthEnv()).toBe(false); - expect(getAuth()).toBeNull(); +describe("hasSessionEnv / hasGoogleEnv / getAuth", () => { + it("has no instance with nothing configured", async () => { + expect(hasSessionEnv()).toBe(false); + expect(hasGoogleEnv()).toBe(false); + expect(await getAuth()).toBeNull(); }); - it("still requires every variable — a partial config is not configured", () => { - vi.stubEnv("AUTH_SECRET", SECRET); + it("builds an instance from AUTH_SECRET alone", async () => { + // The split that Phase 4 needed: database sessions require a secret and + // nothing else, and the E2E suite runs exactly this way — real signed + // cookies against seeded rows, with Google deliberately unconfigured. + stubSessionEnv(); + expect(hasSessionEnv()).toBe(true); + expect(hasGoogleEnv()).toBe(false); + expect(await getAuth()).not.toBeNull(); + }); + + it("needs both Google variables before it calls Google configured", () => { + stubSessionEnv(); vi.stubEnv("GOOGLE_CLIENT_ID", CLIENT_ID); - expect(hasAuthEnv()).toBe(false); - expect(getAuth()).toBeNull(); + expect(hasGoogleEnv()).toBe(false); }); - it("builds and memoizes an instance once fully configured", () => { + it("memoizes the instance and rebuilds when the configuration changes", async () => { stubAuthEnv(); - const first = getAuth(); - expect(first).not.toBeNull(); - expect(getAuth()).toBe(first); + const first = await getAuth(); + expect(await getAuth()).toBe(first); + + vi.stubEnv("GOOGLE_CLIENT_ID", "a-different-client"); + expect(await getAuth()).not.toBe(first); }); - it("rebuilds when the configuration changes", () => { + it("rebuilds when the data substrate changes", async () => { + // A memo keyed only on env would hand back an instance still pointed at + // the previous database. stubAuthEnv(); - const first = getAuth(); - vi.stubEnv("GOOGLE_CLIENT_ID", "a-different-client"); - expect(getAuth()).not.toBe(first); + const first = await getAuth(); + vi.stubEnv("DATABASE_URL", "postgres://example.invalid/db"); + expect(await getAuth()).not.toBe(first); + }); + + it("refuses social sign-in when Google is not configured", async () => { + stubSessionEnv(); + const { POST } = await import("@/app/api/auth/[...all]/route"); + const res = await POST( + new Request(`${ORIGIN}${AUTH_BASE_PATH}/sign-in/social`, { + method: "POST", + headers: { "content-type": "application/json", "x-forwarded-for": "9.9.9.9" }, + body: JSON.stringify({ provider: "google", callbackURL: "/" }), + }) + ); + // 503 is what the header renders as "sign-in is not set up here". + expect(res.status).toBe(503); }); }); @@ -178,45 +227,79 @@ describe("Google authorization URL", () => { }); describe("sign-in callback — institutional account", () => { - it("sets a signed session cookie carrying sub, email, and name", async () => { + it("writes a user row with the default role and a session row", async () => { stubAuthEnv(); const { callback } = await signInThroughGoogle("student@cornell.edu"); - const cookie = setCookieFor(callback, SESSION_COOKIE_NAME); + const row = await userRow("student@cornell.edu"); + expect(row).toBeDefined(); + expect(row.role).toBe("user"); + expect(row.name).toBe("Ada Lovelace"); + + const db = await getDb(); + const sessions = await db.select().from(session).where(eq(session.userId, row.id)); + expect(sessions).toHaveLength(1); + expect(sessions[0].expiresAt.getTime()).toBeGreaterThan(Date.now()); + + const cookie = setCookieFor(callback, "better-auth.session_token"); expect(cookie).toBeDefined(); expect(cookie).toContain("HttpOnly"); expect(cookie).toContain("SameSite=Lax"); - - const token = cookie!.split(";")[0].split("=")[1]; - const payload = await verifySessionToken(token, SECRET); - expect(payload).not.toBeNull(); - expect(payload!.email).toBe("student@cornell.edu"); - expect(payload!.name).toBe("Ada Lovelace"); - expect(payload!.sub).toBeTruthy(); }); - it("issues a cookie no other secret can verify", async () => { + it("no longer mints the retired makerlab.identity cookie", async () => { + // The stateless cookie *was* the session until Phase 4. Nothing reads it + // now, and leaving one behind would be a second, un-revocable identity. stubAuthEnv(); const { callback } = await signInThroughGoogle("student@cornell.edu"); - const token = setCookieFor(callback, SESSION_COOKIE_NAME)! - .split(";")[0] - .split("=")[1]; - expect(await verifySessionToken(token, "not-the-secret")).toBeNull(); + expect(setCookieFor(callback, "makerlab.identity")).toBeUndefined(); + }); + + it("creates an AUTH_SUPER_ADMIN_EMAILS address as super_admin", async () => { + // The bootstrap: no user row exists before the first sign-in, so there is + // no admin to promote anybody. The floor is how the first one comes to be. + stubAuthEnv({ AUTH_SUPER_ADMIN_EMAILS: "ies22@cornell.edu" }); + await signInThroughGoogle("ies22@cornell.edu", "Isaac S"); + + expect((await userRow("ies22@cornell.edu")).role).toBe("super_admin"); + }); + + it("does not raise anyone else to super_admin", async () => { + stubAuthEnv({ AUTH_SUPER_ADMIN_EMAILS: "ies22@cornell.edu" }); + await signInThroughGoogle("student@cornell.edu"); + + expect((await userRow("student@cornell.edu")).role).toBe("user"); }); }); describe("sign-in callback — non-institutional account", () => { - it("refuses the domain server-side and never issues a session cookie", async () => { + it("creates no user row at all", async () => { + // Enforcement #1, in `databaseHooks.user.create.before`. `hd` alone would + // not have stopped this — it only narrows Google's account picker. + stubAuthEnv(); + await signInThroughGoogle("someone@gmail.com"); + + expect(await userRow("someone@gmail.com")).toBeUndefined(); + }); + + it("issues no usable session", async () => { stubAuthEnv(); const { callback } = await signInThroughGoogle("someone@gmail.com"); - const cookie = setCookieFor(callback, SESSION_COOKIE_NAME); - // Either no cookie at all, or an explicitly cleared one — never a usable - // session. `hd` alone would not have stopped this; the server-side check did. + const cookie = setCookieFor(callback, "better-auth.session_token"); if (cookie) { - const token = cookie.split(";")[0].split("=")[1]; - expect(await verifySessionToken(token, SECRET)).toBeNull(); + // Either no cookie at all, or an explicitly cleared one. + expect(cookie.split(";")[0].split("=")[1]).toBe(""); } + // No user row was created, so no session can point at one. (The demo seed + // ships three sessions of its own; none of them is this person's.) + const db = await getDb(); + const sessions = await db.select().from(session); + const users = await db.select().from(user); + const emails = new Map(users.map((row) => [row.id, row.email])); + expect( + sessions.filter((row) => emails.get(row.userId) === "someone@gmail.com") + ).toHaveLength(0); }); it("redirects rather than dead-ending on a stack trace", async () => { diff --git a/v5/src/lib/auth/config.ts b/v5/src/lib/auth/config.ts index 9ad0c7b3b786a9409b4b5dd894cd7115509b6d71..ba7c5e744eae2278b97ba60aeb2bb2cfda5418d1 100644 GIT binary patch literal 8150 zcma)BTXN&p5&hRHI?R+!G88y|#2=L9m?P0MN~DoW(%7kTTp|culCVHr1Q!s8qfu2B zkv(J|SxQ!t)7=*YC~2JNLlObpMn6uUelS_(wkWCNOmSz5eVe75&cWVZqL=S!X3EkO z|F}vZ1)Y``B!O-!YeX+mw z#&VMzx~C^5^L2h~Z`kkeQJ=1thF%F`NFW1WmSGkd>azbRPs4Ia0=_MP%ORNnwIEoEVci`zlNuYmxR0iw*A0=LCF8pd}LENoch7+@+*^ zWDz6bR|bSKl(JF3KY-Y5o-C>YcoD7owkiO~D1x8%akGRA)ZExaX$X#D?{%jw`Upxl z?MWs~kvRM8v#EGt3McHdgZZOd!_mkADB&Z)*jXT95QS+JX5HSDVryKTugX5~E0{46 z#Bg|AnWQP140cD*x!*6>Nd%w{fE;A9VG?B*&rB5J)uf5|xgc@6jHi^thLz!Zz!iy* z?~Ez&A*{AlR#Kis$gVmcz6eMTYUmljcgUwgP|Y5FcR~O?7y?dpkE6HF03U;lw)4hv zLZ&2hPEipIwOT?1ZF%-G{1xnb-un~KnLvBKnr6q6PB8aMhG8SymgA& zkfebD|NI2BbvxgG8w;I(*Xd0)0IC_#xJ;rY{5*$DIKq;O6_>y_gnR%mDX;}Xb9irm z2{qtlREvQZ_e4%=a54k16GRQPz+B4YT>F_b7d!~M)tt0dSqFd8kcr2-&x3&29GZ^b zUB9`xnjBqDuBTLG03JK&fxW z#rXOS9a3k=4b-rf?29O!DYh-J>e1K+tCXq~n0oRS@${?l)ubgqPvjl7=&xR)nrvJ^ z<83S%IB9YxE`R~4Mf~9;M4DZME5I94dkM?IV1>B@*5S{`CSFahu1?P1-kgp9dNY1K z;fSBVJ-&jKcxRu!{2IT$;s3sQsl5V$^il?!mp@_zhMLQd`kFf%5W^hL>+)=p=c%9fMt?m=1wVo6h6 z=d{9AWEN|~r4^+rrWs0D%^xAN0@W?n+EzHaGO#kUwc}Ayg-WE-AlWHoX}(p`9#=T5 z&cDf*6Q6i$n|Y5wg0s2NL0qM4khuac0B3=MwL4RZXn76lCi@erst4V+vs$3D<-CT| za97$2D6>tlIdWVo9gM$HM~eL;*o^uHv+nl@FJQ8!S9+<7(!gaCq!j%-QNGLefuvXsk06rwD~BkAS0 zM?VT@Be$8l4_eRt#cY_@9*qMnM{z898W=E{O!@d!6R!bZlW}!MjNK-Swv7TuUWo? zQ(9F{1_E@NbZbm5L|4Zt=R$yYA|=`M92A_utSwd5UV2p}DZ1$JLy~SrT6hX>;6brC zicYWMngPQ_5d zv_<;1$dBkkJ^k$t{uD&tNq#1C>i+Ig2KVZ$RgjUI1phF^rZ4Wf&DxIR-QL5;7x#$A z4^235ZF+#@7vYLy`uNc!nvwdu!$TI=6BQs|pbp_p7IedPh$`rDgM`A6)Dj@a`Qk&v zF)Brv#HZfYBQ3e{Y=lu-->f}?G9%vJPU{vqvFe;ywI8%e#PcXGj}`%4DB390tZ%ya zxJoq#%l|wZ1A-uFP{S+!P^8@+x-tCly^FA1BKf(8+jbUux9)OBw7b3O#9{iV64i!e(FxWYWrFkWX3{Tu%AU&U|$^JMmu%8+mmOA{^ZfeCx{lZju`D+L&U`+ zC!parICeQ7H9-)f(NSsj$t$V4zjazG#&Z(jTR%9_AKGmUM&8y?B(qaMZ{dCkq0Xb~ zhgz2<^jFljb$n<(a2D|o%=cG)XVv(_cYP<>h(6JA*5axh2sPt{X`X!A5!MgG$Rpj@ zNc!%Ks4~5GTYD<*XgpVV{kTn5BoaXskOr#d@md?fj<}>*PGBJ*TbZQT*+>Ab+D|TQ zn5yf%8|qw>iL!A9kCx3R1Y6dx?JG!P89AY{kaL%h^ypAoSG~=@q4Y#2^%Vvf`u!Ip zV4cTIo-#2dNfltYhq{MBPw3E^ORP1dyP+1!-Q7mYs{r!#f2KTCIbO z6zDkwe_Z67^?%m`FDleQ-(!jUY|1Fyxc zPzaM?RCb@+Mfk=>V(-hhx zGXn03R;}XYEf#T$3Ofccb6Xm|*^=EcmZ{uEGjg!;2Ss2|uthyl;~NKEB}s1#I$4LC zCU$a!jR*FA-pRsWYB$u*79u{wFu)~bgI?ZYd`ADdF2YScdikW=j8EfHzdpO; z7dq;G?+0735XW=woyDG%p}Fm{w{5+&-Oc2vBRJ0>EI{PDnVc$&e? zVnJBQ4vIG@tW%dwM{;8)16}}JkU5(HT-@u9y3ym0sGqpKaRQYaexVN1IcDn5m zqK)x!q3)4|A=;cYi4gfidb+Hr)zfaR7vS)UTYjB%>Z3I|@}f*+1%<(vZ;|N^|2BLv z9AKD`GfKm-lYMt6;BPmXzv)2M;F*WvOVzE`Nu_T!nl8&SccbC(#l7BnMl~JXd{OK0 z0X#2$U0P(tS1(__?8uO{_37i`Z^x}(kg``><<^9y+1S4MxWoQmPiPnY^r>ckf@?l; ydwhC&{u6e?6Re6)ZvG%174C06#W9;nNtOg(LF6PyQY>O>MM+4>2_t9Y2`GpJT*}i>y66vV|xZFHO+mAe&>#?y)im}bUDxW z-{w17sW`S99gWw@#K#u@Cp|Lxs4=<~6$~;PYu;XIx<_<1zPg&6pG}X>&;K$RPtQiD z<6dLya6NUGBYon&SlChlX63E zuX0{6D0lQjv6;;pbh~@hrR$ZZLvc5WzfF=mm4uqw>}ra14uO=XI;3a&&%WvJ|FQq< zPxSA9eHA?I1Q5V7n)|aN$;mrLT zLG#%vno+7Uwbtw!E>TuEQJEah5msSni^NfC!W*4&;CRK;!0*LbyeQ4=t%)GvKmYir z47{EY)OI~e<05~=UExY2uy(GTXb zL<%{ha{UVK&jZ+&1-_huxrY7PhhOYiz{36t>)-~IxTtCi7`;hK4N9QOVF}GN13;h= z$v4KW+O6-ujRXx|v^p~q^&7$C0}z}T>618s0*7WG)kkku8epX`{Z@w#HY7GdF679h zf@6hEm5Y%DIZ`Z;a!u7za~xCDh^DUt7O39|qT&$b@A3KRXmU2a9RGQIbUi+vUW~3^ z(E+svT+)N;xDo{D)CbWbD5cw*Q9&Ulz}4*GDWliducn8it8qhqYUGS2zGQ_)D(j>I z1TIg>_?%s`Fv}v-Q9xs^L!Z#BWk1tglMw)DnJJ(UqRX*pC3bG3J2tmQ<>f(G=lS0KUX=>&F4Y3*lM2xf=u)cX8Z+PWG>WY5pD)l8Vhxy6Na1L> zyY&MlMI0lC_~UtF-48$@ouwHk4>GJ7c(}U`FaXZ?l^y}VW-Mmr!`8?LAR5x#QIP^i z85x0>zZefZ96?4%^9zz91)jj2v}l~pVO-m&h;^UaB%ck`D}2N@wurGG4XA<@p9!2F zkxo&|3@DAj1E77IVx1C=GaWZ0&=tv}5a1Hq<>TqG)Q;1GbAvy({x6-#d14mSe$q^% zPL(>|!D#(b$xQI-MbzUJ2Hg-~p+U>!tV;JH1>?}8r5@6=uhFT#*_Z!z^xfekb@-T^ z)V~lGxzsdAMBlOJZEFOYe*1LDdNXB1a)JU1dO-p32^L4TaKctNsXRTY6lLo@1%k!- zT@q58cXa6B#nz9!ro@)r8;&dugaLK7m?b@zVWIjf!^1zfUw0$NY^#ybQYOO&7S*@8 zcBy0N-hHGZLU_ew0;VF9waUP^!P-3Whg+;9S@UwjES7E(X3uKQ+qkO1@@m&GFyo{k z1!&L#ptCiQ28G3~3Oc=&yb8_A-@)3C-R%Hky^lN^1lS|DIlr&IyS4XA$f|s}R_`R4 z_Um$mHwE*nB_4rm$D{dIQyys(y}9zoG0;O{3_ILxgL96CsSbI$uFm6roBP zB{N$rSGankL-X}rSagPX3DBZK(SIG+wJK9#H@Z(gm(cIGC!-4g{{d!OH=+nADS$0> z?Z>!%BPkxFfLw|YDz#B?80T`C5uTj-(0afrC(3t*veQdX?Ba2Z{K zq7Lb&#>-H<^E0c-MF+p)!2|@F`6*-SmWx)z7I!mTE95QfIXB^`q@Ib~&zVAkK;Q;f zU)Huf33=@);}14tAvsgWTZ=0#=i`>CZKr&Sla&=U_;zQ=44?9*uhD$}Dn+XFcDUxUc$&*y3pijQkd?Amv6UZ%l)| zt*dr8-jMiR1C=gr62gq89GeiCen9Oe6WURaiE^ve39xxxv*>DT1$^A#MOKjMGzM_B z2Y(xUG3fDDhC2_ju#gTWW?{mdh( z-OqS)NzPlm0qe!DV5M&O)&Bmz&y}rDKfYG%xV04Y(*ye&S6N@nO?y5jp8sowCzuac n?S$ { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + + const db = await getDb(); + await db.delete(auditEvents); + await db.delete(session); + await db.delete(user); +}); + +afterAll(() => { + resetDbForTests(); +}); + +/** The identity `resolveIdentity` would hand back for a signed-in person. */ +function identityFor( + person: { id: string; email: string }, + role: Identity["role"] = "super_admin" +): Identity { + return { + role, + userId: person.id, + email: person.email, + name: null, + rateLimitKey: `user:${person.id}`, + }; +} + +async function roleOf(id: string) { + const db = await getDb(); + const [row] = await db.select().from(user).where(eq(user.id, id)); + return row?.role; +} + +describe("reconcileSuperAdminFloor", () => { + it("writes the floor onto a row that says something lesser", async () => { + const person = await seedUser({ email: "founder@cornell.edu", role: "user" }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(true); + expect(await roleOf(person.id)).toBe("super_admin"); + }); + + it("writes nothing when the row already agrees", async () => { + const person = await seedUser({ email: "founder@cornell.edu", role: "super_admin" }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(false); + expect(await listAuditEvents()).toEqual([]); + }); + + it("leaves an address the floor does not name alone", async () => { + const person = await seedUser({ email: "someone@cornell.edu", role: "user" }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(false); + expect(await roleOf(person.id)).toBe("user"); + }); + + it("does nothing at all when no floor is configured", async () => { + const person = await seedUser({ email: "founder@cornell.edu", role: "user" }); + + expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(false); + expect(await roleOf(person.id)).toBe("user"); + }); + + it("refuses a floor entry outside the allowed domain", async () => { + // `isSuperAdminFloor` applies the domain rule, and so must anything that + // writes a role from it: a typo in the env list has to fail closed. + const person = await seedUser({ email: "outsider@example.com", role: "user" }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "outsider@example.com"); + + expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(false); + expect(await roleOf(person.id)).toBe("user"); + }); + + it("does nothing for an anonymous identity", async () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + const anonymous: Identity = { + role: "anonymous", + userId: null, + email: "founder@cornell.edu", + name: null, + rateLimitKey: "ip:abc", + }; + expect(await reconcileSuperAdminFloor(anonymous)).toBe(false); + }); + + it("does nothing when the id names no row", async () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + const ghost = identityFor({ id: "deleted-mid-request", email: "founder@cornell.edu" }); + expect(await reconcileSuperAdminFloor(ghost)).toBe(false); + expect(await listAuditEvents()).toEqual([]); + }); +}); diff --git a/v5/src/lib/auth/floor-role.ts b/v5/src/lib/auth/floor-role.ts new file mode 100644 index 0000000..9b9a859 --- /dev/null +++ b/v5/src/lib/auth/floor-role.ts @@ -0,0 +1,92 @@ +import "server-only"; + +import { eq } from "drizzle-orm"; + +import { recordAuditEvent } from "../data/audit"; +import { findUserById } from "../data/users"; +import { getDb } from "../db/client"; +import { user } from "../db/schema/index"; +import type { Identity } from "./identity"; +import { isSuperAdminFloor } from "./super-admins"; + +/** + * Writing the super-admin floor onto the row it protects. + * + * **The floor was only half a floor.** `identityFromSession` resolves a listed + * address as `super_admin` whatever its row says, which is what makes + * `canReachAdmin` and `can(identity, "users.manage")` pass and `/admin/users` + * render with live controls. But the writes that page performs go through the + * Better Auth admin plugin, and the plugin authorizes against + * `session.user.role` — the **stored** value, which it reads for itself and + * which no override reaches. A floor address whose row still says `user` + * therefore saw every control enabled and every save fail with an opaque + * `failed`: precisely the lock-out the floor exists to undo. + * + * That is not a hypothetical. It is the ordinary shape of both cases the floor + * was written for: + * + * - **A floor set after the fact.** Add (or correct) an address in + * `AUTH_SUPER_ADMIN_EMAILS` for somebody who has already signed in once, and + * `databaseHooks.user.create.before` — the only other place the floor is + * applied — never runs for them. Their row holds the `defaultRole` `user`. + * - **Recovery.** A `super_admin` demoted by a restored backup or a manual SQL + * edit is exactly who the floor is meant to let back in. + * + * **So the row is reconciled rather than the check relaxed.** The alternative — + * teaching the plugin about the floor — is not available: `hasPermission` takes + * the role off the session and there is no hook in front of it. Writing the + * stored role to match what the app already reports is also the more honest + * end state: after this runs, `/admin/users` shows the same role the person + * actually has, and a reader of the table is not left comparing it against an + * environment variable. + * + * It is **only ever a promotion to `super_admin`, and only for an address the + * environment already names.** The authority is `AUTH_SUPER_ADMIN_EMAILS`, + * which is deployment configuration and not user input, and the effect is one + * the app's own identity layer had already granted. Nothing here can lower a + * role or raise one the floor does not list. + */ + +/** + * Make `identity`'s stored role match the floor, if the floor covers them. + * + * Returns true when a row was changed. A no-op — and one cheap query — for + * everybody else, which is every caller in a deployment with no floor set. + * + * Throws on a database failure. The caller is about to perform a write that + * depends on this having happened, so a silent failure here would surface as + * the same unexplained `failed` this function exists to remove. + */ +export async function reconcileSuperAdminFloor(identity: Identity): Promise { + if (!identity.userId) return false; + if (!isSuperAdminFloor(identity.email)) return false; + + const stored = await findUserById(identity.userId); + // No row (deleted mid-request) or already correct: nothing to write. The + // common case by far, once the first reconciliation has happened. + if (!stored || stored.role === "super_admin") return false; + + const db = await getDb(); + await db + .update(user) + .set({ role: "super_admin", updatedAt: new Date() }) + .where(eq(user.id, identity.userId)); + + // Recorded like any other role change, because it is one — and because a + // role that appeared without anybody clicking anything is exactly the entry + // somebody reading the trail later will want an explanation for. The actor + // is null: the environment did this, not a person. + await recordAuditEvent({ + actorUserId: null, + action: "role.changed", + subjectType: "user", + subjectId: identity.userId, + detail: { + from: stored.role, + to: "super_admin", + reason: "super_admin_floor", + }, + }); + + return true; +} diff --git a/v5/src/lib/auth/identity.test.ts b/v5/src/lib/auth/identity.test.ts index ec3789f..f325e4a 100644 --- a/v5/src/lib/auth/identity.test.ts +++ b/v5/src/lib/auth/identity.test.ts @@ -1,39 +1,49 @@ +// @vitest-environment node import { anonymousIdentity, hashIp, resolveIdentity } from "@/lib/auth/identity"; +import { resetAuthForTests } from "@/lib/auth/config"; +import { resetDbForTests } from "@/lib/db/client"; import { - SESSION_COOKIE_NAME, - createSessionPayload, - signSession, - type SessionPayload, -} from "@/lib/auth/session-cookie"; + BETTER_AUTH_SESSION_COOKIE, + seedUser, + signInAs, + signInAsNew, + type SignedInSession, +} from "../../../test/utils/session"; + +/** + * Node, not jsdom: every case here goes through a real session row in PGlite. + * + * Roles are testable without Google because sessions are rows — `signInAs` + * seeds one and mints the cookie that addresses it (`test/utils/session.ts`, + * self-tested against a real `auth.api.getSession()`). + */ const SECRET = "identity-test-secret"; -function stubAuthSecret(secret = SECRET) { - vi.stubEnv("AUTH_SECRET", secret); -} +beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", SECRET); + resetAuthForTests(); +}); + +afterEach(() => { + resetAuthForTests(); + resetDbForTests(); +}); /** A request carrying a session cookie (and an IP, for the anonymous path). */ -function requestWith(token?: string, ip = "203.0.113.7"): Request { +function requestWith( + signedIn?: SignedInSession | string | null, + ip = "203.0.113.7" +): Request { const headers: Record = { "x-forwarded-for": ip }; - if (token) headers.cookie = `theme=dark; ${SESSION_COOKIE_NAME}=${token}`; + const cookie = typeof signedIn === "string" ? signedIn : signedIn?.cookie; + if (cookie) headers.cookie = `theme=dark; ${cookie}`; return new Request("http://localhost/api/chat", { headers }); } -async function tokenFor( - email: string, - overrides: Partial = {}, - secret = SECRET -): Promise { - const payload = { - ...createSessionPayload({ sub: "google-sub-1", email, name: "Ada L" }), - ...overrides, - }; - return signSession(payload, secret); -} - describe("resolveIdentity — anonymous", () => { it("resolves a request with no cookie to anonymous", async () => { - stubAuthSecret(); const identity = await resolveIdentity(requestWith()); expect(identity.role).toBe("anonymous"); expect(identity.userId).toBeNull(); @@ -43,90 +53,152 @@ describe("resolveIdentity — anonymous", () => { }); it("resolves to anonymous when AUTH_SECRET is not configured at all", async () => { - // A deployment with no sign-in configured still serves everyone. - const token = await tokenFor("student@cornell.edu"); - const identity = await resolveIdentity(requestWith(token)); - expect(identity.role).toBe("anonymous"); + // A deployment with no sign-in configured still serves everyone. The + // cookie is minted first, while the secret is still stubbed. + const signedIn = await signInAsNew(); + vi.stubEnv("AUTH_SECRET", ""); + resetAuthForTests(); + + expect((await resolveIdentity(requestWith(signedIn))).role).toBe("anonymous"); }); }); -describe("resolveIdentity — valid session", () => { - it("resolves a valid institutional cookie to a student", async () => { - stubAuthSecret(); - const identity = await resolveIdentity( - requestWith(await tokenFor("student@cornell.edu")) - ); - expect(identity).toEqual({ - role: "student", - userId: "google-sub-1", +describe("resolveIdentity — a session row", () => { + it("resolves a signed-in student to user", async () => { + const signedIn = await signInAsNew({ + email: "student@cornell.edu", + name: "Ada L", + }); + + expect(await resolveIdentity(requestWith(signedIn))).toEqual({ + role: "user", + userId: signedIn.user.id, email: "student@cornell.edu", name: "Ada L", - rateLimitKey: "user:google-sub-1", + rateLimitKey: `user:${signedIn.user.id}`, }); }); - it("resolves a staff address to staff", async () => { - stubAuthSecret(); - vi.stubEnv("AUTH_STAFF_EMAILS", "niti@cornell.edu"); - const identity = await resolveIdentity( - requestWith(await tokenFor("niti@cornell.edu")) - ); - expect(identity.role).toBe("staff"); - expect(identity.email).toBe("niti@cornell.edu"); + it("resolves each stored role from the row, not from the environment", async () => { + for (const role of ["user", "admin", "super_admin"] as const) { + const signedIn = await signInAsNew({ role }); + expect((await resolveIdentity(requestWith(signedIn))).role).toBe(role); + } }); - it("resolves an admin address to admin", async () => { - stubAuthSecret(); - vi.stubEnv("AUTH_ADMIN_EMAILS", "isaac@cornell.edu"); - const identity = await resolveIdentity( - requestWith(await tokenFor("isaac@cornell.edu")) - ); - expect(identity.role).toBe("admin"); + it("sees a role change on the very next request", async () => { + // Goal 3 of the phase, and the reason sessions moved into the database. + const person = await seedUser({ role: "user" }); + const signedIn = await signInAs(person); + expect((await resolveIdentity(requestWith(signedIn))).role).toBe("user"); + + const { getDb } = await import("@/lib/db/client"); + const { user } = await import("@/lib/db/schema/index"); + const { eq } = await import("drizzle-orm"); + const db = await getDb(); + await db.update(user).set({ role: "admin" }).where(eq(user.id, person.id)); + + // A new Request, because the old one's answer is memoized per request. + expect((await resolveIdentity(requestWith(signedIn))).role).toBe("admin"); + }); + + it("reads the session off a multipart upload, not just a JSON post", async () => { + // The regression: `toHeaders` used to hand Better Auth the Request's own + // guarded `Headers`, and the library copies what it is given. A copy of a + // guarded list whose `content-type` came from its body — which is every + // multipart form, so every upload — can arrive without `cookie`, and the + // signed-in person silently resolves anonymous. + const signedIn = await signInAsNew({ email: "uploader@cornell.edu" }); + const form = new FormData(); + form.append("file", new File([new Uint8Array(3)], "lamp.png", { type: "image/png" })); + const req = new Request("http://localhost/api/uploads", { + method: "POST", + headers: { "x-forwarded-for": "203.0.113.9", cookie: signedIn.cookie }, + body: form, + }); + + const identity = await resolveIdentity(req); + + expect(identity.userId).toBe(signedIn.user.id); + expect(identity.role).toBe("user"); + }); + + it("memoizes per request: two calls, one session lookup", async () => { + const signedIn = await signInAsNew(); + const req = requestWith(signedIn); + const spy = vi.spyOn(req.headers, "get"); + + const [a, b] = [await resolveIdentity(req), await resolveIdentity(req)]; + + expect(a).toBe(b); + // The second call never reached the cookie, let alone the database. + const cookieReads = spy.mock.calls.filter(([name]) => name === "cookie").length; + expect(cookieReads).toBeLessThanOrEqual(1); }); }); describe("resolveIdentity — degrades to anonymous, never throws", () => { - it("degrades on an expired cookie", async () => { - stubAuthSecret(); - const past = Math.floor(Date.now() / 1000) - 60; - const token = await tokenFor("student@cornell.edu", { - iat: past - 3600, - exp: past, - }); - const identity = await resolveIdentity(requestWith(token)); + it("degrades on an expired session row", async () => { + const signedIn = await signInAsNew({}, { expiresInSeconds: -60 }); + const identity = await resolveIdentity(requestWith(signedIn)); expect(identity.role).toBe("anonymous"); expect(identity.email).toBeNull(); }); it("degrades on a tampered signature", async () => { - stubAuthSecret(); - const token = await tokenFor("student@cornell.edu"); - const [body, sig] = token.split("."); - const tampered = `${body}.${sig.startsWith("A") ? "B" : "A"}${sig.slice(1)}`; - const identity = await resolveIdentity(requestWith(tampered)); - expect(identity.role).toBe("anonymous"); - expect(identity.userId).toBeNull(); + const signedIn = await signInAsNew(); + const tampered = signedIn.cookie.replace(/.$/, (c) => (c === "A" ? "B" : "A")); + expect((await resolveIdentity(requestWith(tampered))).role).toBe("anonymous"); }); it("degrades on a cookie signed with a rotated-away secret", async () => { - stubAuthSecret("the-new-secret"); - const token = await tokenFor("student@cornell.edu", {}, "the-old-secret"); - expect((await resolveIdentity(requestWith(token))).role).toBe("anonymous"); + const person = await seedUser(); + const stale = await signInAs(person, { secret: "the-old-secret" }); + expect((await resolveIdentity(requestWith(stale))).role).toBe("anonymous"); + }); + + it("degrades on a token naming no session row", async () => { + const { signCookieValue } = await import("../../../test/utils/session"); + const value = await signCookieValue("no-such-session", SECRET); + const cookie = `${BETTER_AUTH_SESSION_COOKIE}=${value}`; + expect((await resolveIdentity(requestWith(cookie))).role).toBe("anonymous"); }); it("degrades on garbage in the cookie rather than 500-ing", async () => { - stubAuthSecret(); for (const junk of ["", "....", "%%%", "a.b.c.d"]) { - const identity = await resolveIdentity(requestWith(junk)); - expect(identity.role).toBe("anonymous"); + const cookie = `${BETTER_AUTH_SESSION_COOKIE}=${junk}`; + expect((await resolveIdentity(requestWith(cookie))).role).toBe("anonymous"); } }); + it("refuses a banned user, immediately", async () => { + // The ban bites on the next request precisely because the row is read on + // every request. Their cookie is still perfectly valid. + const signedIn = await signInAsNew({ banned: true, banReason: "spam" }); + const identity = await resolveIdentity(requestWith(signedIn)); + expect(identity.role).toBe("anonymous"); + expect(identity.userId).toBeNull(); + }); + + it("refuses a session for a non-institutional address", async () => { + // The create hook refuses such a row, so one existing means a restored + // backup or a reconfigured domain — the rule is re-checked every request. + const signedIn = await signInAsNew({ email: "attacker@gmail.com" }); + const identity = await resolveIdentity(requestWith(signedIn)); + expect(identity.role).toBe("anonymous"); + expect(identity.email).toBeNull(); + }); + + it("refuses a session for a domain that has since changed", async () => { + const signedIn = await signInAsNew({ email: "student@cornell.edu" }); + vi.stubEnv("AUTH_ALLOWED_EMAIL_DOMAIN", "example.edu"); + expect((await resolveIdentity(requestWith(signedIn))).role).toBe("anonymous"); + }); + it("re-throws Next's prerender signal instead of swallowing it", async () => { // Reading headers during a prerender throws a `digest`-carrying error that // marks the route dynamic. Catching it would silently prerender a route // that must run per request — the build proves this, but only if we keep it. - stubAuthSecret(); const signal = Object.assign(new Error("bail out"), { digest: "NEXT_PRERENDER_INTERRUPTED", }); @@ -140,63 +212,64 @@ describe("resolveIdentity — degrades to anonymous, never throws", () => { await expect(resolveIdentity(req)).rejects.toBe(signal); }); +}); - it("refuses a validly-signed cookie carrying a non-institutional address", async () => { - // Only reachable if AUTH_SECRET leaked or the callback regressed — either - // way the domain rule is re-checked on every request, not just at sign-in. - stubAuthSecret(); - const token = await tokenFor("attacker@gmail.com"); - const identity = await resolveIdentity(requestWith(token)); - expect(identity.role).toBe("anonymous"); - expect(identity.email).toBeNull(); +describe("resolveIdentity — the super-admin floor", () => { + it("resolves the floor address as super_admin whatever its row says", async () => { + // Spec §10: the last super admin demotes themselves. The floor is what + // makes that recoverable without a database console. + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "ies22@cornell.edu"); + const signedIn = await signInAsNew({ email: "ies22@cornell.edu", role: "user" }); + + expect((await resolveIdentity(requestWith(signedIn))).role).toBe("super_admin"); }); - it("refuses a validly-signed cookie for a domain that has since changed", async () => { - stubAuthSecret(); - const token = await tokenFor("student@cornell.edu"); - vi.stubEnv("AUTH_ALLOWED_EMAIL_DOMAIN", "example.edu"); - expect((await resolveIdentity(requestWith(token))).role).toBe("anonymous"); + it("does not raise anyone else", async () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "ies22@cornell.edu"); + const signedIn = await signInAsNew({ email: "someone@cornell.edu", role: "user" }); + + expect((await resolveIdentity(requestWith(signedIn))).role).toBe("user"); + }); + + it("does not rescue a floor address that is out of domain", async () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "attacker@gmail.com"); + const signedIn = await signInAsNew({ email: "attacker@gmail.com" }); + + expect((await resolveIdentity(requestWith(signedIn))).role).toBe("anonymous"); }); }); describe("rateLimitKey", () => { it("is stable across requests for the same signed-in user", async () => { - stubAuthSecret(); - const token = await tokenFor("student@cornell.edu"); - const a = await resolveIdentity(requestWith(token, "1.1.1.1")); - const b = await resolveIdentity(requestWith(token, "2.2.2.2")); + const signedIn = await signInAsNew(); + const a = await resolveIdentity(requestWith(signedIn, "1.1.1.1")); + const b = await resolveIdentity(requestWith(signedIn, "2.2.2.2")); // Same user from two networks is one bucket — the ceiling must not be // escapable by changing IP, nor unreachable by roaming. expect(a.rateLimitKey).toBe(b.rateLimitKey); - expect(a.rateLimitKey).toBe("user:google-sub-1"); + expect(a.rateLimitKey).toBe(`user:${signedIn.user.id}`); }); it("is stable across requests for the same anonymous IP", async () => { - stubAuthSecret(); - const a = await resolveIdentity(requestWith(undefined, "198.51.100.4")); - const b = await resolveIdentity(requestWith(undefined, "198.51.100.4")); + const a = await resolveIdentity(requestWith(null, "198.51.100.4")); + const b = await resolveIdentity(requestWith(null, "198.51.100.4")); expect(a.rateLimitKey).toBe(b.rateLimitKey); }); it("differs between two anonymous IPs", async () => { - stubAuthSecret(); - const a = await resolveIdentity(requestWith(undefined, "198.51.100.4")); - const b = await resolveIdentity(requestWith(undefined, "198.51.100.5")); + const a = await resolveIdentity(requestWith(null, "198.51.100.4")); + const b = await resolveIdentity(requestWith(null, "198.51.100.5")); expect(a.rateLimitKey).not.toBe(b.rateLimitKey); }); it("never contains the raw IP — the limiter store holds no personal data", async () => { - stubAuthSecret(); - const identity = await resolveIdentity(requestWith(undefined, "198.51.100.4")); + const identity = await resolveIdentity(requestWith(null, "198.51.100.4")); expect(identity.rateLimitKey).not.toContain("198.51.100.4"); expect(identity.rateLimitKey).toMatch(/^ip:[0-9a-f]{64}$/); }); it("separates the signed-in and anonymous key spaces", async () => { - stubAuthSecret(); - const signedIn = await resolveIdentity( - requestWith(await tokenFor("student@cornell.edu")) - ); + const signedIn = await resolveIdentity(requestWith(await signInAsNew())); const anon = await resolveIdentity(requestWith()); expect(signedIn.rateLimitKey.startsWith("user:")).toBe(true); expect(anon.rateLimitKey.startsWith("ip:")).toBe(true); @@ -225,7 +298,6 @@ describe("hashIp", () => { describe("anonymousIdentity", () => { it("buckets a request with no IP headers under a single 'unknown' key", async () => { - stubAuthSecret(); const identity = await anonymousIdentity(new Request("http://localhost/")); expect(identity.role).toBe("anonymous"); expect(identity.rateLimitKey).toBe(`ip:${await hashIp("unknown", SECRET)}`); diff --git a/v5/src/lib/auth/identity.ts b/v5/src/lib/auth/identity.ts index c813e0f..4f927aa 100644 --- a/v5/src/lib/auth/identity.ts +++ b/v5/src/lib/auth/identity.ts @@ -1,33 +1,36 @@ import "server-only"; +import { DbUnavailableError } from "../db/client"; import { getClientIp } from "../rate-limit"; -import { isAllowedEmail, roleForEmail, type Role } from "./roles"; -import { - SESSION_COOKIE_NAME, - readCookie, - verifySessionToken, - type SessionPayload, -} from "./session-cookie"; +import { getAuth } from "./config"; +import { isAllowedEmail, storedRoleOr, type Role } from "./roles"; +import { isSuperAdminFloor } from "./super-admins"; /** * `resolveIdentity(req)` — the one module everything else uses to learn who is - * making a request (auth design spec 2026-07-29 §3.2). + * making a request (data platform design spec §3.4). + * + * Since Phase 4 this is a thin wrapper over `auth.api.getSession()`, which + * reads the session **row** the cookie's token addresses. That is the whole + * point: the role is looked up per request, so a change made on `/admin/users` + * lands on the person's next page load and a ban takes effect immediately. + * Before Phase 4 the identity travelled inside a self-describing signed cookie + * and a role change waited 30 days for it to expire. * * Identity is **context, not a capability**: it is resolved once per request and - * handed to whatever needs it. Nothing here is an authorization decision — the - * one role gate, on adding equipment, is declared by the intake capability and - * enforced in `capabilities/access.ts`, and writes are still drafts by default - * (Article 5). + * handed to whatever needs it. Nothing here is an authorization decision — + * those are `can()` calls against the declaration in `permissions.ts`. * - * **It never throws.** An absent, expired, tampered, or nonsense cookie yields - * the anonymous identity, because anonymous is a first-class state and not a - * failure. A 500 from a stale cookie is on the spec's list of things that would - * embarrass us in production (§10). + * **It never throws.** No cookie, an expired or revoked session, a tampered + * signature, a banned user, an address outside the domain, or a database that + * cannot be reached — all of them yield the anonymous identity, because + * anonymous is a first-class state and not a failure. A public page must not + * 500 because a session lookup did (Article 4). */ export interface Identity { role: Role; - /** Google `sub`, or null when anonymous. */ + /** `user.id`, or null when anonymous. */ userId: string | null; email: string | null; name: string | null; @@ -37,35 +40,117 @@ export interface Identity { export type { Role }; +/** + * One session lookup per request, not one per caller. + * + * The spec suggested React's `cache()`. It only memoizes inside a React render + * scope: in a Route Handler and under Vitest it silently does nothing, which + * would turn a route that asks three times into three database round trips. A + * `WeakMap` keyed by the `Request` is deterministic everywhere and is collected + * with the request itself. + */ +const perRequest = new WeakMap>(); + /** Resolve a request to an {@link Identity}. Never rejects. */ -export async function resolveIdentity(req: Request): Promise { - let payload: SessionPayload | null = null; +export function resolveIdentity(req: Request): Promise { + const memoized = perRequest.get(req); + if (memoized) return memoized; + const promise = resolveUncached(req); + perRequest.set(req, promise); + return promise; +} + +async function resolveUncached(req: Request): Promise { try { - const token = readCookie(req.headers.get("cookie"), SESSION_COOKIE_NAME); - payload = await verifySessionToken(token, authSecret()); + const auth = await getAuth(); + // No `AUTH_SECRET`: sign-in is not set up, so nobody is signed in. That is + // the correct degraded behaviour, not an error (Article 4). + if (!auth) return anonymousIdentity(req); + + const result = await auth.api.getSession({ headers: toHeaders(req.headers) }); + const identity = identityFromSession(result); + return identity ?? anonymousIdentity(req); } catch (err) { - // Defensive: verifySessionToken already swallows its own failures, so this - // only fires if reading headers itself blows up. Log it — a silently - // anonymous population is a symptom worth seeing — and carry on. if (isFrameworkSignal(err)) throw err; - console.warn("[auth] identity resolution failed, treating as anonymous", err); - payload = null; + if (err instanceof DbUnavailableError) { + // Neon is configured but unreachable. Serving the catalogue anonymously + // is right; 500ing a public page because the session store blinked is not. + console.warn("[auth] session store unavailable, treating as anonymous", err); + } else { + // A silently anonymous population is a symptom worth seeing. + console.warn("[auth] identity resolution failed, treating as anonymous", err); + } + return anonymousIdentity(req); } +} - if (payload && isAllowedEmail(payload.email)) { - const role = roleForEmail(payload.email); - if (role !== "anonymous") { - return { - role, - userId: payload.sub, - email: payload.email, - name: payload.name, - rateLimitKey: `user:${payload.sub}`, - }; - } +/** + * The same, for server components, which have no `Request` to hand. + * + * Not memoized: `next/headers` returns a fresh object per call, so there is + * nothing stable to key on. Server components should resolve once in the page + * or layout and pass the result down, which is what `/admin/*` does. + */ +export async function resolveIdentityFromHeaders(): Promise { + try { + const auth = await getAuth(); + const { headers } = await import("next/headers"); + const requestHeaders = await headers(); + if (!auth) return anonymousIdentityFromHeaders(requestHeaders); + + const result = await auth.api.getSession({ headers: toHeaders(requestHeaders) }); + const identity = identityFromSession(result); + return identity ?? anonymousIdentityFromHeaders(requestHeaders); + } catch (err) { + if (isFrameworkSignal(err)) throw err; + console.warn("[auth] identity resolution failed, treating as anonymous", err); + return systemAnonymousIdentity(); } +} - return anonymousIdentity(req); +/** What Better Auth hands back. Typed structurally so no library type leaks out. */ +interface SessionResult { + user: { + id: string; + email: string; + name?: string | null; + role?: string | null; + banned?: boolean | null; + }; +} + +/** + * Turn a resolved session into an {@link Identity}, or null when it must not + * count as one. Four ways it must not: + * + * - **No session.** Nobody is signed in. + * - **Banned.** The person still holds a valid cookie; a ban has to bite on the + * next request, which is only true if it is checked on every one. + * - **Out of domain.** The create hook refuses such a row, so one existing is a + * bug, a restored backup, or a reconfigured domain — never a reason to trust it. + * - **A role outside the vocabulary**, which `storedRoleOr` maps to anonymous. + */ +function identityFromSession(result: SessionResult | null | undefined): Identity | null { + const user = result?.user; + if (!user) return null; + if (user.banned) return null; + if (!isAllowedEmail(user.email)) return null; + + // The floor (§3.4): a listed address resolves `super_admin` whatever its row + // says, so a mistaken demotion or ban cannot lock the lab out of its own + // admin surface. It is the only place the environment still names a role. + const role = isSuperAdminFloor(user.email) + ? "super_admin" + : storedRoleOr(user.role); + if (role === "anonymous") return null; + + return { + role, + userId: user.id, + email: user.email, + name: user.name ?? null, + rateLimitKey: `user:${user.id}`, + }; } /** The anonymous identity for a request, keyed by a hash of its client IP. */ @@ -77,6 +162,21 @@ export async function anonymousIdentity(req: Request): Promise { // A request without usable headers still gets a (shared) bucket. if (isFrameworkSignal(err)) throw err; } + return anonymousFor(ip); +} + +/** The anonymous identity for a server component's headers. */ +async function anonymousIdentityFromHeaders(headers: { + get(name: string): string | null; +}): Promise { + const forwarded = headers.get("x-forwarded-for"); + const ip = forwarded + ? forwarded.split(",")[0].trim() + : headers.get("x-real-ip") || "unknown"; + return anonymousFor(ip); +} + +async function anonymousFor(ip: string): Promise { return { role: "anonymous", userId: null, @@ -97,6 +197,32 @@ export function systemAnonymousIdentity(): Identity { }; } +/** + * Better Auth wants a `Headers` carrying the session cookie. This builds a + * plain one holding exactly that, whatever it is handed. + * + * **It used to pass a Route Handler's own `Headers` straight through, and that + * is not safe.** `auth.api.getSession` copies what it is given + * (`better-auth/dist/api/dispatch.mjs`: `new Headers(input.headers)`), and a + * `Request`'s headers are a *guarded* list. Copying one whose `content-type` + * was set implicitly by its body — which is every `multipart/form-data` + * request, so every upload — can drop `cookie` from the copy. The session then + * resolves to anonymous for somebody who is plainly signed in, silently, + * because `resolveIdentity` treats "no session" as a first-class state rather + * than an error. It is observable under the test harness and depends on the + * runtime's `fetch` implementation, which is not a thing to be at the mercy of. + * + * The cookie is all `getSession` reads — `requestHeaders()` in + * `app/admin/users/actions.ts` says the same and has always done this — so + * building the object here costs nothing and removes the question. + */ +function toHeaders(source: Headers | { get(name: string): string | null }): Headers { + const copy = new Headers(); + const cookie = source.get("cookie"); + if (cookie) copy.set("cookie", cookie); + return copy; +} + /** * `sha256(ip + AUTH_SECRET)`, hex. The rate-limit store therefore holds no * personal data (spec §8) while still bucketing one visitor to one key. @@ -144,7 +270,7 @@ function fallbackHash(input: string): string { /** * `AUTH_SECRET`, read at call time. Absent in local/mock deployments, where no - * cookie can verify and everyone is anonymous — which is the correct degraded + * session can exist and everyone is anonymous — which is the correct degraded * behaviour, not an error. */ export function authSecret(): string { diff --git a/v5/src/lib/auth/permissions.test.ts b/v5/src/lib/auth/permissions.test.ts new file mode 100644 index 0000000..95faae3 --- /dev/null +++ b/v5/src/lib/auth/permissions.test.ts @@ -0,0 +1,233 @@ +import { + ADMIN_SURFACE_PERMISSIONS, + PERMISSIONS, + ac, + can, + canReachAdmin, + isGrantedRole, + roles, + statement, + type Permission, +} from "@/lib/auth/permissions"; +import { IDENTITY_ROLES, type Role } from "@/lib/auth/roles"; + +/** + * Spec §10's permissions unit: every role against every permission, as a table. + * No environment, no database — the declaration is the whole subject, and a + * change to it should either be intended and visible here, or a failure. + */ + +/** The app-level permissions, in the order the spec's §3.5 sketch lists them. */ +const APP_PERMISSIONS = [ + "projects.submit", + "projects.moderate", + "catalog.view_drafts", + "tools.add", + "tools.approve", + "tools.edit", + "tools.publish", + "maintenance.manage", + "feedback.manage", + "mirror.manage", + "users.manage", +] as const satisfies readonly Permission[]; + +/** + * What each role holds, spelled out rather than derived from the declaration — + * a test that recomputed the answer from the thing under test would pass no + * matter what the declaration said. + */ +const EXPECTED: Record = { + anonymous: [], + user: ["projects.submit"], + admin: [ + "projects.submit", + "projects.moderate", + "catalog.view_drafts", + "tools.add", + "tools.approve", + "tools.edit", + "tools.publish", + "maintenance.manage", + "feedback.manage", + "mirror.manage", + ], + super_admin: APP_PERMISSIONS, +}; + +describe("the declaration", () => { + it("covers every app resource the spec names", () => { + expect(Object.keys(statement)).toEqual( + expect.arrayContaining([ + "projects", + "catalog", + "tools", + "maintenance", + "feedback", + "mirror", + "users", + ]) + ); + }); + + it("includes the admin plugin's own resources", () => { + // Not decoration: the plugin authorizes `set-role` against + // `{ user: ["set-role"] }`. Without these, every admin endpoint would + // refuse everybody, super admins included. + expect(statement).toHaveProperty("user"); + expect(statement).toHaveProperty("session"); + expect(ac.statements).toBe(statement); + }); + + it("derives PERMISSIONS from the statement", () => { + for (const permission of APP_PERMISSIONS) { + expect(PERMISSIONS).toContain(permission); + } + expect(PERMISSIONS).toContain("user.set-role"); + expect(new Set(PERMISSIONS).size).toBe(PERMISSIONS.length); + }); + + it("declares exactly the three stored roles", () => { + expect(Object.keys(roles)).toEqual(["user", "admin", "super_admin"]); + expect(isGrantedRole("anonymous")).toBe(false); + expect(isGrantedRole(null)).toBe(false); + }); +}); + +describe("can(role, permission)", () => { + for (const role of IDENTITY_ROLES) { + for (const permission of APP_PERMISSIONS) { + const expected = EXPECTED[role].includes(permission); + it(`${role} ${expected ? "holds" : "does not hold"} ${permission}`, () => { + expect(can({ role }, permission)).toBe(expected); + }); + } + } + + it("gives super_admin every app permission", () => { + for (const permission of APP_PERMISSIONS) { + expect([permission, can({ role: "super_admin" }, permission)]).toEqual([ + permission, + true, + ]); + } + }); + + it("gives super_admin the account-management actions /admin/users performs", () => { + for (const permission of [ + "user.list", + "user.get", + "user.set-role", + "user.ban", + "user.update", + "session.list", + "session.revoke", + ] as const) { + expect([permission, can({ role: "super_admin" }, permission)]).toEqual([ + permission, + true, + ]); + } + }); + + it("withholds the plugin actions v5 deliberately never performs", () => { + // Impersonation: v5 never signs in as somebody else, and a capability + // nobody holds is one nobody can be tricked into using. Create / delete / + // set-password / set-email: accounts come from Google sign-in and nowhere + // else, and there is no password to set. + for (const permission of [ + "user.impersonate", + "user.impersonate-admins", + "user.create", + "user.delete", + "user.set-password", + "user.set-email", + "session.delete", + ] as const) { + expect([permission, can({ role: "super_admin" }, permission)]).toEqual([ + permission, + false, + ]); + } + }); + + it("gives an admin no account-management power", () => { + // The difference between `admin` and `super_admin` is exactly this: a + // SuperMaker runs the catalogue, a director decides who is who. + expect(can({ role: "admin" }, "users.manage")).toBe(false); + expect(can({ role: "admin" }, "user.set-role")).toBe(false); + expect(can({ role: "admin" }, "user.ban")).toBe(false); + }); + + it("gives anonymous nothing at all", () => { + for (const permission of PERMISSIONS) { + expect(can({ role: "anonymous" }, permission)).toBe(false); + } + }); + + it("treats an absent subject as holding nothing", () => { + expect(can(null, "projects.submit")).toBe(false); + expect(can(undefined, "projects.submit")).toBe(false); + expect(can({ role: null }, "projects.submit")).toBe(false); + expect(can({ role: undefined }, "projects.submit")).toBe(false); + }); + + it("treats a role outside the vocabulary as holding nothing", () => { + // A restored backup from the env-list era, or a hand-written UPDATE. + expect(can({ role: "staff" as Role }, "tools.add")).toBe(false); + expect(can({ role: "root" as Role }, "tools.add")).toBe(false); + }); + + it("returns false for a malformed permission rather than throwing", () => { + // A typo in a gate must fail closed, and must not 500 the page it guards. + const malformed = ["", ".", "tools", "tools.", ".add", "tools.nope", "nope.manage"]; + for (const value of malformed) { + expect(() => can({ role: "super_admin" }, value as Permission)).not.toThrow(); + expect(can({ role: "super_admin" }, value as Permission)).toBe(false); + } + }); + + it("does not read the environment", () => { + // The floor lives in `super-admins.ts`; the declaration is pure. A role + // env var must not be able to grant anything here. + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "ies22@cornell.edu"); + expect(can({ role: "user" }, "users.manage")).toBe(false); + }); +}); + +// ── The admin surface (spec §6) ───────────────────────────────────── + +describe("canReachAdmin", () => { + it("lets a SuperMaker and a director in", () => { + // Both see the header link and get past `/admin`'s layout; what each can + // actually open is the individual page's own check. + expect(canReachAdmin({ role: "admin" })).toBe(true); + expect(canReachAdmin({ role: "super_admin" })).toBe(true); + }); + + it("keeps everyone else out", () => { + // Signing in unlocks submitting a project; it opens no admin surface. + expect(canReachAdmin({ role: "user" })).toBe(false); + expect(canReachAdmin({ role: "anonymous" })).toBe(false); + expect(canReachAdmin(null)).toBe(false); + expect(canReachAdmin(undefined)).toBe(false); + expect(canReachAdmin({ role: undefined })).toBe(false); + }); + + it("is exactly 'holds one of the listed permissions', with no list of its own", () => { + // The helper exists so the header and the layout cannot disagree about + // what an admin surface is. This is the assertion that keeps it honest. + for (const role of ["anonymous", "user", "admin", "super_admin"] as const) { + const expected = ADMIN_SURFACE_PERMISSIONS.some((permission) => + can({ role }, permission) + ); + expect(canReachAdmin({ role })).toBe(expected); + } + }); + + it("lists only permissions that exist in the statement", () => { + for (const permission of ADMIN_SURFACE_PERMISSIONS) { + expect(PERMISSIONS).toContain(permission); + } + }); +}); diff --git a/v5/src/lib/auth/permissions.ts b/v5/src/lib/auth/permissions.ts new file mode 100644 index 0000000..db17ed2 --- /dev/null +++ b/v5/src/lib/auth/permissions.ts @@ -0,0 +1,184 @@ +import { createAccessControl } from "better-auth/plugins/access"; +import { defaultStatements } from "better-auth/plugins/admin/access"; +import type { Role } from "./roles"; + +/** + * What each role may do (data platform design spec §3.5). + * + * **The role is a column; the grants are code.** A permissions table is what + * you build when admins edit permissions at runtime, and the lab decided on + * 2026-09-14 that they do not. Three roles make most rows below identical, and + * that is the point: the declaration exists so the *next* change — "SuperMakers + * may add tools but not publish them" — is one line reviewed in a PR rather + * than a search through route handlers. + * + * The shape is Better Auth's access-control module because the admin plugin + * expects roles described that way; passing the same `ac` / `roles` to the + * plugin is what makes `set-role` and `ban-user` answer to this declaration + * instead of to the library's defaults. + * + * **One check, everywhere.** Server actions, route handlers and capability + * composition call {@link can}. Client components call it too, with the role + * `/api/identity` reported, to decide whether to render a control — but hiding + * a control is presentation. The server check is the control. + * + * Client-safe on purpose: `better-auth/plugins/access` is pure data, there is + * no `server-only` import and nothing here touches the database. + */ + +/** + * Every resource and the actions defined on it. + * + * `...defaultStatements` brings in the admin plugin's own `user` and `session` + * resources. They are not decoration: the plugin authorizes `set-role` against + * `{ user: ["set-role"] }`, so a declaration that omitted them would make every + * admin endpoint refuse everybody, including a super admin. + * + * `users.manage` (plural) is ours — the right to open `/admin/users` at all — + * and is deliberately distinct from the plugin's singular `user` actions, which + * are the individual operations that page performs. + */ +export const statement = { + ...defaultStatements, + projects: ["submit", "moderate"], + catalog: ["view_drafts"], + tools: ["add", "approve", "edit", "publish"], + maintenance: ["manage"], + feedback: ["manage"], + mirror: ["manage"], + users: ["manage"], +} as const; + +export const ac = createAccessControl(statement); + +/** + * The account-management actions an admin surface performs. Impersonation is + * excluded from every role: v5 never signs in as somebody else, and a + * capability nobody holds is one nobody can be tricked into using. + */ +const ACCOUNT_MANAGEMENT = { + user: ["list", "get", "set-role", "ban", "update"], + session: ["list", "revoke"], +} as const; + +/** + * The grants, least- to most-privileged. + * + * - `user` — a student, or anyone signed in with an allowed address. Submitting + * a project is the whole of it; browsing and chatting never needed an account. + * - `admin` — a SuperMaker. Runs the catalogue and the lab's day-to-day + * records, but cannot change who is who. + * - `super_admin` — a director. Everything, including roles and bans. + */ +export const roles = { + user: ac.newRole({ + projects: ["submit"], + }), + admin: ac.newRole({ + projects: ["submit", "moderate"], + catalog: ["view_drafts"], + tools: ["add", "approve", "edit", "publish"], + maintenance: ["manage"], + feedback: ["manage"], + mirror: ["manage"], + }), + super_admin: ac.newRole({ + projects: ["submit", "moderate"], + catalog: ["view_drafts"], + tools: ["add", "approve", "edit", "publish"], + maintenance: ["manage"], + feedback: ["manage"], + mirror: ["manage"], + users: ["manage"], + user: [...ACCOUNT_MANAGEMENT.user], + session: [...ACCOUNT_MANAGEMENT.session], + }), +} as const; + +type Statement = typeof statement; + +/** + * `"tools.approve"`, `"users.manage"` — every resource/action pair in + * {@link statement}, derived rather than hand-listed so a new action is a + * compile error at every call site that has to consider it. + */ +export type Permission = { + [R in keyof Statement & string]: `${R}.${Statement[R][number]}`; +}[keyof Statement & string]; + +/** Every {@link Permission} as a runtime array — the table tests enumerate. */ +export const PERMISSIONS: Permission[] = Object.entries(statement).flatMap( + ([resource, actions]) => + (actions as readonly string[]).map((action) => `${resource}.${action}` as Permission) +); + +/** The roles that can actually hold something. `anonymous` is never one. */ +export type GrantedRole = keyof typeof roles; + +/** True when `role` names a role in the declaration. */ +export function isGrantedRole(role: Role | null | undefined): role is GrantedRole { + return typeof role === "string" && role in roles; +} + +/** + * Does `subject` hold `permission`? + * + * Takes the whole subject rather than a bare role so call sites read as + * `can(identity, "tools.add")` and cannot accidentally pass the wrong string. + * Null, undefined and `anonymous` all hold nothing — an absent identity is + * never a pass — and a permission that is not in {@link statement} is false + * rather than an exception, because a typo in a gate must fail closed. + */ +export function can( + subject: { role: Role | null | undefined } | null | undefined, + permission: Permission +): boolean { + const role = subject?.role; + if (!isGrantedRole(role)) return false; + + const request = parsePermission(permission); + if (!request) return false; + + return roles[role].authorize(request as never).success; +} + +/** + * The permissions that make `/admin/*` worth opening at all. + * + * One list rather than a check per page, because two things have to agree about + * it and they live far apart: the `AdminLink` in the header (presentation) and + * the `/admin` layout (the refusal). A director holds `users.manage`, a + * SuperMaker holds the catalogue ones — both see the link, and each individual + * page still gates on the permission it actually needs. + */ +export const ADMIN_SURFACE_PERMISSIONS: Permission[] = [ + "tools.edit", + "tools.approve", + "projects.moderate", + "maintenance.manage", + "feedback.manage", + "mirror.manage", + "users.manage", +]; + +/** True when `subject` holds any {@link ADMIN_SURFACE_PERMISSIONS}. */ +export function canReachAdmin(subject: { role: Role | null | undefined } | null | undefined): boolean { + return ADMIN_SURFACE_PERMISSIONS.some((permission) => can(subject, permission)); +} + +/** `"tools.approve"` → `{ tools: ["approve"] }`, or null if it names nothing real. */ +function parsePermission( + permission: string +): Record | null { + if (typeof permission !== "string") return null; + const dot = permission.indexOf("."); + if (dot <= 0 || dot === permission.length - 1) return null; + + const resource = permission.slice(0, dot); + const action = permission.slice(dot + 1); + + const actions = (statement as Record)[resource]; + if (!actions || !actions.includes(action)) return null; + + return { [resource]: [action] }; +} diff --git a/v5/src/lib/auth/roles.test.ts b/v5/src/lib/auth/roles.test.ts index 2e7f987..8d90c1a 100644 --- a/v5/src/lib/auth/roles.test.ts +++ b/v5/src/lib/auth/roles.test.ts @@ -1,31 +1,38 @@ import { - ROLES, - adminEmails, + IDENTITY_ROLES, allowedEmailDomain, isAllowedEmail, - isAtLeast, + isRole, parseEmailList, - roleForEmail, - roleRank, - staffEmails, + storedRoleOr, } from "@/lib/auth/roles"; +import { ROLES } from "@/lib/db/schema/vocabulary"; // Every helper reads process.env at call time, so `vi.stubEnv` alone is enough // — no resetModules()/dynamic import dance (the setup file unstubs after each). -describe("role ordering", () => { - it("orders roles least- to most-privileged", () => { - expect([...ROLES]).toEqual(["anonymous", "student", "staff", "admin"]); - expect(roleRank("anonymous")).toBeLessThan(roleRank("student")); - expect(roleRank("student")).toBeLessThan(roleRank("staff")); - expect(roleRank("staff")).toBeLessThan(roleRank("admin")); +describe("the role vocabulary", () => { + it("is the stored roles plus anonymous, in that order", () => { + // The stored list is also what the `user_role_check` constraint is built + // from, so this is the assertion that keeps the type and the database in + // step. `anonymous` is never a row — it is the absence of a session. + expect([...IDENTITY_ROLES]).toEqual(["anonymous", ...ROLES]); + expect([...ROLES]).toEqual(["user", "admin", "super_admin"]); }); - it("isAtLeast compares by rank, inclusive of equality", () => { - expect(isAtLeast("admin", "staff")).toBe(true); - expect(isAtLeast("staff", "staff")).toBe(true); - expect(isAtLeast("student", "staff")).toBe(false); - expect(isAtLeast("anonymous", "student")).toBe(false); + it("no longer carries the env-list role names", () => { + // `student` and `staff` are gone: a fixture or a script still using them + // must fail loudly rather than silently resolve to nothing. + expect(isRole("student")).toBe(false); + expect(isRole("staff")).toBe(false); + }); + + it("resolves a stored value, and anything else, through storedRoleOr", () => { + expect(storedRoleOr("admin")).toBe("admin"); + expect(storedRoleOr("super_admin")).toBe("super_admin"); + expect(storedRoleOr("staff")).toBe("anonymous"); + expect(storedRoleOr(null)).toBe("anonymous"); + expect(storedRoleOr(undefined)).toBe("anonymous"); }); }); @@ -85,49 +92,4 @@ describe("parseEmailList", () => { expect(parseEmailList(undefined)).toEqual([]); expect(parseEmailList(null)).toEqual([]); }); - - it("reads the staff and admin rosters from env", () => { - vi.stubEnv("AUTH_STAFF_EMAILS", "niti@cornell.edu"); - vi.stubEnv("AUTH_ADMIN_EMAILS", "isaac@cornell.edu"); - expect(staffEmails()).toEqual(["niti@cornell.edu"]); - expect(adminEmails()).toEqual(["isaac@cornell.edu"]); - }); -}); - -describe("roleForEmail", () => { - it("defaults a valid institutional address to student", () => { - expect(roleForEmail("student@cornell.edu")).toBe("student"); - }); - - it("promotes an address listed in AUTH_STAFF_EMAILS", () => { - vi.stubEnv("AUTH_STAFF_EMAILS", "niti@cornell.edu, other@cornell.edu"); - expect(roleForEmail("niti@cornell.edu")).toBe("staff"); - }); - - it("promotes an address listed in AUTH_ADMIN_EMAILS", () => { - vi.stubEnv("AUTH_ADMIN_EMAILS", "isaac@cornell.edu"); - expect(roleForEmail("isaac@cornell.edu")).toBe("admin"); - }); - - it("prefers admin when an address is on both lists", () => { - vi.stubEnv("AUTH_STAFF_EMAILS", "isaac@cornell.edu"); - vi.stubEnv("AUTH_ADMIN_EMAILS", "isaac@cornell.edu"); - expect(roleForEmail("isaac@cornell.edu")).toBe("admin"); - }); - - it("matches roster entries case-insensitively", () => { - vi.stubEnv("AUTH_STAFF_EMAILS", "Niti@Cornell.edu"); - expect(roleForEmail("NITI@CORNELL.EDU")).toBe("staff"); - }); - - it("refuses to grant a role to an address outside the domain, even if listed", () => { - // A roster typo must not become a privilege escalation. - vi.stubEnv("AUTH_ADMIN_EMAILS", "attacker@gmail.com"); - expect(roleForEmail("attacker@gmail.com")).toBe("anonymous"); - }); - - it("resolves an absent address to anonymous", () => { - expect(roleForEmail(null)).toBe("anonymous"); - expect(roleForEmail("")).toBe("anonymous"); - }); }); diff --git a/v5/src/lib/auth/roles.ts b/v5/src/lib/auth/roles.ts index 2669622..e740c4a 100644 --- a/v5/src/lib/auth/roles.ts +++ b/v5/src/lib/auth/roles.ts @@ -1,40 +1,60 @@ +import { ROLES as STORED_ROLES, type Role as StoredRole } from "../db/schema/vocabulary"; + /** - * Roles and the domain rule (auth design spec 2026-07-29 §3.3). + * The role vocabulary and the domain rule (data platform design spec §3.4). + * + * **Roles are rows now.** Until Phase 4 there was no user table, so two + * comma-separated env lists (`AUTH_STAFF_EMAILS`, `AUTH_ADMIN_EMAILS`) *were* + * the role system and this module resolved a role from an address. Both lists + * are retired: `user.role` is a column, changed on `/admin/users` and visible on + * the person's next request. The one env list that survives is + * `AUTH_SUPER_ADMIN_EMAILS`, the lock-out floor — see `super-admins.ts`. * - * There is no user database in v5, so role assignment is configuration: two - * comma-separated env lists name the staff and the admins, and everyone else who - * signs in with an allowed address is a student. The lab has a handful of staff - * and the roster changes a few times a year — an env list needs no UI, no table, - * and no migration, and it is auditable by whoever operates the deployment. + * What is left here is what has no home in the database: the shape of the role + * union, and the domain rule that decides whose address may become a row at all. * * Every lookup reads `process.env` at **call time** rather than at module load, - * so a redeploy with a new roster takes effect without a cold-start dance and + * so a redeploy with a new value takes effect without a cold-start dance and * tests can `vi.stubEnv` without `resetModules()`. * - * This module is deliberately not `server-only`: `Role` and `isAtLeast` are - * universal, and the env-reading helpers are only ever called from the server - * (they resolve to "no one is staff" in a client bundle, which is safe). + * Deliberately not `server-only`: `Role` is universal and client components + * compare against it. The env-reading helpers resolve to their empty answer in a + * browser bundle, which is safe — the server check is the control. */ -/** Ordered least- to most-privileged. The order *is* the privilege ordering. */ -export const ROLES = ["anonymous", "student", "staff", "admin"] as const; - -export type Role = (typeof ROLES)[number]; +/** + * The roles an identity can hold. The three stored ones come from + * `db/schema/vocabulary.ts`, which is also what the `user.role` CHECK is built + * from, so the type and the constraint cannot drift. `anonymous` is never a row: + * it is the absence of a session, which is a first-class state, not a failure. + * + * There is deliberately **no ordering** here. `student < staff < admin` was a + * rank comparison (`isAtLeast`); what a role may do is now declared in + * `permissions.ts` and asked with `can()`, so that "SuperMakers may add tools + * but not publish them" is one line of declaration rather than a reshuffle. + */ +export const IDENTITY_ROLES = ["anonymous", ...STORED_ROLES] as const; -/** The institution's Google Workspace domain, when nothing overrides it. */ -const DEFAULT_EMAIL_DOMAIN = "cornell.edu"; +export type Role = "anonymous" | StoredRole; -/** Position of `role` in {@link ROLES}; higher means more privileged. */ -export function roleRank(role: Role): number { - const index = ROLES.indexOf(role); - return index === -1 ? 0 : index; +/** True when `value` is one of {@link IDENTITY_ROLES}; narrows the type. */ +export function isRole(value: string | null | undefined): value is Role { + return typeof value === "string" && (IDENTITY_ROLES as readonly string[]).includes(value); } -/** True when `role` is at least as privileged as `minimum`. */ -export function isAtLeast(role: Role, minimum: Role): boolean { - return roleRank(role) >= roleRank(minimum); +/** + * A stored role read back from the database, or `anonymous` for anything else. + * A row carrying a word outside the vocabulary should be impossible (the CHECK + * refuses it), so if one ever appears it is a bug or a restored backup from a + * different schema, and it must resolve to the role that holds nothing. + */ +export function storedRoleOr(value: string | null | undefined): Role { + return isRole(value) ? value : "anonymous"; } +/** The institution's Google Workspace domain, when nothing overrides it. */ +const DEFAULT_EMAIL_DOMAIN = "cornell.edu"; + /** * The email domain sign-in is restricted to. Configurable so the app stays * white-labelled (Article 6) — the default is the Cornell Tech deployment's. @@ -69,28 +89,3 @@ export function parseEmailList(raw: string | null | undefined): string[] { .map((entry) => normalizeEmail(entry)) .filter(Boolean); } - -/** Addresses listed in `AUTH_STAFF_EMAILS`. */ -export function staffEmails(): string[] { - return parseEmailList(process.env.AUTH_STAFF_EMAILS); -} - -/** Addresses listed in `AUTH_ADMIN_EMAILS`. */ -export function adminEmails(): string[] { - return parseEmailList(process.env.AUTH_ADMIN_EMAILS); -} - -/** - * Resolve a verified address to a role. - * - * An address outside the allowed domain resolves to `anonymous` rather than - * `student`: a cookie carrying one should be impossible (the callback refuses - * it), so if one ever appears it is a bug or a forgery and must not be trusted. - */ -export function roleForEmail(email: string | null | undefined): Role { - const normalized = normalizeEmail(email); - if (!normalized || !isAllowedEmail(normalized)) return "anonymous"; - if (adminEmails().includes(normalized)) return "admin"; - if (staffEmails().includes(normalized)) return "staff"; - return "student"; -} diff --git a/v5/src/lib/auth/session-cookie.ts b/v5/src/lib/auth/session-cookie.ts index ea953b9..150b441 100644 --- a/v5/src/lib/auth/session-cookie.ts +++ b/v5/src/lib/auth/session-cookie.ts @@ -1,6 +1,18 @@ import "server-only"; /** + * RETIRED in Phase 4 (data platform design spec §3.4). Nothing in the + * application imports this module any more: sessions are rows, the cookie + * Better Auth sets carries only a token, and `resolveIdentity` looks the row + * up on every request. The file is left in place because deleting it is a + * separate approval; it is reported for deletion, not kept for use. + * + * **Do not reach for it.** A second identity cookie that nothing can revoke is + * exactly what Phase 4 removed. If you need a signed-in caller in a test, use + * `test/utils/session.ts`. + * + * --- + * * The stateless signed session cookie (auth design spec 2026-07-29 §3.1). * * v5's only datastore is Notion, which is the wrong place for session rows, and diff --git a/v5/src/lib/auth/sign-in-client.test.ts b/v5/src/lib/auth/sign-in-client.test.ts index 307659f..dbd4b7f 100644 --- a/v5/src/lib/auth/sign-in-client.test.ts +++ b/v5/src/lib/auth/sign-in-client.test.ts @@ -75,20 +75,20 @@ describe("isSignedIn", () => { }); it("is true for every signed-in role", () => { - expect(isSignedIn({ role: "student", name: "Ada" })).toBe(true); - expect(isSignedIn({ role: "staff", name: "Ada" })).toBe(true); + expect(isSignedIn({ role: "user", name: "Ada" })).toBe(true); expect(isSignedIn({ role: "admin", name: "Ada" })).toBe(true); + expect(isSignedIn({ role: "super_admin", name: "Ada" })).toBe(true); }); }); describe("fetchIdentity", () => { it("reads role and name from the identity endpoint", async () => { const fetchMock = stubFetch(async () => - json({ role: "student", name: "Ada Lovelace" }) + json({ role: "user", name: "Ada Lovelace" }) ); await expect(fetchIdentity()).resolves.toEqual({ - role: "student", + role: "user", name: "Ada Lovelace", }); expect(fetchMock).toHaveBeenCalledWith( diff --git a/v5/src/lib/auth/super-admins.test.ts b/v5/src/lib/auth/super-admins.test.ts new file mode 100644 index 0000000..3a8bed3 --- /dev/null +++ b/v5/src/lib/auth/super-admins.test.ts @@ -0,0 +1,56 @@ +import { isSuperAdminFloor, superAdminEmails } from "@/lib/auth/super-admins"; + +// Every helper reads process.env at call time, so `vi.stubEnv` alone is enough +// — no resetModules()/dynamic import dance (the setup file unstubs after each). + +describe("superAdminEmails", () => { + it("is empty when the variable is unset", () => { + // The whole suite runs with no environment at all; an unset floor must be + // "nobody", never a crash and never a default address. + expect(superAdminEmails()).toEqual([]); + }); + + it("splits, trims and lower-cases the list", () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", " IES22@Cornell.edu , niti@cornell.edu "); + expect(superAdminEmails()).toEqual(["ies22@cornell.edu", "niti@cornell.edu"]); + }); +}); + +describe("isSuperAdminFloor", () => { + it("recognises a listed address regardless of case or padding", () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "ies22@cornell.edu"); + expect(isSuperAdminFloor("ies22@cornell.edu")).toBe(true); + expect(isSuperAdminFloor(" IES22@CORNELL.EDU ")).toBe(true); + }); + + it("is false for an address that is not listed", () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "ies22@cornell.edu"); + expect(isSuperAdminFloor("someone-else@cornell.edu")).toBe(false); + }); + + it("is false when the variable is unset", () => { + expect(isSuperAdminFloor("ies22@cornell.edu")).toBe(false); + }); + + it("refuses an address outside the allowed domain, even when listed", () => { + // A typo in the floor must fail closed. The create hook would never make + // this address a row, so honouring it here would grant the highest role to + // an account that cannot exist. + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "attacker@gmail.com"); + expect(isSuperAdminFloor("attacker@gmail.com")).toBe(false); + }); + + it("follows a reconfigured allowed domain", () => { + vi.stubEnv("AUTH_ALLOWED_EMAIL_DOMAIN", "example.edu"); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "director@example.edu, ies22@cornell.edu"); + expect(isSuperAdminFloor("director@example.edu")).toBe(true); + expect(isSuperAdminFloor("ies22@cornell.edu")).toBe(false); + }); + + it("is false for absent input", () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "ies22@cornell.edu"); + expect(isSuperAdminFloor(null)).toBe(false); + expect(isSuperAdminFloor(undefined)).toBe(false); + expect(isSuperAdminFloor("")).toBe(false); + }); +}); diff --git a/v5/src/lib/auth/super-admins.ts b/v5/src/lib/auth/super-admins.ts new file mode 100644 index 0000000..05f24e3 --- /dev/null +++ b/v5/src/lib/auth/super-admins.ts @@ -0,0 +1,42 @@ +import { isAllowedEmail, normalizeEmail, parseEmailList } from "./roles"; + +/** + * The super-admin floor (data platform design spec §3.4). + * + * `AUTH_SUPER_ADMIN_EMAILS` is **a floor, not a roster**. An address listed + * there is created as `super_admin` and resolves as `super_admin` whatever its + * row says, and `/admin/users` refuses to demote or ban it. Two things depend + * on that: + * + * 1. **Bootstrap.** No `user` row exists until somebody signs in, so there is + * no first admin to promote anybody. The floor is how the first one comes to + * exist — the listed person signs in and is already a super admin. + * 2. **Lock-out.** A super admin who demotes themselves, or a mistaken ban, + * would otherwise leave nobody able to undo it and no UI to fix it with. + * + * It is the one piece of the role system still in the environment, and that is + * deliberate: a floor that lives in the same table it protects protects nothing. + * + * Read at call time, never at module load, so a redeploy takes effect without a + * cold start and tests can `vi.stubEnv` without `resetModules()`. + */ + +/** Addresses listed in `AUTH_SUPER_ADMIN_EMAILS`, normalized. */ +export function superAdminEmails(): string[] { + return parseEmailList(process.env.AUTH_SUPER_ADMIN_EMAILS); +} + +/** + * True when `email` is on the floor. + * + * The domain check runs here too. An address outside + * `AUTH_ALLOWED_EMAIL_DOMAIN` can never hold a role — the create hook refuses + * to make it a row at all — so honouring it here would grant the highest role + * to an account that cannot otherwise exist. A typo in the env list must fail + * closed, not open. + */ +export function isSuperAdminFloor(email: string | null | undefined): boolean { + const normalized = normalizeEmail(email); + if (!normalized || !isAllowedEmail(normalized)) return false; + return superAdminEmails().includes(normalized); +} diff --git a/v5/src/lib/blob.test.ts b/v5/src/lib/blob.test.ts index ba1b108..c99e16f 100644 --- a/v5/src/lib/blob.test.ts +++ b/v5/src/lib/blob.test.ts @@ -59,6 +59,80 @@ describe("put", () => { }); }); +describe("putUpload", () => { + beforeEach(() => { + sdk.put.mockResolvedValue({ + pathname: "uploads/broken-bed-Xa9k2.png", + url: "https://store.public.blob.vercel-storage.com/uploads/broken-bed-Xa9k2.png", + }); + }); + + function photo(name = "broken bed.png", type = "image/png") { + return new File([new Uint8Array([1, 2, 3])], name, { type }); + } + + it("stores an upload at a RANDOM pathname, so an unpublished image is unguessable", async () => { + await getBlobStore().putUpload("uploads/project/", photo(), "public"); + + const [pathname, , options] = sdk.put.mock.calls[0]; + // The stem is readable, the entropy is the SDK's; the caller records the + // pathname that comes back, never the one it asked for. + expect(pathname).toBe("uploads/project/broken-bed.png"); + expect(options.addRandomSuffix).toBe(true); + }); + + it("returns the pathname the store chose, not the one requested", async () => { + const stored = await getBlobStore().putUpload( + "uploads/project/", + photo(), + "public" + ); + + expect(stored.pathname).toBe("uploads/broken-bed-Xa9k2.png"); + expect(stored.url).toContain("broken-bed-Xa9k2.png"); + }); + + it("honours the caller's access, because a maintenance photo may show a person", async () => { + const store = getBlobStore(); + await store.putUpload("uploads/maintenance/", photo(), "private"); + await store.putUpload("uploads/project/", photo(), "public"); + + expect(sdk.put.mock.calls[0][2].access).toBe("private"); + expect(sdk.put.mock.calls[1][2].access).toBe("public"); + }); + + it("keeps the file's own content type so a browser renders it", async () => { + await getBlobStore().putUpload( + "uploads/resource/", + photo("manual.pdf", "application/pdf"), + "public" + ); + + expect(sdk.put.mock.calls[0][2].contentType).toBe("application/pdf"); + }); + + it("strips path separators out of an untrusted filename", async () => { + await getBlobStore().putUpload( + "uploads/project/", + photo("../../etc/passwd.png"), + "public" + ); + + // The name is whatever the browser sent; it must not be able to move the + // file out of its prefix. + expect(sdk.put.mock.calls[0][0]).toBe("uploads/project/etc-passwd.png"); + }); + + it("falls back to a name rather than writing a bare prefix", async () => { + await getBlobStore().putUpload("uploads/chat/", photo("", ""), "private"); + + expect(sdk.put.mock.calls[0][0]).toBe("uploads/chat/upload"); + expect(sdk.put.mock.calls[0][2].contentType).toBe( + "application/octet-stream" + ); + }); +}); + describe("list", () => { it("follows the cursor to the end and normalizes uploadedAt to ISO", async () => { sdk.list diff --git a/v5/src/lib/blob.ts b/v5/src/lib/blob.ts index e94f241..47d8221 100644 --- a/v5/src/lib/blob.ts +++ b/v5/src/lib/blob.ts @@ -8,14 +8,26 @@ import { del, list, put } from "@vercel/blob"; * * Vercel Blob is the store because it adds **no new account**: the backup job * has to survive a handover, and every extra provider is one more credential - * for someone to lose. The seam exists so the job depends on three verbs it can - * be tested against rather than on the SDK's surface. + * for someone to lose. The seam exists so the job depends on a handful of verbs + * it can be tested against rather than on the SDK's surface. * - * **Everything written here is PRIVATE, and that is not a caller's decision.** - * The daily Notion dump contains student names and email addresses from - * Maintenance_Logs, and a public blob URL is unauthenticated, guessable-adjacent - * and permanent. `access: "private"` is therefore hard-coded and there is - * deliberately no parameter to override it. + * **There are two write verbs, and the split is real.** Until the data platform + * spec (2026-09-14 §3.3) this module wrote one kind of file — the nightly dump — + * and hard-coded `access: "private"` with `addRandomSuffix: false` because + * neither was a caller's decision. Uploads need the opposite of both, so the + * invariant is amended rather than worked around: + * + * - {@link BlobStore.put} still writes the **backup**: private, and at exactly + * the pathname it was given, because that pathname *is* the retention key. + * The dump carries student names and reporter emails from `maintenance_logs`, + * and a public blob URL is unauthenticated and permanent — there is still no + * parameter that can make it public. + * - {@link BlobStore.putUpload} writes a **user upload** at a *random* pathname. + * Random because a tool image that has not been published yet must not be + * guessable from the tool's name, and because two students uploading + * `IMG_0001.jpg` must not overwrite each other. Its access is the caller's + * decision — a maintenance photo may show a person and stays private, while a + * project photo is about to be shown on a public page. */ /** A blob as reported by {@link BlobStore.list}. */ @@ -24,6 +36,17 @@ export interface ListedBlob { uploadedAt: string; } +/** Whether a stored file is reachable by URL. Mirrors `attachments.access`. */ +export type BlobAccess = "public" | "private"; + +/** What the store recorded for an uploaded file. */ +export interface StoredUpload { + /** The *actual* pathname, random suffix included — not the one requested. */ + pathname: string; + /** The blob URL. Only usable by an unauthenticated viewer when public. */ + url: string; +} + export interface BlobStore { /** Write (or replace) `pathname`. Always private. Returns the stored path. */ put( @@ -31,6 +54,16 @@ export interface BlobStore { body: string, contentType: string ): Promise<{ pathname: string }>; + /** + * Store one uploaded file under `prefix`, at a random pathname. The file's + * own content type is kept so the browser renders it rather than downloading + * it. + */ + putUpload( + prefix: string, + file: File, + access: BlobAccess + ): Promise; /** Every blob under `prefix`, following pagination to the end. */ list(prefix: string): Promise; /** Delete by pathname. A no-op when the list is empty. */ @@ -50,6 +83,20 @@ export function isBlobConfigured(): boolean { /** Guards a runaway `list` loop; 30 days of daily backups is ~30 blobs. */ const MAX_LIST_PAGES = 20; +/** + * A filename is whatever the browser sent, so it is treated as untrusted text: + * path separators would move the file out of its prefix, and a very long name + * is pointless once a random suffix is appended anyway. The result is cosmetic — + * it only makes the stored path readable in the Blob dashboard. + */ +function safeFilename(name: string): string { + const cleaned = (name || "upload") + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/^[.-]+/, "") + .slice(0, 64); + return cleaned || "upload"; +} + export function getBlobStore(): BlobStore { return { async put(pathname, body, contentType) { @@ -65,6 +112,19 @@ export function getBlobStore(): BlobStore { return { pathname: result.pathname }; }, + async putUpload(prefix, file, access) { + // The requested pathname is only a *stem*: `addRandomSuffix` appends + // entropy, so `uploads/photo.jpg` becomes `uploads/photo-Xa9k2.jpg` and + // the caller records whatever came back. The original filename is kept in + // the `attachments` row, not relied on here — it is untrusted input. + const result = await put(`${prefix}${safeFilename(file.name)}`, file, { + access, + contentType: file.type || "application/octet-stream", + addRandomSuffix: true, + }); + return { pathname: result.pathname, url: result.url }; + }, + async list(prefix) { const blobs: ListedBlob[] = []; let cursor: string | undefined; diff --git a/v5/src/lib/capabilities/access.test.ts b/v5/src/lib/capabilities/access.test.ts index bd08bb7..1c00bb1 100644 --- a/v5/src/lib/capabilities/access.test.ts +++ b/v5/src/lib/capabilities/access.test.ts @@ -1,22 +1,22 @@ import { z } from "zod"; import { nextCacheMock } from "../../../test/mocks/next-cache"; import { - INTAKE_MINIMUM_ROLE, + INTAKE_PERMISSION, canAddEquipment, - capabilitiesForRole, - meetsMinimumRole, + capabilitiesForIdentity, + meetsRequiredPermission, } from "./access"; import { CAPABILITIES } from "./index"; import type { Capability, CapabilityTool } from "./types"; -import type { Role } from "../auth/roles"; +import { IDENTITY_ROLES, type Role } from "../auth/roles"; // The real registry is imported below; its catalog module reads `next/cache`. vi.mock("next/cache", () => nextCacheMock()); /** - * Who may use which capability on the chat surface (auth spec amendment - * 2026-09-14). The rule is declared on a capability and enforced once here, so - * these tests are the whole of the authorization logic — the chat route tests + * Who may use which capability on the chat surface (spec §3.5). The rule is + * declared on a capability as a *permission* and enforced once here, so these + * tests are the whole of the chat's authorization logic — the chat route tests * only confirm the route composes through it. */ @@ -36,55 +36,64 @@ const open: Capability = { tools: [fakeTool("look_up")], }; -const staffOnly: Capability = { - id: "staff-only", - minimumRole: "staff", +const adminOnly: Capability = { + id: "admin-only", + requiredPermission: "tools.add", promptFragment: () => "full instructions", lockedPromptFragment: () => "staff only, sorry", tools: [fakeTool("add_thing")], }; -const adminQuiet: Capability = { - id: "admin-quiet", - minimumRole: "admin", - promptFragment: () => "admin instructions", - tools: [fakeTool("admin_thing")], +const superAdminQuiet: Capability = { + id: "super-admin-quiet", + requiredPermission: "users.manage", + promptFragment: () => "director instructions", + tools: [fakeTool("director_thing")], }; const env = { tools: [] }; -describe("meetsMinimumRole", () => { - it("lets everyone through when there is no minimum", () => { - for (const role of ["anonymous", "student", "staff", "admin"] as Role[]) { - expect(meetsMinimumRole(role, undefined)).toBe(true); +/** A subject carrying just a role, which is all `access.ts` reads. */ +function as(role: Role) { + return { role }; +} + +describe("meetsRequiredPermission", () => { + it("lets everyone through when a capability requires nothing", () => { + for (const role of IDENTITY_ROLES) { + expect(meetsRequiredPermission(as(role), undefined)).toBe(true); } }); - it("treats a missing role as anonymous, never as a pass", () => { - expect(meetsMinimumRole(undefined, "student")).toBe(false); - expect(meetsMinimumRole(null, "staff")).toBe(false); + it("treats a missing subject as anonymous, never as a pass", () => { + expect(meetsRequiredPermission(undefined, "projects.submit")).toBe(false); + expect(meetsRequiredPermission(null, "tools.add")).toBe(false); + expect(meetsRequiredPermission({ role: undefined }, "tools.add")).toBe(false); }); - it("follows the role ladder", () => { - expect(meetsMinimumRole("student", "staff")).toBe(false); - expect(meetsMinimumRole("staff", "staff")).toBe(true); - expect(meetsMinimumRole("admin", "staff")).toBe(true); - expect(meetsMinimumRole("staff", "admin")).toBe(false); + it("asks the declaration rather than comparing role names", () => { + expect(meetsRequiredPermission(as("user"), "tools.add")).toBe(false); + expect(meetsRequiredPermission(as("admin"), "tools.add")).toBe(true); + expect(meetsRequiredPermission(as("super_admin"), "tools.add")).toBe(true); + // An admin runs the catalogue but does not decide who is who — there is no + // ladder to climb here, only what the declaration grants. + expect(meetsRequiredPermission(as("admin"), "users.manage")).toBe(false); + expect(meetsRequiredPermission(as("super_admin"), "users.manage")).toBe(true); }); }); describe("canAddEquipment", () => { - it("requires staff", () => { - expect(INTAKE_MINIMUM_ROLE).toBe("staff"); + it("is the tools.add permission", () => { + expect(INTAKE_PERMISSION).toBe("tools.add"); }); it.each([ ["anonymous", false], - ["student", false], - ["staff", true], + ["user", false], ["admin", true], + ["super_admin", true], ] as const)("%s → %s", (role, expected) => { - expect(canAddEquipment(role)).toBe(expected); + expect(canAddEquipment(as(role))).toBe(expected); }); it("is closed to a caller with no identity", () => { @@ -93,28 +102,35 @@ describe("canAddEquipment", () => { }); }); -describe("capabilitiesForRole", () => { - it("returns a capability with no minimum untouched, for anyone", () => { - const [result] = capabilitiesForRole([open], "anonymous"); +describe("capabilitiesForIdentity", () => { + it("returns a capability with no requirement untouched, for anyone", () => { + const [result] = capabilitiesForIdentity([open], as("anonymous")); expect(result).toBe(open); }); - it("gives a qualifying role every tool and the full instructions", () => { - const [result] = capabilitiesForRole([staffOnly], "staff"); + it("gives a qualifying caller every tool and the full instructions", () => { + const [result] = capabilitiesForIdentity([adminOnly], as("admin")); expect(result.tools.map((t) => t.name)).toEqual(["add_thing"]); expect(result.promptFragment(env)).toBe("full instructions"); }); it("keeps a locked capability's place but strips its tools and says why", () => { - const result = capabilitiesForRole([open, staffOnly, adminQuiet], "student"); + const result = capabilitiesForIdentity( + [open, adminOnly, superAdminQuiet], + as("user") + ); - expect(result.map((c) => c.id)).toEqual(["open", "staff-only", "admin-quiet"]); + expect(result.map((c) => c.id)).toEqual([ + "open", + "admin-only", + "super-admin-quiet", + ]); expect(result[1].tools).toEqual([]); expect(result[1].promptFragment(env)).toBe("staff only, sorry"); }); it("adds nothing to the prompt for a locked capability with no locked fragment", () => { - const [result] = capabilitiesForRole([adminQuiet], "staff"); + const [result] = capabilitiesForIdentity([superAdminQuiet], as("admin")); expect(result.tools).toEqual([]); expect(result.promptFragment(env)).toBe(""); }); @@ -124,12 +140,12 @@ describe("the registry as each role sees it", () => { const INTAKE_TOOLS = ["research_tool", "propose_listing", "create_tool"]; function toolNames(role: Role): string[] { - return capabilitiesForRole(CAPABILITIES, role).flatMap((c) => + return capabilitiesForIdentity(CAPABILITIES, as(role)).flatMap((c) => c.tools.map((t) => t.name) ); } - it.each(["anonymous", "student"] as const)( + it.each(["anonymous", "user"] as const)( "gives %s no way to add equipment", (role) => { const names = toolNames(role); @@ -137,9 +153,12 @@ describe("the registry as each role sees it", () => { } ); - it.each(["staff", "admin"] as const)("gives %s the intake tools", (role) => { - expect(toolNames(role)).toEqual(expect.arrayContaining(INTAKE_TOOLS)); - }); + it.each(["admin", "super_admin"] as const)( + "gives %s the intake tools", + (role) => { + expect(toolNames(role)).toEqual(expect.arrayContaining(INTAKE_TOOLS)); + } + ); it("leaves reporting problems and corrections open to everyone", () => { expect(toolNames("anonymous")).toEqual( diff --git a/v5/src/lib/capabilities/access.ts b/v5/src/lib/capabilities/access.ts index 9cbcfde..b6fba9a 100644 --- a/v5/src/lib/capabilities/access.ts +++ b/v5/src/lib/capabilities/access.ts @@ -1,52 +1,62 @@ -import { isAtLeast, type Role } from "../auth/roles"; +import { can, type Permission } from "../auth/permissions"; +import type { Role } from "../auth/roles"; import type { Capability } from "./types"; /** - * Who may use which capability on a session surface (auth spec amendment - * 2026-09-14). A capability declares the least-privileged role that may use it; - * this module is the one place that rule is enforced, so the chat composes the - * assistant from {@link capabilitiesForRole} instead of checking inside tools. + * Who may use which capability on a session surface (data platform design spec + * §3.5). A capability declares the *permission* it needs; this module is the one + * place that declaration is enforced, so the chat composes the assistant from + * {@link capabilitiesForIdentity} instead of checking inside tools. + * + * Phase 4 replaced the rank comparison this used to do. `minimumRole` plus + * `isAtLeast` ordered four role names; `requiredPermission` plus `can()` asks + * the declaration in `auth/permissions.ts`, which is the same declaration the + * route handlers and the admin plugin use. One check, everywhere. * * MCP is deliberately not a session surface. Its trust boundary is `MCP_TOKEN`, * which already gates every write tool there, and an MCP caller has no role. * - * Client-safe: `roles.ts` is universal and the `Capability` import is type-only, - * so the header can ask {@link canAddEquipment} without pulling the registry - * (and the Notion client behind it) into the browser bundle. + * Client-safe: `permissions.ts` is pure data and the `Capability` import is + * type-only, so the header can ask {@link canAddEquipment} without pulling the + * registry (and the Notion client behind it) into the browser bundle. */ -/** The least-privileged role that may add equipment through intake. */ -export const INTAKE_MINIMUM_ROLE: Role = "staff"; +/** The permission intake requires. Named so call sites read as intent. */ +export const INTAKE_PERMISSION: Permission = "tools.add"; + +/** Anything that carries a role: an `Identity`, or a client identity. */ +export type AccessSubject = { role: Role | null | undefined } | null | undefined; + +/** True when `subject` may add equipment to the catalogue. */ +export function canAddEquipment(subject: AccessSubject): boolean { + return can(subject, INTAKE_PERMISSION); +} /** - * True when `role` meets `minimumRole`. No minimum means everyone, and a missing - * role is treated as anonymous — never as a pass. + * True when `subject` holds `permission`. A capability with no + * `requiredPermission` is open to everyone, anonymous visitors included — + * browsing and asking questions never required an account. */ -export function meetsMinimumRole( - role: Role | null | undefined, - minimumRole: Role | undefined +export function meetsRequiredPermission( + subject: AccessSubject, + permission: Permission | undefined ): boolean { - if (!minimumRole) return true; - return isAtLeast(role ?? "anonymous", minimumRole); -} - -/** True when `role` may add equipment to the catalog. */ -export function canAddEquipment(role: Role | null | undefined): boolean { - return meetsMinimumRole(role, INTAKE_MINIMUM_ROLE); + if (!permission) return true; + return can(subject, permission); } /** - * The registry as `role` may use it. A capability the role does not meet keeps - * its place in the order but contributes no tools, and its prompt fragment is - * swapped for `lockedPromptFragment`, so the assistant can say why rather than - * improvise around tools it cannot see. + * The registry as `subject` may use it. A capability the subject does not hold + * keeps its place in the order but contributes no tools, and its prompt fragment + * is swapped for `lockedPromptFragment`, so the assistant can say why rather + * than improvise around tools it cannot see. */ -export function capabilitiesForRole( +export function capabilitiesForIdentity( capabilities: Capability[], - role: Role | null | undefined + subject: AccessSubject ): Capability[] { return capabilities.map((capability) => - meetsMinimumRole(role, capability.minimumRole) + meetsRequiredPermission(subject, capability.requiredPermission) ? capability : { id: capability.id, diff --git a/v5/src/lib/capabilities/flags.test.ts b/v5/src/lib/capabilities/flags.test.ts index 5b7d99b..d8003fe 100644 --- a/v5/src/lib/capabilities/flags.test.ts +++ b/v5/src/lib/capabilities/flags.test.ts @@ -1,53 +1,48 @@ // @vitest-environment node -import { http, HttpResponse } from "msw"; -import { server } from "../../../test/msw/server"; -import { DB_IDS } from "../../../test/msw/handlers"; +import { eq } from "drizzle-orm"; import { nextCacheMock } from "../../../test/mocks/next-cache"; // `catalog.ts` (pulled in for tool resolution) imports cacheTag/cacheLife. vi.mock("next/cache", () => nextCacheMock()); import { getCatalogTools } from "../catalog"; -import { resetDbForTests } from "../db/client"; -import { DEMO_FORM_4_NOTION_PAGE_ID } from "../db/demo-seed"; +import { getDb, resetDbForTests } from "../db/client"; +import { feedback, tools as toolsTable } from "../db/schema/index"; +import { seedUser } from "../../../test/utils/session"; import { FLAG_FIELDS, MAX_FLAG_TEXT, - buildFlagFields, + buildFeedbackRow, flags, - hasFlagsEnv, parseCorrectionReport, submitCorrection, type CorrectionReport, } from "./flags"; -const NOTION = "https://api.notion.com/v1"; +/** + * Corrections, end to end against the demo-seeded PGlite database with **no + * environment variables at all** — no Notion env, no MSW, no network. The + * whole path is real code from the validation down to the row (Article 3). + */ -// Reads run against the demo-seeded PGlite database (`DATABASE_URL` unset), -// which holds the same two tools the mock catalogue used to. The Form 4's -// catalogue id is a Postgres uuid minted at seed time, so it is resolved rather -// than hard-coded; the Flags relation needs its *Notion page* id instead. const FORM_4_NAME = "Form 4"; let FORM_4_ID = ""; beforeEach(async () => { vi.stubEnv("DATABASE_URL", ""); - const tools = await getCatalogTools(); - FORM_4_ID = tools.find((tool) => tool.slug === "form-4")?.id ?? ""; + const catalogue = await getCatalogTools(); + FORM_4_ID = catalogue.find((tool) => tool.slug === "form-4")?.id ?? ""; + const db = await getDb(); + await db.delete(feedback); }); afterAll(() => { resetDbForTests(); }); -/** The Form 4 as `buildFlagFields` receives it, with its Notion page by default. */ -function flaggedTool(notionPageId: string | null = DEMO_FORM_4_NOTION_PAGE_ID) { - return { id: FORM_4_ID, name: FORM_4_NAME, notionPageId }; -} - -function stubFlagsEnv() { - vi.stubEnv("NOTION_API_KEY", "secret_test"); - vi.stubEnv("NOTION_DB_FLAGS", DB_IDS.flags); +/** The Form 4 as `buildFeedbackRow` receives it. */ +function flaggedTool() { + return { id: FORM_4_ID, name: FORM_4_NAME }; } function report(overrides: Partial = {}): CorrectionReport { @@ -59,30 +54,16 @@ function report(overrides: Partial = {}): CorrectionReport { }; } -/** Capture every POST /pages request body MSW sees. */ -function capturePageCreates() { - const creates: Array<{ parent: { database_id: string }; properties: Record }> = - []; - server.use( - http.post(`${NOTION}/pages`, async ({ request }) => { - const body = (await request.json()) as (typeof creates)[number]; - creates.push(body); - return HttpResponse.json({ - object: "page", - id: "flag-page-1", - created_time: "2026-07-29T10:00:00.000Z", - last_edited_time: "2026-07-29T10:00:00.000Z", - properties: body.properties, - }); - }) - ); - return creates; +/** Every correction currently in the table. */ +async function storedRows() { + const db = await getDb(); + return db.select().from(feedback); } describe("parseCorrectionReport", () => { it("accepts a minimal valid report and trims its text", () => { const parsed = parseCorrectionReport({ - tool_id: " tool-form-4 ", + tool_id: " form-4 ", field_flagged: "description", issue_description: " The bed size is wrong. ", }); @@ -90,7 +71,7 @@ describe("parseCorrectionReport", () => { expect(parsed).toEqual({ ok: true, report: { - tool_id: "tool-form-4", + tool_id: "form-4", field_flagged: "description", issue_description: "The bed size is wrong.", suggested_fix: undefined, @@ -185,130 +166,119 @@ describe("parseCorrectionReport", () => { }); }); -describe("buildFlagFields", () => { - it("generates the title as ' — ' and opens the row as New", () => { - const fields = buildFlagFields(report({ field_flagged: "safety_info" }), flaggedTool()); - expect(fields.title).toBe(`${FORM_4_NAME} — safety_info`); - expect(fields.status).toBe("New"); - // The relation addresses the Notion page, never the Postgres id. - expect(fields.tool).toEqual([DEMO_FORM_4_NOTION_PAGE_ID]); - }); +describe("buildFeedbackRow", () => { + it("maps every field onto its column and opens the row as new", () => { + const row = buildFeedbackRow( + report({ field_flagged: "safety_info", suggested_fix: "Add the PPE line." }), + flaggedTool() + ); - it("omits the relation for a tool that never came from Notion", () => { - // Notion refuses a relation to a page it cannot find, and the correction - // itself is worth more than the link (Article 4). - const fields = buildFlagFields(report(), flaggedTool(null)); - expect(fields.tool).toBeUndefined(); - expect(fields.title).toBe(`${FORM_4_NAME} — materials`); + expect(row).toEqual({ + // The catalogue uuid, which is what `tool_id` takes — there is no page + // id in this path any more. + toolId: FORM_4_ID, + fieldFlagged: "safety_info", + issueDescription: "Resin list is missing Rigid 10K.", + suggestedFix: "Add the PPE line.", + reporterName: null, + reporterEmail: null, + reporterUserId: null, + }); }); - it("omits the optional fields when they were not supplied", () => { - const fields = buildFlagFields(report(), flaggedTool()); - expect(fields.suggested_fix).toBeUndefined(); - expect(fields.reporter).toBeUndefined(); - expect(fields.reporter_email).toBeUndefined(); + it("nulls the optional fields when they were not supplied", () => { + const row = buildFeedbackRow(report(), flaggedTool()); + expect(row.suggestedFix).toBeNull(); + expect(row.reporterName).toBeNull(); + expect(row.reporterEmail).toBeNull(); }); - it("writes reporter_email only for a signed-in reporter", () => { - const anonymous = buildFlagFields(report(), flaggedTool()); - expect(anonymous.reporter_email).toBeUndefined(); + it("writes reporter_email and reporter_user_id only for a signed-in reporter", () => { + expect(buildFeedbackRow(report(), flaggedTool()).reporterEmail).toBeNull(); - const signedIn = buildFlagFields(report(), flaggedTool(), { + const signedIn = buildFeedbackRow(report(), flaggedTool(), { name: "Ada", email: "ada@example.edu", + userId: "google-sub-1", }); - expect(signedIn.reporter_email).toBe("ada@example.edu"); - expect(signedIn.reporter).toBe("Ada"); + expect(signedIn.reporterEmail).toBe("ada@example.edu"); + expect(signedIn.reporterUserId).toBe("google-sub-1"); + expect(signedIn.reporterName).toBe("Ada"); }); it("prefers a self-declared name over the session name", () => { - const fields = buildFlagFields(report({ reporter: "Grace" }), flaggedTool(), { + const row = buildFeedbackRow(report({ reporter: "Grace" }), flaggedTool(), { name: "Ada", email: "ada@example.edu", }); - expect(fields.reporter).toBe("Grace"); - }); -}); - -describe("hasFlagsEnv", () => { - it("is false without the flags database configured", () => { - vi.stubEnv("NOTION_API_KEY", ""); - vi.stubEnv("NOTION_DB_FLAGS", ""); - expect(hasFlagsEnv()).toBe(false); - }); - - it("is true once both vars are set", () => { - stubFlagsEnv(); - expect(hasFlagsEnv()).toBe(true); + expect(row.reporterName).toBe("Grace"); }); }); describe("submitCorrection", () => { - it("creates one page in the Flags database and never touches Tools", async () => { - stubFlagsEnv(); - const creates = capturePageCreates(); + it("inserts one `new` row against the tool and touches nothing else", async () => { + const db = await getDb(); + const toolsBefore = await db.select().from(toolsTable); const result = await submitCorrection(report({ reporter: "Ada" })); - expect(result).toEqual({ ok: true, id: "flag-page-1" }); - expect(creates).toHaveLength(1); - expect(creates[0].parent.database_id).toBe(DB_IDS.flags); - expect(creates[0].parent.database_id).not.toBe(DB_IDS.tools); - expect(creates[0].properties).toMatchObject({ - status: { select: { name: "New" } }, - field_flagged: { select: { name: "materials" } }, - tool: { relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }] }, + expect(result.ok).toBe(true); + const rows = await storedRows(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + toolId: FORM_4_ID, + fieldFlagged: "materials", + status: "new", + reporterName: "Ada", }); + // The assertion that matters (spec §8): a flag is inert. The catalogue is + // byte-for-byte what it was. + expect(await db.select().from(toolsTable)).toEqual(toolsBefore); }); - it("resolves the tool by slug as well as by catalogue id", async () => { - stubFlagsEnv(); - const creates = capturePageCreates(); + it("returns the id of the row it actually wrote", async () => { + const result = await submitCorrection(report()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const db = await getDb(); + const [row] = await db.select().from(feedback).where(eq(feedback.id, result.id)); + expect(row).toBeDefined(); + }); + it("resolves the tool by slug as well as by catalogue id", async () => { const result = await submitCorrection(report({ tool_id: "form-4" })); expect(result.ok).toBe(true); - expect(creates[0].properties.tool).toEqual({ - relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }], - }); + expect((await storedRows())[0].toolId).toBe(FORM_4_ID); }); it("returns unknown_tool without writing anything", async () => { - stubFlagsEnv(); - const creates = capturePageCreates(); - const result = await submitCorrection(report({ tool_id: "no-such-tool" })); expect(result).toEqual({ ok: false, code: "unknown_tool" }); - expect(creates).toHaveLength(0); - }); - - it("returns not_configured when the Flags database is unset", async () => { - vi.stubEnv("NOTION_API_KEY", ""); - vi.stubEnv("NOTION_DB_FLAGS", ""); - expect(await submitCorrection(report())).toEqual({ - ok: false, - code: "not_configured", - }); + expect(await storedRows()).toHaveLength(0); }); - it("swallows the Notion error and reports an opaque write_failure", async () => { - stubFlagsEnv(); + it("swallows the database error and reports an opaque write_failed", async () => { const logged = vi.spyOn(console, "error").mockImplementation(() => {}); - server.use( - http.post(`${NOTION}/pages`, () => - HttpResponse.json( - { object: "error", code: "unauthorized", message: "API token is invalid." }, - { status: 401 } - ) - ) - ); + // A `feedback` row with a description longer than the column's own CHECK + // would be caught by validation, so the failure is staged at the driver: + // a configured database nobody can reach. + const toolId = FORM_4_ID; + vi.stubEnv("DATABASE_URL", "postgres://user:hunter2@127.0.0.1:1/none"); + resetDbForTests(); - const result = await submitCorrection(report()); + const result = await submitCorrection(report({ tool_id: toolId })); expect(result).toEqual({ ok: false, code: "write_failed" }); - expect(JSON.stringify(result)).not.toMatch(/token|unauthorized|401/i); + expect(JSON.stringify(result)).not.toMatch(/hunter2|postgres|ECONNREFUSED/i); expect(logged).toHaveBeenCalled(); + + // Put the demo substrate back before the file's own cleanup runs: the + // stub is still in force until vitest's global afterEach clears it. + vi.stubEnv("DATABASE_URL", ""); + resetDbForTests(); }); }); @@ -322,60 +292,91 @@ describe("report_correction capability tool", () => { expect(tool.chatOnly).toBeUndefined(); }); - it("files a correction and returns the flag id", async () => { - stubFlagsEnv(); - const creates = capturePageCreates(); - + it("files a correction and returns the row id", async () => { const result = (await tool.run(report(), {})) as { success: boolean; flag_id?: string; }; expect(result.success).toBe(true); - expect(result.flag_id).toBe("flag-page-1"); - expect(creates[0].parent.database_id).toBe(DB_IDS.flags); + const rows = await storedRows(); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(result.flag_id); }); it("falls back to the tool whose page the student is reading", async () => { - stubFlagsEnv(); - const creates = capturePageCreates(); - const result = (await tool.run( { ...report(), tool_id: "" }, { focusedToolId: FORM_4_ID } )) as { success: boolean }; expect(result.success).toBe(true); - expect(creates[0].properties.tool).toEqual({ - relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }], + expect((await storedRows())[0].toolId).toBe(FORM_4_ID); + }); + + it("records the chat caller's session, the same way report_issue does", async () => { + // `created_by` references `user.id` since Phase 4; the reporter is a row. + await seedUser({ id: "google-sub-1", email: "ada@cornell.edu" }); + await tool.run(report(), { + identity: { + role: "user", + userId: "google-sub-1", + email: "ada@cornell.edu", + name: "Ada Lovelace", + rateLimitKey: "user:google-sub-1", + }, + }); + + expect((await storedRows())[0]).toMatchObject({ + reporterEmail: "ada@cornell.edu", + reporterUserId: "google-sub-1", + reporterName: "Ada Lovelace", }); }); - it("rejects an empty description without calling Notion", async () => { - stubFlagsEnv(); - const creates = capturePageCreates(); + it("treats an anonymous identity as nobody at all", async () => { + await tool.run(report(), { + identity: { + role: "anonymous", + userId: null, + email: null, + name: null, + rateLimitKey: "ip:deadbeef", + }, + }); + + const [row] = await storedRows(); + expect(row.reporterEmail).toBeNull(); + expect(row.reporterUserId).toBeNull(); + }); - const result = (await tool.run( - { ...report(), issue_description: " " }, - {} - )) as { success: boolean; error?: string }; + it("rejects an empty description without writing", async () => { + const result = (await tool.run({ ...report(), issue_description: " " }, {})) as { + success: boolean; + error?: string; + }; expect(result.success).toBe(false); - expect(creates).toHaveLength(0); + expect(await storedRows()).toHaveLength(0); }); - it("never leaks the Notion error to the model", async () => { - stubFlagsEnv(); + it("never leaks the database error to the model", async () => { vi.spyOn(console, "error").mockImplementation(() => {}); - server.use( - http.post(`${NOTION}/pages`, () => - HttpResponse.json({ object: "error", message: "secret detail" }, { status: 500 }) - ) - ); + vi.stubEnv("DATABASE_URL", "postgres://user:hunter2@127.0.0.1:1/none"); + resetDbForTests(); const result = (await tool.run(report(), {})) as { success: boolean; error?: string }; expect(result.success).toBe(false); - expect(result.error).not.toMatch(/secret detail|500/); + expect(result.error).not.toMatch(/hunter2|postgres|ECONNREFUSED/i); + + vi.stubEnv("DATABASE_URL", ""); + resetDbForTests(); + }); +}); + +describe("flags prompt fragment", () => { + it("no longer tells the assistant that staff read corrections in Notion", () => { + expect(flags.promptFragment({ tools: [] })).not.toMatch(/notion/i); }); }); diff --git a/v5/src/lib/capabilities/flags.ts b/v5/src/lib/capabilities/flags.ts index 7ad36f2..14f6e15 100644 --- a/v5/src/lib/capabilities/flags.ts +++ b/v5/src/lib/capabilities/flags.ts @@ -1,23 +1,25 @@ import { z } from "zod"; import { getCatalogTools } from "../catalog"; -import { notionPageIdForTool } from "../data/notion-ids"; -import type { FlagFields, FlaggedField } from "../types"; +import { createFeedback, type NewFeedback } from "../data/feedback"; +import type { FlaggedField } from "../types"; import type { Capability, CapabilityCtx, CapabilityTool } from "./types"; /** * The `flags` capability: filing catalog corrections ("report a correction", - * design spec 2026-07-29). The `Flags` Notion database already existed and was - * unused — this connects it. + * design spec 2026-07-29). Since Phase 3 a correction is a row in the + * `feedback` table (data platform spec §3.10, §4.9) rather than a page in the + * Notion `Flags` database — the raw `fetch` §3.10 names for removal is gone, + * and `src/lib/data/feedback.ts` is the only thing that touches the table. * * Two surfaces share this module so there is exactly one validation and one * write path (constitution Art. 2, spec §3): the assistant calls * `report_correction`, and `POST /api/flags` calls {@link parseCorrectionReport} * + {@link submitCorrection} directly. * - * A flag is inert by construction (spec §8): it only ever creates a row in the - * Flags database. Nothing here writes to Tools, Units, or anything else — the - * only path from a student's report to the catalog runs through a human in - * Notion. + * A flag is inert by construction (spec §8): it only ever inserts into + * `feedback`. Nothing here writes to `tools`, `units`, or anything else — the + * only path from a student's report to the catalog runs through a person on + * `/admin/corrections`. */ // ── Contract ─────────────────────────────────────────────────────── @@ -43,7 +45,14 @@ export const MAX_FLAG_TEXT = 2_000; /** Length cap on the free-text reporter name. */ export const MAX_REPORTER_CHARS = 200; -/** Every way a submission can fail. Surfaces map these to their own messages. */ +/** + * Every way a submission can fail. Surfaces map these to their own messages. + * + * `not_configured` is no longer *returned* — the write is local now, and there + * is no credential that could be missing. It stays in the union and in + * `FlagButton`'s message map because removing it would be a client change, a + * translated string retired and a route status table edited, for nothing. + */ export type FlagErrorCode = | "invalid_input" | "unknown_tool" @@ -52,7 +61,7 @@ export type FlagErrorCode = /** A validated, normalized correction report — the input to the write. */ export interface CorrectionReport { - /** Notion page id (or slug) of the tool the report is about. */ + /** Catalogue id (a Postgres uuid) or slug of the tool the report is about. */ tool_id: string; field_flagged: FlaggedField; issue_description: string; @@ -64,20 +73,21 @@ export interface CorrectionReport { /** * The signed-in reporter, when there is one. Deliberately **not** part of * {@link CorrectionReport}: a client may not assert its own identity, so - * `reporter_email` is only ever written from a server-resolved session. No - * surface passes one yet — that lands with the auth spec (spec §4, §9.4). + * `reporter_email` is only ever written from a server-resolved session. Both + * surfaces resolve one now; an anonymous caller simply has none. */ export interface ReporterIdentity { name?: string; email?: string; + /** The signed-in user's id, recorded on the row so staff can follow up. */ + userId?: string; } /** - * What gets written to the Flags database. `reporter_email` is not on - * `FlagFields` yet because the Notion property is new (spec §4); it rides - * alongside until `types.ts` catches up. + * What gets written. The column shape of one `feedback` row, built by + * {@link buildFeedbackRow} and inserted by `src/lib/data/feedback.ts`. */ -export type FlagWriteFields = Partial & { reporter_email?: string }; +export type FeedbackRow = NewFeedback; export type SubmitCorrectionResult = | { ok: true; id: string } @@ -96,7 +106,7 @@ interface ReportCorrectionInput { const reportCorrectionInputSchema: z.ZodType = z.object({ tool_id: z .string() - .describe("Notion page id (or slug) of the tool the report is about"), + .describe("Catalogue id or slug of the tool the report is about"), field_flagged: z .enum(FLAG_FIELDS) .describe("Which field of the catalog entry is wrong"), @@ -148,151 +158,71 @@ export function parseCorrectionReport( } /** - * Build the Flags row. Pure — the Notion call is separate so title generation, - * the `New` status, and the `reporter_email`-only-when-signed-in rule are all - * unit-testable without touching the network. + * Build the `feedback` row. Pure — the insert is separate so the `new` status + * and the `reporter_email`-only-when-signed-in rule stay unit-testable without + * a database. + * + * There is no title to generate any more: `feedback` has no title column, and + * the ` — ` string only ever existed because a Notion page needs + * one. `/admin/corrections` renders the tool and the field from their own + * columns. */ -export function buildFlagFields( +export function buildFeedbackRow( report: CorrectionReport, tool: FlaggedTool, identity?: ReporterIdentity -): FlagWriteFields { - const fields: FlagWriteFields = { - title: `${tool.name} — ${report.field_flagged}`, - field_flagged: report.field_flagged, - issue_description: report.issue_description, - status: "New", +): FeedbackRow { + const row: FeedbackRow = { + // Always the catalogue uuid, never the slug the caller may have passed: + // `findTool` resolves either and reports the id. + toolId: tool.id, + fieldFlagged: report.field_flagged, + issueDescription: report.issue_description, + suggestedFix: report.suggested_fix ?? null, + reporterName: report.reporter || identity?.name || null, + reporterEmail: null, + reporterUserId: identity?.userId ?? null, }; - // The `tool` relation addresses a Notion page, and `tool.id` is a Postgres - // uuid since the read path moved (spec §3.10). A tool with no imported page - // is filed without the relation rather than with an id Notion would reject: - // a correction staff have to match up by title beats one that never arrived. - if (tool.notionPageId) fields.tool = [tool.notionPageId]; - - if (report.suggested_fix) fields.suggested_fix = report.suggested_fix; - - const reporter = report.reporter || identity?.name; - if (reporter) fields.reporter = reporter; // Only ever from a server-resolved session — never from the request body. - if (identity?.email) fields.reporter_email = identity.email; - - return fields; -} - -// ── Notion write ─────────────────────────────────────────────────── - -const NOTION_API_URL = "https://api.notion.com/v1"; -const NOTION_VERSION = "2022-06-28"; - -/** True when the Flags database is configured. Reads are unaffected. */ -export function hasFlagsEnv(): boolean { - return Boolean(process.env.NOTION_API_KEY && process.env.NOTION_DB_FLAGS); -} - -type NotionWriteProperty = Record; + if (identity?.email) row.reporterEmail = identity.email; -function richText(value: string): NotionWriteProperty { - return { rich_text: [{ text: { content: value } }] }; -} - -/** - * Create one page in the Flags database. Deliberately scoped to that single - * database id — there is no code path here that can address another one. - */ -async function createFlagPage(fields: FlagWriteFields): Promise { - const apiKey = process.env.NOTION_API_KEY as string; - const databaseId = process.env.NOTION_DB_FLAGS as string; - - const properties: Record = { - title: { title: [{ text: { content: fields.title || "Correction report" } }] }, - status: { select: { name: fields.status || "New" } }, - }; - if (fields.field_flagged) { - properties.field_flagged = { select: { name: fields.field_flagged } }; - } - if (fields.tool?.length) { - properties.tool = { relation: fields.tool.map((id) => ({ id })) }; - } - if (fields.issue_description) { - properties.issue_description = richText(fields.issue_description); - } - if (fields.suggested_fix) { - properties.suggested_fix = richText(fields.suggested_fix); - } - if (fields.reporter) { - properties.reporter = richText(fields.reporter); - } - if (fields.reporter_email) { - properties.reporter_email = { email: fields.reporter_email }; - } - - const res = await fetch(`${NOTION_API_URL}/pages`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - "Notion-Version": NOTION_VERSION, - }, - body: JSON.stringify({ - parent: { database_id: databaseId }, - properties, - }), - }); - - if (!res.ok) { - // Body is read for the server log only; it never reaches the caller. - const body = await res.text().catch(() => ""); - throw new Error(`Notion API ${res.status}: ${body}`); - } - - const page = (await res.json()) as { id?: string }; - if (!page.id) throw new Error("Notion API returned no page id"); - return page.id; + return row; } // ── Submission (shared by both surfaces) ─────────────────────────── -/** - * The tool a report is about, with the Notion page behind it. - * - * `id` is the catalogue id (a Postgres uuid); `notionPageId` is what the Flags - * relation needs, and is null for a tool that never came from Notion. - */ +/** The tool a report is about. `id` is the catalogue id, a Postgres uuid. */ export interface FlaggedTool { id: string; name: string; - notionPageId?: string | null; } /** Resolve by catalogue id or slug — surfaces disagree about which they hold. */ async function findTool(toolId: string): Promise { const tools = await getCatalogTools(); const match = tools.find((tool) => tool.id === toolId || tool.slug === toolId); - if (!match) return null; - return { - id: match.id, - name: match.name, - notionPageId: await notionPageIdForTool(match.id), - }; + return match ? { id: match.id, name: match.name } : null; } /** - * File a validated report. Never throws and never returns the underlying Notion - * error — a failed write is logged server-side and reported to the caller as an - * opaque `write_failed` (spec §10, "without leaking the Notion error"). + * File a validated report. Never throws and never returns the underlying + * database error — a failed write is logged server-side and reported to the + * caller as an opaque `write_failed` (spec §10, "without leaking the error"). */ export async function submitCorrection( report: CorrectionReport, identity?: ReporterIdentity ): Promise { - if (!hasFlagsEnv()) return { ok: false, code: "not_configured" }; - - const tool = await findTool(report.tool_id); - if (!tool) return { ok: false, code: "unknown_tool" }; - try { - const id = await createFlagPage(buildFlagFields(report, tool, identity)); + // The catalogue read is inside the try with the write: both are Postgres + // now, so an unreachable database fails the tool lookup first, and that has + // to come back as a failed write rather than a thrown promise the caller + // was not expecting. + const tool = await findTool(report.tool_id); + if (!tool) return { ok: false, code: "unknown_tool" }; + + const { id } = await createFeedback(buildFeedbackRow(report, tool, identity)); return { ok: true, id }; } catch (err) { console.error("Flag submission failed", err); @@ -309,7 +239,22 @@ interface ReportCorrectionResult { error?: string; } -/** Model-facing failure text. Opaque by design — no Notion detail escapes. */ +/** + * The capability context's identity as a {@link ReporterIdentity}, or undefined + * when nobody is signed in. An anonymous identity is present on the context but + * carries no one, and must not be mistaken for a session. + */ +function identityOf(ctx: CapabilityCtx): ReporterIdentity | undefined { + const identity = ctx.identity; + if (!identity || (!identity.email && !identity.userId)) return undefined; + return { + name: identity.name ?? undefined, + email: identity.email ?? undefined, + userId: identity.userId ?? undefined, + }; +} + +/** Model-facing failure text. Opaque by design — no database detail escapes. */ const FAILURE_MESSAGES: Record = { invalid_input: "A tool and a description of the problem are required.", unknown_tool: "That tool is not in the catalog.", @@ -336,9 +281,11 @@ const reportCorrection: CapabilityTool nextCacheMock()); +// `create_tool` still writes to Notion until Phase 6, so the one write it makes +// is captured at the module boundary rather than stubbed with MSW — what these +// tests care about is the *arguments*, specifically that no upload id travels. +const notionHook = vi.hoisted(() => ({ + createTool: vi + .fn<(fields: Record) => Promise<{ id: string }>>() + .mockResolvedValue({ id: "notion-tool-1" }), +})); + +vi.mock("../notion", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createTool: (fields: unknown) => notionHook.createTool(fields as never), + }; +}); + /** * Intake ↔ confidence wiring (confidence spec phases 2, 4 and 5). The catalog * read runs against the PGlite demo seed (no `DATABASE_URL`, so @@ -261,8 +278,8 @@ describe("research_tool fan-out", () => { it("applies the turn's photos to a lone candidate but not across a batch", async () => { const ctx = { attachments: [ - { file_upload_id: "up_1", name: "a.jpg", contentType: "image/jpeg" }, - { file_upload_id: "up_2", name: "b.jpg", contentType: "image/jpeg" }, + { attachmentId: "up_1", name: "a.jpg", contentType: "image/jpeg" }, + { attachmentId: "up_2", name: "b.jpg", contentType: "image/jpeg" }, ], } as CapabilityCtx; const run = toolByName("research_tool").run; @@ -495,10 +512,10 @@ describe("duplicate card", () => { }); }); -// ── Who may add equipment (auth spec amendment 2026-09-14) ─────────── +// ── Who may add equipment (data platform design spec §3.5) ────────── describe("intake access", () => { - it("requires staff", () => { - expect(intake.minimumRole).toBe("staff"); + it("requires the tools.add permission", () => { + expect(intake.requiredPermission).toBe("tools.add"); }); it("explains the limit instead of the flow when locked", () => { @@ -507,3 +524,53 @@ describe("intake access", () => { expect(locked).not.toContain("research_tool"); }); }); + +describe("create_tool — photos do not travel to Notion", () => { + beforeEach(() => { + notionHook.createTool.mockClear(); + }); + + it("sends no image uploads, because a Postgres uuid is not a Notion file_upload id", async () => { + const run = toolByName("create_tool").run; + + await run( + { + candidate: candidate({ + image_upload_ids: ["8f14e45f-ceea-467a-9f36-3a1c6e3c1a11"], + }), + }, + {} as CapabilityCtx + ); + + const [fields] = notionHook.createTool.mock.calls[0]; + // Notion rejects the whole page when it does not recognise a file_upload + // id, so sending one would lose the listing, not just the picture. + expect(fields.image_uploads).toBeUndefined(); + }); + + it("warns that the photos stayed in the app rather than implying they attached", async () => { + const run = toolByName("create_tool").run; + + const result = (await run( + { + candidate: candidate({ + image_upload_ids: ["8f14e45f-ceea-467a-9f36-3a1c6e3c1a11"], + }), + }, + {} as CapabilityCtx + )) as { success: boolean; warnings: string[] }; + + expect(result.warnings.some((w) => /stayed in the app/i.test(w))).toBe(true); + }); + + it("says nothing about photos when none were offered", async () => { + const run = toolByName("create_tool").run; + + const result = (await run( + { candidate: candidate() }, + {} as CapabilityCtx + )) as { warnings: string[] }; + + expect(result.warnings.some((w) => /photo/i.test(w))).toBe(false); + }); +}); diff --git a/v5/src/lib/capabilities/intake.ts b/v5/src/lib/capabilities/intake.ts index 5c8f255..45db637 100644 --- a/v5/src/lib/capabilities/intake.ts +++ b/v5/src/lib/capabilities/intake.ts @@ -8,7 +8,7 @@ import { findOrCreateLocation, } from "../notion"; import type { MakerLabTool } from "../../components/catalog-types"; -import { INTAKE_MINIMUM_ROLE } from "./access"; +import { INTAKE_PERMISSION } from "./access"; import { scoreConfidence, toEvidence } from "./confidence"; import { findTool } from "./helpers"; import { @@ -383,14 +383,13 @@ const researchTool: CapabilityTool = { { candidates }, ctx: CapabilityCtx ): Promise => { - // Carry through the turn's image uploads so the eventual create_tool can - // re-attach the same photos without re-uploading — but only for a single - // item. In a batch the turn's photos belong to different machines, and the + // Carry through the turn's image uploads so the photos stay with the item + // they show — but only for a single item. In a batch the turn's photos belong to different machines, and the // model already assigned them per candidate; merging them all into every // candidate would put all eight photos on all eight tools. const extraImageIds = candidates.length === 1 - ? (ctx.attachments || []).map((a) => a.file_upload_id) + ? (ctx.attachments || []).map((a) => a.attachmentId) : []; // Bounded, all-settled fan-out (spec §3.3): eight photos resolve in roughly @@ -746,7 +745,9 @@ const createToolTool: CapabilityTool = { "Create a draft catalog listing in Notion for a confirmed candidate: find-or-create its Category and Location, create the Tool (published=false), create each Unit linked to the tool, and create each manual/video Resource (published=false). NEVER call this without a prior propose_listing and an explicit user confirmation. Everything is created as a draft — staff publish it later in Notion. Returns the created ids and a draft link; on partial failure it reports exactly what landed so nothing is lost silently.", inputSchema: createInputSchema, kind: "write", - run: async ({ candidate }, ctx: CapabilityCtx): Promise => { + // No `ctx`: the turn's photos used to be read here to name the Notion file + // uploads. Nothing in this write reaches outside the candidate any more. + run: async ({ candidate }): Promise => { const warnings: string[] = []; const created: CreateResult["created"] = { tool: false, @@ -792,13 +793,21 @@ const createToolTool: CapabilityTool = { // 2. Create the tool (published=false). If this fails there is nothing to // link units/resources to, so we bail with a clean failure. - const attachmentNames = new Map( - (ctx.attachments || []).map((a) => [a.file_upload_id, a.name]) - ); - const imageUploads = candidate.image_upload_ids.map((id) => ({ - id, - name: attachmentNames.get(id) || "photo", - })); + // + // **Photos do not travel with it.** They used to: `/api/upload-notion` + // handed back a Notion `file_upload_id` and this call passed it straight + // into the new page's `image_attachments`. Uploads are Vercel Blob now + // (data platform spec §3.3), so `image_upload_ids` holds Postgres uuids — + // and Notion rejects an entire page whose file_upload id it does not + // recognise. Tool creation itself does not move off Notion until Phase 6, + // so the honest answer in between is to create the tool without the + // pictures and say so, rather than lose the listing to a rejected page or + // claim an attachment that is not there (Article 4). + if (candidate.image_upload_ids.length > 0) { + warnings.push( + `The ${candidate.image_upload_ids.length === 1 ? "photo" : "photos"} stayed in the app and were not attached to the new listing — add them by hand in Notion.` + ); + } let toolId: string; try { @@ -812,7 +821,6 @@ const createToolTool: CapabilityTool = { tags: candidate.tags, training_required: candidate.training_required, use_restrictions: candidate.use_restrictions, - image_uploads: imageUploads.length ? imageUploads : undefined, }); toolId = toolRecord.id; created.tool = true; @@ -937,7 +945,7 @@ function promptFragment(_env: PromptEnv): string { ` - **Medium** — the card leads with what is unresolved and its primary button resolves it. Do not talk the user past it; the ambiguity is the point.`, ` - **Low** — **no card was rendered, and you must not describe the item as though one was.** Do not restate a listing in prose, and do not offer to add it. Ask for the single thing named in that item's \`ask\` array, in one short sentence, phrased as something the person can do in five seconds — e.g. "I can see it's a filament 3D printer but I can't read the model — could you photograph the label on the front or side?" When they answer, re-run \`research_tool\` with the new information.`, `4. **Handle duplicates.** If \`research_tool\` reported a \`duplicate_of\`, the card surfaces "Already in catalog". Tell the user it is already listed and link the existing tool using its catalog slug. Adding another unit to an existing tool is not available here yet — staff add units for now. Only create a separate listing if the user explicitly asks for one.`, - `5. **Create on confirmation.** Once the user confirms, call \`create_tool\` with that single candidate. Everything is saved as a **draft** (\`published = false\`) — tell the user it's saved as a draft and that staff will publish it. If \`create_tool\` reports \`warnings\` (a partial write), relay exactly what landed and what to finish in Notion; never claim full success when steps failed.`, + `5. **Create on confirmation.** Once the user confirms, call \`create_tool\` with that single candidate. Everything is saved as a **draft** (\`published = false\`) — tell the user it's saved as a draft and that staff will publish it. If \`create_tool\` reports \`warnings\` (a partial write), relay exactly what landed and what to finish in Notion; never claim full success when steps failed. **Photos are not attached to the listing yet** — when a warning says so, tell the user the picture stayed in the app and has to be added by hand; never say the photo is on the new listing.`, `**Batches:** when the user describes several items at once (a long list, or multiple photos), assemble one candidate per item, pass them all to a single \`research_tool\` call, then all of them to a single \`propose_listing\` call so each gets its own card. Confirm and \`create_tool\` each item independently; if the user says "add all", create each confirmed candidate in turn — but never create one that came back under \`needs_more_info\`, since the user never saw a card for it. In a batch, assign each photo to the candidate it actually shows via that candidate's \`image_upload_ids\`; the turn's photos are not applied to every item.`, `Confirmation messages from card buttons arrive as short follow-ups like \`confirm add: \`, \`confirm model: = \`, \`confirm variant: = \`, \`create new tool anyway: \`, \`edit: \`, or \`discard: \`. Resolve \`confirm add\` to a \`create_tool\` call for the matching candidate. \`create new tool anyway\` is the user explicitly asking for a separate listing despite a catalog match — treat it the same way. \`confirm model\` and \`confirm variant\` are the user resolving an ambiguity: adopt the named model as the candidate's \`name\`, correct any spec that differs between the variants (re-fetch the right page if they do), and then call \`create_tool\` — that click is the human confirmation, so no second one is needed. On \`edit\`, ask what to change and re-run \`propose_listing\`; on \`discard\`, drop that candidate.`, ].join("\n\n"); @@ -959,8 +967,9 @@ function lockedPromptFragment(): string { export const intake: Capability = { id: "intake", - // Staff and admins only on the chat surface — enforced in `access.ts`. - minimumRole: INTAKE_MINIMUM_ROLE, + // Admins and super admins only on the chat surface — `tools.add`, + // enforced once in `access.ts` against the declaration in `auth/permissions.ts`. + requiredPermission: INTAKE_PERMISSION, promptFragment, lockedPromptFragment, // Heterogeneous tool input/output types are erased to the registry's loose diff --git a/v5/src/lib/capabilities/maintenance.test.ts b/v5/src/lib/capabilities/maintenance.test.ts index 2fcecde..4a9fd38 100644 --- a/v5/src/lib/capabilities/maintenance.test.ts +++ b/v5/src/lib/capabilities/maintenance.test.ts @@ -1,32 +1,40 @@ // @vitest-environment node +import { eq } from "drizzle-orm"; import { nextCacheMock } from "../../../test/mocks/next-cache"; import { getCatalogTools } from "../catalog"; -import { resetDbForTests } from "../db/client"; -import { DEMO_FORM_4_UNIT_NOTION_PAGE_ID } from "../db/demo-seed"; +import { getDb, resetDbForTests } from "../db/client"; +import { attachments, maintenanceLogs } from "../db/schema/index"; +import { seedUser } from "../../../test/utils/session"; import type { CapabilityCtx } from "./types"; import type { Identity } from "../auth/identity"; // `catalog.ts` (pulled in to resolve unit labels) imports cacheTag/cacheLife. vi.mock("next/cache", () => nextCacheMock()); -// Only the write is mocked; everything else in notion.ts stays real. Units are -// resolved against the demo-seeded PGlite database (`DATABASE_URL` unset), so -// no test here needs a network call to resolve "Form 4 // A" (Article 3). -const mocks = vi.hoisted(() => ({ createMaintenanceLog: vi.fn() })); -vi.mock("../notion", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, createMaintenanceLog: mocks.createMaintenanceLog }; -}); -const { createMaintenanceLog } = mocks; - import { maintenance } from "./maintenance"; +/** + * Filing a ticket, end to end against the demo-seeded PGlite database with + * **no environment variables at all** — no Notion, no MSW, no network. The + * whole path the student's report takes is real code from here down (Article 3). + */ + const reportIssue = maintenance.tools[0]; -/** A signed-in caller, as `resolveIdentity` would return one. */ +/** + * A signed-in caller, as `resolveIdentity` would return one — plus the `user` + * row they are. Since Phase 4 `created_by` references `user.id`, so a ticket + * filed by a session that is not a row is refused, which is exactly what + * production does too. + */ +async function signedInUser(overrides: Partial = {}): Promise { + await seedUser({ id: "google-sub-1", email: "ada@cornell.edu", name: "Ada Lovelace" }); + return signedIn(overrides); +} + function signedIn(overrides: Partial = {}): Identity { return { - role: "student", + role: "user", userId: "google-sub-1", email: "ada@cornell.edu", name: "Ada Lovelace", @@ -55,24 +63,36 @@ function issue(overrides: Record = {}) { }; } -/** The fields handed to `createMaintenanceLog` on the Nth (default first) call. */ -function writtenFields(call = 0): Record { - return createMaintenanceLog.mock.calls[call][0] as Record; +interface TicketResult { + success: boolean; + ticket_id?: string; + unit_resolved?: { id: string; label: string } | null; + message?: string; + error?: string; } -function ticket(id = "ticket-1") { - return { - id, - createdTime: "2026-07-29T10:00:00.000Z", - lastEditedTime: "2026-07-29T10:00:00.000Z", - fields: { title: "Bed not leveling" }, - }; +/** The row the capability actually wrote. */ +async function storedTicket(id: string) { + const db = await getDb(); + const [row] = await db.select().from(maintenanceLogs).where(eq(maintenanceLogs.id, id)); + return row; +} + +/** File a ticket and fail the test loudly if the write did not land. */ +async function file(input: Record, ctx: CapabilityCtx = {}) { + const result = (await reportIssue.run(input, ctx)) as TicketResult; + if (!result.success || !result.ticket_id) { + throw new Error(`report_issue failed: ${result.error}`); + } + return { result, row: await storedTicket(result.ticket_id) }; } -beforeEach(() => { +beforeEach(async () => { vi.stubEnv("DATABASE_URL", ""); - createMaintenanceLog.mockReset(); - createMaintenanceLog.mockResolvedValue(ticket()); + vi.stubEnv("LAB_TIMEZONE", "America/New_York"); + const db = await getDb(); + await db.delete(attachments); + await db.delete(maintenanceLogs); }); afterAll(() => { @@ -84,162 +104,182 @@ async function seededUnit(toolName: string) { const tools = await getCatalogTools(); const tool = tools.find((t) => t.name === toolName); if (!tool?.units[0]) throw new Error(`No seeded unit for ${toolName}`); - return tool.units[0]; + return { tool, unit: tool.units[0] }; } +describe("report_issue — the ticket that lands", () => { + it("writes an open issue_report against the resolved unit and its tool", async () => { + const { tool, unit } = await seededUnit("Form 4"); + + const { result, row } = await file(issue({ unit_label: "Form 4 // A", priority: "High" })); + + expect(result.unit_resolved).toEqual({ id: unit.id, label: "Form 4 // A" }); + expect(result.message).toContain(result.ticket_id); + // The unit id and the tool id are Postgres uuids on both sides now — no + // translation left between the catalogue and the ticket. + expect(row.unitId).toBe(unit.id); + expect(row.toolId).toBe(tool.id); + expect(row.unitLabel).toBe("Form 4 // A"); + expect(row.toolName).toBe("Form 4"); + // Display casing in, stored vocabulary out. + expect(row.type).toBe("issue_report"); + expect(row.priority).toBe("high"); + expect(row.status).toBe("open"); + expect(row.dateReported).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("files a ticket unlinked when the unit label resolves to nothing", async () => { + const { result, row } = await file(issue({ unit_label: "Prusa #99" })); + + // An unresolvable label is not an error: a ticket with no target is still + // a ticket (§4.8). + expect(result.unit_resolved).toBeNull(); + expect(row.unitId).toBeNull(); + expect(row.toolId).toBeNull(); + expect(row.title).toBe("Bed not leveling"); + }); + + it("maps the default priority down to the stored value", async () => { + const { row } = await file(issue()); + expect(row.priority).toBe("medium"); + }); +}); + describe("report_issue — verified authorship", () => { it("records the session's name and email when the student is signed in", async () => { - const ctx: CapabilityCtx = { identity: signedIn() }; - - const result = (await reportIssue.run(issue(), ctx)) as { success: boolean }; + const { row } = await file(issue(), { identity: await signedInUser() }); - expect(result.success).toBe(true); - expect(writtenFields()).toMatchObject({ - reported_by: "Ada Lovelace", - reporter_email: "ada@cornell.edu", - }); + expect(row.reportedByName).toBe("Ada Lovelace"); + expect(row.reportedByEmail).toBe("ada@cornell.edu"); + expect(row.reportedByUserId).toBe("google-sub-1"); }); it("prefers the verified name over the one the model supplied", async () => { - const ctx: CapabilityCtx = { identity: signedIn() }; - - await reportIssue.run(issue({ reported_by: "Somebody Else" }), ctx); + const { row } = await file(issue({ reported_by: "Somebody Else" }), { + identity: await signedInUser(), + }); - expect(writtenFields().reported_by).toBe("Ada Lovelace"); - expect(writtenFields().reporter_email).toBe("ada@cornell.edu"); + expect(row.reportedByName).toBe("Ada Lovelace"); + expect(row.reportedByEmail).toBe("ada@cornell.edu"); }); it("falls back to the model-supplied name, with no email, when there is no identity", async () => { - await reportIssue.run(issue({ reported_by: "Grace Hopper" }), {}); + const { row } = await file(issue({ reported_by: "Grace Hopper" })); - expect(writtenFields().reported_by).toBe("Grace Hopper"); - expect(writtenFields().reporter_email).toBeUndefined(); + expect(row.reportedByName).toBe("Grace Hopper"); + expect(row.reportedByEmail).toBeNull(); }); it("treats an anonymous identity exactly as no identity at all", async () => { - const ctx: CapabilityCtx = { identity: anonymous() }; - - await reportIssue.run(issue({ reported_by: "Grace Hopper" }), ctx); + const { row } = await file(issue({ reported_by: "Grace Hopper" }), { + identity: anonymous(), + }); - expect(writtenFields().reported_by).toBe("Grace Hopper"); - expect(writtenFields().reporter_email).toBeUndefined(); + expect(row.reportedByName).toBe("Grace Hopper"); + expect(row.reportedByEmail).toBeNull(); + expect(row.reportedByUserId).toBeNull(); }); it("files an anonymous ticket with no reporter at all", async () => { - const result = (await reportIssue.run(issue(), {})) as { success: boolean }; + const { row } = await file(issue()); - expect(result.success).toBe(true); - expect(writtenFields().reported_by).toBeUndefined(); - expect(writtenFields().reporter_email).toBeUndefined(); + expect(row.reportedByName).toBeNull(); + expect(row.reportedByEmail).toBeNull(); }); it("ignores a reporter_email supplied as tool input", async () => { - // A client may never assert its own identity. `reporter_email` is not on the - // input schema, and `run()` must not pass one through even if it arrives. - await reportIssue.run(issue({ reporter_email: "attacker@cornell.edu" }), {}); + // A client may never assert its own identity. `reporter_email` is not on + // the input schema, and `run()` must not pass one through even if it + // arrives. + const { row } = await file(issue({ reporter_email: "attacker@cornell.edu" })); - expect(writtenFields().reporter_email).toBeUndefined(); + expect(row.reportedByEmail).toBeNull(); }); it("ignores a reporter_email supplied as tool input even when signed in", async () => { - const ctx: CapabilityCtx = { identity: signedIn() }; - - await reportIssue.run(issue({ reporter_email: "attacker@cornell.edu" }), ctx); + const { row } = await file(issue({ reporter_email: "attacker@cornell.edu" }), { + identity: await signedInUser(), + }); - expect(writtenFields().reporter_email).toBe("ada@cornell.edu"); + expect(row.reportedByEmail).toBe("ada@cornell.edu"); }); +}); - it("still links a resolved unit and reports the ticket id", async () => { - const unit = await seededUnit("Form 4"); +describe("report_issue — photos", () => { + it("attaches an uploaded photo to the new ticket", async () => { + const db = await getDb(); + const [photo] = await db + .insert(attachments) + .values({ blobPathname: "uploads/a.png", access: "private", contentType: "image/png" }) + .returning({ id: attachments.id }); - const result = (await reportIssue.run( - issue({ unit_label: "Form 4 // A", priority: "High" }), - { identity: signedIn() } - )) as { success: boolean; ticket_id?: string; unit_resolved?: unknown }; + const { result } = await file( + issue({ photo_attachment_ids: [photo.id] }) + ); - expect(result.ticket_id).toBe("ticket-1"); - // The caller is told the catalogue id — a Postgres uuid, the one every - // other capability accepts back. - expect(result.unit_resolved).toEqual({ - id: unit.id, - label: "Form 4 // A", - }); - // The Notion relation is the imported page id, not the uuid: Notion rejects - // a relation to a page it cannot find. - expect(writtenFields().unit).toEqual([DEMO_FORM_4_UNIT_NOTION_PAGE_ID]); + const [row] = await db.select().from(attachments).where(eq(attachments.id, photo.id)); + expect(row.ownerType).toBe("maintenance_log"); + expect(row.ownerId).toBe(result.ticket_id); + expect(result.message).not.toMatch(/could not be attached/i); }); - it("files the ticket unlinked when the unit never came from Notion", async () => { + it("tells the model the photos did not attach rather than letting it imply they did", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const unit = await seededUnit("Trotec Speedy 400"); - const result = (await reportIssue.run( - issue({ unit_label: "Trotec Speedy 400" }), - {} - )) as { success: boolean; ticket_id?: string; unit_resolved?: unknown }; + // A stale id from a previous session, or one whose upload was swept by the + // nightly cleanup. No `attachments` row answers to it. + const { result } = await file( + issue({ photo_attachment_ids: [crypto.randomUUID()] }) + ); - // The resolution still happened and is reported; only the relation is gone. + // The ticket is still filed — the photo is not worth losing the report + // over — but nobody is told a picture arrived that did not (Article 4). expect(result.success).toBe(true); - expect(result.unit_resolved).toEqual({ id: unit.id, label: "Trotec Speedy 400" }); - expect(writtenFields().unit).toBeUndefined(); + expect(result.message).toMatch(/could not be attached/i); expect(warn).toHaveBeenCalled(); }); }); -describe("report_issue — missing Notion property", () => { - it("re-files without reporter_email when Notion rejects the property", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - createMaintenanceLog.mockReset(); - createMaintenanceLog - .mockRejectedValueOnce( - new Error( - 'Notion API 400: {"code":"validation_error","message":"reporter_email is not a property that exists"}' - ) - ) - .mockResolvedValueOnce(ticket("ticket-2")); - - const result = (await reportIssue.run(issue(), { - identity: signedIn(), - })) as { success: boolean; ticket_id?: string }; - - // The ticket survives; only the email is dropped, and loudly. - expect(result.success).toBe(true); - expect(result.ticket_id).toBe("ticket-2"); - expect(createMaintenanceLog).toHaveBeenCalledTimes(2); - expect(writtenFields(1).reporter_email).toBeUndefined(); - expect(writtenFields(1).reported_by).toBe("Ada Lovelace"); - expect(warn).toHaveBeenCalled(); - }); - - it("does not retry an unrelated failure", async () => { - createMaintenanceLog.mockReset(); - createMaintenanceLog.mockRejectedValue(new Error("Notion API 401: unauthorized")); - - const result = (await reportIssue.run(issue(), { - identity: signedIn(), - })) as { success: boolean; error?: string }; - - expect(result.success).toBe(false); - expect(result.error).toMatch(/401/); - expect(createMaintenanceLog).toHaveBeenCalledTimes(1); +describe("report_issue — validation and failure", () => { + it("refuses a call with no title", async () => { + const parsed = reportIssue.inputSchema.safeParse({ + description: "Something is wrong", + priority: "Medium", + }); + expect(parsed.success).toBe(false); }); - it("does not retry when there was no email to drop", async () => { - createMaintenanceLog.mockReset(); - createMaintenanceLog.mockRejectedValue( - new Error("Notion API 400: reporter_email is not a property that exists") - ); + it("reports a failed write as a ticket that did not land, and leaks nothing", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + // A database that is configured and unreachable — the case that must never + // come back as "logged your ticket". + vi.stubEnv("DATABASE_URL", "postgres://user:hunter2@127.0.0.1:1/none"); + resetDbForTests(); - const result = (await reportIssue.run(issue(), {})) as { success: boolean }; + const result = (await reportIssue.run(issue(), {})) as TicketResult; expect(result.success).toBe(false); - expect(createMaintenanceLog).toHaveBeenCalledTimes(1); + expect(result.ticket_id).toBeUndefined(); + expect(result.error).toMatch(/could not be filed/i); + // The driver's own words — which can carry a connection string — stay in + // the server log and never reach the model. + expect(result.error).not.toMatch(/hunter2|postgres|ECONNREFUSED/i); + expect(error).toHaveBeenCalled(); + + vi.stubEnv("DATABASE_URL", ""); + resetDbForTests(); }); }); describe("maintenance prompt fragment", () => { const env = { tools: [] }; + it("no longer tells the assistant the ticket goes to Notion", () => { + const fragment = maintenance.promptFragment(env); + expect(fragment).not.toMatch(/notion/i); + expect(reportIssue.description).not.toMatch(/notion/i); + }); + it("names the signed-in student and tells the assistant not to ask", () => { const fragment = maintenance.promptFragment({ ...env, diff --git a/v5/src/lib/capabilities/maintenance.ts b/v5/src/lib/capabilities/maintenance.ts index bbde95b..a1e2132 100644 --- a/v5/src/lib/capabilities/maintenance.ts +++ b/v5/src/lib/capabilities/maintenance.ts @@ -1,8 +1,6 @@ import { z } from "zod"; import { getCatalogTools } from "../catalog"; -import { notionPageIdForUnit } from "../data/notion-ids"; -import { createMaintenanceLog } from "../notion"; -import type { MaintenanceLogFields, MaintenanceLogRecord } from "../types"; +import { createMaintenanceLog } from "../data/maintenance"; import { buildUnitLookup, findUnit } from "./helpers"; import type { Capability, @@ -16,12 +14,20 @@ import type { * MCP). Ported byte-for-byte from the chat route's `report_issue` tool and its * "Reporting maintenance issues" system-prompt section (design spec §3.4, §7). * - * One thing has since changed: **authorship**. When `ctx.identity` carries a - * signed-in caller, the ticket records that name and email rather than whatever - * the conversation supplied (auth spec §3.4, §9.5). Verified authorship is one - * of the reasons sign-in exists. Anonymous reporting still works exactly as it - * did — MCP and scheduled callers have no identity, and neither does a visitor - * who never signed in. + * Two things have since changed: + * + * - **Authorship.** When `ctx.identity` carries a signed-in caller, the ticket + * records that name and email rather than whatever the conversation supplied + * (auth spec §3.4, §9.5). Verified authorship is one of the reasons sign-in + * exists. Anonymous reporting still works exactly as it did — MCP and + * scheduled callers have no identity, and neither does a visitor who never + * signed in. + * - **The ticket lands in Postgres** (data platform spec §3.10, §4.8), not in + * a Notion page. The capability no longer knows anything about Notion: it + * resolves the unit against the catalogue, hands a validated ticket to + * `src/lib/data/maintenance.ts`, and reports what came back. A write that + * throws is reported to the student as a ticket that did **not** land — the + * one thing this path may never get wrong (Article 4). */ const PRIORITIES = ["Critical", "High", "Medium", "Low"] as const; @@ -34,7 +40,7 @@ interface ReportIssueInput { unit_label?: string; priority: (typeof PRIORITIES)[number]; reported_by?: string; - photo_uploads?: Array<{ id: string; name: string }>; + photo_attachment_ids?: string[]; } interface ReportIssueResult { @@ -64,115 +70,105 @@ const reportIssueInputSchema: z.ZodType = z.object({ .describe( "Student name or NetID if they gave one. Ignored when the student is signed in — the verified name from their session is recorded instead." ), - photo_uploads: z - .array( - z.object({ - id: z.string().describe("Notion file_upload_id"), - name: z.string().describe("Original filename"), - }) - ) + photo_attachment_ids: z + .array(z.string()) .optional() .describe( - "Notion file_upload references. Parse these from the [Attached photos: file_upload_id=... name=...] hint in the student's message." + "Attachment ids of photos the student uploaded. Parse the attachment_id values out of the [Attached photos: ...] hint in their message." ), }); +/** + * What the model is told when photos were offered and none of them attached. + * + * English on purpose: it is appended to the ticket-result message the assistant + * paraphrases for the student, and the assistant answers in their language + * (Article 6 — the *ticket itself* is the English exception, this is a hint to + * the model, not a string shown to a person). + */ +const PHOTOS_NOT_ATTACHED = + "The photos could not be attached to this ticket — tell the student the report was filed without them and to describe what the photo showed if it matters."; + const reportIssue: CapabilityTool = { name: "report_issue", description: - "File a maintenance ticket in Notion when a student reports a problem with a tool or unit. Gather a short title and a clear description first. If they named a specific unit (like 'Prusa #1'), include it so the log is linked. Ask for the reporter's name only when nobody is signed in — a signed-in student's verified name is recorded automatically.", + "File a maintenance ticket in the app when a student reports a problem with a tool or unit. Gather a short title and a clear description first. If they named a specific unit (like 'Prusa #1'), include it so the log is linked. Ask for the reporter's name only when nobody is signed in — a signed-in student's verified name is recorded automatically.", inputSchema: reportIssueInputSchema, kind: "write", async run(input: ReportIssueInput, ctx: CapabilityCtx): Promise { - const { title, description, unit_label, priority, reported_by, photo_uploads } = - input; - const tools = await getCatalogTools(); - const unitLookup = buildUnitLookup(tools); - const match = unit_label ? findUnit(unitLookup, unit_label) : null; - // The `unit` relation addresses a Notion page; `match.id` is the Postgres - // uuid the catalogue now hands out (spec §3.10). A unit with no imported - // page is linked in the description-by-title sense only — the ticket is - // filed either way, because Notion rejects a relation to a page it cannot - // find and a rejected write loses the student's report (Article 4). - const unitPageId = match ? await notionPageIdForUnit(match.id) : null; - if (match && !unitPageId) { - console.warn( - "[maintenance] no Notion page for unit — filing the ticket unlinked", - match.label - ); - } + const { title, description, unit_label, priority, reported_by } = input; + // Already uuids: `POST /api/uploads` hands out `attachments.id`s, and the + // data layer claims them onto the new ticket. Anything else is dropped + // there rather than reaching a uuid column. + const photoIds = input.photo_attachment_ids ?? []; + + // The catalogue read is inside the try with the write: an unreachable + // database fails the label lookup first, and a thrown tool call is a worse + // answer than a reported failure — the student has to be told the report + // did not land. try { - const record = await createTicket({ + const tools = await getCatalogTools(); + const unitLookup = buildUnitLookup(tools); + const match = unit_label ? findUnit(unitLookup, unit_label) : null; + + const record = await createMaintenanceLog({ title, description, + // The capability speaks Notion's display casing because that is what + // the input schema was written against; the data module maps it down to + // the stored vocabulary (`issue_report`, `medium`, `open`). type: "Issue Report", priority, status: "Open", + // The catalogue id is a Postgres uuid, and so is `unit_id` — no + // translation left to do. An unresolved label files an unlinked + // ticket, which is normal: most live logs have no unit at all. + unitId: match?.id ?? null, // The session wins over the model's `reported_by`. A client may never // assert its own identity, and a ticket that says who actually filed it // is the reason sign-in was worth building. - reported_by: ctx.identity?.name || reported_by || undefined, + reportedByName: ctx.identity?.name || reported_by || null, // Server-resolved only. There is no input field for this, and there is // deliberately no path that would let one exist. - reporter_email: ctx.identity?.email || undefined, - unit: unitPageId ? [unitPageId] : undefined, - date_reported: new Date().toISOString().split("T")[0], - photo_uploads: photo_uploads?.length ? photo_uploads : undefined, + reportedByEmail: ctx.identity?.email || null, + reportedByUserId: ctx.identity?.userId || null, + photoAttachmentIds: photoIds, }); + + // Photos offered but none claimed: say so rather than let the student + // believe staff can see the picture they took (Article 4). Until the + // upload route moves to Blob this is the normal case, because the ids in + // the hint are still Notion file_upload ids and no `attachments` row + // answers to them. + const photosLost = photoIds.length > 0 && record.photosAttached === 0; + if (photosLost) { + console.warn( + `[maintenance] ticket ${record.id} filed without its ${photoIds.length} photo(s) — no attachment matched the ids supplied` + ); + } + return { success: true, ticket_id: record.id, unit_resolved: match ? { id: match.id, label: match.label } : null, - message: `Logged maintenance ticket ${record.id}.`, + message: photosLost + ? `Logged maintenance ticket ${record.id}. ${PHOTOS_NOT_ATTACHED}` + : `Logged maintenance ticket ${record.id}.`, }; } catch (err) { - const message = err instanceof Error ? err.message : "Failed to file ticket"; - return { success: false, error: message }; + // The database's own words never reach the model: a driver message can + // carry a connection string, and nothing the student can do with it is + // useful. The detail stays in the server log. + console.error("[maintenance] filing a ticket failed", err); + return { + success: false, + error: + "The ticket could not be filed and nothing was recorded. Tell the student to try again shortly, or to find staff if it is urgent.", + }; } }, }; -/** - * Create the ticket, retrying once without `reporter_email` if Notion refuses - * that property. - * - * `reporter_email` is a new Email column a person has to add to - * `Maintenance_Logs` by hand (auth spec §4) — Notion has no migrations, and it - * rejects any write naming a property that does not exist. Losing a student's - * report of an unsafe machine to a missing column is the wrong way to fail: - * file the ticket, drop the email, and make the misconfiguration loud in the - * logs (Article 4 — fail toward stale, not toward wrong). - */ -async function createTicket( - fields: Partial -): Promise { - try { - return await createMaintenanceLog(fields); - } catch (err) { - if (!fields.reporter_email || !isUnknownPropertyError(err, "reporter_email")) { - throw err; - } - console.warn( - "[maintenance] Notion rejected `reporter_email` — filing the ticket without it. Add the Email property to Maintenance_Logs (auth spec §4).", - err - ); - const withoutEmail = { ...fields }; - delete withoutEmail.reporter_email; - return createMaintenanceLog(withoutEmail); - } -} - -/** - * Does this look like Notion refusing an unknown property? `notionFetch` throws - * `Notion API : `, and a schema mismatch is a 400 whose body names - * the offending property. Narrow on both so a 401 or a network blip still - * surfaces as the failure it is. - */ -function isUnknownPropertyError(err: unknown, property: string): boolean { - const message = err instanceof Error ? err.message : String(err); - return message.includes("400") && message.includes(property); -} - // ── Prompt fragment ──────────────────────────────────────────────── /** @@ -209,7 +205,7 @@ You are a first-line helper, not a ticket-creation machine. Follow this order: **Who is reporting.** ${reporterLine} -If the student's message includes a hint like \`[Attached photos: file_upload_id= name=; ...]\`, parse each \`file_upload_id\` and \`name\` pair and pass them as the \`photo_uploads\` argument to \`report_issue\` (do not echo the raw hint back to the student). The IDs are already uploaded to Notion and will be attached to the ticket. +If the student's message includes a hint like \`[Attached photos: attachment_id= name=; ...]\`, pass each \`attachment_id\` value as the \`photo_attachment_ids\` argument to \`report_issue\` (do not echo the raw hint back to the student). If the tool result says the photos could not be attached, tell the student the ticket was filed without them rather than implying staff can see the picture. Priority guide: Critical = unsafe or blocks all lab use · High = tool unusable · Medium = degraded performance · Low = cosmetic.`; } diff --git a/v5/src/lib/capabilities/types.ts b/v5/src/lib/capabilities/types.ts index 792658a..530589e 100644 --- a/v5/src/lib/capabilities/types.ts +++ b/v5/src/lib/capabilities/types.ts @@ -5,7 +5,7 @@ import type { MakerLabTool } from "../../components/catalog-types"; // imported by client components (ChatFab, IdentificationCard). A `import type` // is erased at emit, so no server module reaches the browser bundle. import type { Identity } from "../auth/identity"; -import type { Role } from "../auth/roles"; +import type { Permission } from "../auth/permissions"; /** * Shared contract for the capability-registry architecture (design spec §3, @@ -22,15 +22,22 @@ import type { Role } from "../auth/roles"; /** * A photo the user attached to the current chat turn. Mirrors the response - * shape of `/api/upload-notion` (`{ file_upload_id, name, contentType, size }`) - * and the client-side `PendingPhoto` tracked in `ChatFab`. The `file_upload_id` - * is what `create_tool` re-uses to attach the same photo to the new Notion page - * without re-uploading; `dataUrl` (optional) carries the image bytes so the - * model can actually see the picture for identification (design spec §6.1). + * shape of `POST /api/uploads` (`{ attachmentId, previewUrl, name, ... }`) and + * the client-side `PendingPhoto` tracked in `ChatFab`. + * + * **`attachmentId` is a Postgres uuid, not a Notion handle.** Until the data + * platform spec moved uploads to Blob (§3.3) this field was a Notion + * `file_upload_id` and `create_tool` re-used it to attach the same photo to the + * Notion page it created. That is no longer possible: the id now addresses an + * `attachments` row, and a write claims it (`data/attachments.ts`) rather than + * forwarding it to Notion. + * + * `dataUrl` (optional) carries the image bytes so the model can actually see + * the picture for identification (design spec §6.1). */ export interface UploadedImage { - /** Notion file_upload id returned by `/api/upload-notion`. */ - file_upload_id: string; + /** `attachments.id` — the uuid returned by `POST /api/uploads`. */ + attachmentId: string; /** Original filename. */ name: string; /** MIME type, e.g. "image/png" / "image/jpeg". */ @@ -41,7 +48,7 @@ export interface UploadedImage { /** Zod schema for {@link UploadedImage}. */ export const uploadedImageSchema = z.object({ - file_upload_id: z.string(), + attachmentId: z.string(), name: z.string(), contentType: z.string(), dataUrl: z.string().optional(), @@ -145,16 +152,20 @@ export interface Capability { /** Instructions appended to the system prompt for this capability. */ promptFragment: (env: PromptEnv) => string; /** - * Optional. The least-privileged role that may use this capability on a + * Optional. The permission a caller must hold to use this capability on a * session surface (chat). Absent means everyone, anonymous visitors included. - * Enforced once, by `capabilitiesForRole` in `access.ts` — never inside a + * Enforced once, by `capabilitiesForIdentity` in `access.ts` — never inside a * tool's `run()`. + * + * A permission rather than a role since Phase 4 (spec §3.5): the same + * declaration gates the routes, the admin plugin and the chat, so a change to + * who may add equipment is one line in `auth/permissions.ts`. */ - minimumRole?: Role; + requiredPermission?: Permission; /** * Optional. Used in place of {@link promptFragment} when the caller does not - * meet {@link minimumRole}, so the assistant can explain the limit instead of - * improvising around tools it cannot see. + * hold {@link requiredPermission}, so the assistant can explain the limit + * instead of improvising around tools it cannot see. */ lockedPromptFragment?: (env: PromptEnv) => string; /** The tools this capability contributes. */ @@ -338,7 +349,15 @@ export interface ToolCandidate { use_restrictions?: string; units: { label: string; status?: string; condition?: string; serial?: string }[]; resources: { title: string; url: string; type: "Manual" | "Video" | "Other" }[]; - /** Notion file_upload ids from `/api/upload-notion`. */ + /** + * `attachments.id`s from `POST /api/uploads`, one per photo of this item. + * + * The field keeps its name because it is part of the candidate shape the + * model assembles and `propose_listing` renders, but the values are Postgres + * uuids now, not Notion `file_upload_id`s. They ride along so a batch keeps + * each photo with the item it actually shows; `create_tool` cannot yet attach + * them, and says so (see `intake.ts`). + */ image_upload_ids: string[]; /** Provenance: URLs the agent read. */ source_urls: string[]; diff --git a/v5/src/lib/chat/photo-parts.test.ts b/v5/src/lib/chat/photo-parts.test.ts index ad3d1f9..1d880e6 100644 --- a/v5/src/lib/chat/photo-parts.test.ts +++ b/v5/src/lib/chat/photo-parts.test.ts @@ -81,8 +81,9 @@ describe("withRecentPhotos", () => { expect(photosIn(out[0])).toEqual(["p3.jpg"]); }); - it("never removes text, including the Notion upload hint", () => { - const hint = "[Attached photos: file_upload_id=fu_1 name=p1.jpg]"; + it("never removes text, including the upload hint", () => { + const hint = + "[Attached photos: attachment_id=3f2504e0-4f89-41d3-9a0c-0305e82c3301 name=p1.jpg]"; const messages = [user("u1", text(hint), photo(1)), user("u2", text("next"))]; const out = withRecentPhotos(messages, 0); diff --git a/v5/src/lib/chat/photo-parts.ts b/v5/src/lib/chat/photo-parts.ts index cbbeaf1..f9caf20 100644 --- a/v5/src/lib/chat/photo-parts.ts +++ b/v5/src/lib/chat/photo-parts.ts @@ -3,7 +3,7 @@ import type { FileUIPart, UIMessage } from "ai"; /** * Photos as the model sees them (intake spec §6.1). * - * The chat uploads each photo to Notion, which is the record, and separately + * The chat uploads each photo to Blob, which is the record, and separately * sends a downscaled copy of its bytes on the user message so the model can * actually look at it. These helpers shape that copy and keep it from riding * along on every later turn. @@ -40,8 +40,8 @@ export function toVisionFileParts(photos: VisionPhoto[]): FileUIPart[] { * photo ever attached would go to the model again on each message. The latest * user message keeps all of its photos — that is the turn asking about them — * and earlier turns keep at most `limit` more, newest first, so a follow-up - * question about a recent photo still works. Text parts, including the Notion - * upload hint, are never touched. + * question about a recent photo still works. Text parts, including the upload + * hint, are never touched. */ export function withRecentPhotos( messages: M[], diff --git a/v5/src/lib/cron/backup-policy.test.ts b/v5/src/lib/cron/backup-policy.test.ts new file mode 100644 index 0000000..c687137 --- /dev/null +++ b/v5/src/lib/cron/backup-policy.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment node +import { getTableName } from "drizzle-orm"; +import { account, session, tools, user, verification } from "../db/schema/index"; +import { EXCLUDED_TABLES, isExcludedFromBackup, redactRows } from "./backup-policy"; + +/** + * The policy is small enough to read, so these tests assert the *decision* + * rather than the mechanism: what a nightly file may and may not contain. If a + * later phase widens `EXCLUDED_TABLES` or the redaction map, these are the + * assertions that should have to be rewritten on purpose. + */ + +describe("EXCLUDED_TABLES", () => { + it("skips the two tables whose rows are live credentials", () => { + expect(isExcludedFromBackup(session)).toBe(true); + expect(isExcludedFromBackup(verification)).toBe(true); + expect(EXCLUDED_TABLES.size).toBe(2); + }); + + it("keeps `user`, because role and ban state are what a restore needs", () => { + expect(isExcludedFromBackup(user)).toBe(false); + }); + + it("keeps `account`, because the provider link is not a secret", () => { + expect(isExcludedFromBackup(account)).toBe(false); + }); + + it("keeps an ordinary catalogue table", () => { + expect(isExcludedFromBackup(tools)).toBe(false); + }); +}); + +describe("redactRows", () => { + const accountRow = { + id: "acc-1", + accountId: "google-sub-1", + providerId: "google", + userId: "user-1", + accessToken: "ya29.live-access-token", + refreshToken: "1//live-refresh-token", + idToken: "eyJ.live-id-token", + password: "argon2-hash", + scope: "openid email profile", + }; + + it("blanks every account secret, keeping the link that identifies the person", () => { + const [row] = redactRows(getTableName(account), [accountRow]) as Record[]; + + expect(row.accessToken).toBeNull(); + expect(row.refreshToken).toBeNull(); + expect(row.idToken).toBeNull(); + expect(row.password).toBeNull(); + + // The half that a restore actually needs survives untouched. + expect(row.accountId).toBe("google-sub-1"); + expect(row.providerId).toBe("google"); + expect(row.userId).toBe("user-1"); + expect(row.scope).toBe("openid email profile"); + }); + + it("nulls the column rather than dropping the key", () => { + const [row] = redactRows(getTableName(account), [accountRow]) as Record[]; + + // A missing key reads as "this backup predates the column", which is a + // different and more confusing thing to hand somebody mid-restore. + expect("accessToken" in row).toBe(true); + }); + + it("does not mutate the row it was given", () => { + redactRows(getTableName(account), [accountRow]); + + expect(accountRow.accessToken).toBe("ya29.live-access-token"); + }); + + it("passes a table with no redactions straight through", () => { + const rows = [{ id: "t-1", name: "Formlabs Form 3" }]; + + expect(redactRows(getTableName(tools), rows)).toBe(rows); + }); +}); diff --git a/v5/src/lib/cron/backup-policy.ts b/v5/src/lib/cron/backup-policy.ts new file mode 100644 index 0000000..34a026c --- /dev/null +++ b/v5/src/lib/cron/backup-policy.ts @@ -0,0 +1,83 @@ +import { getTableName } from "drizzle-orm"; +import type { PgTable } from "drizzle-orm/pg-core"; +import { account, session, verification } from "../db/schema/auth"; + +/** + * What the nightly export deliberately leaves out (data platform design spec + * §3.9, and the Phase 3 note that asked whoever landed Better Auth's tables to + * decide this). + * + * `backup.ts` discovers its tables rather than listing them, which is what a + * backup should do: a table added in a later phase is exported because it + * exists, not because somebody remembered. But Phase 4 added tables whose rows + * are *live credentials*, and a `select *` over them writes bearer tokens into + * a file that is then kept for thirty days. A restorable copy of the catalogue + * is worth having; a thirty-day archive of session tokens is a way for anyone + * holding one backup to sign in as anybody. + * + * So the default stays "back it up", and the exceptions are named here: + * + * - **`session` and `verification` are skipped whole.** A session row *is* a + * bearer token, and a verification row is a half-finished OAuth handshake + * that is meaningless minutes later. Neither is worth restoring — and + * restoring them would mean reviving sign-ins that should have ended with + * whatever outage forced the restore. + * - **`account` is kept, with its secret columns blanked.** The row that + * matters is the link — this person is this Google `sub` — which is exactly + * what a restore needs to put somebody back together. The tokens are + * Google's and are reissued on the next sign-in, so losing them costs + * nothing and keeping them costs a credential in a file. + * - **`user` is kept whole, deliberately.** `role` and `banned` are the state + * a restore would most need to get right, and the row carries no secret. + * + * Everything is named through the table objects rather than string literals, so + * renaming a table or a column fails the typecheck here instead of quietly + * un-redacting it. + */ + +/** + * `account` columns blanked in the export. Typed against the table's own row + * shape: rename `refreshToken` and this list stops compiling. + */ +const ACCOUNT_SECRETS = [ + "accessToken", + "refreshToken", + "idToken", + "password", +] as const satisfies readonly (keyof typeof account.$inferSelect)[]; + +/** Tables the nightly file does not contain at all. */ +export const EXCLUDED_TABLES: ReadonlySet = new Set([ + getTableName(session), + getTableName(verification), +]); + +/** Per-table column blanklists, by SQL table name. */ +const REDACTED_COLUMNS: Readonly> = { + [getTableName(account)]: ACCOUNT_SECRETS, +}; + +/** True when this table's rows must not be written to a backup file at all. */ +export function isExcludedFromBackup(table: PgTable): boolean { + return EXCLUDED_TABLES.has(getTableName(table)); +} + +/** + * A copy of `rows` with this table's secret columns set to null. + * + * Null rather than absent: the column still exists and is nullable, so the + * exported row stays the right shape for a restore to insert. A missing key + * would read as "this backup predates the column", which is a different and + * more confusing thing to hand somebody at 3am. + */ +export function redactRows(tableName: string, rows: unknown[]): unknown[] { + const secrets = REDACTED_COLUMNS[tableName]; + if (!secrets) return rows; + return rows.map((row) => { + const copy = { ...(row as Record) }; + for (const key of secrets) { + if (key in copy) copy[key] = null; + } + return copy; + }); +} diff --git a/v5/src/lib/cron/backup.test.ts b/v5/src/lib/cron/backup.test.ts new file mode 100644 index 0000000..542bbe2 --- /dev/null +++ b/v5/src/lib/cron/backup.test.ts @@ -0,0 +1,211 @@ +// @vitest-environment node +import { eq, getTableName } from "drizzle-orm"; +import { createPgliteDb } from "../db/pglite"; +import { DEMO_ACCOUNTS, seedDemo } from "../db/demo-seed"; +import { account, attachments, session, tools, user } from "../db/schema/index"; +import type { Db } from "../db/types"; +import type { BlobStore } from "../blob"; +import { backupTables, expiredBackups, runBackup } from "./backup"; + +/** + * The nightly export against a real (in-process) Postgres, with the Blob seam + * stubbed. No environment variable, no network. + */ + +let db: Db; + +beforeAll(async () => { + db = await createPgliteDb({ seed: seedDemo }); +}); + +function fakeStore() { + const store = { + put: vi.fn().mockResolvedValue({ pathname: "written" }), + putUpload: vi.fn(), + list: vi.fn().mockResolvedValue([]), + del: vi.fn().mockResolvedValue(undefined), + }; + return store as typeof store & BlobStore; +} + +/** The body handed to `store.put`, parsed. */ +function writtenFile(store: ReturnType) { + return JSON.parse(store.put.mock.calls[0][1] as string); +} + +describe("backupTables", () => { + it("discovers every table in the schema rather than listing them", async () => { + const names = backupTables().map(getTableName); + + // Listed by hand, a table added in a later phase is backed up only if + // somebody remembers — which is the failure a backup exists to prevent. + expect(names).toContain("tools"); + expect(names).toContain("maintenance_logs"); + expect(names).toContain("feedback"); + expect(names).toContain("projects"); + expect(names).toContain("attachments"); + expect(names).toContain("audit_events"); + + // Phase 4's people, yes — their roles and bans are the state a restore + // would most need to get right. + expect(names).toContain("user"); + expect(names).toContain("account"); + + // Phase 4's credentials, no. Discovery would have archived thirty days of + // live bearer tokens without anybody choosing to (backup-policy.ts). + expect(names).not.toContain("session"); + expect(names).not.toContain("verification"); + }); +}); + +describe("runBackup", () => { + it("writes no credential into the file", async () => { + const store = fakeStore(); + + await runBackup(store, { db, now: new Date("2026-09-20T07:17:00.000Z") }); + const body = store.put.mock.calls[0][1] as string; + const file = JSON.parse(body); + + // The demo seed signs four people in, so there are real session rows here. + const sessions = await db.select().from(session); + expect(sessions.length).toBeGreaterThan(0); + + expect(file.tables.session).toBeUndefined(); + expect(file.tables.verification).toBeUndefined(); + // The strongest form of the assertion: the token is not in the bytes at + // all, by whatever route it might have got there. + expect(body).not.toContain(DEMO_ACCOUNTS.superAdmin.sessionToken); + + // The people survive, because that is what a restore is for. + expect(file.tables.user.rowCount).toBe( + (await db.select().from(user)).length + ); + }); + + it("blanks the account tokens but keeps the Google link", async () => { + const store = fakeStore(); + await db.insert(account).values({ + id: "backup-test-account", + accountId: "google-sub-backup-test", + providerId: "google", + userId: DEMO_ACCOUNTS.user.id, + accessToken: "ya29.live-access-token", + refreshToken: "1//live-refresh-token", + }); + + try { + await runBackup(store, { db, now: new Date("2026-09-20T07:17:00.000Z") }); + const body = store.put.mock.calls[0][1] as string; + const file = JSON.parse(body); + const row = file.tables.account.rows.find( + (r: { id: string }) => r.id === "backup-test-account" + ); + + expect(row.accessToken).toBeNull(); + expect(row.refreshToken).toBeNull(); + expect(row.accountId).toBe("google-sub-backup-test"); + expect(body).not.toContain("ya29.live-access-token"); + } finally { + await db.delete(account).where(eq(account.id, "backup-test-account")); + } + }); + + it("writes one JSON file per day, named backups/YYYY-MM-DD.json", async () => { + const store = fakeStore(); + + const result = await runBackup(store, { + db, + now: new Date("2026-09-20T07:17:00.000Z"), + }); + + expect(result.pathname).toBe("backups/2026-09-20.json"); + expect(store.put).toHaveBeenCalledTimes(1); + expect(store.put.mock.calls[0][0]).toBe("backups/2026-09-20.json"); + expect(store.put.mock.calls[0][2]).toBe("application/json"); + }); + + it("exports the rows themselves, not a count", async () => { + const store = fakeStore(); + + await runBackup(store, { db, now: new Date("2026-09-20T07:17:00.000Z") }); + const file = writtenFile(store); + const seeded = await db.select().from(tools); + + expect(file.version).toBe(2); + // A restore has to be able to tell a Postgres export from the version-1 + // Notion dump it replaces. + expect(file.source).toBe("postgres"); + expect(file.tables.tools.rowCount).toBe(seeded.length); + expect(file.tables.tools.rows).toHaveLength(seeded.length); + expect(file.tables.tools.rows[0].name).toBeTruthy(); + }); + + it("reports each table's row count in its result", async () => { + const store = fakeStore(); + + const result = await runBackup(store, { db }); + + const seeded = await db.select().from(tools); + expect(result.tables.tools).toBe(seeded.length); + expect(result.bytes).toBeGreaterThan(0); + expect(result.retentionDays).toBe(30); + }); + + it("includes an empty table rather than omitting it", async () => { + const store = fakeStore(); + await db.delete(attachments); + + await runBackup(store, { db }); + + // A missing key would read as "this table was not backed up" on restore. + expect(writtenFile(store).tables.attachments).toEqual({ + rowCount: 0, + rows: [], + }); + }); + + it("throws rather than reporting success when the write fails", async () => { + const store = fakeStore(); + store.put.mockRejectedValueOnce(new Error("blob down")); + + await expect(runBackup(store, { db })).rejects.toThrow("blob down"); + }); +}); + +describe("runBackup — 30-day retention", () => { + it("prunes backups past the window and keeps the rest", async () => { + const store = fakeStore(); + const now = new Date("2026-09-20T07:17:00.000Z"); + store.list.mockResolvedValue([ + { pathname: "backups/2026-08-01.json", uploadedAt: "" }, + { pathname: "backups/2026-09-19.json", uploadedAt: "" }, + ]); + + const result = await runBackup(store, { db, now }); + + expect(result.pruned).toEqual(["backups/2026-08-01.json"]); + expect(store.del).toHaveBeenCalledWith(["backups/2026-08-01.json"]); + }); +}); + +describe("expiredBackups", () => { + const now = new Date("2026-09-20T00:00:00.000Z"); + + it("never deletes a blob whose pathname it does not recognise", () => { + // A prune step that deletes files it does not recognise is a hazard, not a + // housekeeper — an upload that landed under the wrong prefix must survive. + expect( + expiredBackups( + ["uploads/project/lamp-Xa9k2.png", "backups/notes.txt", "backups/"], + now + ) + ).toEqual([]); + }); + + it("keeps a backup exactly one day inside the window", () => { + expect(expiredBackups(["backups/2026-08-22.json"], now)).toEqual([]); + expect(expiredBackups(["backups/2026-08-21.json"], now)).toEqual([ + "backups/2026-08-21.json", + ]); + }); +}); diff --git a/v5/src/lib/cron/backup.ts b/v5/src/lib/cron/backup.ts new file mode 100644 index 0000000..87aa423 --- /dev/null +++ b/v5/src/lib/cron/backup.ts @@ -0,0 +1,167 @@ +import { getTableName, is } from "drizzle-orm"; +import { PgTable } from "drizzle-orm/pg-core"; +import type { BlobStore } from "../blob"; +import { getDb } from "../db/client"; +import * as schema from "../db/schema/index"; +import type { Db } from "../db/types"; +import { isExcludedFromBackup, redactRows } from "./backup-policy"; + +/** + * The nightly export (data platform design spec §3.9). + * + * This replaces the Notion dump that `GET /api/admin/backup` wrote. The reason + * for it has not changed: before it existed there was **no backup at all**, and + * one deleted database took ~100 machines of accumulated staff work with it. + * What changed is where the data lives — the source of truth is Postgres now + * (Article 7), so the file is a row-level export of every table rather than a + * pile of raw Notion pages. + * + * **It never swallows a failure.** A backup that fails quietly is worse than no + * backup, because you find out on the day you need it. Every error here throws + * so the route can answer non-200 and the invocation shows up as failed in + * Vercel's cron log. + * + * **The dump is PII.** `maintenance_logs` carries student names and reporter + * email addresses and `feedback` carries reporter emails, so the file goes to + * *private* blob storage (`blob.ts`'s `put` cannot write any other kind) and + * belongs in whatever data inventory the university keeps. It is PII and not + * credentials: `backup-policy.ts` holds back the tables and columns that would + * let a reader of one backup sign in as somebody. + */ + +export const BACKUP_PREFIX = "backups/"; +const BACKUP_PATHNAME = /^backups\/(\d{4}-\d{2}-\d{2})\.json$/; +export const RETENTION_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Serialized file format. `version` is here so a future reader can tell what it has. */ +export interface BackupFile { + version: 2; + /** `notion` was version 1's; a restore has to know which shape it holds. */ + source: "postgres"; + createdAt: string; + tables: Record; +} + +export interface BackupResult { + pathname: string; + bytes: number; + retentionDays: number; + tables: Record; + pruned: string[]; +} + +export interface BackupOptions { + /** A handle to use instead of {@link getDb} — tests pass an isolated one. */ + db?: Db; + /** Clock injection, so a test can stage retention without waiting 30 days. */ + now?: Date; +} + +/** + * Every table in the schema, discovered rather than listed — minus the few + * `backup-policy.ts` names as credentials rather than data. + * + * Discovery is deliberate: the old route derived its targets from `notion.ts`'s + * env contract for exactly this reason. When Phase 6 adds `pending_tools` it is + * backed up because it exists, not because somebody remembered to add it here — + * which is precisely the class of failure a backup exists to prevent. + * + * The exclusions are the other half of that bargain. Phase 4 landed Better + * Auth's tables, and `session` rows are bearer tokens; discovery would have + * archived thirty days of live sign-ins without anyone choosing to. The policy + * module says which tables and columns are held back, and why. + */ +export function backupTables(): PgTable[] { + // `schema` also exports the vocabulary tuples, so the cast to `unknown[]` + // is what lets the `is(...)` guard do the narrowing rather than TypeScript + // trying to union every table's exact shape. + return (Object.values(schema) as unknown[]) + .filter((value): value is PgTable => is(value, PgTable)) + .filter((table) => !isExcludedFromBackup(table)) + .sort((a, b) => getTableName(a).localeCompare(getTableName(b))); +} + +/** + * Read every table and write one JSON file to private Blob, then prune anything + * past the retention window. + * + * Reads are sequential: this runs once a day over a few thousand rows, and a + * fan-out would only buy contention on one Neon connection. + */ +export async function runBackup( + store: BlobStore, + options: BackupOptions = {} +): Promise { + const db = options.db ?? (await getDb()); + const now = options.now ?? new Date(); + + const file: BackupFile = { + version: 2, + source: "postgres", + createdAt: now.toISOString(), + tables: {}, + }; + + for (const table of backupTables()) { + const name = getTableName(table); + // No projection and no filter: a backup exists to restore what the database + // held, not what the site showed. A `select *` is the point here — and then + // `redactRows` blanks the handful of columns that are credentials rather + // than records (see `backup-policy.ts`). `rowCount` is the true count + // either way; redaction empties fields, it never drops a row. + const rows = redactRows(name, await db.select().from(table)); + file.tables[name] = { rowCount: rows.length, rows }; + } + + const pathname = `${BACKUP_PREFIX}${isoDate(now)}.json`; + const body = JSON.stringify(file); + await store.put(pathname, body, "application/json"); + + // Retention runs in the same job so nobody has to remember it. + const existing = await store.list(BACKUP_PREFIX); + const pruned = expiredBackups( + existing.map((blob) => blob.pathname), + now + ); + await store.del(pruned); + + return { + pathname, + bytes: byteLength(body), + retentionDays: RETENTION_DAYS, + tables: Object.fromEntries( + Object.entries(file.tables).map(([name, entry]) => [name, entry.rowCount]) + ), + pruned, + }; +} + +/** + * Backups older than the retention window, by the date in their own filename. + * Anything that does not match the pattern is left alone — a prune step that + * deletes files it does not recognise is a hazard, not a housekeeper. + */ +export function expiredBackups(pathnames: string[], now: Date): string[] { + return pathnames.filter((pathname) => { + const match = BACKUP_PATHNAME.exec(pathname); + if (!match) return false; + const stamped = Date.parse(`${match[1]}T00:00:00.000Z`); + if (Number.isNaN(stamped)) return false; + return (now.getTime() - stamped) / DAY_MS >= RETENTION_DAYS; + }); +} + +/** + * UTC, not `LAB_TIMEZONE`: this names a *file*, and the retention window that + * reads the name back counts UTC days. Ticket dates are the lab's; an ops + * artifact's is the machine's. + */ +function isoDate(now: Date): string { + return now.toISOString().slice(0, 10); +} + +/** Byte length, so the reported size is the file's rather than the string's. */ +function byteLength(value: string): number { + return new TextEncoder().encode(value).length; +} diff --git a/v5/src/lib/cron/cleanup.test.ts b/v5/src/lib/cron/cleanup.test.ts new file mode 100644 index 0000000..61ed3de --- /dev/null +++ b/v5/src/lib/cron/cleanup.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment node +import { eq } from "drizzle-orm"; +import { createPgliteDb } from "../db/pglite"; +import { attachments, projects } from "../db/schema/index"; +import type { Db } from "../db/types"; +import type { BlobStore } from "../blob"; +import { runCleanup } from "./cleanup"; + +/** + * Orphaned-upload cleanup against a real (in-process) Postgres, with the Blob + * seam stubbed. No environment variable, no network. + */ + +const HOUR = 60 * 60 * 1000; +const NOW = new Date("2026-09-20T12:00:00.000Z"); + +let db: Db; + +beforeAll(async () => { + db = await createPgliteDb(); +}); + +beforeEach(async () => { + await db.delete(attachments); + await db.delete(projects); +}); + +function fakeStore() { + const store = { + put: vi.fn(), + putUpload: vi.fn(), + list: vi.fn().mockResolvedValue([]), + del: vi.fn().mockResolvedValue(undefined), + }; + return store as typeof store & BlobStore; +} + +async function upload( + ageHours: number, + overrides: Partial = {} +): Promise { + const [row] = await db + .insert(attachments) + .values({ + blobPathname: `uploads/chat/${crypto.randomUUID()}.png`, + access: "private", + createdAt: new Date(NOW.getTime() - ageHours * HOUR), + ...overrides, + }) + .returning({ id: attachments.id }); + return row.id; +} + +async function ownerRow(): Promise { + const [row] = await db + .insert(projects) + .values({ slug: `p-${crypto.randomUUID()}`, title: "A project" }) + .returning({ id: projects.id }); + return row.id; +} + +async function exists(id: string): Promise { + const rows = await db + .select({ id: attachments.id }) + .from(attachments) + .where(eq(attachments.id, id)); + return rows.length > 0; +} + +describe("runCleanup", () => { + it("deletes an unclaimed upload older than 24 hours from both Blob and Postgres", async () => { + const store = fakeStore(); + const stale = await upload(25); + + const result = await runCleanup(store, { db, now: NOW }); + + expect(result).toEqual({ orphans: 1, blobsDeleted: 1, rowsDeleted: 1 }); + expect(store.del).toHaveBeenCalledTimes(1); + expect(await exists(stale)).toBe(false); + }); + + it("leaves a recent unclaimed upload alone", async () => { + const store = fakeStore(); + // A half-finished submission left open over lunch must still be able to + // submit its photos. + const fresh = await upload(1); + + const result = await runCleanup(store, { db, now: NOW }); + + expect(result.orphans).toBe(0); + expect(store.del).not.toHaveBeenCalled(); + expect(await exists(fresh)).toBe(true); + }); + + it("never touches a claimed file, however old it is", async () => { + const store = fakeStore(); + const projectId = await ownerRow(); + const owned = await upload(24 * 365, { + ownerType: "project", + ownerId: projectId, + }); + + await runCleanup(store, { db, now: NOW }); + + // That file is somebody's record. Age is not a reason to delete it. + expect(store.del).not.toHaveBeenCalled(); + expect(await exists(owned)).toBe(true); + }); + + it("deletes the blob before the row, so a failure cannot orphan the bytes", async () => { + const store = fakeStore(); + const stale = await upload(48); + store.del.mockRejectedValueOnce(new Error("blob down")); + + await expect(runCleanup(store, { db, now: NOW })).rejects.toThrow( + "blob down" + ); + + // The row survives, so the next run finds the file again. The opposite + // order would leave bytes nothing can ever address. + expect(await exists(stale)).toBe(true); + }); + + it("asks the store nothing when there is nothing to sweep", async () => { + const store = fakeStore(); + + expect(await runCleanup(store, { db, now: NOW })).toEqual({ + orphans: 0, + blobsDeleted: 0, + rowsDeleted: 0, + }); + expect(store.del).not.toHaveBeenCalled(); + }); +}); diff --git a/v5/src/lib/cron/cleanup.ts b/v5/src/lib/cron/cleanup.ts new file mode 100644 index 0000000..d01469e --- /dev/null +++ b/v5/src/lib/cron/cleanup.ts @@ -0,0 +1,76 @@ +import type { BlobStore } from "../blob"; +import { getDb } from "../db/client"; +import { + deleteAttachments, + listOrphanedAttachments, +} from "../data/attachments"; +import type { Db } from "../db/types"; + +/** + * Orphaned-upload cleanup (data platform design spec §3.3, §3.9). + * + * `POST /api/uploads` writes an `attachments` row with no owner, because the + * ticket or project the photo belongs to has not been written yet. Most of + * those get claimed seconds later. The ones that do not — a student who picked + * a photo and then closed the tab — would otherwise accumulate in Blob forever, + * costing storage and keeping a picture of somebody nobody ever asked to keep. + * + * **Twenty-four hours, not one.** The window is generous on purpose: a + * half-finished project submission left open over lunch must still be able to + * submit its photos. Nothing here touches a claimed file, however old — that + * file is somebody's record. + * + * **Blob first, then the row.** A row without a blob renders as a broken image + * on a page; a blob without a row is invisible and gets swept on the next run. + * Of the two half-failures, the second is the one to prefer. + * + * Pending-tool cleanup (§4.10) is deliberately not here: that table has no + * writer until Phase 6, so a sweep of it would be code guarding nothing. + */ + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** How long an upload may sit unclaimed. */ +export const ORPHAN_MAX_AGE_MS = DAY_MS; + +export interface CleanupResult { + /** Rows that were unclaimed and past the window. */ + orphans: number; + /** Blobs actually removed from the store. */ + blobsDeleted: number; + /** Rows actually removed from Postgres. */ + rowsDeleted: number; +} + +export interface CleanupOptions { + db?: Db; + now?: Date; +} + +export async function runCleanup( + store: BlobStore, + options: CleanupOptions = {} +): Promise { + const db = options.db ?? (await getDb()); + const now = options.now ?? new Date(); + const cutoff = new Date(now.getTime() - ORPHAN_MAX_AGE_MS); + + const orphans = await listOrphanedAttachments(cutoff, { db }); + if (orphans.length === 0) { + return { orphans: 0, blobsDeleted: 0, rowsDeleted: 0 }; + } + + const pathnames = orphans.map((row) => row.blobPathname); + await store.del(pathnames); + + const rowsDeleted = await deleteAttachments( + orphans.map((row) => row.id), + { db } + ); + + return { + orphans: orphans.length, + blobsDeleted: pathnames.length, + rowsDeleted, + }; +} diff --git a/v5/src/lib/data/attachments.test.ts b/v5/src/lib/data/attachments.test.ts new file mode 100644 index 0000000..02b957a --- /dev/null +++ b/v5/src/lib/data/attachments.test.ts @@ -0,0 +1,263 @@ +// @vitest-environment node +import { eq } from "drizzle-orm"; +import { createPgliteDb } from "../db/pglite"; +import { attachments, projects } from "../db/schema/index"; +import type { Db } from "../db/types"; +import { + claimAttachments, + createAttachment, + deleteAttachments, + findAttachmentsByIds, + listOrphanedAttachments, +} from "./attachments"; + +/** + * Ownership claiming against a real (in-process) Postgres. No env, no network. + */ + +let db: Db; + +beforeAll(async () => { + db = await createPgliteDb(); +}); + +beforeEach(async () => { + await db.delete(attachments); + await db.delete(projects); +}); + +/** An uploaded-but-unattached file, as `POST /api/uploads` will leave one. */ +async function upload( + overrides: Partial = {} +): Promise { + const [row] = await db + .insert(attachments) + .values({ + blobPathname: `uploads/${crypto.randomUUID()}.png`, + access: "public", + contentType: "image/png", + ...overrides, + }) + .returning({ id: attachments.id }); + return row.id; +} + +async function ownerRow(): Promise { + const [row] = await db + .insert(projects) + .values({ slug: `p-${crypto.randomUUID()}`, title: "A project" }) + .returning({ id: projects.id }); + return row.id; +} + +async function readAttachment(id: string) { + const [row] = await db.select().from(attachments).where(eq(attachments.id, id)); + return row; +} + +describe("claimAttachments", () => { + it("stamps the owner and the caller's order onto each file", async () => { + const projectId = await ownerRow(); + const first = await upload(); + const second = await upload(); + + const claimed = await claimAttachments(db, [second, first], { + ownerType: "project", + ownerId: projectId, + }); + + expect(claimed).toBe(2); + // Position follows the order the ids were given, not insertion order — + // the first photo in the list is the cover (§4.10). + expect(await readAttachment(second)).toMatchObject({ + ownerType: "project", + ownerId: projectId, + position: 0, + }); + expect(await readAttachment(first)).toMatchObject({ position: 1 }); + }); + + it("leaves a file that already has an owner alone", async () => { + const mine = await ownerRow(); + const yours = await ownerRow(); + const taken = await upload({ ownerType: "project", ownerId: yours, position: 0 }); + const free = await upload(); + + const claimed = await claimAttachments(db, [taken, free], { + ownerType: "project", + ownerId: mine, + }); + + // The whole point: a replayed submission carrying somebody else's + // attachment id must not be able to steal their photo. + expect(claimed).toBe(1); + expect((await readAttachment(taken)).ownerId).toBe(yours); + expect((await readAttachment(free)).ownerId).toBe(mine); + }); + + it("drops ids that are not uuid-shaped instead of handing them to Postgres", async () => { + const projectId = await ownerRow(); + const real = await upload(); + + // A Notion file_upload id, free text from a model — neither is a uuid, and + // a uuid column answers a cast error rather than an empty result. + const claimed = await claimAttachments(db, ["file-upload-1", "", real], { + ownerType: "project", + ownerId: projectId, + }); + + expect(claimed).toBe(1); + expect((await readAttachment(real)).ownerId).toBe(projectId); + }); + + it("claims nothing, and asks Postgres nothing, for an empty list", async () => { + const projectId = await ownerRow(); + + expect( + await claimAttachments(db, [], { ownerType: "project", ownerId: projectId }) + ).toBe(0); + expect( + await claimAttachments(db, ["not-a-uuid"], { + ownerType: "maintenance_log", + ownerId: projectId, + }) + ).toBe(0); + }); + + it("counts a repeated id once", async () => { + const projectId = await ownerRow(); + const photo = await upload(); + + const claimed = await claimAttachments(db, [photo, photo], { + ownerType: "project", + ownerId: projectId, + }); + + expect(claimed).toBe(1); + expect((await readAttachment(photo)).position).toBe(0); + }); + + it("reports zero when every id was already spoken for", async () => { + const mine = await ownerRow(); + const yours = await ownerRow(); + const taken = await upload({ ownerType: "project", ownerId: yours }); + + // Zero is what lets a caller say "the photos did not attach" rather than + // silently filing a record without them. + expect( + await claimAttachments(db, [taken], { ownerType: "project", ownerId: mine }) + ).toBe(0); + }); +}); + +describe("createAttachment", () => { + it("records an upload with NO owner — the ticket it belongs to does not exist yet", async () => { + const { id } = await createAttachment( + { + blobPathname: "uploads/project/lamp-Xa9k2.png", + access: "public", + publicUrl: "https://store.public.blob.vercel-storage.com/lamp-Xa9k2.png", + contentType: "image/png", + sizeBytes: 1234, + originalFilename: "lamp.png", + uploadedBy: null, + }, + { db } + ); + + expect(await readAttachment(id)).toMatchObject({ + ownerType: null, + ownerId: null, + blobPathname: "uploads/project/lamp-Xa9k2.png", + access: "public", + sizeBytes: 1234, + originalFilename: "lamp.png", + uploadedBy: null, + }); + }); + + it("stores a private upload with no public url, because a private blob has none", async () => { + const { id } = await createAttachment( + { + blobPathname: "uploads/maintenance/bed-Q1.png", + access: "private", + publicUrl: null, + contentType: "image/png", + sizeBytes: 10, + originalFilename: "bed.png", + uploadedBy: "user_1", + }, + { db } + ); + + const row = await readAttachment(id); + expect(row.access).toBe("private"); + expect(row.publicUrl).toBeNull(); + // The signed-in uploader is recorded so staff can ask them about the photo. + expect(row.uploadedBy).toBe("user_1"); + }); +}); + +describe("findAttachmentsByIds", () => { + it("returns the rows asked for and ignores ids that are not uuid-shaped", async () => { + const first = await upload(); + const second = await upload(); + + const found = await findAttachmentsByIds( + [first, "file-upload-1", second], + { db } + ); + + expect(found.map((row) => row.id).sort()).toEqual([first, second].sort()); + }); + + it("asks Postgres nothing when no id could address a row", async () => { + expect(await findAttachmentsByIds(["", "nope"], { db })).toEqual([]); + }); +}); + +describe("listOrphanedAttachments", () => { + const HOUR = 60 * 60 * 1000; + const NOW = new Date("2026-09-20T12:00:00.000Z"); + const CUTOFF = new Date(NOW.getTime() - 24 * HOUR); + + it("returns only unowned files older than the cutoff", async () => { + const stale = await upload({ + createdAt: new Date(NOW.getTime() - 25 * HOUR), + }); + await upload({ createdAt: new Date(NOW.getTime() - HOUR) }); + + const orphans = await listOrphanedAttachments(CUTOFF, { db }); + + expect(orphans.map((row) => row.id)).toEqual([stale]); + }); + + it("never returns a claimed file, however old it is", async () => { + const projectId = await ownerRow(); + await upload({ + createdAt: new Date("2020-01-01T00:00:00.000Z"), + ownerType: "project", + ownerId: projectId, + }); + + // A claimed photo is somebody's record. Age is not a reason to delete it. + expect(await listOrphanedAttachments(CUTOFF, { db })).toEqual([]); + }); +}); + +describe("deleteAttachments", () => { + it("removes the rows it is given and reports the count", async () => { + const doomed = await upload(); + const kept = await upload(); + + expect(await deleteAttachments([doomed, "not-a-uuid"], { db })).toBe(1); + expect(await readAttachment(doomed)).toBeUndefined(); + expect(await readAttachment(kept)).toBeDefined(); + }); + + it("deletes nothing for an empty list", async () => { + const kept = await upload(); + expect(await deleteAttachments([], { db })).toBe(0); + expect(await readAttachment(kept)).toBeDefined(); + }); +}); diff --git a/v5/src/lib/data/attachments.ts b/v5/src/lib/data/attachments.ts new file mode 100644 index 0000000..04fe852 --- /dev/null +++ b/v5/src/lib/data/attachments.ts @@ -0,0 +1,209 @@ +import { and, eq, inArray, isNull, lt } from "drizzle-orm"; +import { getDb } from "../db/client.ts"; +import { attachments } from "../db/schema/index.ts"; +import type { AttachmentAccess, AttachmentOwner } from "../db/schema/vocabulary.ts"; +import type { Db } from "../db/types.ts"; +import { isUuid } from "./uuid.ts"; + +/** + * Attachment ownership (spec §3.3, §4.7). + * + * A file uploaded through the app lands in Blob and gets an `attachments` row + * with **no owner**: at upload time the ticket or the project it belongs to + * does not exist yet. The write that creates that row then *claims* its + * uploads, which is what turns a loose file into a photo on a record. Anything + * still unclaimed after 24 hours is what the daily cron sweeps up. + * + * Relative imports with `.ts` extensions, no `@/` alias and no `"server-only"`, + * like every other module under `src/lib/data/`. + */ + +export interface ClaimOwner { + ownerType: AttachmentOwner; + /** The row that now owns these files. */ + ownerId: string; +} + +/** + * Stamp `ids` with an owner, in the order given, and report how many were + * actually claimed. + * + * Takes its handle explicitly rather than calling `getDb()`, because every + * caller claims inside the same transaction that inserts the owning row: a + * ticket that rolls back must not leave its photos pointing at a row that was + * never committed. + * + * Two rules, both of them about not trusting the caller's ids: + * + * - **Only unowned rows are claimed.** `owner_id is null` is part of the WHERE, + * so a replayed submission carrying somebody else's attachment id moves + * nothing — a student cannot annex another student's photos by guessing. + * - **Non-uuid ids are dropped here**, not handed to Postgres, which would + * answer a uuid cast error rather than an empty result. + * + * The count comes back so the caller can tell the difference between "no photos + * were sent" and "photos were sent and none of them stuck", which is the + * difference between saying nothing and saying so (Article 4). + */ +export async function claimAttachments( + db: Db, + ids: readonly string[], + owner: ClaimOwner +): Promise { + // Deduplicated so a repeated id cannot consume two positions. + const candidates = [...new Set(ids.filter(isUuid))]; + if (candidates.length === 0) return 0; + + let claimed = 0; + // One statement per id: `position` is the caller's order, so this is not a + // set-based update, and the callers are capped at 8 photos each (§3.3). + for (const [position, id] of candidates.entries()) { + const rows = await db + .update(attachments) + .set({ ownerType: owner.ownerType, ownerId: owner.ownerId, position }) + .where(and(eq(attachments.id, id), isNull(attachments.ownerId))) + .returning({ id: attachments.id }); + claimed += rows.length; + } + return claimed; +} + +/** One uploaded file, before anything owns it. */ +export interface NewAttachment { + /** The pathname the store actually chose, random suffix included. */ + blobPathname: string; + access: AttachmentAccess; + /** Only a public blob has a URL a viewer can follow; private files are null. */ + publicUrl: string | null; + contentType: string; + sizeBytes: number; + /** As the browser sent it. Display only — never used to address the blob. */ + originalFilename: string; + /** The signed-in uploader, or null. Anonymous uploads stay allowed (§3.3). */ + uploadedBy: string | null; +} + +export interface AttachmentReadOptions { + /** A handle to use instead of {@link getDb} — tests pass an isolated one. */ + db?: Db; +} + +/** + * Record a file that has just landed in Blob. + * + * **The owner columns are left null deliberately.** At upload time the ticket + * or the project the file belongs to does not exist yet — the student is still + * typing it. The write that creates that row claims its uploads with + * {@link claimAttachments}; anything never claimed is swept by the daily cron + * 24 hours later. + */ +export async function createAttachment( + row: NewAttachment, + options: AttachmentReadOptions = {} +): Promise<{ id: string }> { + const db = options.db ?? (await getDb()); + + const [created] = await db + .insert(attachments) + .values({ + blobPathname: row.blobPathname, + access: row.access, + publicUrl: row.publicUrl, + contentType: row.contentType, + sizeBytes: row.sizeBytes, + originalFilename: row.originalFilename, + uploadedBy: row.uploadedBy, + }) + .returning({ id: attachments.id }); + + return { id: created.id }; +} + +/** An attachment as the cleanup and the delete paths need to see it. */ +export interface StoredAttachment { + id: string; + blobPathname: string; + access: string; + publicUrl: string | null; + contentType: string | null; + originalFilename: string | null; + ownerType: string | null; + ownerId: string | null; + position: number; +} + +/** + * Look several attachments up by id, dropping anything that is not uuid-shaped + * before Postgres sees it (a uuid column answers a cast error, not an empty + * result). Order is not guaranteed — callers that care sort by `position`. + */ +export async function findAttachmentsByIds( + ids: readonly string[], + options: AttachmentReadOptions = {} +): Promise { + const candidates = [...new Set(ids.filter(isUuid))]; + if (candidates.length === 0) return []; + + const db = options.db ?? (await getDb()); + return db + .select(ATTACHMENT_COLUMNS) + .from(attachments) + .where(inArray(attachments.id, candidates)); +} + +/** + * Files nobody claimed, uploaded before `olderThan` (spec §3.3: 24 hours). + * + * `owner_id is null` is the whole condition that makes a file an orphan — a + * claimed photo is somebody's record and is never swept, however old. The + * cutoff is passed in rather than computed here so the cron's test can stage + * a row's age without touching the clock. + */ +export async function listOrphanedAttachments( + olderThan: Date, + options: AttachmentReadOptions = {} +): Promise { + const db = options.db ?? (await getDb()); + return db + .select(ATTACHMENT_COLUMNS) + .from(attachments) + .where( + and(isNull(attachments.ownerId), lt(attachments.createdAt, olderThan)) + ); +} + +/** + * Remove rows by id, and report how many went. + * + * Only ever called *after* the bytes are gone from Blob: a row without a blob + * is a broken image on a page, while a blob without a row is invisible and gets + * swept next time. Losing the row first is the worse of the two failures. + */ +export async function deleteAttachments( + ids: readonly string[], + options: AttachmentReadOptions = {} +): Promise { + const candidates = [...new Set(ids.filter(isUuid))]; + if (candidates.length === 0) return 0; + + const db = options.db ?? (await getDb()); + const rows = await db + .delete(attachments) + .where(inArray(attachments.id, candidates)) + .returning({ id: attachments.id }); + return rows.length; +} + +/** The projection every read here shares — never `select *`, so a new column + * cannot silently start travelling to a caller that does not expect it. */ +const ATTACHMENT_COLUMNS = { + id: attachments.id, + blobPathname: attachments.blobPathname, + access: attachments.access, + publicUrl: attachments.publicUrl, + contentType: attachments.contentType, + originalFilename: attachments.originalFilename, + ownerType: attachments.ownerType, + ownerId: attachments.ownerId, + position: attachments.position, +}; diff --git a/v5/src/lib/data/audit.test.ts b/v5/src/lib/data/audit.test.ts new file mode 100644 index 0000000..8042780 --- /dev/null +++ b/v5/src/lib/data/audit.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment node +import { createPgliteDb } from "../db/pglite"; +import { auditEvents } from "../db/schema/index"; +import { expectViolation } from "../../../test/db"; +import { insertUserRow } from "../../../test/utils/session"; +import type { Db } from "../db/types"; +import * as audit from "./audit"; +import { listAuditEvents, recordAuditEvent } from "./audit"; + +/** + * The audit trail against a real (in-process) Postgres. No env, no network. + */ + +let db: Db; +let actorId: string; + +beforeAll(async () => { + db = await createPgliteDb(); + const actor = await insertUserRow(db, { + id: "audit-actor", + email: "director@cornell.edu", + role: "super_admin", + }); + actorId = actor.id; +}); + +beforeEach(async () => { + await db.delete(auditEvents); +}); + +describe("recordAuditEvent", () => { + it("round-trips an event, its jsonb detail, and a server-set timestamp", async () => { + const before = Date.now(); + const { id } = await recordAuditEvent( + { + actorUserId: actorId, + action: "role.changed", + subjectType: "user", + subjectId: "someone-else", + detail: { from: "user", to: "admin" }, + }, + { db } + ); + + const [event] = await listAuditEvents({ db }); + expect(event.id).toBe(id); + expect(event).toMatchObject({ + actorUserId: actorId, + action: "role.changed", + subjectType: "user", + subjectId: "someone-else", + detail: { from: "user", to: "admin" }, + }); + // `at` is the column default, not the caller's clock — a skewed instance + // must not be able to reorder the trail. + expect(event.at.getTime()).toBeGreaterThanOrEqual(before - 1000); + }); + + it("stores no detail as null rather than an empty object", async () => { + await recordAuditEvent( + { + actorUserId: actorId, + action: "user.banned", + subjectType: "user", + subjectId: "someone-else", + }, + { db } + ); + + const [event] = await listAuditEvents({ db }); + expect(event.detail).toBeNull(); + }); + + it("accepts a null actor — an action nobody took on somebody's behalf", async () => { + await recordAuditEvent( + { + actorUserId: null, + action: "tool.archived", + subjectType: "tool", + subjectId: "some-tool", + }, + { db } + ); + + const [event] = await listAuditEvents({ db }); + expect(event.actorUserId).toBeNull(); + }); + + it("refuses an actor that names no user row (the Phase 4 foreign key)", async () => { + await expectViolation( + recordAuditEvent( + { + actorUserId: "nobody-by-that-id", + action: "role.changed", + subjectType: "user", + subjectId: "someone-else", + }, + { db } + ), + /actor_user_id|foreign key/i + ); + }); +}); + +describe("listAuditEvents", () => { + beforeEach(async () => { + for (const subjectId of ["alpha", "beta", "alpha"]) { + await recordAuditEvent( + { + actorUserId: actorId, + action: "role.changed", + subjectType: "user", + subjectId, + }, + { db } + ); + } + }); + + it("returns every event when given no subject", async () => { + expect(await listAuditEvents({ db })).toHaveLength(3); + }); + + it("narrows to one subject when given both halves of the key", async () => { + const events = await listAuditEvents({ + db, + subjectType: "user", + subjectId: "alpha", + }); + expect(events).toHaveLength(2); + expect(events.every((event) => event.subjectId === "alpha")).toBe(true); + }); + + it("ignores half a subject key rather than reading the whole table by it", async () => { + // Half a composite key is not a filter, so the honest behaviour is to + // return everything rather than pretend to have narrowed. + expect(await listAuditEvents({ db, subjectType: "user" })).toHaveLength(3); + }); + + it("caps at the requested limit", async () => { + expect(await listAuditEvents({ db, limit: 2 })).toHaveLength(2); + }); +}); + +describe("the module's shape", () => { + it("exports no way to change or remove an event", () => { + // Append-only is the guarantee (spec §4.11). The cheapest way to make it + // reviewable is for the edit to not exist, and this is the test that keeps + // it that way when somebody adds a convenience later. + const mutators = Object.keys(audit).filter((name) => + /^(update|delete|remove|clear|edit)/i.test(name) + ); + expect(mutators).toEqual([]); + }); +}); diff --git a/v5/src/lib/data/audit.ts b/v5/src/lib/data/audit.ts new file mode 100644 index 0000000..8d97f13 --- /dev/null +++ b/v5/src/lib/data/audit.ts @@ -0,0 +1,138 @@ +import { and, desc, eq } from "drizzle-orm"; +import { getDb } from "../db/client.ts"; +import { auditEvents } from "../db/schema/index.ts"; +import type { AuditAction } from "../db/schema/index.ts"; +import type { Db } from "../db/types.ts"; + +/** + * The audit trail (data platform design spec §4.11, Article 5). + * + * **Append-only by construction, not by convention.** This module exports one + * insert and one select and nothing else — no `updateAuditEvent`, no + * `deleteAuditEvent`, not even a private one. A record of who changed whose + * role is worth exactly as much as the guarantee that nobody rewrote it, and + * the cheapest way to make that guarantee reviewable is for the edit to not + * exist in the codebase. (Postgres privileges would be stronger; the app and + * the migrations share one connection string, so that is not available here.) + * + * **Security-relevant actions only.** `AUDIT_ACTIONS` in `db/schema/audit.ts` + * is the whole vocabulary: role changes, bans, publishing, archiving, approving + * a researched tool, connecting a mirror. Ordinary edits are deliberately not + * logged — a table that records everything is one nobody reads. Phase 4 writes + * the first two; the rest arrive with the surfaces that perform them. + * + * Relative imports with `.ts` extensions, no `@/` alias and no `"server-only"`, + * like every other module under `src/lib/data/`. + */ + +/** One event, in the column shape the table takes. */ +export interface NewAuditEvent { + /** + * Who did it. `user.id`, and the foreign key means it must name a real row — + * correct, because in production this comes from a resolved session. Null is + * accepted for an action the system took on nobody's behalf. + */ + actorUserId: string | null; + action: AuditAction; + /** What kind of thing it happened to: `"user"`, `"tool"`, `"project"`. */ + subjectType: string; + /** That thing's id, as text — subjects are not all uuids (`user.id` is not). */ + subjectId: string; + /** Anything worth reading later: the old and new role, a ban reason. */ + detail?: Record | null; +} + +export interface AuditWriteOptions { + /** A handle to use instead of {@link getDb} — tests pass an isolated one. */ + db?: Db; +} + +/** + * Record one event. `at` is left to the column default so the timestamp is the + * database's rather than the caller's: a clock skewed by a few seconds on one + * serverless instance must not reorder the trail. + * + * Throws on a database failure. Callers write the event *after* the change it + * describes has landed, so a throw here means "the change happened but was not + * recorded" — which is worth failing the action over and saying so, rather than + * quietly leaving a gap (Article 4). + */ +export async function recordAuditEvent( + event: NewAuditEvent, + options: AuditWriteOptions = {} +): Promise<{ id: string }> { + const db = options.db ?? (await getDb()); + + const [created] = await db + .insert(auditEvents) + .values({ + actorUserId: event.actorUserId || null, + action: event.action, + subjectType: event.subjectType, + subjectId: event.subjectId, + detail: event.detail ?? null, + }) + .returning({ id: auditEvents.id }); + + return { id: created.id }; +} + +/** One event as the admin surfaces read it back. */ +export interface AuditEventRecord { + id: string; + at: Date; + actorUserId: string | null; + action: string; + subjectType: string; + subjectId: string; + detail: Record | null; +} + +export interface ListAuditEventsQuery { + /** Narrow to one kind of subject — both of these, or neither. */ + subjectType?: string; + subjectId?: string; + /** Newest-first cap. Defaults to 100; the trail grows without bound. */ + limit?: number; + db?: Db; +} + +/** How many events one call will return when the caller names no limit. */ +export const DEFAULT_AUDIT_LIMIT = 100; + +/** + * Events, newest first. Filtering by subject uses the composite index on + * `(subject_type, subject_id)`, which is why both are given together or not at + * all — half of a composite key reads the whole table. + */ +export async function listAuditEvents( + query: ListAuditEventsQuery = {} +): Promise { + const db = query.db ?? (await getDb()); + const limit = Math.max(1, query.limit ?? DEFAULT_AUDIT_LIMIT); + + const where = + query.subjectType && query.subjectId + ? and( + eq(auditEvents.subjectType, query.subjectType), + eq(auditEvents.subjectId, query.subjectId) + ) + : undefined; + + const rows = await db + .select() + .from(auditEvents) + .where(where) + .orderBy(desc(auditEvents.at), desc(auditEvents.id)) + .limit(limit); + + return rows.map((row) => ({ + id: row.id, + at: row.at, + actorUserId: row.actorUserId, + action: row.action, + subjectType: row.subjectType, + subjectId: row.subjectId, + detail: row.detail ?? null, + })); +} diff --git a/v5/src/lib/data/feedback.test.ts b/v5/src/lib/data/feedback.test.ts new file mode 100644 index 0000000..43932e6 --- /dev/null +++ b/v5/src/lib/data/feedback.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment node +import { eq } from "drizzle-orm"; +import { createPgliteDb } from "../db/pglite"; +import { feedback, tools } from "../db/schema/index"; +import { insertUserRow } from "../../../test/utils/session"; +import type { Db } from "../db/types"; +import { createFeedback } from "./feedback"; + +/** + * Corrections against a real (in-process) Postgres. No env, no network. + */ + +let db: Db; +let toolId: string; + +beforeAll(async () => { + db = await createPgliteDb(); +}); + +beforeEach(async () => { + await db.delete(feedback); + await db.delete(tools); + const [tool] = await db + .insert(tools) + .values({ slug: "form-4", name: "Form 4", published: true }) + .returning({ id: tools.id }); + toolId = tool.id; +}); + +async function storedRow(id: string) { + const [row] = await db.select().from(feedback).where(eq(feedback.id, id)); + return row; +} + +describe("createFeedback", () => { + it("opens the correction as `new` against the tool it names", async () => { + const { id } = await createFeedback( + { + toolId, + fieldFlagged: "materials", + issueDescription: "Resin list is missing Rigid 10K.", + suggestedFix: "Add Rigid 10K.", + reporterName: "Ada", + }, + { db } + ); + + expect(await storedRow(id)).toMatchObject({ + toolId, + fieldFlagged: "materials", + issueDescription: "Resin list is missing Rigid 10K.", + suggestedFix: "Add Rigid 10K.", + reporterName: "Ada", + status: "new", + }); + }); + + it("leaves the optional columns null rather than empty", async () => { + const { id } = await createFeedback( + { toolId, fieldFlagged: "description", issueDescription: "Wrong." }, + { db } + ); + + const row = await storedRow(id); + expect(row.suggestedFix).toBeNull(); + expect(row.reporterName).toBeNull(); + expect(row.reporterEmail).toBeNull(); + expect(row.reporterUserId).toBeNull(); + expect(row.createdBy).toBeNull(); + }); + + it("records the reporter's email and id when a session supplied them", async () => { + // `created_by` references `user.id` since Phase 4; the reporter is a row. + await insertUserRow(db, { id: "google-sub-1", email: "ada@cornell.edu" }); + const { id } = await createFeedback( + { + toolId, + fieldFlagged: "location", + issueDescription: "Lives in the Resin Bench.", + reporterEmail: "ada@cornell.edu", + reporterUserId: "google-sub-1", + }, + { db } + ); + + const row = await storedRow(id); + expect(row.reporterEmail).toBe("ada@cornell.edu"); + expect(row.reporterUserId).toBe("google-sub-1"); + expect(row.createdBy).toBe("google-sub-1"); + }); + + it("stores a tool id that is not uuid-shaped as null rather than casting", async () => { + // A slug or free text reaching a uuid column answers a cast error, not an + // empty result — a correction staff match up by hand beats one lost. + const { id } = await createFeedback( + { toolId: "form-4", fieldFlagged: null, issueDescription: "Something is off." }, + { db } + ); + + const row = await storedRow(id); + expect(row.toolId).toBeNull(); + expect(row.issueDescription).toBe("Something is off."); + }); + + it("accepts a correction with no field named", async () => { + const { id } = await createFeedback( + { toolId, fieldFlagged: null, issueDescription: "General complaint." }, + { db } + ); + + expect((await storedRow(id)).fieldFlagged).toBeNull(); + }); + + it("writes only to feedback — the tool it names is untouched", async () => { + const before = await db.select().from(tools).where(eq(tools.id, toolId)); + + await createFeedback( + { toolId, fieldFlagged: "name", issueDescription: "Misspelled." }, + { db } + ); + + // The assertion that matters (spec §8): a flag is inert. + const after = await db.select().from(tools).where(eq(tools.id, toolId)); + expect(after).toEqual(before); + }); +}); diff --git a/v5/src/lib/data/feedback.ts b/v5/src/lib/data/feedback.ts new file mode 100644 index 0000000..3ab700a --- /dev/null +++ b/v5/src/lib/data/feedback.ts @@ -0,0 +1,75 @@ +import { getDb } from "../db/client.ts"; +import { feedback } from "../db/schema/index.ts"; +import type { FlagField } from "../db/schema/vocabulary.ts"; +import type { Db } from "../db/types.ts"; +import { isUuid } from "./uuid.ts"; + +/** + * Catalogue corrections on Postgres (spec §3.10, §4.9). + * + * This is the module §3.10 names as the replacement for the raw Notion `fetch` + * that used to live inside `capabilities/flags.ts`. The capability keeps the + * validation and the row building — both pure, both unit-tested — and this + * module is the only thing that touches the table. + * + * A correction is **inert by construction** (spec §8): the only statement here + * is an insert into `feedback`. There is no code path from a student's report + * to the catalogue; that runs through a person on `/admin/corrections`. + * + * Relative imports with `.ts` extensions, no `@/` alias and no `"server-only"`, + * like every other module under `src/lib/data/`. + */ + +/** One correction, in the column shape the table takes. */ +export interface NewFeedback { + /** The tool the report is about, or null when it could not be resolved. */ + toolId: string | null; + fieldFlagged: FlagField | null; + issueDescription: string; + suggestedFix?: string | null; + reporterName?: string | null; + /** **Session only.** No request field reaches this, by design. */ + reporterEmail?: string | null; + reporterUserId?: string | null; +} + +export interface FeedbackWriteOptions { + /** A handle to use instead of {@link getDb} — tests pass an isolated one. */ + db?: Db; +} + +/** + * File one correction, open (`status: "new"`) for staff to triage. + * + * A `tool_id` that is not uuid-shaped is stored as null rather than passed to a + * uuid column, which would answer a cast error: a correction staff have to + * match up by hand still beats one that never arrived (Article 4). + * + * Throws on a database failure; the caller turns that into the opaque + * `write_failed` the surfaces already know how to report. + */ +export async function createFeedback( + row: NewFeedback, + options: FeedbackWriteOptions = {} +): Promise<{ id: string }> { + const db = options.db ?? (await getDb()); + + const [created] = await db + .insert(feedback) + .values({ + toolId: row.toolId && isUuid(row.toolId) ? row.toolId : null, + fieldFlagged: row.fieldFlagged, + issueDescription: row.issueDescription, + suggestedFix: row.suggestedFix || null, + reporterName: row.reporterName || null, + reporterEmail: row.reporterEmail || null, + reporterUserId: row.reporterUserId || null, + status: "new", + // Null for an anonymous report, which stays the intended default (§8). + createdBy: row.reporterUserId || null, + updatedBy: row.reporterUserId || null, + }) + .returning({ id: feedback.id }); + + return { id: created.id }; +} diff --git a/v5/src/lib/data/maintenance.test.ts b/v5/src/lib/data/maintenance.test.ts index e998cf4..a70d64e 100644 --- a/v5/src/lib/data/maintenance.test.ts +++ b/v5/src/lib/data/maintenance.test.ts @@ -1,11 +1,16 @@ // @vitest-environment node +import { eq } from "drizzle-orm"; import { createPgliteDb } from "../db/pglite"; -import { maintenanceLogs, tools, units } from "../db/schema/index"; +import { attachments, maintenanceLogs, tools, units } from "../db/schema/index"; +import { MAINTENANCE_PRIORITY, MAINTENANCE_TYPE } from "../db/schema/vocabulary"; +import { insertUserRow } from "../../../test/utils/session"; import type { Db } from "../db/types"; import { + createMaintenanceLog, listMaintenanceHistoryForUnit, toDisplayLabel, toMaintenanceHistoryEntry, + toStoredValue, } from "./maintenance"; /** @@ -14,6 +19,7 @@ import { */ let db: Db; +let toolId: string; let unitId: string; let otherUnitId: string; @@ -22,6 +28,8 @@ beforeAll(async () => { }); beforeEach(async () => { + vi.stubEnv("LAB_TIMEZONE", "America/New_York"); + await db.delete(attachments); await db.delete(maintenanceLogs); // Deleting the tools cascades to their units. await db.delete(tools); @@ -37,6 +45,7 @@ beforeEach(async () => { { toolId: form4.id, unitLabel: "Form 4 // B" }, ]) .returning({ id: units.id }); + toolId = form4.id; unitId = unitA.id; otherUnitId = unitB.id; }); @@ -191,3 +200,197 @@ describe("toDisplayLabel", () => { expect(toDisplayLabel("")).toBe(""); }); }); + +// ── createMaintenanceLog (spec §4.8) ──────────────────────────────── + +describe("createMaintenanceLog", () => { + /** Everything the filed row holds, read straight back out. */ + async function storedLog(id: string) { + const [row] = await db.select().from(maintenanceLogs).where(eq(maintenanceLogs.id, id)); + return row; + } + + it("copies the unit's tool and snapshots both display names", async () => { + const created = await createMaintenanceLog( + { + title: "Resin tank cloudy", + description: "Prints are coming out foggy.", + type: "Issue Report", + priority: "Medium", + status: "Open", + unitId, + }, + { db } + ); + + const row = await storedLog(created.id); + expect(row.unitId).toBe(unitId); + // `tool_id` is copied from the unit at write time, and the two names are + // snapshots so the history survives the unit being retired (§4.8). + expect(row.toolId).toBe(toolId); + expect(row.toolName).toBe("Form 4"); + expect(row.unitLabel).toBe("Form 4 // A"); + expect(created.toolId).toBe(toolId); + }); + + it("maps the capability's display casing down to the stored vocabulary", async () => { + const created = await createMaintenanceLog( + { title: "Bed not leveling", type: "Issue Report", priority: "Medium", status: "Open" }, + { db } + ); + + const row = await storedLog(created.id); + // The CHECK constraints store snake_case; the capability speaks Notion's + // select casing. A mismatch here fails the whole insert. + expect(row.type).toBe("issue_report"); + expect(row.priority).toBe("medium"); + expect(row.status).toBe("open"); + // And it round-trips back to what the assistant has always seen. + expect(toDisplayLabel(row.type)).toBe("Issue Report"); + expect(toDisplayLabel(row.priority)).toBe("Medium"); + }); + + it("stores an unrecognised priority as null rather than failing the insert", async () => { + const created = await createMaintenanceLog( + { title: "Odd one", priority: "Spicy", type: "Issue Report" }, + { db } + ); + + // Losing a report of an unsafe machine to a priority spelled oddly is the + // wrong failure (Article 4). + const row = await storedLog(created.id); + expect(row.priority).toBeNull(); + expect(row.title).toBe("Odd one"); + }); + + it("files a ticket with no unit at all", async () => { + const created = await createMaintenanceLog({ title: "Lab smells of solvent" }, { db }); + + // No CHECK requires a unit: most live logs have neither unit nor tool, and + // a ticket with no target is still a ticket (§4.8). + const row = await storedLog(created.id); + expect(row.unitId).toBeNull(); + expect(row.toolId).toBeNull(); + expect(row.status).toBe("open"); + }); + + it("ignores a unit id that is not uuid-shaped", async () => { + const created = await createMaintenanceLog( + { title: "Unresolvable", unitId: "Form 4 // A" }, + { db } + ); + + expect((await storedLog(created.id)).unitId).toBeNull(); + }); + + it("dates the ticket in the lab's timezone, not the server's", async () => { + vi.stubEnv("LAB_TIMEZONE", "Pacific/Kiritimati"); + + const created = await createMaintenanceLog({ title: "Dated" }, { db }); + + // +14: the lab's day is reliably ahead of UTC's, which is what makes this + // assertion about the timezone rather than about the clock. + const row = await storedLog(created.id); + expect(row.dateReported).toBe(created.dateReported); + expect(row.dateReported).not.toBeNull(); + expect(row.dateReported! >= new Date().toISOString().slice(0, 10)).toBe(true); + }); + + it("writes reported_by_email only when one was passed, and never reads it back", async () => { + // `created_by` references `user.id` since Phase 4; the reporter is a row. + await insertUserRow(db, { id: "google-sub-1", email: "ada@cornell.edu" }); + const created = await createMaintenanceLog( + { + title: "Signed in", + unitId, + reportedByName: "Ada Lovelace", + reportedByEmail: "ada@cornell.edu", + reportedByUserId: "google-sub-1", + }, + { db } + ); + + const row = await storedLog(created.id); + expect(row.reportedByEmail).toBe("ada@cornell.edu"); + expect(row.reportedByName).toBe("Ada Lovelace"); + // The audit columns record who filed it. + expect(row.createdBy).toBe("google-sub-1"); + + // …and the history read never selects the email (spec §8, PII). + const [entry] = await listMaintenanceHistoryForUnit(unitId, { db }); + expect(entry.reportedByName).toBe("Ada Lovelace"); + expect(JSON.stringify(entry)).not.toContain("ada@cornell.edu"); + }); + + it("files anonymously with no reporter columns at all", async () => { + const created = await createMaintenanceLog({ title: "Anonymous" }, { db }); + + const row = await storedLog(created.id); + expect(row.reportedByName).toBeNull(); + expect(row.reportedByEmail).toBeNull(); + expect(row.reportedByUserId).toBeNull(); + expect(row.createdBy).toBeNull(); + }); + + it("claims the photos onto the new ticket and says how many stuck", async () => { + const [photo] = await db + .insert(attachments) + .values({ blobPathname: "uploads/a.png", access: "private", contentType: "image/png" }) + .returning({ id: attachments.id }); + + const created = await createMaintenanceLog( + { title: "With a photo", unitId, photoAttachmentIds: [photo.id, "file-upload-2"] }, + { db } + ); + + expect(created.photosAttached).toBe(1); + const [row] = await db.select().from(attachments).where(eq(attachments.id, photo.id)); + expect(row).toMatchObject({ ownerType: "maintenance_log", ownerId: created.id, position: 0 }); + }); + + it("reports zero photos attached when none of the ids matched", async () => { + const created = await createMaintenanceLog( + { title: "Photo lost", photoAttachmentIds: ["file-upload-1"] }, + { db } + ); + + // Zero is what lets the capability tell the student the ticket was filed + // without their picture instead of implying staff can see it. + expect(created.photosAttached).toBe(0); + }); + + it("shows up in the unit's history immediately", async () => { + await createMaintenanceLog( + { title: "Freshly filed", type: "Issue Report", priority: "High", unitId }, + { db } + ); + + const history = await listMaintenanceHistoryForUnit(unitId, { db }); + expect(history.map((entry) => entry.title)).toEqual(["Freshly filed"]); + expect(history[0].priority).toBe("High"); + // And only on that unit. + expect(await listMaintenanceHistoryForUnit(otherUnitId, { db })).toEqual([]); + }); +}); + +describe("toStoredValue", () => { + it("is the exact inverse of toDisplayLabel for every vocabulary value", () => { + for (const value of [...MAINTENANCE_TYPE, ...MAINTENANCE_PRIORITY]) { + const list = (MAINTENANCE_TYPE as readonly string[]).includes(value) + ? MAINTENANCE_TYPE + : MAINTENANCE_PRIORITY; + expect(toStoredValue(toDisplayLabel(value), list)).toBe(value); + } + }); + + it("accepts a value that is already stored-shaped", () => { + expect(toStoredValue("issue_report", MAINTENANCE_TYPE)).toBe("issue_report"); + }); + + it("returns null for anything outside the vocabulary", () => { + expect(toStoredValue("Spicy", MAINTENANCE_PRIORITY)).toBeNull(); + expect(toStoredValue("", MAINTENANCE_PRIORITY)).toBeNull(); + expect(toStoredValue(null, MAINTENANCE_PRIORITY)).toBeNull(); + expect(toStoredValue(undefined, MAINTENANCE_PRIORITY)).toBeNull(); + }); +}); diff --git a/v5/src/lib/data/maintenance.ts b/v5/src/lib/data/maintenance.ts index d2b0d46..5284a0e 100644 --- a/v5/src/lib/data/maintenance.ts +++ b/v5/src/lib/data/maintenance.ts @@ -1,15 +1,24 @@ import { desc, eq, sql } from "drizzle-orm"; import { getDb } from "../db/client.ts"; -import { maintenanceLogs } from "../db/schema/index.ts"; +import { maintenanceLogs, tools, units } from "../db/schema/index.ts"; +import { + MAINTENANCE_PRIORITY, + MAINTENANCE_STATUS, + MAINTENANCE_TYPE, + isOneOf, +} from "../db/schema/vocabulary.ts"; import type { Db } from "../db/types.ts"; +import { labToday } from "../lab-time.ts"; +import { claimAttachments } from "./attachments.ts"; import { isUuid } from "./uuid.ts"; /** - * Maintenance history reads on Postgres (spec §3.10, §4.8). + * Maintenance logs on Postgres — the reads (spec §3.10) and, since Phase 3, + * the write (§4.8). * - * This replaces `fetchMaintenanceLogsByUnit` from `src/lib/notion.ts` behind - * the `units` capability. Tickets are still *written* to Notion until Phase 3; - * only the read moved. + * The write lives beside the read on purpose: this module is already this + * table's data access, and the two share the vocabulary translation that is + * the easiest thing in the whole path to get wrong in one direction only. * * Two rules shape what comes back: * @@ -147,3 +156,151 @@ export function toDisplayLabel(value: string | null | undefined): string { .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } + +/** + * `"In Progress"` → `in_progress`, but only when the result is in `allowed`. + * + * The exact inverse of {@link toDisplayLabel}, and the seam Phase 3 needed: + * `report_issue`'s input schema is display-cased because it was written against + * Notion's select options, while the CHECK constraints store snake_case. A + * value that does not map to a known one comes back null rather than being + * written, because a `text` column with a CHECK rejects the *whole insert* — and + * losing a student's report of an unsafe machine to a priority spelled oddly is + * exactly the wrong failure (Article 4). + */ +export function toStoredValue( + label: string | null | undefined, + allowed: T +): T[number] | null { + if (!label) return null; + const candidate = label.trim().toLowerCase().replace(/\s+/g, "_"); + return isOneOf(allowed, candidate) ? candidate : null; +} + +// ── Filing a ticket (spec §4.8) ───────────────────────────────────── + +/** A validated ticket, as the `maintenance` capability hands one over. */ +export interface NewMaintenanceLog { + title: string; + description?: string | null; + /** Display-cased or stored; anything unrecognised is stored as null. */ + type?: string | null; + priority?: string | null; + status?: string | null; + /** The catalogue unit the report is about, when one resolved. */ + unitId?: string | null; + reportedByName?: string | null; + /** **Session only.** There is deliberately no request field that reaches this. */ + reportedByEmail?: string | null; + reportedByUserId?: string | null; + /** `attachments.id`s uploaded for this report, in display order. */ + photoAttachmentIds?: readonly string[]; +} + +export interface CreatedMaintenanceLog { + id: string; + /** The tool copied from the unit, when the unit resolved. */ + toolId: string | null; + toolName: string | null; + unitLabel: string | null; + /** The date stored, in `LAB_TIMEZONE`. */ + dateReported: string; + /** How many of {@link NewMaintenanceLog.photoAttachmentIds} actually attached. */ + photosAttached: number; +} + +export interface MaintenanceWriteOptions { + /** A handle to use instead of {@link getDb} — tests pass an isolated one. */ + db?: Db; +} + +/** + * File one maintenance ticket. + * + * Everything the ticket needs to stay readable after the unit is retired is + * copied in at write time (§4.8): the unit's `tool_id`, and `tool_name` / + * `unit_label` as snapshots. A unit that does not resolve is not an error — + * most live logs have no unit at all, and a ticket with no target is still a + * ticket. + * + * The insert and the photo claim share one transaction, so a ticket that fails + * to write cannot leave its photos pointing at a row nobody has. + * + * Throws on a database failure. The caller reports that to the student as a + * failure to file — never as a filed ticket (Article 4). + */ +export async function createMaintenanceLog( + input: NewMaintenanceLog, + options: MaintenanceWriteOptions = {} +): Promise { + const db = options.db ?? (await getDb()); + const target = await findUnitTarget(db, input.unitId); + const dateReported = labToday(); + + return db.transaction(async (tx) => { + const [row] = await tx + .insert(maintenanceLogs) + .values({ + title: input.title, + description: input.description || null, + type: toStoredValue(input.type, MAINTENANCE_TYPE), + priority: toStoredValue(input.priority, MAINTENANCE_PRIORITY), + // `status` is not null in the schema; an unrecognised one opens the + // ticket rather than refusing it. + status: toStoredValue(input.status, MAINTENANCE_STATUS) ?? "open", + unitId: target?.unitId ?? null, + toolId: target?.toolId ?? null, + toolName: target?.toolName ?? null, + unitLabel: target?.unitLabel ?? null, + reportedByName: input.reportedByName || null, + reportedByEmail: input.reportedByEmail || null, + reportedByUserId: input.reportedByUserId || null, + dateReported, + // Who filed it, for the audit columns every table carries. Null for an + // anonymous report, which stays a first-class path. + createdBy: input.reportedByUserId || null, + updatedBy: input.reportedByUserId || null, + }) + .returning({ id: maintenanceLogs.id }); + + const photosAttached = await claimAttachments(tx, input.photoAttachmentIds ?? [], { + ownerType: "maintenance_log", + ownerId: row.id, + }); + + return { + id: row.id, + toolId: target?.toolId ?? null, + toolName: target?.toolName ?? null, + unitLabel: target?.unitLabel ?? null, + dateReported, + photosAttached, + }; + }); +} + +interface UnitTarget { + unitId: string; + toolId: string | null; + toolName: string | null; + unitLabel: string | null; +} + +/** The unit, its tool and both display names — or null for anything unresolvable. */ +async function findUnitTarget(db: Db, unitId: string | null | undefined): Promise { + if (!unitId || !isUuid(unitId)) return null; + + const [row] = await db + .select({ + unitId: units.id, + unitLabel: units.unitLabel, + toolId: units.toolId, + toolName: tools.name, + }) + .from(units) + .leftJoin(tools, eq(units.toolId, tools.id)) + .where(eq(units.id, unitId)) + .limit(1); + + return row ?? null; +} diff --git a/v5/src/lib/data/notion-ids.ts b/v5/src/lib/data/notion-ids.ts index 5cfd6b1..29fffbf 100644 --- a/v5/src/lib/data/notion-ids.ts +++ b/v5/src/lib/data/notion-ids.ts @@ -7,7 +7,13 @@ import { isUuid } from "./uuid.ts"; /** * Postgres row → Notion page id (spec §3.10, §9 Phase 2). * - * Reads moved to Postgres in Phase 2; three writes have not yet (a correction, + * **SUPERSEDED — this bridge has no importers as of Phase 3**, and is kept on + * disk only because deletions are approved separately. Its three call sites + * (`capabilities/maintenance.ts`, `capabilities/flags.ts` and + * `api/projects/route.ts`) all write to Postgres now, so there is no longer a + * Notion page id to translate to. Everything below describes why it existed. + * + * Reads moved to Postgres in Phase 2; three writes had not yet (a correction, * a maintenance ticket, a project submission). Those still create Notion pages * whose `relation` properties address Notion **page** ids, while every id the * app now hands around — `tool.id`, `unit.id` — is a Postgres uuid. Notion diff --git a/v5/src/lib/data/projects.test.ts b/v5/src/lib/data/projects.test.ts index 78daaaa..738e875 100644 --- a/v5/src/lib/data/projects.test.ts +++ b/v5/src/lib/data/projects.test.ts @@ -1,6 +1,8 @@ // @vitest-environment node +import { eq } from "drizzle-orm"; import { createPgliteDb } from "../db/pglite"; import { attachments, projectTools, projects, tools } from "../db/schema/index"; +import { insertUserRow } from "../../../test/utils/session"; import type { Db } from "../db/types"; /** @@ -18,7 +20,12 @@ vi.mock("../db/client.ts", () => ({ getDb: () => Promise.resolve(dbHolder.current), })); -import { findPublishedProject, listPublishedProjects, listPublishedProjectsForTool } from "./projects"; +import { + createProjectSubmission, + findPublishedProject, + listPublishedProjects, + listPublishedProjectsForTool, +} from "./projects"; async function insertProject( db: Db, @@ -168,6 +175,200 @@ describe("src/lib/data/projects.ts", () => { }); }); + describe("createProjectSubmission", () => { + /** An uploaded-but-unattached file, as `POST /api/uploads` will leave one. */ + async function upload( + overrides: Partial = {} + ): Promise { + const [row] = await db + .insert(attachments) + .values({ + blobPathname: `uploads/${crypto.randomUUID()}.png`, + access: "public", + contentType: "image/png", + ...overrides, + }) + .returning({ id: attachments.id }); + return row.id; + } + + async function storedProject(id: string) { + const [row] = await db.select().from(projects).where(eq(projects.id, id)); + return row; + } + + function submission(overrides: Record = {}) { + return { + title: "Lamp from scrap plywood", + body: "Cut on the laser, glued, sanded.", + authorName: "Ada Lovelace", + ...overrides, + }; + } + + it("writes one unpublished row that the gallery cannot see", async () => { + const created = await createProjectSubmission(submission(), { db }); + + const row = await storedProject(created.id); + // Article 5: a submission is a draft, full stop. There is no argument + // this function takes that could make it otherwise. + expect(row.published).toBe(false); + expect(row.publishedAt).toBeNull(); + expect(row.title).toBe("Lamp from scrap plywood"); + expect(await listPublishedProjects()).toEqual([]); + expect(await findPublishedProject(created.slug)).toBeNull(); + }); + + it("derives the slug from the title and suffixes a collision", async () => { + const first = await createProjectSubmission(submission(), { db }); + const second = await createProjectSubmission(submission(), { db }); + const third = await createProjectSubmission(submission(), { db }); + + expect(first.slug).toBe("lamp-from-scrap-plywood"); + expect(second.slug).toBe("lamp-from-scrap-plywood-2"); + expect(third.slug).toBe("lamp-from-scrap-plywood-3"); + }); + + it("does not collide with a slug that merely starts the same way", async () => { + await insertProject(db, { title: "Lamp", slug: "lamp" }); + + const created = await createProjectSubmission(submission({ title: "Lamp stand" }), { db }); + expect(created.slug).toBe("lamp-stand"); + }); + + it("links only the tool ids that name a real tool", async () => { + const real = await insertTool(db, { name: "Form 4", slug: "form-4" }); + const stale = crypto.randomUUID(); + + const created = await createProjectSubmission( + submission({ toolIds: [real, stale, "not-a-uuid"] }), + { db } + ); + + // A stale id in a form that has been open a while drops out rather than + // costing the student their write-up (Article 4). + expect(created.toolsLinked).toBe(1); + const links = await db + .select() + .from(projectTools) + .where(eq(projectTools.projectId, created.id)); + expect(links.map((link) => link.toolId)).toEqual([real]); + }); + + it("claims the photos onto the project, cover first", async () => { + const cover = await upload(); + const second = await upload(); + + const created = await createProjectSubmission( + submission({ photoAttachmentIds: [cover, second] }), + { db } + ); + + expect(created.photosAttached).toBe(2); + const rows = await db + .select() + .from(attachments) + .where(eq(attachments.ownerId, created.id)) + .orderBy(attachments.position); + expect(rows.map((row) => row.id)).toEqual([cover, second]); + expect(rows.every((row) => row.ownerType === "project")).toBe(true); + }); + + it("records the author id from the session, and nothing when anonymous", async () => { + // Since Phase 4 `created_by` references `user.id`, so the author has to + // be a row — which in production they are, because the id comes from a + // session. + await insertUserRow(db, { id: "google-sub-1", email: "ada@cornell.edu" }); + const signedIn = await createProjectSubmission( + submission({ authorUserId: "google-sub-1" }), + { db } + ); + const anonymous = await createProjectSubmission(submission(), { db }); + + expect(await storedProject(signedIn.id)).toMatchObject({ + authorUserId: "google-sub-1", + createdBy: "google-sub-1", + authorName: "Ada Lovelace", + }); + // Anonymous submission stays a first-class path at the data layer; the + // sign-in requirement is enforced at the route, not here. + const row = await storedProject(anonymous.id); + expect(row.authorUserId).toBeNull(); + expect(row.createdBy).toBeNull(); + }); + + it("stores materials and the link, and normalises an absent link to null", async () => { + const withLink = await createProjectSubmission( + submission({ materials: ["Plywood", "Glue"], link: "https://example.com/lamp" }), + { db } + ); + const without = await createProjectSubmission(submission({ title: "No link" }), { db }); + + expect(await storedProject(withLink.id)).toMatchObject({ + materials: ["Plywood", "Glue"], + link: "https://example.com/lamp", + }); + const bare = await storedProject(without.id); + expect(bare.link).toBeNull(); + expect(bare.materials).toEqual([]); + }); + + it("becomes visible the moment somebody publishes it, with its photos and tools intact", async () => { + const toolId = await insertTool(db, { name: "Trotec Speedy 400", slug: "trotec" }); + const cover = await upload({ publicUrl: "https://blob.test/cover.png" }); + const created = await createProjectSubmission( + submission({ toolIds: [toolId], photoAttachmentIds: [cover] }), + { db } + ); + + await db.update(projects).set({ published: true }).where(eq(projects.id, created.id)); + + const [project] = await listPublishedProjects(); + expect(project.title).toBe("Lamp from scrap plywood"); + expect(project.photos).toEqual(["https://blob.test/cover.png"]); + expect(project.tools).toEqual([ + { id: toolId, name: "Trotec Speedy 400", slug: "trotec" }, + ]); + }); + + it("retries once against the current slug set when the unique index refuses one", async () => { + // PGlite is a single connection, so a genuine race cannot be staged. The + // handle stands in for the loser of one: the first transaction comes back + // as SQLSTATE 23505 (what Postgres answers when somebody else took the + // slug between the read and the insert), and the second runs for real. + let refused = false; + const flaky: Db = Object.create(db); + flaky.transaction = ((callback: Parameters[0]) => { + if (refused) return db.transaction(callback); + refused = true; + return Promise.reject(Object.assign(new Error("duplicate key"), { code: "23505" })); + }) as Db["transaction"]; + + const created = await createProjectSubmission(submission(), { db: flaky }); + + expect(refused).toBe(true); + expect(created.slug).toBe("lamp-from-scrap-plywood"); + expect((await storedProject(created.id)).published).toBe(false); + }); + + it("gives up on a failure that is not a slug collision, leaving nothing behind", async () => { + const boom: Db = Object.create(db); + let attempts = 0; + boom.transaction = (() => { + attempts += 1; + return Promise.reject(new Error("connection terminated")); + }) as Db["transaction"]; + + await expect(createProjectSubmission(submission(), { db: boom })).rejects.toThrow( + /connection terminated/ + ); + // One attempt, not two: only a collision is worth retrying, and a caller + // told the write failed must be told the truth (Article 4). + expect(attempts).toBe(1); + expect(await db.select().from(projects)).toEqual([]); + }); + }); + describe("listPublishedProjectsForTool", () => { it("returns only published projects that reference the given tool", async () => { const toolId = await insertTool(db, { name: "Trotec Speedy 400" }); diff --git a/v5/src/lib/data/projects.ts b/v5/src/lib/data/projects.ts index 76dbaa6..94d460f 100644 --- a/v5/src/lib/data/projects.ts +++ b/v5/src/lib/data/projects.ts @@ -1,15 +1,18 @@ -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, like, or } from "drizzle-orm"; import { getDb } from "../db/client.ts"; import { attachments, projectTools, projects, tools } from "../db/schema/index.ts"; +import { slugify, uniqueSlug } from "../db/slug.ts"; import type { Db } from "../db/types.ts"; +import { claimAttachments } from "./attachments.ts"; import { isUuid } from "./uuid.ts"; import type { MakerLabProject, ProjectToolRef } from "../../components/catalog-types.ts"; /** * Postgres reads for published projects (data platform design spec 2026-09-14 - * §3.10, §4.10, §4.13). This is the query module `src/lib/projects.ts` wraps - * in `"use cache"`; nothing here is cached itself, so every export is a plain - * round trip and safe to call as often as the caller needs a fresh answer. + * §3.10, §4.10, §4.13), and since Phase 3 the submission write. This is the + * query module `src/lib/projects.ts` wraps in `"use cache"`; nothing here is + * cached itself, so every export is a plain round trip and safe to call as + * often as the caller needs a fresh answer. * * Imports are relative with `.ts` extensions and skip the `@/` alias, like * everything under `src/lib/db/` and `src/lib/import/`: scripts load these @@ -68,6 +71,161 @@ export async function listPublishedProjectsForTool(toolId: string): Promise { + const db = options.db ?? (await getDb()); + try { + return await insertSubmission(db, input); + } catch (err) { + if (!isUniqueViolation(err)) throw err; + // Somebody else took the slug between the read and the insert. The second + // attempt reads a set that now includes theirs. + return insertSubmission(db, input); + } +} + +async function insertSubmission(db: Db, input: NewProjectSubmission): Promise { + return db.transaction(async (tx) => { + const slug = await allocateSlug(tx, slugify(input.title)); + const authorUserId = input.authorUserId || null; + + const [row] = await tx + .insert(projects) + .values({ + slug, + title: input.title, + body: input.body, + link: input.link || null, + materials: [...(input.materials ?? [])], + authorName: input.authorName || null, + authorUserId, + // Article 5. Not a parameter, deliberately. + published: false, + createdBy: authorUserId, + updatedBy: authorUserId, + }) + .returning({ id: projects.id }); + + const toolsLinked = await linkTools(tx, row.id, input.toolIds ?? []); + const photosAttached = await claimAttachments(tx, input.photoAttachmentIds ?? [], { + ownerType: "project", + ownerId: row.id, + }); + + return { id: row.id, slug, toolsLinked, photosAttached }; + }); +} + +/** The first free slug in the `base`, `base-2`, `base-3`, … family. */ +async function allocateSlug(db: Db, base: string): Promise { + const rows = await db + .select({ slug: projects.slug }) + .from(projects) + // Only the family, not the whole table: a lab with a thousand projects + // should not read a thousand slugs to name one (Article 4). + .where(or(eq(projects.slug, base), like(projects.slug, `${base}-%`))); + + return uniqueSlug(base, new Set(rows.map((row) => row.slug))); +} + +/** Insert `project_tools` rows for the ids that name a real tool; returns how many. */ +async function linkTools(db: Db, projectId: string, toolIds: readonly string[]): Promise { + const candidates = [...new Set(toolIds.filter(isUuid))]; + if (candidates.length === 0) return 0; + + const existing = await db + .select({ id: tools.id }) + .from(tools) + .where(inArray(tools.id, candidates)); + if (existing.length === 0) return 0; + + await db.insert(projectTools).values(existing.map((tool) => ({ projectId, toolId: tool.id }))); + return existing.length; +} + +/** + * A Postgres unique-constraint violation (SQLSTATE 23505). Both drivers surface + * the code somewhere on the error or its cause, so this reads the chain rather + * than assuming either one's shape. + */ +function isUniqueViolation(err: unknown): boolean { + for (let current: unknown = err, depth = 0; current && depth < 5; depth += 1) { + const candidate = current as { code?: unknown; cause?: unknown }; + if (candidate.code === "23505") return true; + current = candidate.cause; + } + return false; +} + /** Attaches each row's photos and tool refs, then maps onto the view model. */ async function hydrateProjects(db: Db, rows: ProjectRow[]): Promise { if (rows.length === 0) return []; diff --git a/v5/src/lib/data/users.test.ts b/v5/src/lib/data/users.test.ts new file mode 100644 index 0000000..a75b44a --- /dev/null +++ b/v5/src/lib/data/users.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +import { eq } from "drizzle-orm"; +import { createPgliteDb } from "../db/pglite"; +import { user } from "../db/schema/index"; +import { insertUserRow } from "../../../test/utils/session"; +import type { Db } from "../db/types"; +import { countUsersWithRole, findUserById, listUsers } from "./users"; + +/** + * The admin roster against a real (in-process) Postgres. No env, no network. + */ + +let db: Db; + +beforeAll(async () => { + db = await createPgliteDb(); +}); + +beforeEach(async () => { + await db.delete(user); +}); + +async function seed( + email: string, + role: "user" | "admin" | "super_admin", + extra: { banned?: boolean; banReason?: string | null; name?: string } = {} +) { + return insertUserRow(db, { id: `u-${email}`, email, role, ...extra }); +} + +describe("listUsers", () => { + it("returns everyone in email order, whatever order they were created in", async () => { + await seed("zoe@cornell.edu", "user"); + await seed("ada@cornell.edu", "super_admin"); + await seed("marie@cornell.edu", "admin"); + + const rows = await listUsers({ db }); + + expect(rows.map((row) => row.email)).toEqual([ + "ada@cornell.edu", + "marie@cornell.edu", + "zoe@cornell.edu", + ]); + expect(rows.map((row) => row.role)).toEqual(["super_admin", "admin", "user"]); + }); + + it("reports a ban and its reason", async () => { + await seed("banned@cornell.edu", "user", { + banned: true, + banReason: "Repeatedly ignored the laser rules", + }); + + const [row] = await listUsers({ db }); + expect(row.banned).toBe(true); + expect(row.banReason).toBe("Repeatedly ignored the laser rules"); + }); + + it("is empty on a database nobody has signed in to", async () => { + expect(await listUsers({ db })).toEqual([]); + }); + + it("falls back to `user` for a row whose role column is null", async () => { + // Better Auth declares `role` optional, so a row written before the + // default applied would read back null. It must not become `anonymous`. + await seed("nullrole@cornell.edu", "user"); + await db.update(user).set({ role: null }).where(eq(user.email, "nullrole@cornell.edu")); + + const [row] = await listUsers({ db }); + expect(row.role).toBe("user"); + }); +}); + +describe("findUserById", () => { + it("finds the row the role-change action needs to check the floor", async () => { + const seeded = await seed("ada@cornell.edu", "admin", { name: "Ada" }); + + const found = await findUserById(seeded.id, { db }); + expect(found).toMatchObject({ + id: seeded.id, + email: "ada@cornell.edu", + name: "Ada", + role: "admin", + banned: false, + }); + }); + + it("answers null for an unknown id, and for no id at all", async () => { + expect(await findUserById("nobody", { db })).toBeNull(); + expect(await findUserById("", { db })).toBeNull(); + }); +}); + +describe("countUsersWithRole", () => { + it("counts the holders of a role", async () => { + await seed("a@cornell.edu", "super_admin"); + await seed("b@cornell.edu", "super_admin"); + await seed("c@cornell.edu", "user"); + + expect(await countUsersWithRole("super_admin", { db })).toBe(2); + expect(await countUsersWithRole("admin", { db })).toBe(0); + }); + + it("excludes the person about to be changed — 'who would be left?'", async () => { + const only = await seed("only@cornell.edu", "super_admin"); + + expect(await countUsersWithRole("super_admin", { db })).toBe(1); + expect( + await countUsersWithRole("super_admin", { db, excludeUserId: only.id }) + ).toBe(0); + }); + + it("does not count a banned holder — they resolve to anonymous and can undo nothing", async () => { + await seed("banned-director@cornell.edu", "super_admin", { banned: true }); + await seed("director@cornell.edu", "super_admin"); + + expect(await countUsersWithRole("super_admin", { db })).toBe(1); + }); + + it("counts a holder whose `banned` column is null, not just one that is false", async () => { + // `banned <> true` is unknown for a null in SQL, so a naive filter would + // quietly report zero super admins and refuse every demotion. + await seed("nullban@cornell.edu", "super_admin"); + await db.update(user).set({ banned: null }).where(eq(user.email, "nullban@cornell.edu")); + + expect(await countUsersWithRole("super_admin", { db })).toBe(1); + }); +}); diff --git a/v5/src/lib/data/users.ts b/v5/src/lib/data/users.ts new file mode 100644 index 0000000..3f102a4 --- /dev/null +++ b/v5/src/lib/data/users.ts @@ -0,0 +1,122 @@ +import { and, asc, count, eq, isNull, ne, or } from "drizzle-orm"; +import { getDb } from "../db/client.ts"; +import { user } from "../db/schema/index.ts"; +import type { Role } from "../db/schema/vocabulary.ts"; +import type { Db } from "../db/types.ts"; + +/** + * Reading the roster for `/admin/users` (data platform design spec §5.2). + * + * **Read straight from Postgres, not through the admin plugin's `list-users` + * endpoint.** The page is a server component; asking Better Auth would mean a + * round trip through the auth handler — headers, session re-resolution, the + * plugin's own pagination — to select from a table this process already has a + * handle on. The *writes* still go through the plugin (`set-role`, `ban-user` + * in `app/admin/users/actions.ts`), because those carry behaviour worth having: + * a ban deletes the person's sessions. + * + * Nothing here decides anything. The page gates on `can(identity, + * "users.manage")` before it calls, and each server action checks again. + * + * Emails *are* returned, unlike everywhere else in the app: this is the one + * surface whose whole job is telling a super admin which account is which, and + * a roster of display names cannot do that. It goes no further — not into a + * prompt, not into the mirror, not into a log line (spec §8). + * + * Relative imports with `.ts` extensions, no `@/` alias and no `"server-only"`, + * like every other module under `src/lib/data/`. + */ + +/** One account as the admin roster shows it. */ +export interface UserRecord { + id: string; + email: string; + name: string; + /** The stored role. Never `anonymous` — that is the absence of a row. */ + role: Role; + banned: boolean; + banReason: string | null; + createdAt: Date; +} + +export interface UserQueryOptions { + /** A handle to use instead of {@link getDb} — tests pass an isolated one. */ + db?: Db; +} + +/** + * Every account, ordered by email. + * + * Unpaginated on purpose. The roster is one lab's SuperMakers and the students + * who have signed in — hundreds, not millions — and a super admin looking for + * one person is better served by one page they can search in the browser than + * by a pager. If it ever stops being one screenful, that is the moment to add + * one, and the call site is this function. + */ +export async function listUsers(options: UserQueryOptions = {}): Promise { + const db = options.db ?? (await getDb()); + const rows = await db.select().from(user).orderBy(asc(user.email)); + return rows.map(toUserRecord); +} + +/** + * One account by id, or null. + * + * The role-change action needs this *before* it calls the plugin, for two + * reasons it cannot get any other way: the email, to test against the + * super-admin floor, and the current role, so the audit event can say what the + * change was rather than only what it became. + */ +export async function findUserById( + id: string, + options: UserQueryOptions = {} +): Promise { + if (!id) return null; + const db = options.db ?? (await getDb()); + const [row] = await db.select().from(user).where(eq(user.id, id)).limit(1); + return row ? toUserRecord(row) : null; +} + +/** + * How many accounts hold `role`, ignoring one id. + * + * `excludeUserId` is the person about to be changed, so the caller asks "who + * would still hold this afterwards?" rather than "who holds it now?" — which is + * what "the last super admin demotes themselves" (spec §10) actually needs to + * know. Banned accounts are not counted: a banned super admin resolves to + * anonymous on every request and cannot undo anything. + */ +export async function countUsersWithRole( + role: Role, + { excludeUserId, db: handle }: UserQueryOptions & { excludeUserId?: string } = {} +): Promise { + const db = handle ?? (await getDb()); + // `banned` is nullable (Better Auth declares it optional), and in SQL + // `banned <> true` is unknown — not true — for a null. Spelling both out is + // the difference between "nobody is left" and "nobody is left that I noticed". + const conditions = [ + eq(user.role, role), + or(eq(user.banned, false), isNull(user.banned)), + ]; + if (excludeUserId) conditions.push(ne(user.id, excludeUserId)); + + const [row] = await db + .select({ total: count() }) + .from(user) + .where(and(...conditions)); + return Number(row?.total ?? 0); +} + +function toUserRecord(row: typeof user.$inferSelect): UserRecord { + return { + id: row.id, + email: row.email, + name: row.name, + // The `user_role_check` constraint makes anything else impossible; the cast + // is the type system catching up with the database, not a guess. + role: (row.role ?? "user") as Role, + banned: Boolean(row.banned), + banReason: row.banReason ?? null, + createdAt: row.createdAt, + }; +} diff --git a/v5/src/lib/db/demo-seed.test.ts b/v5/src/lib/db/demo-seed.test.ts index c36f3ac..6417dac 100644 --- a/v5/src/lib/db/demo-seed.test.ts +++ b/v5/src/lib/db/demo-seed.test.ts @@ -1,13 +1,23 @@ // @vitest-environment node import { and, eq } from "drizzle-orm"; import { + DEMO_ACCOUNTS, DEMO_FORM_4_NOTION_PAGE_ID, DEMO_FORM_4_UNIT_NOTION_PAGE_ID, DEMO_PROJECT_SLUG, seedDemo, } from "./demo-seed"; import { createPgliteDb } from "./pglite"; -import { attachments, projectTools, projects, resources, tools, units } from "./schema/index"; +import { + attachments, + projectTools, + projects, + resources, + session, + tools, + units, + user, +} from "./schema/index"; describe("seedDemo", () => { it("inserts the two demo tools with their units and resources", async () => { @@ -80,6 +90,31 @@ describe("seedDemo", () => { expect(photos.every((p) => p.access === "public")).toBe(true); }); + it("seeds an account per role, plus one to promote, each with a constant session token", async () => { + // What makes a role testable without Google: the E2E browser presents a + // cookie carrying one of these tokens and the server reads the role off + // the `user` row. `promotable` is the spare the role-change E2E changes, + // so that test cannot race the ones asserting an ordinary account's + // controls. + const db = await createPgliteDb({ seed: seedDemo }); + + const users = await db.select().from(user).orderBy(user.email); + expect(users.map((row) => [row.email, row.role])).toEqual([ + [DEMO_ACCOUNTS.user.email, "user"], + [DEMO_ACCOUNTS.superAdmin.email, "super_admin"], + [DEMO_ACCOUNTS.admin.email, "admin"], + [DEMO_ACCOUNTS.promotable.email, "user"], + ]); + + const sessions = await db.select().from(session); + expect(sessions.map((row) => row.token).sort()).toEqual( + Object.values(DEMO_ACCOUNTS) + .map((account) => account.sessionToken) + .sort() + ); + expect(sessions.every((row) => row.expiresAt.getTime() > Date.now())).toBe(true); + }); + it("is idempotent", async () => { const db = await createPgliteDb({ seed: seedDemo }); await seedDemo(db); diff --git a/v5/src/lib/db/demo-seed.ts b/v5/src/lib/db/demo-seed.ts index 3938ffa..6656119 100644 --- a/v5/src/lib/db/demo-seed.ts +++ b/v5/src/lib/db/demo-seed.ts @@ -5,8 +5,10 @@ import { projectTools, projects, resources, + session, tools, units, + user, } from "./schema/index.ts"; import type { Db } from "./types.ts"; @@ -24,19 +26,79 @@ import type { Db } from "./types.ts"; * * Form 4 carries a `notionPageId`, exercising the legacy `/tools/` * redirect (spec Goal 2) end to end without a real database. Its unit carries - * one too: the writes still on Notion until Phase 3 relate rows by page id, so - * an imported row and a purely local one (the Trotec, which has neither) are - * both worth having in the seed. + * one too. No write reads those ids any more — Phase 3 moved the last three + * onto Postgres — but an imported row and a purely local one (the Trotec, which + * has neither) are both worth having in the seed, because the redirect has to + * keep working for every QR label already stuck to a machine. */ export const DEMO_FORM_4_NOTION_PAGE_ID = "1f2e3d4c-5b6a-4789-8abc-def012345678"; -/** The Notion page behind the Form 4's one unit — the `unit` relation target. */ +/** The Notion page the Form 4's one unit was imported from. */ export const DEMO_FORM_4_UNIT_NOTION_PAGE_ID = "2a3b4c5d-6e7f-4890-9abc-def012345678"; /** The sample project's slug, for tests and E2E. */ export const DEMO_PROJECT_SLUG = "laser-cut-plywood-lamp"; +/** + * One demo account per role, each with a session row whose token is a constant + * (spec §10). Sessions are rows since Phase 4, so this is what lets the E2E + * suite be somebody without Google: the browser presents a cookie carrying one + * of these tokens, signed with the test-only `AUTH_SECRET` the Playwright + * server boots with, and the server resolves the role from the `user` row. + * + * **Demo data only.** These rows exist exclusively in the PGlite substrate — + * `seedDemo` runs from `createPgliteDb` and nowhere else, so a deployment with + * `DATABASE_URL` set never sees them. The tokens are public constants in a + * public repository, and they are worthless without the secret that signs + * them; a deployment that set a real `AUTH_SECRET` also set `DATABASE_URL`, + * and these rows are not in that database. + */ +export const DEMO_ACCOUNTS = { + user: { + id: "demo-user-casey", + name: "Casey Rivera", + email: "casey@cornell.edu", + role: "user", + sessionToken: "demo-session-user", + }, + admin: { + id: "demo-user-niti", + name: "Niti Parikh", + email: "niti@cornell.edu", + role: "admin", + sessionToken: "demo-session-admin", + }, + superAdmin: { + id: "demo-user-isaac", + name: "Isaac Steinberg", + email: "isaac@cornell.edu", + role: "super_admin", + sessionToken: "demo-session-super-admin", + }, + /** + * A second ordinary account, for the one E2E that *changes* a role + * (spec §10 scenario 6). + * + * Its own row on purpose: the E2E suite runs its files in parallel against + * one server, so promoting `user` would race `auth.spec.ts`'s assertion that + * an ordinary account has no admin controls. Nothing but + * `e2e/admin-users.spec.ts` touches this one. + */ + promotable: { + id: "demo-user-pat", + name: "Pat Promotable", + email: "pat@cornell.edu", + role: "user", + sessionToken: "demo-session-promotable", + }, +} as const; + +/** Far enough out that no demo session expires mid-suite. */ +const DEMO_SESSION_EXPIRES_AT = new Date("2099-01-01T00:00:00.000Z"); + export async function seedDemo(db: Db): Promise { + await seedDemoAccounts(db); + const existing = await db.select({ id: tools.id }).from(tools).limit(1); if (existing.length > 0) return; @@ -191,3 +253,34 @@ export async function seedDemo(db: Db): Promise { ]); }); } + +/** + * The three demo accounts and their sessions. Separately guarded from the + * catalogue above so a database seeded before Phase 4 picks them up, and + * idempotent for the same reason the rest of the seed is. + */ +async function seedDemoAccounts(db: Db): Promise { + const existing = await db.select({ id: user.id }).from(user).limit(1); + if (existing.length > 0) return; + + const accounts = Object.values(DEMO_ACCOUNTS); + + await db.insert(user).values( + accounts.map((account) => ({ + id: account.id, + name: account.name, + email: account.email, + emailVerified: true, + role: account.role, + })) + ); + + await db.insert(session).values( + accounts.map((account) => ({ + id: `${account.id}-session`, + token: account.sessionToken, + userId: account.id, + expiresAt: DEMO_SESSION_EXPIRES_AT, + })) + ); +} diff --git a/v5/src/lib/db/migrations/0003_better_auth.sql b/v5/src/lib/db/migrations/0003_better_auth.sql new file mode 100644 index 0000000..4e3cf4d --- /dev/null +++ b/v5/src/lib/db/migrations/0003_better_auth.sql @@ -0,0 +1,88 @@ +-- Phase 4: Better Auth's four tables, plus the `created_by` / `updated_by` and +-- `audit_events.actor_user_id` foreign keys Phase 1 deliberately deferred to +-- this migration (they could not exist before `user` did). +-- +-- Order matters and is correct as generated: every table is created before any +-- `ALTER TABLE … ADD CONSTRAINT` names it. The one risk on real data is the +-- deferred foreign keys — a non-null `created_by` naming no `user` row aborts +-- the whole migration. The import and the demo seed both leave those columns +-- null, so PGlite and a freshly imported Neon apply this cleanly; a production +-- database should be checked first: +-- select count(*) from tools where created_by is not null; -- and so on +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp with time zone, + "refresh_token_expires_at" timestamp with time zone, + "scope" text, + "password" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "impersonated_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "role" text DEFAULT 'user', + "banned" boolean DEFAULT false, + "ban_reason" text, + "ban_expires" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email"), + CONSTRAINT "user_role_check" CHECK ("role" in ('user', 'admin', 'super_admin')) +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_user_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_user_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "user_email_idx" ON "user" USING btree ("email");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint +ALTER TABLE "categories" ADD CONSTRAINT "categories_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "categories" ADD CONSTRAINT "categories_updated_by_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "locations" ADD CONSTRAINT "locations_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "locations" ADD CONSTRAINT "locations_updated_by_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tools" ADD CONSTRAINT "tools_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tools" ADD CONSTRAINT "tools_updated_by_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "units" ADD CONSTRAINT "units_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "units" ADD CONSTRAINT "units_updated_by_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "resources" ADD CONSTRAINT "resources_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "resources" ADD CONSTRAINT "resources_updated_by_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "maintenance_logs" ADD CONSTRAINT "maintenance_logs_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "maintenance_logs" ADD CONSTRAINT "maintenance_logs_updated_by_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feedback" ADD CONSTRAINT "feedback_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feedback" ADD CONSTRAINT "feedback_updated_by_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_updated_by_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_actor_user_id_user_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/v5/src/lib/db/migrations/meta/0003_snapshot.json b/v5/src/lib/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..70fd30b --- /dev/null +++ b/v5/src/lib/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,2279 @@ +{ + "id": "850add15-d29a-406e-aaac-15b5d3218c8d", + "prevId": "2929b0ef-9134-4b15-b91d-e81fb0675600", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_idx": { + "name": "account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_role_check": { + "name": "user_role_check", + "value": "\"role\" in ('user', 'admin', 'super_admin')" + } + }, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notion_page_id": { + "name": "notion_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "categories_name_group_key": { + "name": "categories_name_group_key", + "columns": [ + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "lower(coalesce(\"group\", ''))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "categories_created_by_user_id_fk": { + "name": "categories_created_by_user_id_fk", + "tableFrom": "categories", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "categories_updated_by_user_id_fk": { + "name": "categories_updated_by_user_id_fk", + "tableFrom": "categories", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_notion_page_id_unique": { + "name": "categories_notion_page_id_unique", + "nullsNotDistinct": false, + "columns": [ + "notion_page_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.locations": { + "name": "locations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room": { + "name": "room", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone": { + "name": "zone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "map_tag": { + "name": "map_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notion_page_id": { + "name": "notion_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "locations_room_zone_key": { + "name": "locations_room_zone_key", + "columns": [ + { + "expression": "lower(\"room\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "lower(\"zone\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "locations_created_by_user_id_fk": { + "name": "locations_created_by_user_id_fk", + "tableFrom": "locations", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "locations_updated_by_user_id_fk": { + "name": "locations_updated_by_user_id_fk", + "tableFrom": "locations", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "locations_map_tag_unique": { + "name": "locations_map_tag_unique", + "nullsNotDistinct": false, + "columns": [ + "map_tag" + ] + }, + "locations_notion_page_id_unique": { + "name": "locations_notion_page_id_unique", + "nullsNotDistinct": false, + "columns": [ + "notion_page_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tools": { + "name": "tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "materials": { + "name": "materials", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "ppe_required": { + "name": "ppe_required", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "training_required": { + "name": "training_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_restrictions": { + "name": "use_restrictions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emergency_stop": { + "name": "emergency_stop", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_reviewed_at": { + "name": "last_reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_reviewed_by": { + "name": "last_reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notion_page_id": { + "name": "notion_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tools_name_trgm_idx": { + "name": "tools_name_trgm_idx", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tools_category_idx": { + "name": "tools_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tools_location_idx": { + "name": "tools_location_idx", + "columns": [ + { + "expression": "location_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tools_category_id_categories_id_fk": { + "name": "tools_category_id_categories_id_fk", + "tableFrom": "tools", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tools_location_id_locations_id_fk": { + "name": "tools_location_id_locations_id_fk", + "tableFrom": "tools", + "tableTo": "locations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tools_created_by_user_id_fk": { + "name": "tools_created_by_user_id_fk", + "tableFrom": "tools", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tools_updated_by_user_id_fk": { + "name": "tools_updated_by_user_id_fk", + "tableFrom": "tools", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tools_slug_unique": { + "name": "tools_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "tools_notion_page_id_unique": { + "name": "tools_notion_page_id_unique", + "nullsNotDistinct": false, + "columns": [ + "notion_page_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.units": { + "name": "units", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tool_id": { + "name": "tool_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "unit_label": { + "name": "unit_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serial_number": { + "name": "serial_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_tag": { + "name": "asset_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_acquired": { + "name": "date_acquired", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notion_page_id": { + "name": "notion_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "units_tool_idx": { + "name": "units_tool_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "units_tool_serial_key": { + "name": "units_tool_serial_key", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"serial_number\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"units\".\"serial_number\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "units_tool_id_tools_id_fk": { + "name": "units_tool_id_tools_id_fk", + "tableFrom": "units", + "tableTo": "tools", + "columnsFrom": [ + "tool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "units_created_by_user_id_fk": { + "name": "units_created_by_user_id_fk", + "tableFrom": "units", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "units_updated_by_user_id_fk": { + "name": "units_updated_by_user_id_fk", + "tableFrom": "units", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "units_notion_page_id_unique": { + "name": "units_notion_page_id_unique", + "nullsNotDistinct": false, + "columns": [ + "notion_page_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "units_status_check": { + "name": "units_status_check", + "value": "\"status\" in ('available', 'in_use', 'under_maintenance', 'out_of_service', 'retired')" + }, + "units_condition_check": { + "name": "units_condition_check", + "value": "\"condition\" in ('excellent', 'good', 'fair', 'needs_repair', 'new')" + } + }, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tool_id": { + "name": "tool_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notion_page_id": { + "name": "notion_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resources_tool_idx": { + "name": "resources_tool_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resources_tool_id_tools_id_fk": { + "name": "resources_tool_id_tools_id_fk", + "tableFrom": "resources", + "tableTo": "tools", + "columnsFrom": [ + "tool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resources_created_by_user_id_fk": { + "name": "resources_created_by_user_id_fk", + "tableFrom": "resources", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resources_updated_by_user_id_fk": { + "name": "resources_updated_by_user_id_fk", + "tableFrom": "resources", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resources_notion_page_id_unique": { + "name": "resources_notion_page_id_unique", + "nullsNotDistinct": false, + "columns": [ + "notion_page_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "blob_pathname": { + "name": "blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_url": { + "name": "public_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_owner_idx": { + "name": "attachments_owner_idx", + "columns": [ + { + "expression": "owner_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_source_key_unique": { + "name": "attachments_source_key_unique", + "nullsNotDistinct": false, + "columns": [ + "source_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "attachments_owner_type_check": { + "name": "attachments_owner_type_check", + "value": "\"owner_type\" in ('tool', 'resource', 'maintenance_log', 'project', 'pending_tool')" + }, + "attachments_access_check": { + "name": "attachments_access_check", + "value": "\"access\" in ('public', 'private')" + } + }, + "isRLSEnabled": false + }, + "public.maintenance_logs": { + "name": "maintenance_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_id": { + "name": "tool_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_label": { + "name": "unit_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_by_name": { + "name": "reported_by_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_by_email": { + "name": "reported_by_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_by_user_id": { + "name": "reported_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_to_user_id": { + "name": "assigned_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_to_name": { + "name": "assigned_to_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_reported": { + "name": "date_reported", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "date_resolved": { + "name": "date_resolved", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "notion_page_id": { + "name": "notion_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "maintenance_logs_unit_idx": { + "name": "maintenance_logs_unit_idx", + "columns": [ + { + "expression": "unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "maintenance_logs_tool_idx": { + "name": "maintenance_logs_tool_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "maintenance_logs_status_idx": { + "name": "maintenance_logs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "maintenance_logs_unit_id_units_id_fk": { + "name": "maintenance_logs_unit_id_units_id_fk", + "tableFrom": "maintenance_logs", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "maintenance_logs_tool_id_tools_id_fk": { + "name": "maintenance_logs_tool_id_tools_id_fk", + "tableFrom": "maintenance_logs", + "tableTo": "tools", + "columnsFrom": [ + "tool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "maintenance_logs_created_by_user_id_fk": { + "name": "maintenance_logs_created_by_user_id_fk", + "tableFrom": "maintenance_logs", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "maintenance_logs_updated_by_user_id_fk": { + "name": "maintenance_logs_updated_by_user_id_fk", + "tableFrom": "maintenance_logs", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "maintenance_logs_notion_page_id_unique": { + "name": "maintenance_logs_notion_page_id_unique", + "nullsNotDistinct": false, + "columns": [ + "notion_page_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "maintenance_logs_type_check": { + "name": "maintenance_logs_type_check", + "value": "\"type\" in ('issue_report', 'preventive_maintenance', 'repair', 'inspection', 'calibration')" + }, + "maintenance_logs_priority_check": { + "name": "maintenance_logs_priority_check", + "value": "\"priority\" in ('low', 'medium', 'high', 'critical')" + }, + "maintenance_logs_status_check": { + "name": "maintenance_logs_status_check", + "value": "\"status\" in ('open', 'in_progress', 'resolved', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.feedback": { + "name": "feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tool_id": { + "name": "tool_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "field_flagged": { + "name": "field_flagged", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_description": { + "name": "issue_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_fix": { + "name": "suggested_fix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_name": { + "name": "reporter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_email": { + "name": "reporter_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_user_id": { + "name": "reporter_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "notion_page_id": { + "name": "notion_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_tool_idx": { + "name": "feedback_tool_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_status_idx": { + "name": "feedback_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_tool_id_tools_id_fk": { + "name": "feedback_tool_id_tools_id_fk", + "tableFrom": "feedback", + "tableTo": "tools", + "columnsFrom": [ + "tool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "feedback_created_by_user_id_fk": { + "name": "feedback_created_by_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "feedback_updated_by_user_id_fk": { + "name": "feedback_updated_by_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feedback_notion_page_id_unique": { + "name": "feedback_notion_page_id_unique", + "nullsNotDistinct": false, + "columns": [ + "notion_page_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "feedback_field_flagged_check": { + "name": "feedback_field_flagged_check", + "value": "\"field_flagged\" in ('description', 'image', 'name', 'category', 'location', 'materials', 'safety_info')" + }, + "feedback_status_check": { + "name": "feedback_status_check", + "value": "\"status\" in ('new', 'reviewed', 'fixed', 'dismissed')" + } + }, + "isRLSEnabled": false + }, + "public.project_tools": { + "name": "project_tools", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_id": { + "name": "tool_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "project_tools_tool_idx": { + "name": "project_tools_tool_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_tools_project_id_projects_id_fk": { + "name": "project_tools_project_id_projects_id_fk", + "tableFrom": "project_tools", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_tools_tool_id_tools_id_fk": { + "name": "project_tools_tool_id_tools_id_fk", + "tableFrom": "project_tools", + "tableTo": "tools", + "columnsFrom": [ + "tool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_tools_project_id_tool_id_pk": { + "name": "project_tools_project_id_tool_id_pk", + "columns": [ + "project_id", + "tool_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materials": { + "name": "materials", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notion_page_id": { + "name": "notion_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_published_idx": { + "name": "projects_published_idx", + "columns": [ + { + "expression": "published", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_created_by_user_id_fk": { + "name": "projects_created_by_user_id_fk", + "tableFrom": "projects", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_updated_by_user_id_fk": { + "name": "projects_updated_by_user_id_fk", + "tableFrom": "projects", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "projects_notion_page_id_unique": { + "name": "projects_notion_page_id_unique", + "nullsNotDistinct": false, + "columns": [ + "notion_page_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_events_subject_idx": { + "name": "audit_events_subject_idx", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_at_idx": { + "name": "audit_events_at_idx", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_actor_user_id_user_id_fk": { + "name": "audit_events_actor_user_id_user_id_fk", + "tableFrom": "audit_events", + "tableTo": "user", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/v5/src/lib/db/migrations/meta/_journal.json b/v5/src/lib/db/migrations/meta/_journal.json index 00f6fd7..2206a04 100644 --- a/v5/src/lib/db/migrations/meta/_journal.json +++ b/v5/src/lib/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1789435214789, "tag": "0002_updated_at_triggers", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1790018924852, + "tag": "0003_better_auth", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/v5/src/lib/db/schema/audit.ts b/v5/src/lib/db/schema/audit.ts index f803a54..de2852e 100644 --- a/v5/src/lib/db/schema/audit.ts +++ b/v5/src/lib/db/schema/audit.ts @@ -1,4 +1,5 @@ import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { user } from "./auth.ts"; /** * Audit events — append-only (spec §4.11). The data layer exposes insert and @@ -25,7 +26,10 @@ export const auditEvents = pgTable( { id: uuid("id").primaryKey().defaultRandom(), at: timestamp("at", { withTimezone: true }).notNull().defaultNow(), - actorUserId: text("actor_user_id"), + // Deferred from Phase 1 to Phase 4's migration, when `user` came to exist. + // `set null` rather than `cascade`: deleting the person must not delete the + // record that they changed someone's role. + actorUserId: text("actor_user_id").references(() => user.id, { onDelete: "set null" }), action: text("action").notNull(), subjectType: text("subject_type").notNull(), subjectId: text("subject_id").notNull(), diff --git a/v5/src/lib/db/schema/auth.test.ts b/v5/src/lib/db/schema/auth.test.ts new file mode 100644 index 0000000..48e4e61 --- /dev/null +++ b/v5/src/lib/db/schema/auth.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment node +import { eq } from "drizzle-orm"; +import { expectViolation } from "../../../../test/db"; +import { createPgliteDb } from "../pglite"; +import { account, session, user } from "./auth"; +import { auditEvents } from "./audit"; +import { tools } from "./tools"; +import type { Db } from "../types"; + +/** + * Migration `0003` against a real (in-process) Postgres. Better Auth writes + * these rows in production, but the *shape* is ours — the role CHECK, the + * unique email, the cascade, and the `created_by` foreign key Phase 1 deferred + * to this migration. Each of those is a thing that can only fail at runtime, + * so each one is asserted here rather than read off the schema file. + */ +describe("Better Auth schema", () => { + let db: Db; + + beforeAll(async () => { + db = await createPgliteDb(); + }); + + async function insertUser(over: Partial = {}) { + const id = over.id ?? `u-${Math.random().toString(36).slice(2)}`; + await db.insert(user).values({ + id, + name: over.name ?? "Test Person", + email: over.email ?? `${id}@cornell.edu`, + ...over, + }); + return id; + } + + it("defaults a new user to the least-privileged stored role", async () => { + const id = await insertUser(); + const [row] = await db.select().from(user).where(eq(user.id, id)); + expect(row.role).toBe("user"); + expect(row.banned).toBe(false); + expect(row.emailVerified).toBe(false); + }); + + it("accepts every role in the vocabulary", async () => { + for (const role of ["user", "admin", "super_admin"] as const) { + const id = await insertUser({ role }); + const [row] = await db.select().from(user).where(eq(user.id, id)); + expect(row.role).toBe(role); + } + }); + + it("refuses a role outside the vocabulary", async () => { + // "staff" was a role in the env-list era. The CHECK is what stops a stale + // script, or a hand-written UPDATE, reintroducing a word nothing grants. + await expectViolation(insertUser({ role: "staff" }), /user_role_check/); + }); + + it("refuses a duplicate email", async () => { + await insertUser({ email: "dup@cornell.edu" }); + await expectViolation(insertUser({ email: "dup@cornell.edu" }), /user_email_unique/); + }); + + it("refuses a duplicate session token", async () => { + const userId = await insertUser(); + const row = { + token: "same-token", + userId, + expiresAt: new Date(Date.now() + 60_000), + }; + await db.insert(session).values({ id: "s-dup-1", ...row }); + await expectViolation( + db.insert(session).values({ id: "s-dup-2", ...row }), + /session_token_unique/ + ); + }); + + it("cascades a user delete to their sessions and accounts", async () => { + const userId = await insertUser(); + await db.insert(session).values({ + id: "s-cascade", + token: "cascade-token", + userId, + expiresAt: new Date(Date.now() + 60_000), + }); + await db.insert(account).values({ + id: "a-cascade", + accountId: "google-sub-1", + providerId: "google", + userId, + }); + + await db.delete(user).where(eq(user.id, userId)); + + expect(await db.select().from(session).where(eq(session.userId, userId))).toHaveLength(0); + expect(await db.select().from(account).where(eq(account.userId, userId))).toHaveLength(0); + }); + + it("refuses a created_by that names no user, and accepts null", async () => { + // The foreign key Phase 1 deferred. Null is the normal case: imported rows + // and demo-seed rows have no author. + await expectViolation( + db.insert(tools).values({ + slug: "ghost-author", + name: "Ghost Author", + createdBy: "no-such-user", + }), + /tools_created_by_user_id_fk/ + ); + + await db.insert(tools).values({ slug: "no-author", name: "No Author" }); + const [row] = await db.select().from(tools).where(eq(tools.slug, "no-author")); + expect(row.createdBy).toBeNull(); + }); + + it("keeps a tool alive when its author is deleted", async () => { + const userId = await insertUser(); + await db.insert(tools).values({ slug: "authored", name: "Authored", createdBy: userId }); + + await db.delete(user).where(eq(user.id, userId)); + + const [row] = await db.select().from(tools).where(eq(tools.slug, "authored")); + expect(row).toBeDefined(); + expect(row.createdBy).toBeNull(); + }); + + it("refuses an audit event whose actor names no user", async () => { + await expectViolation( + db.insert(auditEvents).values({ + actorUserId: "no-such-user", + action: "role.changed", + subjectType: "user", + subjectId: "someone", + }), + /audit_events_actor_user_id_user_id_fk/ + ); + }); +}); diff --git a/v5/src/lib/db/schema/auth.ts b/v5/src/lib/db/schema/auth.ts new file mode 100644 index 0000000..89f9894 --- /dev/null +++ b/v5/src/lib/db/schema/auth.ts @@ -0,0 +1,131 @@ +import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { inListCheck } from "./checks.ts"; +import { ROLES } from "./vocabulary.ts"; + +/** + * Better Auth's four tables (data platform design spec 2026-09-14 §4.2). + * + * The library owns these rows: it inserts and updates them through the Drizzle + * adapter, and nothing in `src/lib/data/` writes them. What *we* own is the + * shape — the `user.role` CHECK, the indexes, and the fact that `created_by` + * on every other table now references `user.id` (see `helpers.ts`). + * + * **The property keys are load-bearing.** The Drizzle adapter resolves a Better + * Auth field to a column by looking the *JavaScript property name* up on the + * table object (`schema[model][field]` in `@better-auth/drizzle-adapter`), so + * every key here is Better Auth's camelCase field name, exactly. The SQL column + * names are ours to choose and stay snake_case like the rest of the schema. + * + * The field list is taken from the library itself rather than from the docs: + * `@better-auth/core/dist/db/get-tables.mjs` for the four core tables, and + * `better-auth/dist/plugins/admin/schema.d.mts` for what the admin plugin adds + * (`role`, `banned`, `banReason`, `banExpires` on user; `impersonatedBy` on + * session). The spec says these are generated by `npx @better-auth/cli + * generate`; that CLI is not a dependency and wants the network, and the + * generator reads exactly the two modules above. See the Phase 4 amendment. + * + * No `updated_at` trigger (migration `0002`) is attached to these tables: + * Better Auth writes `updatedAt` itself on every update, and a table the + * library owns should not have a second writer. + */ + +/** + * A person. `role` is the whole authorization story — what each role may do is + * declared in code (`src/lib/auth/permissions.ts`), not in a table, because the + * lab decided on 2026-09-14 that admins do not edit permissions at runtime. + */ +export const user = pgTable( + "user", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").notNull().default(false), + image: text("image"), + // Admin plugin. Nullable because the plugin declares it optional; the + // default and the `defaultRole: "user"` option both land it as `user`. + role: text("role").default("user"), + banned: boolean("banned").default(false), + banReason: text("ban_reason"), + banExpires: timestamp("ban_expires", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + // A role outside the vocabulary is a bug or a forgery; the database is the + // last place to catch it, and `can()` grants an unknown role nothing anyway. + inListCheck("user_role_check", "role", ROLES), + index("user_email_idx").on(t.email), + ] +); + +/** + * A live sign-in. **This table is the reason Phase 4 exists**: the session is a + * row, not a self-describing cookie, so a role change is visible on the + * person's next request and a ban takes effect immediately (spec §3.4, Goal 3). + */ +export const session = pgTable( + "session", + { + id: text("id").primaryKey(), + token: text("token").notNull().unique(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + // Admin plugin. Unused — v5 never impersonates — but the column has to + // exist or the plugin's queries reference a column that is not there. + impersonatedBy: text("impersonated_by"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("session_user_idx").on(t.userId)] +); + +/** + * The link to an external identity provider. For v5 that is Google and nothing + * else: `providerId` is `"google"` and `accountId` is Google's `sub`. The token + * columns exist because Better Auth's schema has them; v5 asks for no Google + * scopes beyond the profile, so they stay empty. + */ +export const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }), + scope: text("scope"), + // Password auth is not enabled; the column is part of Better Auth's shape. + password: text("password"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("account_user_idx").on(t.userId)] +); + +/** + * Short-lived verification values (OAuth state, PKCE verifiers). Rows here are + * consumed within one handshake and mean nothing afterwards. + */ +export const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("verification_identifier_idx").on(t.identifier)] +); diff --git a/v5/src/lib/db/schema/checks.ts b/v5/src/lib/db/schema/checks.ts new file mode 100644 index 0000000..69b44ae --- /dev/null +++ b/v5/src/lib/db/schema/checks.ts @@ -0,0 +1,32 @@ +import { sql, type SQL } from "drizzle-orm"; +import { check } from "drizzle-orm/pg-core"; + +/** + * Named CHECK constraints over a stored vocabulary (spec §4). + * + * These live apart from `helpers.ts` for one structural reason: from Phase 4 + * `helpers.ts` imports `auth.ts` (so `created_by` can reference `user.id`) and + * `auth.ts` needs a CHECK for `user.role`. Keeping the check builders in a leaf + * module nothing else imports keeps `helpers → auth` acyclic instead of relying + * on ESM's tolerance for a cycle. `helpers.ts` re-exports both names, so every + * existing `import { inListCheck } from "./helpers.ts"` still resolves. + */ + +/** + * A named CHECK that restricts `column` to `values`. Written with `sql.raw` so + * drizzle-kit renders the literals into the migration instead of `$1` + * placeholders. + */ +export function inListCheck( + name: string, + column: string, + values: readonly string[] +): ReturnType { + return check(name, inList(column, values)); +} + +/** `"column" in ('a', 'b', …)` as raw SQL; a null column value passes the CHECK. */ +export function inList(column: string, values: readonly string[]): SQL { + const literals = values.map((value) => `'${value.replace(/'/g, "''")}'`).join(", "); + return sql.raw(`"${column}" in (${literals})`); +} diff --git a/v5/src/lib/db/schema/helpers.ts b/v5/src/lib/db/schema/helpers.ts index b223e2d..11a0f5c 100644 --- a/v5/src/lib/db/schema/helpers.ts +++ b/v5/src/lib/db/schema/helpers.ts @@ -1,5 +1,5 @@ -import { sql, type SQL } from "drizzle-orm"; -import { check, text, timestamp } from "drizzle-orm/pg-core"; +import { text, timestamp } from "drizzle-orm/pg-core"; +import { user } from "./auth.ts"; /** * Column and constraint helpers shared by every table in `schema/`. @@ -24,14 +24,19 @@ export function timestamps() { } /** - * Who created or last changed a row. Plain `text` for now: it will reference - * Better Auth's `user.id` once Phase 4 creates that table, and the foreign key - * is added in that phase's migration. Null on imported rows. + * Who created or last changed a row. `text` referencing Better Auth's + * `user.id`, which Phase 4 created; Phase 1 deferred the foreign key to that + * migration because the table it points at did not exist yet. + * + * Null on imported rows and on anything the demo seed writes — nobody signed + * in to create them — so the columns stay nullable and `on delete set null` + * keeps a row alive when the person who made it is removed. Deleting a user + * must never delete the catalogue. */ export function actorColumns() { return { - createdBy: text("created_by"), - updatedBy: text("updated_by"), + createdBy: text("created_by").references(() => user.id, { onDelete: "set null" }), + updatedBy: text("updated_by").references(() => user.id, { onDelete: "set null" }), }; } @@ -41,20 +46,9 @@ export function notionPageId() { } /** - * A named CHECK that restricts `column` to `values`. Written with `sql.raw` so - * drizzle-kit renders the literals into the migration instead of `$1` - * placeholders. + * `inListCheck` / `inList` live in `./checks.ts` and are re-exported here so + * every existing import keeps working. They had to move: this module now + * imports `auth.ts` for the `user.id` reference above, and `auth.ts` needs a + * CHECK for `user.role` — leaving them here would have made the pair circular. */ -export function inListCheck( - name: string, - column: string, - values: readonly string[] -): ReturnType { - return check(name, inList(column, values)); -} - -/** `"column" in ('a', 'b', …)` as raw SQL; a null column value passes the CHECK. */ -export function inList(column: string, values: readonly string[]): SQL { - const literals = values.map((value) => `'${value.replace(/'/g, "''")}'`).join(", "); - return sql.raw(`"${column}" in (${literals})`); -} +export { inList, inListCheck } from "./checks.ts"; diff --git a/v5/src/lib/db/schema/index.ts b/v5/src/lib/db/schema/index.ts index 82df228..ee1c94f 100644 --- a/v5/src/lib/db/schema/index.ts +++ b/v5/src/lib/db/schema/index.ts @@ -3,11 +3,14 @@ * group; this module is what `drizzle.config.ts` points at and what * `drizzle()` receives as its `schema`, so relational queries see every table. * - * Later phases add `pending_tools` (Phase 6), the Notion mirror tables - * (Phase 8) and Better Auth's `user` / `session` / `account` / `verification` - * tables (Phase 4), each with its own migration. + * Later phases add `pending_tools` (Phase 6) and the Notion mirror tables + * (Phase 8), each with its own migration. + * + * `auth.ts` is exported first because `helpers.ts` — which every other table + * uses for `created_by` / `updated_by` — references `user.id`. */ export * from "./vocabulary.ts"; +export * from "./auth.ts"; export * from "./taxonomy.ts"; export * from "./tools.ts"; export * from "./units.ts"; diff --git a/v5/src/lib/lab-time.test.ts b/v5/src/lib/lab-time.test.ts new file mode 100644 index 0000000..1347c14 --- /dev/null +++ b/v5/src/lib/lab-time.test.ts @@ -0,0 +1,66 @@ +import { DEFAULT_LAB_TIMEZONE, labTimezone, labToday } from "./lab-time"; + +/** + * Pure `Intl`: no env beyond the stub under test, no database, no network. + * + * The instant that matters is the one either side of midnight — that is the + * whole reason this helper exists rather than `toISOString().split("T")[0]`. + */ + +// 2026-09-22T01:30:00Z is 2026-09-21 21:30 in New York (EDT, UTC-4). +const LATE_EVENING_EDT = new Date("2026-09-22T01:30:00.000Z"); +// 2026-01-15T04:30:00Z is 2026-01-14 23:30 in New York (EST, UTC-5). +const LATE_EVENING_EST = new Date("2026-01-15T04:30:00.000Z"); + +describe("labToday", () => { + it("dates a late-evening instant by the lab's day, not UTC's", () => { + vi.stubEnv("LAB_TIMEZONE", ""); + + expect(labToday(LATE_EVENING_EDT)).toBe("2026-09-21"); + expect(LATE_EVENING_EDT.toISOString().slice(0, 10)).toBe("2026-09-22"); + }); + + it("follows daylight saving, because the offset is not a constant", () => { + vi.stubEnv("LAB_TIMEZONE", ""); + + // -4 in September, -5 in January; both land on the previous local day. + expect(labToday(LATE_EVENING_EST)).toBe("2026-01-14"); + }); + + it("uses the configured timezone when one is set", () => { + vi.stubEnv("LAB_TIMEZONE", "Asia/Tokyo"); + + // 01:30Z on the 22nd is already 10:30 on the 22nd in Tokyo. + expect(labToday(LATE_EVENING_EDT)).toBe("2026-09-22"); + }); + + it("pads single-digit months and days", () => { + vi.stubEnv("LAB_TIMEZONE", "UTC"); + + expect(labToday(new Date("2026-03-07T12:00:00.000Z"))).toBe("2026-03-07"); + }); + + it("falls back to UTC on a timezone Intl does not recognise, rather than throwing", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubEnv("LAB_TIMEZONE", "Mars/Olympus_Mons"); + + // A typo in an env var must not cost a student their maintenance report. + expect(labToday(LATE_EVENING_EDT)).toBe("2026-09-22"); + expect(warn).toHaveBeenCalled(); + }); +}); + +describe("labTimezone", () => { + it("defaults to the lab's timezone when unset or blank", () => { + vi.stubEnv("LAB_TIMEZONE", ""); + expect(labTimezone()).toBe(DEFAULT_LAB_TIMEZONE); + + vi.stubEnv("LAB_TIMEZONE", " "); + expect(labTimezone()).toBe(DEFAULT_LAB_TIMEZONE); + }); + + it("trims a configured value", () => { + vi.stubEnv("LAB_TIMEZONE", " Europe/Berlin "); + expect(labTimezone()).toBe("Europe/Berlin"); + }); +}); diff --git a/v5/src/lib/lab-time.ts b/v5/src/lib/lab-time.ts new file mode 100644 index 0000000..986ba3d --- /dev/null +++ b/v5/src/lib/lab-time.ts @@ -0,0 +1,65 @@ +/** + * Dates in the lab's timezone (data platform design spec §3.11, §4.8). + * + * `maintenance_logs.date_reported` is a `date`, and §4.8 says it is "computed + * in `LAB_TIMEZONE`, never from the server clock". The distinction is not + * pedantic: a Vercel function runs in UTC, so a ticket filed at 9pm on a + * Tuesday in New York is 01:00 Wednesday UTC, and `new Date().toISOString()` + * would date it *tomorrow* — a day staff would then not find it under. + * + * `LAB_TIMEZONE` is configuration rather than a constant (Article 6): the code + * is white-labelled and the next lab to run it is not in New York. + * + * No `"server-only"` and no `@/` alias: `src/lib/data/*` imports this, and + * `scripts/` loads those modules under plain Node. + */ + +/** Cornell Tech's timezone — the default when `LAB_TIMEZONE` is unset. */ +export const DEFAULT_LAB_TIMEZONE = "America/New_York"; + +/** The configured lab timezone, or the default. */ +export function labTimezone(): string { + return process.env.LAB_TIMEZONE?.trim() || DEFAULT_LAB_TIMEZONE; +} + +/** + * Today's date in the lab's timezone, as `YYYY-MM-DD` — the shape a Postgres + * `date` column takes as a string. + * + * A timezone `Intl` does not recognise falls back to UTC with a warning rather + * than throwing: a typo in an environment variable must not be able to lose a + * student's maintenance report (Article 4). + */ +export function labToday(now: Date = new Date()): string { + const timeZone = labTimezone(); + try { + return formatIsoDate(now, timeZone); + } catch (err) { + console.warn( + `[lab-time] LAB_TIMEZONE="${timeZone}" is not a timezone Intl recognises — dating in UTC instead.`, + err + ); + return formatIsoDate(now, "UTC"); + } +} + +/** + * `Intl` rather than arithmetic on the epoch, because the offset depends on the + * date (daylight saving) and only the runtime's timezone database knows it. + * Parts are reassembled by name; `en-CA` happens to render ISO order, but + * relying on a locale's format string is how this breaks on a different ICU + * build. + */ +function formatIsoDate(instant: Date, timeZone: string): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(instant); + + const find = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? ""; + + return `${find("year")}-${find("month")}-${find("day")}`; +} diff --git a/v5/src/lib/rate-limit.test.ts b/v5/src/lib/rate-limit.test.ts index 24b95e9..d0fa810 100644 --- a/v5/src/lib/rate-limit.test.ts +++ b/v5/src/lib/rate-limit.test.ts @@ -182,15 +182,15 @@ describe("chat tiers", () => { expect(chatTierFor("anonymous")).toEqual({ limit: 8, windowMs: 3_600_000 }); }); - it("gives signed-in students a generous allowance", async () => { + it("gives an ordinary signed-in user a generous allowance", async () => { const { chatTierFor } = await freshModule(); - expect(chatTierFor("student")).toEqual({ limit: 60, windowMs: 3_600_000 }); + expect(chatTierFor("user")).toEqual({ limit: 60, windowMs: 3_600_000 }); }); - it("gives staff and admins the same, highest allowance", async () => { + it("gives admins and super admins the same, highest allowance", async () => { const { chatTierFor } = await freshModule(); - expect(chatTierFor("staff")).toEqual({ limit: 200, windowMs: 3_600_000 }); expect(chatTierFor("admin")).toEqual({ limit: 200, windowMs: 3_600_000 }); + expect(chatTierFor("super_admin")).toEqual({ limit: 200, windowMs: 3_600_000 }); }); it("lets RATE_LIMIT_ANON_CHAT raise the anonymous ceiling (conference NAT)", async () => { @@ -211,7 +211,7 @@ describe("chat tiers", () => { describe("tierFor", () => { it("returns the chat tier for the chat scope", async () => { const { tierFor, chatTierFor } = await freshModule(); - expect(tierFor("chat", "student")).toEqual(chatTierFor("student")); + expect(tierFor("chat", "user")).toEqual(chatTierFor("user")); }); it("returns each route's unchanged pre-auth limit, regardless of role", async () => { @@ -238,16 +238,16 @@ describe("checkRateLimit", () => { expect(allowed).toEqual([...Array(8).fill(true), false]); }); - it("gives a student far more than the anonymous ceiling", async () => { + it("gives a signed-in user far more than the anonymous ceiling", async () => { const { checkRateLimit } = await freshModule(); - const student = identity("student", "user:sub-1"); + const signedIn = identity("user", "user:sub-1"); for (let i = 0; i < 20; i += 1) { - expect((await checkRateLimit("chat", student)).allowed).toBe(true); + expect((await checkRateLimit("chat", signedIn)).allowed).toBe(true); } - const decision = await checkRateLimit("chat", student); + const decision = await checkRateLimit("chat", signedIn); expect(decision.limit).toBe(60); - expect(decision.role).toBe("student"); + expect(decision.role).toBe("user"); }); it("keys per identity — one caller's ceiling does not spend another's", async () => { @@ -288,7 +288,7 @@ describe("checkRateLimit", () => { expect((await checkRateLimit("chat", anon)).allowed).toBe(false); // Same person, same IP, now signed in — a fresh, larger allowance. - const signedIn = identity("student", "user:sub-upgrade"); + const signedIn = identity("user", "user:sub-upgrade"); expect((await checkRateLimit("chat", signedIn)).allowed).toBe(true); }); }); diff --git a/v5/src/lib/rate-limit.ts b/v5/src/lib/rate-limit.ts index 65c6623..fbf0f8f 100644 --- a/v5/src/lib/rate-limit.ts +++ b/v5/src/lib/rate-limit.ts @@ -136,19 +136,32 @@ function anonChatLimit(): number { /** * Chat messages per hour, by role. Signed-in callers are keyed by user id, so * these are per-person; anonymous callers are keyed by hashed IP. + * + * The numbers are unchanged from the env-list era; only the role names moved + * (`student` → `user`, `staff` → `admin`, and `super_admin` on top). */ export function chatTierFor(role: Role): RateLimitTier { switch (role) { + case "super_admin": case "admin": - case "staff": return { limit: 200, windowMs: HOUR_MS }; - case "student": + case "user": return { limit: 60, windowMs: HOUR_MS }; default: return { limit: anonChatLimit(), windowMs: HOUR_MS }; } } +/** + * Administrative actions per minute, per signed-in admin (spec §8). + * + * A server action is a POST like any other, so `/admin/*` needs a ceiling too. + * Generous on purpose — promoting a room full of SuperMakers at the induction + * session must never trip it — while still bounding a script that got hold of + * a session cookie. + */ +export const ADMIN_ACTION_TIER: RateLimitTier = { limit: 120, windowMs: 60_000 }; + /** * Limits for the non-chat routes — unchanged from before sign-in existed. Only * the *key* got better (identity rather than raw IP); the numbers are the same. diff --git a/v5/src/styles/globals.css b/v5/src/styles/globals.css index 5581a81..583f8b5 100644 --- a/v5/src/styles/globals.css +++ b/v5/src/styles/globals.css @@ -3767,3 +3767,205 @@ p { font-weight: 500; text-transform: uppercase; } + +/* ── /admin (data platform spec §6) ───────────────────────────────── + The technical-schematic system, applied to a working surface rather than a + display one: 0px radii, mono metadata, tonal separation instead of borders + between sections. The table is the only place in the app that shows an email + address, and it is set in mono because it is an identifier, not prose. */ +.admin-shell { + padding-bottom: 64px; +} + +.admin-section { + display: flex; + flex-direction: column; + gap: 20px; +} + +.admin-section-head h2, +.admin-index h2 { + margin: 4px 0 0; + font-family: var(--font-display); + font-size: clamp(22px, 3vw, 30px); + font-weight: 500; +} + +.admin-lede, +.admin-index-note { + margin: 0; + max-width: 68ch; + color: var(--on-surface-muted); + line-height: 1.6; +} + +.admin-index-list { + margin: 16px 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 12px; +} + +.admin-index-list li { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px; + background: var(--surface-container-low); +} + +.admin-index-list span { + color: var(--on-surface-muted); + font-size: 13px; +} + +.admin-empty { + margin: 0; + padding: 24px 0; +} + +/* Horizontal scroll rather than a squeeze: four columns at a phone width is a + scroll or a lie about how much room there is. */ +.admin-table-scroll { + overflow-x: auto; +} + +.admin-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + border: 1px solid var(--outline); + background: var(--surface-container); + font-size: 14px; +} + +.admin-table th, +.admin-table td { + padding: 14px 16px; + border-bottom: 1px solid var(--outline); + text-align: left; + vertical-align: top; +} + +.admin-table thead th { + background: var(--surface-container-high); + color: var(--on-surface-muted); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.admin-table tbody tr:last-child th, +.admin-table tbody tr:last-child td { + border-bottom: none; +} + +/* A banned row is dimmed, not hidden: the point of the roster is that every + account is on it, including the ones that cannot get in. */ +.admin-table tbody tr.is-banned th, +.admin-table tbody tr.is-banned td { + color: var(--on-surface-muted); +} + +.admin-person-name { + display: flex; + gap: 8px; + align-items: baseline; + font-weight: 500; +} + +.admin-person-email, +.admin-date { + display: block; + color: var(--on-surface-muted); + font-family: var(--font-mono); + font-size: 12px; +} + +.admin-tag { + padding: 1px 6px; + border: 1px solid var(--outline); + color: var(--on-surface-muted); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.admin-role-select, +.admin-ban-toggle { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.admin-role-select select, +.admin-ban-reason { + padding: 8px 10px; + border: 1px solid var(--outline); + border-radius: 0; + background: var(--background); + color: var(--on-surface); + font-family: var(--font-body); + font-size: 13px; +} + +.admin-ban-reason { + min-width: 14ch; + max-width: 22ch; +} + +.admin-role-select select:focus, +.admin-ban-reason:focus { + border-color: var(--primary); + outline: none; +} + +.admin-role-select select:disabled, +.admin-ban-reason:disabled, +.admin-ban-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.admin-banned-note { + margin: 0 0 8px; + color: var(--secondary); + font-size: 12px; +} + +/* The one line every outcome of a row control speaks through — pending, saved, + and the reason a row cannot change. `:empty` keeps it out of the layout + until it has something to say, as the header's refresh status does. */ +.admin-row-status { + flex-basis: 100%; + color: var(--on-surface-muted); + font-size: 12px; + line-height: 1.4; +} + +.admin-row-status:empty { + display: none; +} + +.admin-row-status.is-error { + color: var(--secondary); +} + +/* Visually hidden, still announced: the role select's label is the person's + name, which the row already shows to anyone who can see it. */ +.admin-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/v5/test/README.md b/v5/test/README.md index afa1a07..284de47 100644 --- a/v5/test/README.md +++ b/v5/test/README.md @@ -17,6 +17,8 @@ your `*.test.ts(x)` files and import from here. | `test/mocks/next-cache.ts` | `nextCacheMock()` factory for `vi.mock("next/cache", …)` | | `test/mocks/server-only.ts` | empty stub aliased for `import "server-only"` | | `test/utils/render.tsx` | RTL `render` wrapped in `NextIntlClientProvider` + `userEvent` | +| `test/utils/session.ts` | `seedUser` / `signInAs` — be somebody, with no Google (see below) | +| `test/utils/better-auth-cookie.ts` | Just the cookie format, import-free, so Playwright can use it too | | `playwright.config.ts` | E2E config; dev server boots with `DATABASE_URL` unset (PGlite demo seed) | ## Scripts @@ -109,28 +111,126 @@ it("reads the seeded demo tools", async () => { `vi.unstubAllEnvs()` runs automatically after every test (setup file). -**Write paths are still on Notion in this phase** (maintenance tickets, -corrections, project submission, uploads, intake) — see -`docs/specs/2026-09-14-v5-data-platform-design.md` §9. Testing those still -uses the real-Notion MSW path: `vi.stubEnv` all 8 Notion vars (set the -`NOTION_DB_*` ones to the `DB_IDS` sentinels so the default handlers route -correctly), then let MSW serve `api.notion.com`. +**Writes are on Postgres as of Phase 3** — maintenance tickets, corrections and +project submissions all write to the same PGlite database the reads come from, +so their tests stub **no Notion environment at all**. Assert by reading the row +back (`db.select().from(maintenanceLogs)`), not by inspecting a request body. + +The only write still on Notion is intake's `create_tool` (Phase 6), which is +captured at the module boundary rather than over MSW — see the `vi.mock("../notion", …)` +block at the top of `src/lib/capabilities/intake.test.ts`. + +**Vercel Blob is never called for real.** Its SDK talks to a signed API and +would need a token, so it is mocked at the seam `src/lib/blob.ts` exists to +provide. Copy this from `src/app/api/uploads/route.test.ts`: + +```ts +const blob = vi.hoisted(() => ({ + configured: { value: true }, + put: vi.fn(), + putUpload: vi.fn(), + list: vi.fn(), + del: vi.fn(), +})); + +vi.mock("../../../lib/blob", () => ({ + isBlobConfigured: () => blob.configured.value, + getBlobStore: () => ({ ...blob }), +})); +``` + +Set `blob.configured.value = false` to test the unconfigured path — the one that +has to refuse rather than invent an attachment id. + +--- + +## Being signed in, without Google + +Sessions are **database rows** as of Phase 4: the cookie carries only a token, +and every request looks the session and its user up. So a test does not need an +OAuth handshake to be somebody — it needs a `user` row, a `session` row, and a +cookie signed the way Better Auth signs one. That is `test/utils/session.ts`. + +```ts +// @vitest-environment node // it touches PGlite +import { resetAuthForTests } from "@/lib/auth/config"; +import { seedUser, signInAs, signInAsNew } from "../../test/utils/session"; + +beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", "whatever-this-file-wants"); // signs the cookie + resetAuthForTests(); // drop the memoised instance +}); + +it("lets an admin refresh the catalogue", async () => { + const { cookie } = await signInAsNew({ role: "admin" }); + const res = await POST(requestWith(cookie)); + expect(res.status).toBe(200); +}); +``` + +- `seedUser({ id?, email?, name?, role?, banned? })` inserts the `user` row and + returns it. `role` is one of `user | admin | super_admin`. +- `signInAs(person, { expiresInSeconds?, secret? })` inserts the `session` row + and returns `{ user, token, cookie }`. `expiresInSeconds: -60` gives an + expired session; `secret: "wrong"` gives a forged cookie. Both resolve to + anonymous, which is the point. +- `signInAsNew(seedOptions, signInOptions)` does both in one call. +- `insertUserRow(db, options)` is the same seed against a handle you already + hold — for the `src/lib/data/*` tests, which each run their own isolated + `createPgliteDb()`. + +**`AUTH_SECRET` must be stubbed before `signInAs`**, and `resetAuthForTests()` +belongs in `beforeEach`/`afterEach` of any file that stubs it: `getAuth()` +memoises the instance per env fingerprint plus substrate, and a stale one points +at the previous database. + +**`created_by` references `user.id` since Phase 4.** A write whose author is not +a row is refused by the foreign key — which is correct, because in production +that id comes from a session. If a data test asserts on a specific author id, +seed it: `await insertUserRow(db, { id: "google-sub-1", email: "ada@cornell.edu" })`. + +The old `makerlab.identity` cookie and `src/lib/auth/session-cookie.ts` are +retired. Do not mint one; nothing reads it. + +**E2E** is the same idea one level out. `playwright.config.ts` boots the server +with a test-only `AUTH_SECRET` and blank `GOOGLE_*`, the demo seed ships one +account per role with a constant session token (`DEMO_ACCOUNTS` in +`src/lib/db/demo-seed.ts`), and `e2e/utils/session.ts`'s `signIn(context, +account, baseURL)` puts a properly signed cookie in the browser. Nothing is +intercepted — the real `/api/identity` reads the real row. See +`e2e/auth.spec.ts` and `e2e/admin-users.spec.ts`. + +`DEMO_ACCOUNTS.promotable` exists for the one E2E that *changes* a role. The +suite runs its files in parallel against a single server, so a test that mutates +a shared row must mutate one nobody else asserts on — promoting +`DEMO_ACCOUNTS.user` would race `auth.spec.ts`. + +## Server components and server actions + +`resolveIdentityFromHeaders()` and the `/admin` server actions read the request +through `next/headers`, which only exists inside a Next request scope. Stub it +with `test/mocks/next-headers.ts`, and point it at a cookie `session.ts` minted: ```ts -import { DB_IDS } from "../../test/msw/handlers"; - -function stubNotionEnv() { - vi.stubEnv("NOTION_API_KEY", "secret_test"); - vi.stubEnv("NOTION_DB_TOOLS", DB_IDS.tools); - vi.stubEnv("NOTION_DB_CATEGORIES", DB_IDS.categories); - vi.stubEnv("NOTION_DB_LOCATIONS", DB_IDS.locations); - vi.stubEnv("NOTION_DB_UNITS", DB_IDS.units); - vi.stubEnv("NOTION_DB_RESOURCES", DB_IDS.resources); - vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", DB_IDS.maintenance_logs); - vi.stubEnv("NOTION_DB_FLAGS", DB_IDS.flags); -} +// @vitest-environment node +import { nextCacheMock } from "../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); // revalidatePath/Tag +vi.mock("next/headers", () => nextHeadersMock()); + +const { cookie } = await signInAsNew({ role: "super_admin" }); +setMockHeaders({ cookie }); // …or setMockHeaders() for anonymous +expect(await setUserRole({ userId, role: "admin" })).toEqual({ ok: true, role: "admin" }); ``` +The mutable state lives in the mock module rather than the test, because +`vi.mock`'s factory is hoisted above your imports and may not close over +anything. `setMockHeaders({ "x-forwarded-for": "198.51.100.7" })` gives an +anonymous caller their own rate-limit bucket — the limiter is a per-process +singleton, so a test that exhausts a window needs a key no other test shares. + --- ## Env stubbing for module-load-time reads @@ -290,8 +390,9 @@ Notes: - The route rate-limits **before** parsing. To assert the 429 path, drive the in-memory limiter over its limit (21 calls in a window) or stub Upstash + override the `*/pipeline` handler to return a count over the limit. -- To test `report_issue.execute` filing a ticket, stub the Notion env and let - MSW's `POST /pages` handler respond (returns `id: "created-page-1"`), then - assert `result.success === true` and `result.ticket_id`. +- To test `report_issue.execute` filing a ticket, set nothing: the write lands + in the same PGlite database (`DATABASE_URL` unset). Assert + `result.success === true`, then read the `maintenance_logs` row back by + `result.ticket_id`. - For `get_unit_details` against the **PGlite demo seed** (`DATABASE_URL` unset), the catalog units are `Form 4 // A` and `Trotec Speedy 400`. diff --git a/v5/test/mocks/next-cache.ts b/v5/test/mocks/next-cache.ts index 2c266e5..bc5a433 100644 --- a/v5/test/mocks/next-cache.ts +++ b/v5/test/mocks/next-cache.ts @@ -25,5 +25,6 @@ export function nextCacheMock() { cacheLife: vi.fn(), cacheTag: vi.fn(), revalidateTag: vi.fn(), + revalidatePath: vi.fn(), }; } diff --git a/v5/test/mocks/next-headers.ts b/v5/test/mocks/next-headers.ts new file mode 100644 index 0000000..c6d7076 --- /dev/null +++ b/v5/test/mocks/next-headers.ts @@ -0,0 +1,37 @@ +import { vi } from "vitest"; + +/** + * Factory for a `next/headers` mock, plus the knob that sets what it returns. + * + * `resolveIdentityFromHeaders()` and the `/admin` server actions read the + * request through `next/headers`, which only exists inside a Next request + * scope. Tests stub it and hand it a cookie minted by `test/utils/session.ts`, + * which is what lets a server action be called directly as the person it is + * about to refuse — or accept. + * + * Usage: + * + * import { nextHeadersMock, setMockHeaders } from "@/../test/mocks/next-headers"; + * vi.mock("next/headers", () => nextHeadersMock()); + * … + * setMockHeaders({ cookie }); // signed in as whoever minted it + * setMockHeaders(); // anonymous + * + * HOISTING CAVEAT: as with `next-cache.ts`, `vi.mock(...)` is hoisted above + * your imports, so the factory must not close over module-scope variables. + * The mutable state lives *here* instead, which is why `setMockHeaders` is + * exported from this file rather than built in the test. + */ + +let current = new Headers(); + +/** What the next `headers()` call will return. Call with nothing for anonymous. */ +export function setMockHeaders(init: Record = {}): void { + current = new Headers(init); +} + +export function nextHeadersMock() { + return { + headers: vi.fn(async () => current), + }; +} diff --git a/v5/test/utils/better-auth-cookie.ts b/v5/test/utils/better-auth-cookie.ts new file mode 100644 index 0000000..a9dc99d --- /dev/null +++ b/v5/test/utils/better-auth-cookie.ts @@ -0,0 +1,41 @@ +/** + * Better Auth's signed-cookie format, on its own, with no imports. + * + * Kept apart from `session.ts` because Playwright's process loads it: `session.ts` + * reaches `src/lib/db/client` and `src/lib/auth/config` (which is `server-only`), + * and an E2E spec must not drag a Next server module into the test runner just + * to build a cookie string. + * + * The format is taken from the library, not guessed. `better-call`'s + * `signCookieValue` (see `node_modules/better-call/dist/crypto.mjs`) builds + * `encodeURIComponent(value + "." + btoa(HMAC-SHA256(secret, value)))`, and + * `better-auth/dist/cookies/index.mjs` names the cookie `.session_token`, + * adding a `__Secure-` prefix only when the base URL is https. The test and E2E + * base URLs are http, so the name is the bare one. + * + * It is reimplemented rather than imported because it is not a public export of + * either package, and a test helper that reaches into a dependency's internals + * breaks on a patch release. `test/utils/session.test.ts` proves the format + * against a real `auth.api.getSession()`; that is what licenses its use here. + */ + +/** The cookie Better Auth reads on an http origin. */ +export const BETTER_AUTH_SESSION_COOKIE = "better-auth.session_token"; + +/** `encodeURIComponent(value + "." + base64(HMAC-SHA256(secret, value)))`. */ +export async function signCookieValue(value: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(value) + ); + const base64 = btoa(String.fromCharCode(...new Uint8Array(signature))); + return encodeURIComponent(`${value}.${base64}`); +} diff --git a/v5/test/utils/session.test.ts b/v5/test/utils/session.test.ts new file mode 100644 index 0000000..76b5af9 --- /dev/null +++ b/v5/test/utils/session.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import { createAuth } from "../../src/lib/auth/config"; +import { getDb, resetDbForTests } from "../../src/lib/db/client"; +import { + BETTER_AUTH_SESSION_COOKIE, + seedUser, + signInAs, + signInAsNew, +} from "./session"; + +/** + * The helper's own self-test, and the most load-bearing test in Phase 4. + * + * Every later test that asserts "an admin may do X" trusts that a cookie this + * helper minted is a cookie Better Auth accepts. That is a claim about an + * undocumented signing format inside a dependency, so it is asserted here + * against the real `auth.api.getSession()` rather than assumed. + */ + +const SECRET = "session-helper-test-secret"; + +beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", SECRET); +}); + +afterEach(() => { + resetDbForTests(); +}); + +async function auth() { + return createAuth(await getDb()); +} + +async function sessionFor(cookie: string) { + const instance = await auth(); + return instance.api.getSession({ headers: new Headers({ cookie }) }); +} + +describe("signInAs", () => { + it("mints a cookie a real Better Auth instance accepts", async () => { + const signedIn = await signInAsNew({ role: "admin", name: "Niti" }); + + const result = await sessionFor(signedIn.cookie); + + expect(result).not.toBeNull(); + expect(result!.user.id).toBe(signedIn.user.id); + expect(result!.user.email).toBe(signedIn.user.email); + expect(result!.user.role).toBe("admin"); + }); + + it("names the cookie the way Better Auth names it on an http origin", () => { + expect(BETTER_AUTH_SESSION_COOKIE).toBe("better-auth.session_token"); + }); + + it("is rejected when signed with a different secret", async () => { + // The whole point of the signature: a token alone is not a session. + const person = await seedUser(); + const forged = await signInAs(person, { secret: "not-the-secret" }); + + expect(await sessionFor(forged.cookie)).toBeNull(); + }); + + it("is rejected when the signature is tampered with", async () => { + const signedIn = await signInAsNew(); + const tampered = signedIn.cookie.replace(/.$/, (c) => (c === "A" ? "B" : "A")); + + expect(await sessionFor(tampered)).toBeNull(); + }); + + it("is rejected once the session row has expired", async () => { + const signedIn = await signInAsNew({}, { expiresInSeconds: -60 }); + + expect(await sessionFor(signedIn.cookie)).toBeNull(); + }); + + it("reports a banned user's ban to the caller", async () => { + // `getSession` still resolves; refusing a banned user is `resolveIdentity`'s + // job, and this is the field it reads. + const signedIn = await signInAsNew({ banned: true, banReason: "spam" }); + + const result = await sessionFor(signedIn.cookie); + expect(result?.user.banned).toBe(true); + }); + + it("refuses to mint a cookie with no secret configured", async () => { + vi.stubEnv("AUTH_SECRET", ""); + const person = await seedUser(); + await expect(signInAs(person)).rejects.toThrow(/AUTH_SECRET/); + }); +}); diff --git a/v5/test/utils/session.ts b/v5/test/utils/session.ts new file mode 100644 index 0000000..e593c7d --- /dev/null +++ b/v5/test/utils/session.ts @@ -0,0 +1,176 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; + +import { getDb } from "../../src/lib/db/client"; +import { session, user } from "../../src/lib/db/schema/index"; +import { SESSION_MAX_AGE_SECONDS } from "../../src/lib/auth/config"; +import { BETTER_AUTH_SESSION_COOKIE, signCookieValue } from "./better-auth-cookie"; +import type { Role } from "../../src/lib/auth/roles"; +import type { Db } from "../../src/lib/db/types"; + +/** + * Seed a signed-in person, without Google. + * + * This is the helper that makes Phase 4 testable. Sessions are database rows + * now, and the cookie carries only a token, so a test does not need an OAuth + * handshake to be somebody — it needs a `user` row, a `session` row, and a + * cookie signed the way Better Auth signs one. + * + * The cookie format lives in `./better-auth-cookie.ts`, which has no imports + * so Playwright can load it too. `session.test.ts` proves that format against + * a real `auth.api.getSession()` — the one assertion that licenses every other + * test to trust this file. + * + * Node environment only (`// @vitest-environment node`): it touches PGlite. + */ + +export { BETTER_AUTH_SESSION_COOKIE, signCookieValue }; + +export interface SeedUserOptions { + /** Fix the id, for a test that asserts on a specific `created_by` value. */ + id?: string; + email?: string; + role?: Exclude; + name?: string; + banned?: boolean; + banReason?: string | null; +} + +export interface SeededUser { + id: string; + email: string; + name: string; + role: Exclude; + banned: boolean; +} + +/** Insert a `user` row. Defaults to an ordinary institutional student. */ +export async function seedUser(options: SeedUserOptions = {}): Promise { + return insertUserRow(await getDb(), options); +} + +/** + * The same, against a handle the caller already has. + * + * `src/lib/data/*` tests each run their own isolated `createPgliteDb()`, and + * since Phase 4 their writes have a real foreign key: a `created_by` naming no + * `user` row is refused. They seed the author through this. + */ +export async function insertUserRow( + db: Db, + options: SeedUserOptions = {} +): Promise { + const id = options.id ?? `test-user-${randomUUID()}`; + const row: SeededUser = { + id, + email: options.email ?? `${id}@cornell.edu`, + name: options.name ?? "Test Person", + role: options.role ?? "user", + banned: options.banned ?? false, + }; + + // `on conflict do nothing`, then read back: the demo database is not reset + // between every test, so seeding the same person twice in one file should be + // a no-op rather than a key error in a test that is about something else. + // Reading back matters — the caller needs the id that is actually in the + // table, not the one this call would have used. + const [inserted] = await db + .insert(user) + .values({ + id: row.id, + name: row.name, + email: row.email, + emailVerified: true, + role: row.role, + banned: row.banned, + banReason: options.banReason ?? null, + }) + .onConflictDoNothing() + .returning(); + + if (inserted) return row; + + const [existing] = await db.select().from(user).where(eq(user.email, row.email)); + return { + id: existing.id, + email: existing.email, + name: existing.name, + role: (existing.role ?? "user") as SeededUser["role"], + banned: Boolean(existing.banned), + }; +} + +export interface SignedInSession { + user: SeededUser; + /** The raw session token — the value inside the signed cookie. */ + token: string; + /** `name=value`, ready for a `Cookie` request header. */ + cookie: string; +} + +export interface SignInAsOptions { + /** Seconds from now until the session row expires. Negative for an expired one. */ + expiresInSeconds?: number; + /** Sign the cookie with a different secret, to test a forged one. */ + secret?: string; +} + +/** + * Insert a `session` row for `person` and mint the cookie that addresses it. + * + * `AUTH_SECRET` must already be stubbed — the cookie is signed with it, and a + * cookie signed with nothing is a cookie Better Auth will not accept. + */ +export async function signInAs( + person: SeededUser, + options: SignInAsOptions = {} +): Promise { + const secret = options.secret ?? process.env.AUTH_SECRET ?? ""; + if (!secret) { + throw new Error( + "signInAs needs AUTH_SECRET stubbed — an unsigned cookie is never accepted" + ); + } + + const token = `test-session-${randomUUID()}`; + const ttl = options.expiresInSeconds ?? SESSION_MAX_AGE_SECONDS; + + const db = await getDb(); + await db.insert(session).values({ + id: `test-session-row-${randomUUID()}`, + token, + userId: person.id, + expiresAt: new Date(Date.now() + ttl * 1000), + }); + + const value = await signCookieValue(token, secret); + return { user: person, token, cookie: `${BETTER_AUTH_SESSION_COOKIE}=${value}` }; +} + +/** Seed a person and sign them in, in one step. */ +export async function signInAsNew( + options: SeedUserOptions = {}, + signIn: SignInAsOptions = {} +): Promise { + return signInAs(await seedUser(options), signIn); +} + +/** A `Cookie` request header, optionally alongside other cookies. */ +export function cookieHeader( + signedIn: SignedInSession | null, + ...others: string[] +): string { + return [...others, signedIn?.cookie] + .filter((part): part is string => Boolean(part)) + .join("; "); +} + +/** The same session as a Playwright `context.addCookies()` entry. */ +export function playwrightCookie( + signedIn: SignedInSession, + { url = "http://localhost:3100" }: { url?: string } = {} +): { name: string; value: string; url: string } { + const [, ...rest] = signedIn.cookie.split("="); + return { name: BETTER_AUTH_SESSION_COOKIE, value: rest.join("="), url }; +} + diff --git a/v5/vercel.json b/v5/vercel.json index 2e0abe3..0f92819 100644 --- a/v5/vercel.json +++ b/v5/vercel.json @@ -3,7 +3,7 @@ "framework": "nextjs", "crons": [ { - "path": "/api/admin/backup", + "path": "/api/cron/daily", "schedule": "17 7 * * *" } ] From 3c768399d6c3aa935666f7083fa67617ac270546 Mon Sep 17 00:00:00 2001 From: Isaac S Date: Tue, 22 Sep 2026 16:28:29 -0400 Subject: [PATCH 2/4] Fix four review findings, and write down what 1c967c4 already fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reviews returned nine findings on this branch. Four of them described code that was already here — the review had read an earlier working state and the fixes had been amended into 1c967c4 itself, whose message still claims they are outstanding. Nothing to do for those but say so, which the spec amendment now does, along with the warning that every line number in those reports is off by the size of the fixes they missed. Four held up. The super-admin floor now beats a ban, not just a demotion. identityFromSession read the floor after the ban check, so a listed address whose row was banned resolved anonymous: a recovery for exactly half of what the floor promises. It is read first now. It cannot rescue a sign-in — the admin plugin throws BANNED_USER from a hook that runs ahead of anything this app can register — so reconcileSuperAdminFloor also lifts the ban off the row, and the first admin write a recovered director performs makes ordinary sign-in work again. This contradicts one sentence in spec 3.4 and the amendment flags it as the lab's call rather than settling it quietly. An audit write that fails after the change committed is now a warning on a success. It was awaited unguarded after auth.api.setRole had returned, so a transient failure threw and the island restored the old role over a database holding the new one. AdminActionResult gains warning on its ok variant, and both islands keep the new value and say what was not recorded. A change that landed minus a guarantee is never ok: false — a refusal is what the islands answer by rolling back. POST /api/projects reports photosSubmitted and photosAttached, and the form says so on the confirmation. A form left open overnight submits ids the cron has already swept, and thanking a student for pictures nobody has is the quiet lie Article 4 forbids. Partial loss counts, unlike the maintenance path. /projects/new tells three identity states apart instead of two. A failed /api/identity fetch was read as "not signed in", so a 429 replaced the whole form with a sign-in wall; anonymous comes back as a 200 with a role, so null means only "could not ask". Unavailable keeps the form up with a notice and a retry, and the server stays the authority. Documentation the three passes deferred: the spec amendment entry, and AGENTS.md on the floor's ban exception, floor-role.ts, admin_api_not_exposed and the warning channel Phase 5 should reuse. Gate observed green with every environment variable unset: lint 0 errors (3 pre-existing warnings), typecheck clean, vitest 97 files / 1356 tests, playwright 49 passed, spec:coverage 73 items / 0 undocumented, build succeeds with no database. Each code fix confirmed red with the fix reverted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE --- .../2026-09-14-v5-data-platform-design.md | 107 +++++++++++++ v5/AGENTS.md | 43 +++++- v5/messages/en.json | 9 ++ v5/src/app/admin/users/action-result.ts | 18 ++- v5/src/app/admin/users/actions.audit.test.ts | 132 ++++++++++++++++ v5/src/app/admin/users/actions.ts | 54 ++++++- v5/src/app/api/projects/route.test.ts | 97 +++++++++++- v5/src/app/api/projects/route.ts | 29 +++- v5/src/components/ProjectSubmitForm.test.tsx | 146 +++++++++++++++++- v5/src/components/ProjectSubmitForm.tsx | 106 +++++++++++-- v5/src/components/admin/BanToggle.test.tsx | 15 ++ v5/src/components/admin/BanToggle.tsx | 19 ++- v5/src/components/admin/RoleSelect.test.tsx | 15 ++ v5/src/components/admin/RoleSelect.tsx | 19 ++- v5/src/lib/auth/floor-role.test.ts | 92 ++++++++++- v5/src/lib/auth/floor-role.ts | 81 +++++++--- v5/src/lib/auth/identity.test.ts | 38 +++++ v5/src/lib/auth/identity.ts | 30 +++- v5/src/styles/globals.css | 17 ++ 19 files changed, 1000 insertions(+), 67 deletions(-) create mode 100644 v5/src/app/admin/users/actions.audit.test.ts diff --git a/docs/specs/2026-09-14-v5-data-platform-design.md b/docs/specs/2026-09-14-v5-data-platform-design.md index d05c28c..7ae74d7 100644 --- a/docs/specs/2026-09-14-v5-data-platform-design.md +++ b/docs/specs/2026-09-14-v5-data-platform-design.md @@ -1147,3 +1147,110 @@ defect was found between the phases and fixed. **Status.** Accepted. Phases 3 and 4 are integrated and green; what remains before production is credentials, which no test can stand in for. + +### 2026-09-22 — Nine review findings, and one sentence in §3.4 that needs choosing + +**What changed.** The Phase 3/4 branch was reviewed three times over and nine findings +came back. Four described code that was already on the branch — the review had read an +earlier working state, and the fixes had been amended into `1c967c4` itself, whose message +still says they are "outstanding and fixed in the next commit". They are not outstanding; +that line is stale. For the record, the three the message meant are the admin plugin's HTTP +endpoints (`/api/auth/admin/*` refused with 403 `admin_api_not_exposed` before the auth +instance is constructed), the super-admin floor the plugin could not see +(`src/lib/auth/floor-role.ts`), and `POST /api/uploads` granting a public Blob URL on the +caller's say-so (`KIND_POLICY` plus `uploadRefusal`, which pairs each `kind` with both its +access and the permission the consuming surface enforces). **Anyone reading a review of +this branch should check the current file before trusting a cited line number** — every one +of them is off by the size of those fixes. + +Four findings held up and were fixed. Details the spec did not name: + +- **A banned floor address is no longer anonymous, and this contradicts §3.4.** The section + says both "resolves as `super_admin` whatever its row says" and "A banned user resolves + to anonymous" (also §4.11's table and §6's signed-out note), which cannot both be true of + the same row. `identityFromSession` now reads the floor *first*: a listed address survives + a ban on a session it already holds. The domain rule still runs before both, and + `isSuperAdminFloor` applies it itself, so an out-of-domain floor entry still fails closed. + Half a recovery was the alternative, and the floor exists precisely so that "a mistaken + demotion **or ban** cannot lock the lab out". + + It does not rescue a sign-in, and the reason is worth not rediscovering: the admin plugin + registers `databaseHooks.session.create.before` and throws `BANNED_USER` there, and + `runPluginInit` (`better-auth/dist/context/helpers.mjs`) pushes plugin hooks *ahead* of + the app's own, so no hook this app can register runs in front of it. The row itself has to + change. `reconcileSuperAdminFloor` therefore reconciles `banned` as well as `role` + (clearing `banReason` and `banExpires` with it, so the plugin's auto-unban branch cannot + fire later against a row nobody banned), which means the first admin write a recovered + director performs restores ordinary sign-in. It still only ever promotes and unbans, only + for an address the environment already names, and the lift is recorded as `user.banned` + with `detail.banned: false` because `AUDIT_ACTIONS` has no `user.unbanned` (§4.11) — the + same shape `setUserBanned` writes. + + **This is the one item in this entry that is a decision and not a detail.** The code, both + module docstrings and §8's "so the lab can always recover" agree; §3.4's sentence does + not. It is written up here rather than edited into §3.4 because the original text is never + edited, and flagged rather than settled because it is the lab's call: if the ban should + win instead, it is a two-line revert in `identity.ts` plus the `lift` branch in + `floor-role.ts`, and the docstrings are what need correcting. + +- **An audit write that fails after the change committed is a warning on a success.** + `recordAuditEvent` was awaited unguarded after `auth.api.setRole` had already returned, so + a transient failure threw out of the server action and the island restored the *old* role + over a database holding the new one — asserting a state that does not exist, which is the + quiet lie Article 4 forbids, with the sign flipped. `AdminActionResult`'s ok variant gains + `warning?: AdminActionWarning`, a `record()` helper reports rather than throws, and both + islands keep the new value and render `admin.warnings.audit_unavailable` in + `.admin-row-status.is-warning`. The rule this sets, which Phase 5's admin writes inherit: + **a change that landed minus a guarantee is `{ ok: true, …, warning }`, never + `{ ok: false }`**, because a refusal is what the islands answer by rolling back. + + One deliberate asymmetry: `reconcileSuperAdminFloor`'s own audit writes are *not* on this + channel. If one fails, `authorize()` answers `failed` and the requested action never runs + — which is honest, because nothing the director asked for was saved — and the row it + already wrote makes the next attempt a no-op that succeeds. It self-heals in one click, + and plumbing a warning out through the gate would cost more than it buys. + +- **`POST /api/projects` answers `photosSubmitted` and `photosAttached`.** It discarded + `createProjectSubmission`'s count and returned a bare 201, so a student whose photos had + been swept by the nightly cron — a form left open overnight submits ids that are already + gone — was thanked for a write-up with no pictures. The form now says so on the + confirmation. **Partial loss counts too** (`photosAttached < photosSubmitted`), which is a + deliberate departure from the sibling write path in `capabilities/maintenance.ts`, whose + check is `photosAttached === 0`: two of three lost is exactly as silent as three of three. + Making maintenance symmetric is a reasonable follow-up; its tests pin the current + behaviour. + +- **`/projects/new` tells three states apart, not two.** A failed `/api/identity` fetch was + read as "not signed in", so a 429 or a dropped connection replaced the whole form with a + sign-in wall. The distinction was already in the data and was being thrown away: + `/api/identity` answers **200 `{role:"anonymous"}`** for a signed-out visitor, so + `fetchIdentity`'s `null` means only "could not ask". `IdentityStatus` is now + `pending | answered | unavailable`; `unavailable` keeps the form up with a notice and a + "Check again" control, and the server stays the authority, so a signed-in student can + still submit. A 401 from the submit now renders a translated sentence rather than the + route's English prose. **That invariant is now load-bearing** (`src/lib/auth/ + sign-in-client.ts`, both halves pinned by its test): if `/api/identity` is ever changed to + answer 401 for anonymous, this branch starts catching genuinely signed-out visitors. + `PrimaryNav`'s opposite choice was checked and left alone — it maps `null` to "signed + out" deliberately and documents why, and it withholds nothing, so there is no false + assertion with a cost. + +**Refusal strings on the two write routes are hardcoded English**, not `next-intl` keys — +`POST /api/uploads` matches `POST /api/projects`' existing idiom and `ProjectSubmitForm` +renders `data.error` verbatim, so a refusal reads English in all twelve locales. Translating +them is one job across both routes, not one route. Everything else new is seven keys in +`messages/en.json`, inherited by the other eleven through `withEnglishFallback` (Article 6 +as amended), which `src/i18n/messages.test.ts` enforces. + +**Gate.** Observed green with every environment variable unset: `npm run lint` (0 errors, the +same 3 pre-existing warnings), `npm run typecheck` (clean), `npx vitest run` (**97 files, +1356 tests**), `npx playwright test` (**49 passed**; an earlier run of the same commit was +48 passed and one flaky — `tool-detail.spec.ts`'s gallery-card click, green on retry, the +client-router race under load the Phase 2 amendment describes and not a new defect), +`npm run spec:coverage` (73 surface items · 0 undocumented) and `npm run build` +(succeeds with no database). Each of the three code fixes was confirmed red with the fix +reverted. Not covered: nothing in `e2e/` exercises a banned floor address or a failed audit +write — both are proved at unit and component level only. + +**Status.** Accepted, except the §3.4 sentence, which is open. Everything else is a detail +the spec left to implementation. diff --git a/v5/AGENTS.md b/v5/AGENTS.md index ba3fe33..f7dae9d 100644 --- a/v5/AGENTS.md +++ b/v5/AGENTS.md @@ -65,7 +65,12 @@ variable list. upload route; it writes the blob, inserts an **unowned** `attachments` row and returns `{ attachmentId, previewUrl }`. The write that follows *claims* those ids (`claimAttachments`), and `/api/cron/daily` deletes anything still - unclaimed after 24 hours. With no `BLOB_READ_WRITE_TOKEN` the route answers + unclaimed after 24 hours. **Both write paths say when a photo did not stick** + — `report_issue` appends it to the message the assistant paraphrases, and + `POST /api/projects` answers `photosSubmitted` / `photosAttached` so the form + can say it on the confirmation. A form left open overnight submits ids the + cron has already swept, and thanking a student for pictures nobody has is the + quiet lie Article 4 forbids. With no `BLOB_READ_WRITE_TOKEN` the route answers 503 `{ code: "blob_not_configured" }` and both clients show a translated "photo uploads are unavailable" — never a fabricated id (Article 4). - **Failing toward stale, not wrong (Article 4).** `DATABASE_URL` unset serves @@ -84,8 +89,9 @@ approval. Do not mint one. - **Sessions are rows.** The cookie carries only a token; `resolveIdentity` looks the session and its user up on every request. That is why a role change - lands on the person's next request and a ban bites immediately. Better Auth's - cookie cache is deliberately off. + lands on the person's next request and a ban bites immediately — **with one + exception, a floor address, described under `AUTH_SUPER_ADMIN_EMAILS` + below.** Better Auth's cookie cache is deliberately off. - **Roles** are `anonymous` (never a row) plus the stored `user | admin | super_admin` (`src/lib/db/schema/vocabulary.ts`, which also backs the `user_role_check` constraint). `student` and `staff` are gone; today's `admin` @@ -106,6 +112,21 @@ approval. Do not mint one. `super_admin` and resolves as `super_admin` whatever its row says. It is the bootstrap (no user row exists until somebody signs in) and the lock-out guarantee. +- **"Whatever its row says" includes `banned`**, and that is the one exception + to "a ban bites immediately". `identityFromSession` reads the floor *before* + the ban check, so a listed address keeps resolving `super_admin` on a session + it already holds. It does not rescue a *sign-in*: the admin plugin throws + `BANNED_USER` from its own `session.create.before` hook, which runs ahead of + anything this app can register, so the row itself has to change. + `src/lib/auth/floor-role.ts` (`reconcileSuperAdminFloor`) is where it does — + called from `/admin/users`' `authorize()` after the permission check, it + writes the floor's `role` **and** clears `banned` on the caller's own row, so + the first admin write a recovered director performs makes ordinary sign-in + work again. It only ever promotes and unbans, only for an address the + environment already names, and it records both as audit events with a null + actor. It exists because `can()` honours the floor and the admin plugin does + not: the plugin reads the stored `role` and `banned` for itself, so a row left + disagreeing produces an opaque `failed` on every save. - **Everything stays optional.** `AUTH_SECRET` alone gives database sessions; the two `GOOGLE_*` variables are what make *starting* one possible, and without them `/api/auth/sign-in/social` answers 503 and the header says @@ -137,6 +158,21 @@ Phase 5 extends both. The shape it sets: trusts nothing from the page that rendered the control (spec §8). Refusals are **values** (`{ ok: false, error }`), not exceptions, so the island can render the reason; every code has an `admin.errors.` string. +- **The server actions are the only way in.** The admin plugin also mounts its + own HTTP endpoints under `/api/auth/admin/*`, which would be a second, + unreviewed door onto the same writes. `src/app/api/auth/[...all]/route.ts` + refuses every path under that prefix with 403 `admin_api_not_exposed`, before + the auth instance is even constructed — percent-decoding and lower-casing the + path first, so `%61dmin` and `/ADMIN/` are the same refusal. +- **A change that landed minus a guarantee is a warning, not an error.** The + audit write happens *after* `auth.api.setRole` / `banUser` has committed, so + throwing there would make the page show the old value over a database holding + the new one. `record()` reports instead, and the action answers + `{ ok: true, …, warning: "audit_unavailable" }`. Both islands keep the new + value and show `admin.warnings.` in `.admin-row-status.is-warning`. + **Never answer `{ ok: false }` for a write that landed** — both islands + respond to a refusal by restoring the previous value, which would then assert + a state the database does not hold. Phase 5's admin writes should reuse this. - **Two things cannot be undone, so they cannot be done.** An address in `AUTH_SUPER_ADMIN_EMAILS` cannot be demoted or banned, and the last unbanned `super_admin` cannot be demoted. The table disables those rows with @@ -177,6 +213,7 @@ Phase 5 extends both. The shape it sets: | `src/lib/auth/identity.ts` | `resolveIdentity(req)` — the one way to learn who is calling. Never throws | | `src/lib/auth/permissions.ts` | `statement` / `ac` / `roles` / `can()` — what each role may do | | `src/lib/auth/super-admins.ts` | `AUTH_SUPER_ADMIN_EMAILS`, the lock-out floor | +| `src/lib/auth/floor-role.ts` | `reconcileSuperAdminFloor` — writes the floor's role and lifts its ban onto the row, because the admin plugin reads the row and not `can()` | | `src/app/admin/layout.tsx` | The `/admin` front door — signed in? holds an admin permission? | | `src/app/admin/users/actions.ts` | `setUserRole` / `setUserBanned` — the app's first server actions | | `src/lib/data/users.ts` | The `/admin/users` roster, read straight from Postgres | diff --git a/v5/messages/en.json b/v5/messages/en.json index fb3c799..d51ebcc 100644 --- a/v5/messages/en.json +++ b/v5/messages/en.json @@ -141,12 +141,18 @@ "uploadsUnavailable": "Photo uploads are unavailable right now. You can still submit your project without photos.", "requiredError": "A title and a write-up are required.", "submitError": "Something went wrong. Please try again.", + "signInRequiredError": "You need to be signed in to submit a project. Use the Sign in control in the header, then submit again — nothing you have typed is lost.", "thanksTitle": "Thanks — your project is pending review", "thanksBody": "A staff member will review your submission and publish it to the gallery soon.", + "thanksPhotosNone": "Your photos were not attached, so the write-up was saved without them. An uploaded photo is discarded after a day, so pictures added to a form left open overnight are already gone.", + "thanksPhotosSome": "Some of your photos were not attached, so the write-up was saved without them. An uploaded photo is discarded after a day, so pictures added to a form left open overnight are already gone.", "backToGallery": "Back to gallery", "signInTitle": "Sign in to share your project", "signInBody": "Project write-ups are credited to your {institution} account, so you need to be signed in to submit one. Use the Sign in control in the header.", "signInBrowse": "Browse projects", + "identityUnknown": "We could not check whether you are signed in just now, so this page cannot tell you either way. You can keep writing — if you are signed in, submitting will work.", + "identityRetry": "Check again", + "identityChecking": "Checking…", "authorNote": "Your project will be credited to {name}." }, "about": { @@ -318,6 +324,9 @@ "last_super_admin": "This is the last director. Promote somebody else first, or nobody could undo it.", "self_ban": "You cannot ban yourself.", "failed": "That did not save. Nothing was changed." + }, + "warnings": { + "audit_unavailable": "Saved, but this change could not be written to the audit log. Tell whoever runs the deployment." } } } diff --git a/v5/src/app/admin/users/action-result.ts b/v5/src/app/admin/users/action-result.ts index 0b399ac..2208f48 100644 --- a/v5/src/app/admin/users/action-result.ts +++ b/v5/src/app/admin/users/action-result.ts @@ -35,6 +35,22 @@ export type AdminActionError = | "self_ban" | "failed"; +/** + * A change that landed with less than the full guarantee behind it. + * + * - `audit_unavailable` — the row changed and `audit_events` did not record it. + * + * It rides on `ok: true` deliberately. The plugin's write commits in its own + * statement, and with the Neon HTTP driver the audit insert is a second request + * that can fail on its own; reporting that as a failure would make the island + * snap back to the previous value and leave the page asserting a role the + * database no longer holds — the one thing `RoleSelect` promises never to do. + * Reporting it as a plain success would leave a hole in the trail nobody was + * told about (spec §4.11, Article 4). So it is a success that says what is + * missing, and every code has an `admin.warnings.` message. + */ +export type AdminActionWarning = "audit_unavailable"; + export type AdminActionResult = - | { ok: true; role?: Role; banned?: boolean } + | { ok: true; role?: Role; banned?: boolean; warning?: AdminActionWarning } | { ok: false; error: AdminActionError }; diff --git a/v5/src/app/admin/users/actions.audit.test.ts b/v5/src/app/admin/users/actions.audit.test.ts new file mode 100644 index 0000000..3ef26f3 --- /dev/null +++ b/v5/src/app/admin/users/actions.audit.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment node +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); +vi.mock("next/headers", () => nextHeadersMock()); + +// The one seam `actions.test.ts` cannot have: a database that answers the +// *second* statement with an error. `vi.hoisted` because the `vi.mock` factory +// runs before module scope exists. +const audit = vi.hoisted(() => ({ failing: false })); + +vi.mock("../../../lib/data/audit", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + recordAuditEvent: async (event: Parameters[0]) => { + if (audit.failing) throw new Error("connection terminated unexpectedly"); + return actual.recordAuditEvent(event); + }, + }; +}); + +import { resetAuthForTests } from "../../../lib/auth/config"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { auditEvents, session, user } from "../../../lib/db/schema/index"; +import { findUserById } from "../../../lib/data/users"; +import { seedUser, signInAsNew } from "../../../../test/utils/session"; +import { setUserBanned, setUserRole } from "./actions"; + +/** + * What happens when the change lands and the audit event does not (§4.11). + * + * Its own file because it is the only one here that replaces `data/audit.ts`: + * `actions.test.ts` asserts against the real table, and a module mocked for one + * test in a file is a module mocked for all of them. The failure is worth + * provoking because it is not exotic — `auth.api.setRole` commits in its own + * statement and, on the Neon HTTP driver, the audit insert is a separate + * request that can fail on its own. + * + * **The property under test is that the browser is never told the change failed + * when it did not.** `RoleSelect` and `BanToggle` answer a refusal by restoring + * the previous value, so an exception here would leave the page asserting a + * role the database no longer holds. + */ + +const AUTH_SECRET = "admin-users-audit-test-secret"; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + resetAuthForTests(); + audit.failing = false; + + const db = await getDb(); + await db.delete(auditEvents); + await db.delete(session); + await db.delete(user); +}); + +afterEach(() => { + audit.failing = false; + resetAuthForTests(); + resetDbForTests(); +}); + +async function asDirector() { + const signedIn = await signInAsNew({ + email: "director@cornell.edu", + role: "super_admin", + name: "Dee Rector", + }); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; +} + +describe("an audit write that fails after the change landed", () => { + it("reports the role change as a success, with the gap named", async () => { + await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + audit.failing = true; + + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: true, + role: "admin", + warning: "audit_unavailable", + }); + // The half that makes the answer honest: the row really did move. + expect((await findUserById(target.id))?.role).toBe("admin"); + }); + + it("does not throw out of the server action", async () => { + await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + audit.failing = true; + + // A rejected action reaches the island as a caught failure, and the island + // answers that by putting the old role back — over a database holding the + // new one. + await expect(setUserRole({ userId: target.id, role: "admin" })).resolves.toMatchObject( + { ok: true } + ); + }); + + it("reports a ban the same way, and the ban still stands", async () => { + await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + audit.failing = true; + + expect( + await setUserBanned({ userId: target.id, banned: true, reason: "spam" }) + ).toEqual({ + ok: true, + banned: true, + warning: "audit_unavailable", + }); + expect((await findUserById(target.id))?.banned).toBe(true); + }); + + it("carries no warning when the trail was written", async () => { + await asDirector(); + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + + // The ordinary path, asserted here too so the warning cannot become a + // constant that nobody notices is always set. + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: true, + role: "admin", + }); + }); +}); diff --git a/v5/src/app/admin/users/actions.ts b/v5/src/app/admin/users/actions.ts index 563ec39..15631c6 100644 --- a/v5/src/app/admin/users/actions.ts +++ b/v5/src/app/admin/users/actions.ts @@ -7,7 +7,7 @@ import { reconcileSuperAdminFloor } from "../../../lib/auth/floor-role"; import { resolveIdentityFromHeaders, type Identity } from "../../../lib/auth/identity"; import { can } from "../../../lib/auth/permissions"; import { isSuperAdminFloor } from "../../../lib/auth/super-admins"; -import { recordAuditEvent } from "../../../lib/data/audit"; +import { recordAuditEvent, type NewAuditEvent } from "../../../lib/data/audit"; import { countUsersWithRole, findUserById, type UserRecord } from "../../../lib/data/users"; import { isOneOf, ROLES, type Role } from "../../../lib/db/schema/vocabulary"; import { ADMIN_ACTION_TIER, rateLimitAsync } from "../../../lib/rate-limit"; @@ -15,6 +15,7 @@ import { ADMIN_USERS_PATH, type AdminActionError, type AdminActionResult, + type AdminActionWarning, } from "./action-result"; /** @@ -37,6 +38,10 @@ import { * `next-intl` string. A thrown error in a server action reaches the browser as * a digest and an error boundary, which is the wrong shape for "you cannot * demote the floor address, and here is why" (§5.2). + * + * **And a change that lands without its audit event is a success with a + * warning, not a failure.** The two writes are two statements and only the + * first one is the change; see {@link record}. */ /** @@ -84,7 +89,7 @@ export async function setUserRole(input: { return { ok: false, error: "failed" }; } - await recordAuditEvent({ + const recorded = await record({ actorUserId: identity.userId, action: "role.changed", subjectType: "user", @@ -95,7 +100,7 @@ export async function setUserRole(input: { }); revalidatePath(ADMIN_USERS_PATH); - return { ok: true, role }; + return { ok: true, role, ...(recorded ? {} : { warning: AUDIT_WARNING }) }; } /** @@ -154,7 +159,7 @@ export async function setUserBanned(input: { return { ok: false, error: "failed" }; } - await recordAuditEvent({ + const recorded = await record({ actorUserId: identity.userId, action: "user.banned", subjectType: "user", @@ -166,7 +171,11 @@ export async function setUserBanned(input: { }); revalidatePath(ADMIN_USERS_PATH); - return { ok: true, banned: input.banned }; + return { + ok: true, + banned: input.banned, + ...(recorded ? {} : { warning: AUDIT_WARNING }), + }; } // ── The shared preamble ───────────────────────────────────────────── @@ -264,3 +273,38 @@ async function requestHeaders(): Promise { if (cookie) copy.set("cookie", cookie); return copy; } + +// ── Recording it ──────────────────────────────────────────────────── + +/** The one warning either action can carry. Named so the two cannot drift. */ +const AUDIT_WARNING: AdminActionWarning = "audit_unavailable"; + +/** + * Write the audit event, and say whether it landed. + * + * **The order is not negotiable and neither is the shape.** The event describes + * a change that has already committed — `auth.api.setRole` / `banUser` have + * returned — and `recordAuditEvent` throws on any database failure. With the + * Neon HTTP driver each statement is its own request, so a transient 5xx + * between the two is an ordinary outcome rather than an exotic one, and there + * is no transaction spanning them to roll back. + * + * Letting the throw propagate would reach the island as a rejected action, and + * both islands answer a rejection by restoring the previous value: the page + * would show the old role over a database holding the new one, which is exactly + * what `RoleSelect`'s comment says it never does. Swallowing it silently would + * leave a gap in the trail nobody was told about (spec §4.11). + * + * So the failure becomes a `warning` on a successful result: the row changed, + * the page says so, and it also says the change was not recorded. The console + * line is the operator's copy — it is the only place the event now exists. + */ +async function record(event: NewAuditEvent): Promise { + try { + await recordAuditEvent(event); + return true; + } catch (err) { + console.error("[admin/users] audit write failed after the change landed", err); + return false; + } +} diff --git a/v5/src/app/api/projects/route.test.ts b/v5/src/app/api/projects/route.test.ts index 2d3357b..53cc57e 100644 --- a/v5/src/app/api/projects/route.test.ts +++ b/v5/src/app/api/projects/route.test.ts @@ -31,7 +31,20 @@ let TROTEC_ID = ""; let defaultSession: Awaited>; let sessionCounter = 0; +/** + * The route warns when a submission's photos did not attach, and most payloads + * here carry a photo id no `attachments` row answers to — so the warning is the + * expected case, not a surprise. Captured rather than printed, and read back + * with `vi.mocked(console.warn)` where it is the thing under test. + * `vi.restoreAllMocks()` in the global setup puts the real console back after + * every test. + */ +function warnings() { + return vi.mocked(console.warn); +} + beforeEach(async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); vi.stubEnv("DATABASE_URL", ""); vi.stubEnv("AUTH_SECRET", AUTH_SECRET); resetAuthForTests(); @@ -160,7 +173,14 @@ describe("POST /api/projects (drafts by default)", () => { expect(res.status).toBe(201); const rows = await storedProjects(); expect(rows).toHaveLength(1); - expect(await res.json()).toEqual({ id: rows[0].id, slug: rows[0].slug }); + expect(await res.json()).toEqual({ + id: rows[0].id, + slug: rows[0].slug, + // The photo counts ride along on every submission — see the "photos that + // did not attach" block below for why they are there. + photosSubmitted: 1, + photosAttached: 0, + }); expect(rows[0].published).toBe(false); expect(rows[0].slug).toBe("lamp-from-scrap-plywood"); }); @@ -260,6 +280,81 @@ describe("POST /api/projects (drafts by default)", () => { }); }); +// ── Photos that did not attach (Article 4) ────────────────────────── + +describe("POST /api/projects (photos that did not attach)", () => { + it("reports both counts so the form can tell the student what landed", async () => { + const kept = await upload(); + + const res = await post( + submitRequest( + validPayload({ + photos: [ + { id: kept, name: "cover.png" }, + // Uploaded, then swept by the nightly cron while the tab sat open: + // uuid-shaped, and nothing answers to it any more. + { id: crypto.randomUUID(), name: "detail.png" }, + ], + }) + ) + ); + + expect(res.status).toBe(201); + expect(await res.json()).toMatchObject({ photosSubmitted: 2, photosAttached: 1 }); + }); + + it("says none attached rather than thanking the student for pictures nobody has", async () => { + // The exact shape of the reviewed bug: three photos uploaded yesterday, + // `runCleanup` deleted the unclaimed rows overnight, the write-up lands and + // the pictures do not. A 201 that says nothing about it is a quiet lie. + const stale = Array.from({ length: 3 }, (_, i) => ({ + id: crypto.randomUUID(), + name: `photo-${i}.png`, + })); + + const res = await post(submitRequest(validPayload({ photos: stale }))); + + expect(res.status).toBe(201); + expect(await res.json()).toMatchObject({ photosSubmitted: 3, photosAttached: 0 }); + // And staff can find it afterwards, the way the maintenance path already + // logs its own lost photos. + expect(warnings()).toHaveBeenCalledWith( + expect.stringContaining("saved without 3 of its 3 photo(s)") + ); + // The write-up itself is still there — losing the photos never costs the + // student the submission. + expect(await storedProjects()).toHaveLength(1); + }); + + it("claims nothing and says so when the photos belong to somebody else", async () => { + // An already-claimed attachment is not annexable (`owner_id is null` is part + // of the WHERE), so the count tells the truth here too. + const someoneElses = await upload(); + await post(submitRequest(validPayload({ photos: [{ id: someoneElses, name: "a.png" }] }))); + + const res = await post( + submitRequest(validPayload({ photos: [{ id: someoneElses, name: "a.png" }] })) + ); + + expect(res.status).toBe(201); + expect(await res.json()).toMatchObject({ photosSubmitted: 1, photosAttached: 0 }); + }); + + it("stays quiet when every photo attached, and when none was sent", async () => { + const first = await upload(); + const both = await post( + submitRequest(validPayload({ photos: [{ id: first, name: "cover.png" }] })) + ); + expect(await both.json()).toMatchObject({ photosSubmitted: 1, photosAttached: 1 }); + + const none = await post(submitRequest(validPayload({ photos: [] }))); + expect(await none.json()).toMatchObject({ photosSubmitted: 0, photosAttached: 0 }); + + // Nothing was lost in either case, so nothing is logged about it. + expect(warnings()).not.toHaveBeenCalled(); + }); +}); + // ── Signing in is the gate (spec §5.5) ────────────────────────────── describe("POST /api/projects (sign-in required)", () => { diff --git a/v5/src/app/api/projects/route.ts b/v5/src/app/api/projects/route.ts index 16bf34f..0ddf4bc 100644 --- a/v5/src/app/api/projects/route.ts +++ b/v5/src/app/api/projects/route.ts @@ -193,9 +193,34 @@ export async function POST(req: NextRequest) { toolIds: tools, photoAttachmentIds: photoIds, }); + // Photos offered but not all of them claimed: say so rather than let the + // student believe the gallery will show a picture that is not there + // (Article 4). `createProjectSubmission` returns the count for exactly this + // reason, and the sibling write path — `report_issue` in + // `src/lib/capabilities/maintenance.ts` — already tells the student the same + // thing, so answering 201 and dropping the count on the floor here was the + // odd one out. The usual cause is time: an upload nobody claims is deleted + // by the nightly cron after 24 hours, so a form left open overnight submits + // ids that no `attachments` row answers to any more. + const photosLost = photoIds.length - record.photosAttached; + if (photosLost > 0) { + console.warn( + `[projects] submission ${record.id} saved without ${photosLost} of its ${photoIds.length} photo(s) — no unclaimed attachment matched the ids supplied` + ); + } // The id is what the form has always been handed back; the slug rides - // along for the admin page that will publish it. - return Response.json({ id: record.id, slug: record.slug }, { status: 201 }); + // along for the admin page that will publish it. The two counts are the + // form's evidence — it renders the "saved without your photos" line off + // the server's answer rather than assuming its own uploads stuck. + return Response.json( + { + id: record.id, + slug: record.slug, + photosSubmitted: photoIds.length, + photosAttached: record.photosAttached, + }, + { status: 201 } + ); } catch (err) { console.error("Project submission failed", err); return Response.json( diff --git a/v5/src/components/ProjectSubmitForm.test.tsx b/v5/src/components/ProjectSubmitForm.test.tsx index 1b3471c..3c7d8ba 100644 --- a/v5/src/components/ProjectSubmitForm.test.tsx +++ b/v5/src/components/ProjectSubmitForm.test.tsx @@ -78,6 +78,9 @@ function submitButton() { } const SIGNED_IN: ClientIdentity = { role: "user", name: "Ada Lovelace" }; +// The answer a signed-out visitor gets: a 200 that says so. Told apart from +// `null` — "the endpoint could not answer" — everywhere below. +const ANONYMOUS: ClientIdentity = { role: "anonymous", name: null }; beforeEach(() => { fetchIdentity.mockClear(); @@ -137,7 +140,7 @@ describe("ProjectSubmitForm validation", () => { describe("ProjectSubmitForm — signing in is the gate", () => { it("shows the sign-in prompt instead of the form for an anonymous visitor", async () => { - fetchIdentity.mockResolvedValue(null); + fetchIdentity.mockResolvedValue(ANONYMOUS); render(); expect( @@ -152,7 +155,7 @@ describe("ProjectSubmitForm — signing in is the gate", () => { }); it("names the institution from config rather than leaving the placeholder", async () => { - fetchIdentity.mockResolvedValue(null); + fetchIdentity.mockResolvedValue(ANONYMOUS); render(); const body = await screen.findByText(/credited to your/); @@ -160,11 +163,10 @@ describe("ProjectSubmitForm — signing in is the gate", () => { expect(body.textContent).not.toContain("{institution}"); }); - it("treats an identity endpoint that cannot answer as anonymous", async () => { - // A failed `/api/identity` used to mean "type your own name"; now it means - // the form cannot know who is submitting, and the server would refuse the - // post anyway. Showing the prompt is the honest answer (Article 4). - fetchIdentity.mockResolvedValue(null); + it("shows the prompt for the anonymous answer, which is an answer", async () => { + // `/api/identity` answers 200 `{ role: "anonymous" }` for a signed-out + // visitor — a fact, unlike the `null` below. + fetchIdentity.mockResolvedValue(ANONYMOUS); render(); expect( @@ -182,7 +184,7 @@ describe("ProjectSubmitForm — signing in is the gate", () => { expect(screen.queryByRole("heading", { name: "Sign in to share your project" })).toBeNull(); expect(screen.getByRole("button", { name: "Submit project" })).toBeInTheDocument(); - pending.resolve(null); + pending.resolve(ANONYMOUS); expect( await screen.findByRole("heading", { name: "Sign in to share your project" }) ).toBeInTheDocument(); @@ -236,6 +238,82 @@ describe("ProjectSubmitForm — signing in is the gate", () => { }); +// ── When the identity endpoint cannot answer (Article 4) ──────────── + +describe("ProjectSubmitForm — an identity that could not be checked", () => { + // `fetchIdentity` resolves to `null` for a 429 from the identity tier + // (120/min), a 5xx, or a dropped connection — never for a signed-out visitor, + // who comes back as `{ role: "anonymous" }`. The form must not turn "we could + // not ask" into "you are signed out". + it("keeps the form up rather than telling a signed-in student to sign in", async () => { + fetchIdentity.mockResolvedValue(null); + render(); + + expect( + await screen.findByText(/could not check whether you are signed in/) + ).toBeInTheDocument(); + expect( + screen.queryByRole("heading", { name: "Sign in to share your project" }) + ).toBeNull(); + // The server is the authority on the session, and it would accept the post. + expect(submitButton()).toBeInTheDocument(); + }); + + it("offers a way to ask again, and takes the answer when it comes", async () => { + const user = userEvent.setup(); + fetchIdentity.mockResolvedValue(null); + render(); + + await screen.findByText(/could not check whether you are signed in/); + fetchIdentity.mockResolvedValue(SIGNED_IN); + await user.click(screen.getByRole("button", { name: "Check again" })); + + expect( + await screen.findByText(/Your project will be credited to Ada Lovelace/) + ).toBeInTheDocument(); + expect( + screen.queryByText(/could not check whether you are signed in/) + ).toBeNull(); + }); + + it("shows the sign-in prompt once a retry says the visitor really is signed out", async () => { + const user = userEvent.setup(); + fetchIdentity.mockResolvedValue(null); + render(); + + await screen.findByText(/could not check whether you are signed in/); + fetchIdentity.mockResolvedValue(ANONYMOUS); + await user.click(screen.getByRole("button", { name: "Check again" })); + + expect( + await screen.findByRole("heading", { name: "Sign in to share your project" }) + ).toBeInTheDocument(); + }); + + it("says a 401 in the visitor's own words instead of the route's English", async () => { + // The other half of the same honesty: the form rendered without knowing, + // the post came back 401, and "sign in, then submit again" is what the + // student needs to read — translated, like every other string here + // (Article 6). + const user = userEvent.setup(); + fetchIdentity.mockResolvedValue(null); + stubFetch(async () => + jsonResponse({ error: "Sign in to share a project.", code: "sign_in_required" }, 401) + ); + render(); + + await screen.findByText(/could not check whether you are signed in/); + await fillRequired(user); + await user.click(submitButton()); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "You need to be signed in to submit a project." + ); + // And the write-up survives the refusal. + expect(screen.getByLabelText("Project title")).toHaveValue("Plywood lamp"); + }); +}); + // ── Successful submission ─────────────────────────────────────────── describe("ProjectSubmitForm submission", () => { @@ -514,6 +592,58 @@ describe("ProjectSubmitForm photos", () => { expect(lastSubmitBody(fetchMock).photos).toEqual([]); }); + it("tells the student when none of the photos attached, instead of a plain thank-you", async () => { + // The reviewed bug: photos uploaded yesterday, swept by the nightly cron + // overnight, claimed by nothing — and the student thanked as if the + // gallery would show them. The route reports the counts; this renders them. + const user = userEvent.setup(); + stubFetch(async () => + jsonResponse({ id: "p1", slug: "lamp", photosSubmitted: 3, photosAttached: 0 }, 201) + ); + render(); + + await fillRequired(user); + await user.click(submitButton()); + + await screen.findByRole("heading", { + name: "Thanks — your project is pending review", + }); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Your photos were not attached" + ); + }); + + it("says *some* when only some of them attached", async () => { + const user = userEvent.setup(); + stubFetch(async () => + jsonResponse({ id: "p1", slug: "lamp", photosSubmitted: 3, photosAttached: 2 }, 201) + ); + render(); + + await fillRequired(user); + await user.click(submitButton()); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Some of your photos were not attached" + ); + }); + + it("says nothing about photos when they all landed, or when none was sent", async () => { + const user = userEvent.setup(); + stubFetch(async () => + jsonResponse({ id: "p1", slug: "lamp", photosSubmitted: 0, photosAttached: 0 }, 201) + ); + render(); + + await fillRequired(user); + await user.click(submitButton()); + + await screen.findByRole("heading", { + name: "Thanks — your project is pending review", + }); + expect(screen.queryByRole("alert")).toBeNull(); + }); + it("surfaces an upload failure and adds no photo", async () => { const user = userEvent.setup(); stubFetch(async () => diff --git a/v5/src/components/ProjectSubmitForm.tsx b/v5/src/components/ProjectSubmitForm.tsx index 26fe2ca..4ca880c 100644 --- a/v5/src/components/ProjectSubmitForm.tsx +++ b/v5/src/components/ProjectSubmitForm.tsx @@ -30,6 +30,26 @@ interface UploadedPhoto { /** Ties the read-only byline to the note explaining where the name came from. */ const AUTHOR_NOTE_ID = "project-author-note"; +/** + * What the form knows about who is submitting. + * + * Three states, not two, because `/api/identity` has three answers: a signed-in + * identity, the anonymous identity (a normal 200), and *no answer at all* — a + * 429 from the identity tier (120/min), or a dropped connection on lab wifi. + * `fetchIdentity` resolves the last of those to `null`, which is why `null` + * here means "could not ask" and never "signed out": an anonymous visitor comes + * back as `{ role: "anonymous" }`. Collapsing the two would tell a signed-in + * student they are signed out — an assertion the form has no evidence for, and + * one the server would contradict if they posted anyway (Article 4). + */ +type IdentityStatus = "pending" | "answered" | "unavailable"; + +/** How many of a submission's photos actually landed, as the route reports it. */ +interface PhotoOutcome { + submitted: number; + attached: number; +} + /** * The display name of a signed-in identity, or "" for anyone else — including a * signed-in account Google gave no name for, which is indistinguishable from @@ -53,18 +73,24 @@ export function ProjectSubmitForm({ tools }: ProjectSubmitFormProps) { // statically-shelled layout — so it asks after mount, exactly as the header // does (auth spec §6). Since Phase 4 the answer decides what renders at all: // submitting requires an account (spec §5.5), so an anonymous visitor gets - // the sign-in prompt in place of the form. `resolved` is what tells "not - // signed in" apart from "has not answered yet" — showing the prompt to - // somebody who *is* signed in, for the half-second before the fetch lands, - // would be the most annoying possible bug here. + // the sign-in prompt in place of the form. `status` is what tells the three + // answers apart — showing the prompt to somebody who *is* signed in, either + // for the half-second before the fetch lands or because the fetch never + // landed, would be the most annoying possible bug here. const [identity, setIdentity] = useState(null); - const [resolved, setResolved] = useState(false); + const [status, setStatus] = useState("pending"); + // Bumped by "Try again". A failed identity fetch is the one state here the + // visitor can do something about, so it gets a way to do it rather than a + // dead end that only a reload escapes. + const [attempt, setAttempt] = useState(0); + const [retrying, setRetrying] = useState(false); const [toolQuery, setToolQuery] = useState(""); const [uploading, setUploading] = useState(0); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [submitted, setSubmitted] = useState(false); + const [photoOutcome, setPhotoOutcome] = useState(null); useEffect(() => { const controller = new AbortController(); @@ -72,13 +98,15 @@ export function ProjectSubmitForm({ tools }: ProjectSubmitFormProps) { fetchIdentity(controller.signal).then((answer) => { if (!active) return; setIdentity(answer); - setResolved(true); + // `null` is "no answer", not "anonymous" — see IdentityStatus. + setStatus(answer ? "answered" : "unavailable"); + setRetrying(false); }); return () => { active = false; controller.abort(); }; - }, []); + }, [attempt]); const signedIn = isSignedIn(identity); const verifiedName = nameOf(identity); @@ -181,9 +209,32 @@ export function ProjectSubmitForm({ tools }: ProjectSubmitFormProps) { const data = (await res.json().catch(() => null)) as | { error?: string } | null; + // 401 is its own sentence, in the visitor's language (Article 6): it + // means the session ended — or never resolved, when the identity fetch + // failed and the form rendered optimistically — and "sign in, then + // submit again" is advice the route's English prose does not give. + if (res.status === 401) throw new Error(t("signInRequiredError")); throw new Error(data?.error || t("submitError")); } + // The route reports how many of the photo ids actually attached, because + // an upload nobody claimed is deleted after 24 hours and a form left open + // overnight submits ids that no longer name anything. Thanking a student + // for a write-up whose pictures were silently dropped is the kind of + // quiet lie Article 4 exists to forbid, so the confirmation says it. + const data = (await res.json().catch(() => null)) as + | { photosSubmitted?: number; photosAttached?: number } + | null; + if ( + typeof data?.photosSubmitted === "number" && + typeof data.photosAttached === "number" + ) { + setPhotoOutcome({ + submitted: data.photosSubmitted, + attached: data.photosAttached, + }); + } + setSubmitted(true); } catch (err) { setError(err instanceof Error ? err.message : t("submitError")); @@ -199,6 +250,13 @@ export function ProjectSubmitForm({ tools }: ProjectSubmitFormProps) {

{t("eyebrow")}

{t("thanksTitle")}

{t("thanksBody")}

+ {photoOutcome && photoOutcome.attached < photoOutcome.submitted ? ( +

+ {photoOutcome.attached === 0 + ? t("thanksPhotosNone") + : t("thanksPhotosSome")} +

+ ) : null}
{t("backToGallery")} @@ -209,11 +267,12 @@ export function ProjectSubmitForm({ tools }: ProjectSubmitFormProps) { ); } - // Anonymous, and we know it: the prompt replaces the form (spec §5.5, §6). - // Deliberately not a redirect to sign-in — the visitor asked for this page, - // and a header control they can use without losing their place is a better - // answer than a bounce. Browsing the gallery stays open to them. - if (resolved && !signedIn) { + // Anonymous, and we know it — `status === "answered"` is the part that makes + // it knowledge rather than a guess: the prompt replaces the form (spec §5.5, + // §6). Deliberately not a redirect to sign-in — the visitor asked for this + // page, and a header control they can use without losing their place is a + // better answer than a bounce. Browsing the gallery stays open to them. + if (status === "answered" && !signedIn) { return (
@@ -247,6 +306,29 @@ export function ProjectSubmitForm({ tools }: ProjectSubmitFormProps) { {t("lede", { institution: siteConfig.institution })}

+ {/* The identity endpoint could not answer — a 429 from its 120/min tier, + or a connection that dropped. The form stays open rather than + claiming the visitor is signed out: the server is the authority on + that and would accept a signed-in student's post. What the page owes + them is the truth about what it does not know, and a way to ask + again (Article 4). */} + {status === "unavailable" ? ( +
+

{t("identityUnknown")}

+ +
+ ) : null} +
diff --git a/v5/src/components/admin/RoleSelect.test.tsx b/v5/src/components/admin/RoleSelect.test.tsx index 3a7b5e6..1dbe151 100644 --- a/v5/src/components/admin/RoleSelect.test.tsx +++ b/v5/src/components/admin/RoleSelect.test.tsx @@ -82,6 +82,21 @@ describe("RoleSelect — changing it", () => { await waitFor(() => expect(theSelect()).toHaveValue("super_admin")); }); + it("keeps the new role and names the gap when the audit write failed", async () => { + const user = userEvent.setup(); + const { action } = renderSelect(); + action.mockResolvedValue({ ok: true, role: "admin", warning: "audit_unavailable" }); + + await user.selectOptions(theSelect(), "admin"); + + expect(await screen.findByText(/could not be written to the audit log/i)).toBeInTheDocument(); + // The change landed, so the control must not snap back — that is the whole + // reason the action reports this as a success. + expect(theSelect()).toHaveValue("admin"); + // And it is not "Saved": something is missing and somebody has to see it. + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); + it("reports a refusal the page did not anticipate, rather than nothing", async () => { const user = userEvent.setup(); const { action } = renderSelect(); diff --git a/v5/src/components/admin/RoleSelect.tsx b/v5/src/components/admin/RoleSelect.tsx index f46734b..8079b01 100644 --- a/v5/src/components/admin/RoleSelect.tsx +++ b/v5/src/components/admin/RoleSelect.tsx @@ -3,7 +3,11 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import { ROLES, type Role } from "../../lib/db/schema/vocabulary"; -import type { AdminActionError, AdminActionResult } from "../../app/admin/users/action-result"; +import type { + AdminActionError, + AdminActionResult, + AdminActionWarning, +} from "../../app/admin/users/action-result"; /** * The one interactive control on `/admin/users`: pick a role, and the change @@ -49,6 +53,10 @@ export function RoleSelect({ const [current, setCurrent] = useState(role); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); + // A success that is worth qualifying: the role changed and the audit trail + // did not record it. It cannot be an error, because the select must keep + // showing what the database now holds. + const [warning, setWarning] = useState(null); /** * Deliberately **not** wrapped in `useTransition`. @@ -65,6 +73,7 @@ export function RoleSelect({ const previous = current; setCurrent(next as Role); setError(null); + setWarning(null); setSaved(false); setPending(true); @@ -72,6 +81,7 @@ export function RoleSelect({ const result = await action({ userId, role: next }); if (result.ok) { setSaved(true); + setWarning(result.warning ?? null); return; } setCurrent(previous); @@ -109,11 +119,14 @@ export function RoleSelect({ {/* One live region for every outcome this control can have, so a screen reader hears the refusal in the same place it heard the confirmation. */} {pending ? t("saving") : null} - {!pending && saved && !error ? t("saved") : null} + {!pending && saved && !error && !warning ? t("saved") : null} + {!pending && warning ? t(`warnings.${warning}`) : null} {!pending && note ? t(`errors.${note}`) : null} diff --git a/v5/src/lib/auth/floor-role.test.ts b/v5/src/lib/auth/floor-role.test.ts index 7dbf3cd..074458f 100644 --- a/v5/src/lib/auth/floor-role.test.ts +++ b/v5/src/lib/auth/floor-role.test.ts @@ -44,10 +44,14 @@ function identityFor( }; } -async function roleOf(id: string) { +async function rowOf(id: string) { const db = await getDb(); const [row] = await db.select().from(user).where(eq(user.id, id)); - return row?.role; + return row; +} + +async function roleOf(id: string) { + return (await rowOf(id))?.role; } describe("reconcileSuperAdminFloor", () => { @@ -67,6 +71,90 @@ describe("reconcileSuperAdminFloor", () => { expect(await listAuditEvents()).toEqual([]); }); + it("lifts a ban off a floor row, so sign-in works again", async () => { + // `identityFromSession` overrides the ban, which gets the person back in on + // the session they still hold. The *plugin* refuses to create a new one + // while the row says banned (`session.create.before`), so until this runs + // the recovery expires with that session. + const person = await seedUser({ + email: "founder@cornell.edu", + role: "super_admin", + banned: true, + banReason: "a restored backup said so", + }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(true); + + const row = await rowOf(person.id); + expect(row?.banned).toBe(false); + expect(row?.banReason).toBeNull(); + expect(row?.banExpires).toBeNull(); + }); + + it("records the lift as the ban event it is, and not as a role change", async () => { + const person = await seedUser({ + email: "founder@cornell.edu", + role: "super_admin", + banned: true, + }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + await reconcileSuperAdminFloor(identityFor(person)); + + // `AUDIT_ACTIONS` has no `user.unbanned`, so a lift is `user.banned` with + // `banned: false` — the same shape `setUserBanned` writes. The role was + // already right, so nothing claims it changed. + expect(await listAuditEvents()).toMatchObject([ + { + actorUserId: null, + action: "user.banned", + subjectId: person.id, + detail: { banned: false, reason: "super_admin_floor" }, + }, + ]); + }); + + it("lifts the ban and the demotion together when the row holds both", async () => { + const person = await seedUser({ + email: "founder@cornell.edu", + role: "user", + banned: true, + }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(true); + expect(await roleOf(person.id)).toBe("super_admin"); + expect((await rowOf(person.id))?.banned).toBe(false); + + const actions = (await listAuditEvents()).map((event) => event.action).sort(); + expect(actions).toEqual(["role.changed", "user.banned"]); + }); + + it("never bans anybody — a floor row that is not banned is left alone", async () => { + const person = await seedUser({ email: "founder@cornell.edu", role: "user" }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + await reconcileSuperAdminFloor(identityFor(person)); + + expect((await rowOf(person.id))?.banned).toBe(false); + expect((await listAuditEvents()).map((event) => event.action)).toEqual([ + "role.changed", + ]); + }); + + it("leaves a banned address the floor does not name banned", async () => { + const person = await seedUser({ + email: "someone@cornell.edu", + role: "user", + banned: true, + }); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + + expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(false); + expect((await rowOf(person.id))?.banned).toBe(true); + }); + it("leaves an address the floor does not name alone", async () => { const person = await seedUser({ email: "someone@cornell.edu", role: "user" }); vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); diff --git a/v5/src/lib/auth/floor-role.ts b/v5/src/lib/auth/floor-role.ts index 9b9a859..1e0bc0b 100644 --- a/v5/src/lib/auth/floor-role.ts +++ b/v5/src/lib/auth/floor-role.ts @@ -20,7 +20,8 @@ import { isSuperAdminFloor } from "./super-admins"; * `session.user.role` — the **stored** value, which it reads for itself and * which no override reaches. A floor address whose row still says `user` * therefore saw every control enabled and every save fail with an opaque - * `failed`: precisely the lock-out the floor exists to undo. + * `failed`: precisely the lock-out the floor exists to undo. The same is true + * of `banned`, which the plugin reads off the row to refuse a new session. * * That is not a hypothetical. It is the ordinary shape of both cases the floor * was written for: @@ -40,15 +41,25 @@ import { isSuperAdminFloor } from "./super-admins"; * actually has, and a reader of the table is not left comparing it against an * environment variable. * - * It is **only ever a promotion to `super_admin`, and only for an address the - * environment already names.** The authority is `AUTH_SUPER_ADMIN_EMAILS`, - * which is deployment configuration and not user input, and the effect is one - * the app's own identity layer had already granted. Nothing here can lower a - * role or raise one the floor does not list. + * It is **only ever a promotion to `super_admin` and a lifted ban, and only for + * an address the environment already names.** The authority is + * `AUTH_SUPER_ADMIN_EMAILS`, which is deployment configuration and not user + * input, and the effect is one the app's own identity layer had already + * granted. Nothing here can lower a role, raise one the floor does not list, or + * ban anybody. */ /** - * Make `identity`'s stored role match the floor, if the floor covers them. + * Make `identity`'s stored row match the floor, if the floor covers them. + * + * Two columns, for the same reason: `role`, and `banned`. `identityFromSession` + * overrides both — a floor address resolves `super_admin` whether its row was + * demoted or banned — and the plugin reads both off the row, so a row left + * disagreeing about either one produces the same opaque `failed`. A ban is also + * the half of the guarantee the environment variable cannot deliver on its own: + * the plugin refuses to *create a session* for a banned row, so until the ban + * comes off the row, the recovered director can use the session they already + * have and nothing else. * * Returns true when a row was changed. A no-op — and one cheap query — for * everybody else, which is every caller in a deployment with no floor set. @@ -62,31 +73,57 @@ export async function reconcileSuperAdminFloor(identity: Identity): Promise { expect((await resolveIdentity(requestWith(signedIn))).role).toBe("user"); }); + it("resolves a floor address whose row is banned", async () => { + // "Whatever its row says" includes `banned`. The floor is the lock-out + // guarantee, and a ban that resolved anonymous made it a recovery for + // exactly half of what `super-admins.ts` promises — with the ban being the + // half nobody can undo from the UI, because the app refuses to ban a floor + // address in the first place. + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "ies22@cornell.edu"); + const signedIn = await signInAsNew({ + email: "ies22@cornell.edu", + role: "user", + banned: true, + banReason: "a restored backup said so", + }); + + const identity = await resolveIdentity(requestWith(signedIn)); + expect(identity.role).toBe("super_admin"); + expect(identity.userId).toBe(signedIn.user.id); + }); + + it("still refuses a banned address the floor does not name", async () => { + // The override is the floor's, not a hole in the ban. + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "ies22@cornell.edu"); + const signedIn = await signInAsNew({ + email: "someone@cornell.edu", + role: "admin", + banned: true, + }); + + expect((await resolveIdentity(requestWith(signedIn))).role).toBe("anonymous"); + }); + + it("does not rescue a banned floor address that is out of domain", async () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "attacker@gmail.com"); + const signedIn = await signInAsNew({ email: "attacker@gmail.com", banned: true }); + + expect((await resolveIdentity(requestWith(signedIn))).role).toBe("anonymous"); + }); + it("does not rescue a floor address that is out of domain", async () => { vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "attacker@gmail.com"); const signedIn = await signInAsNew({ email: "attacker@gmail.com" }); diff --git a/v5/src/lib/auth/identity.ts b/v5/src/lib/auth/identity.ts index 4f927aa..cc68baa 100644 --- a/v5/src/lib/auth/identity.ts +++ b/v5/src/lib/auth/identity.ts @@ -124,24 +124,42 @@ interface SessionResult { * count as one. Four ways it must not: * * - **No session.** Nobody is signed in. - * - **Banned.** The person still holds a valid cookie; a ban has to bite on the - * next request, which is only true if it is checked on every one. * - **Out of domain.** The create hook refuses such a row, so one existing is a * bug, a restored backup, or a reconfigured domain — never a reason to trust it. + * - **Banned**, unless the floor names them. The person still holds a valid + * cookie; a ban has to bite on the next request, which is only true if it is + * checked on every one. * - **A role outside the vocabulary**, which `storedRoleOr` maps to anonymous. */ function identityFromSession(result: SessionResult | null | undefined): Identity | null { const user = result?.user; if (!user) return null; - if (user.banned) return null; if (!isAllowedEmail(user.email)) return null; // The floor (§3.4): a listed address resolves `super_admin` whatever its row // says, so a mistaken demotion or ban cannot lock the lab out of its own // admin surface. It is the only place the environment still names a role. - const role = isSuperAdminFloor(user.email) - ? "super_admin" - : storedRoleOr(user.role); + // + // **Read before the ban check, not after, and that ordering is the whole + // point.** `banned` is part of what "whatever its row says" means: a floor + // address whose row is banned used to resolve anonymous, which made the + // environment variable a recovery for exactly half of what this module and + // `super-admins.ts` both promise. The app refuses to ban a floor address + // (`app/admin/users/actions.ts`), so a banned one means a restored backup, a + // manual `UPDATE`, or an address added to the list after the ban — none of + // which delete the person's sessions, so the session they still hold now + // resolves and `/admin/users` opens. + // + // It does not rescue a sign-in: the admin plugin refuses to create a session + // for a banned row (`session.create.before`, thrown as `BANNED_USER`), and + // that hook runs ahead of anything this app can register. What closes the + // gap is `reconcileSuperAdminFloor`, which lifts the ban off the row on the + // first write the recovered director performs — after which the row and the + // running app agree again and an ordinary sign-in works. + const onFloor = isSuperAdminFloor(user.email); + if (user.banned && !onFloor) return null; + + const role = onFloor ? "super_admin" : storedRoleOr(user.role); if (role === "anonymous") return null; return { diff --git a/v5/src/styles/globals.css b/v5/src/styles/globals.css index 583f8b5..293de62 100644 --- a/v5/src/styles/globals.css +++ b/v5/src/styles/globals.css @@ -3744,6 +3744,16 @@ p { margin: 0; } +/* "We could not check whether you are signed in" — the note plus its retry + control, kept on one line where there is room. The form stays usable behind + it, so this is a notice and not a banner. */ +.project-form-unknown { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + /* Demo-data banner — the catalogue is built-in sample data, not the lab's inventory (operational-hardening spec §6). Deliberately loud: the failure it describes used to be invisible. Uses the secondary (crimson) token rather @@ -3957,6 +3967,13 @@ p { color: var(--secondary); } +/* A change that landed, minus a guarantee — the row moved and the audit trail + did not record it. Not the error colour, because nothing failed to save, and + not the muted default, because somebody has to notice. */ +.admin-row-status.is-warning { + color: var(--td-warning); +} + /* Visually hidden, still announced: the role select's label is the person's name, which the row already shows to anyone who can see it. */ .admin-visually-hidden { From bb8c6aebae0c886a28a15b0836c6a75bd451c5e9 Mon Sep 17 00:00:00 2001 From: Isaac S Date: Tue, 22 Sep 2026 16:43:44 -0400 Subject: [PATCH 3/4] The floor reconciliation can no longer deny a change it made MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial verification of 3c76839 found the guard it had just added to actions.ts missing one function away. reconcileSuperAdminFloor commits its row UPDATE and then writes two audit events unguarded, so an unreachable audit table threw past a committed change: authorize() caught it and returned "failed", which the page renders as "That did not save. Nothing was changed" — over a row that had just been promoted and, since 3c76839, un-banned. A ban lifted with no trail and the UI saying nothing happened. The function now reports {changed, audited} instead of a bare boolean, and guards its audit writes the way actions.ts already guards its own. The gap rides back through authorize() as the existing audit_unavailable warning, so both halves of a two-write action answer the admin's one question: did the trail record this. A success with a gap keeps the value the database holds; only the row UPDATE itself still throws. Two smaller things the same pass turned up: An upload refused for want of a session showed the route's English to a reader in another language. /projects/new tells three identity states apart now, so picking a photo while signed out is a routine path rather than an edge case; the 401 gets signInRequiredError, as submit already did. A test comment claimed the permission check "refuses before reading the file, so the refusal costs nothing". req.formData() has already parsed the body by then. The assertion was right and the explanation was not. Verified with every credential unset: lint 0 errors (3 pre-existing warnings), typecheck clean, 97 files / 1358 tests, spec:coverage 73 items / 0 undocumented. The two new regression tests were confirmed red against the unguarded version, failing with exactly {ok: false, error: "failed"}. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE --- v5/src/app/admin/users/actions.audit.test.ts | 75 ++++++++++++++++++ v5/src/app/admin/users/actions.ts | 50 +++++++++--- v5/src/app/api/uploads/route.test.ts | 12 ++- v5/src/components/ProjectSubmitForm.tsx | 5 ++ v5/src/lib/auth/floor-role.test.ts | 20 ++--- v5/src/lib/auth/floor-role.ts | 83 +++++++++++++++----- 6 files changed, 203 insertions(+), 42 deletions(-) diff --git a/v5/src/app/admin/users/actions.audit.test.ts b/v5/src/app/admin/users/actions.audit.test.ts index 3ef26f3..0bae903 100644 --- a/v5/src/app/admin/users/actions.audit.test.ts +++ b/v5/src/app/admin/users/actions.audit.test.ts @@ -21,6 +21,8 @@ vi.mock("../../../lib/data/audit", async (importOriginal) => { }; }); +import { eq } from "drizzle-orm"; + import { resetAuthForTests } from "../../../lib/auth/config"; import { getDb, resetDbForTests } from "../../../lib/db/client"; import { auditEvents, session, user } from "../../../lib/db/schema/index"; @@ -130,3 +132,76 @@ describe("an audit write that fails after the change landed", () => { }); }); }); + +/** + * The same property, one function earlier. + * + * `authorize()` reconciles the super-admin floor *before* the action's own + * write, and that reconciliation is itself a row UPDATE followed by audit + * inserts. While those inserts threw, a floor director whose row was stale hit + * this: their own row was promoted — and any ban on it lifted — and then the + * throw was caught and returned as `failed`, so the page said "nothing was + * changed" over a database that had changed two columns and recorded neither. + * + * The recovery path is exactly where a silent, unrecorded promotion is least + * acceptable, which is why it is asserted separately from the actions above. + */ +describe("an audit write that fails during the floor reconciliation", () => { + it("still performs the action, and names the gap instead of denying it", async () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + resetAuthForTests(); + + // Signed in first, then the row is spoiled behind them: a restored backup + // or a manual UPDATE, which is the only way a floor row is banned at all. + const founder = await signInAsNew({ + email: "founder@cornell.edu", + role: "user", + name: "Fou Nder", + }); + setMockHeaders({ cookie: founder.cookie }); + const db = await getDb(); + await db + .update(user) + .set({ banned: true, banReason: "mistake" }) + .where(eq(user.id, founder.user.id)); + + const target = await seedUser({ email: "student@cornell.edu", role: "user" }); + audit.failing = true; + + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: true, + role: "admin", + warning: "audit_unavailable", + }); + + // Both rows moved, and the caller is told the trail is incomplete rather + // than being told nothing happened. + expect((await findUserById(target.id))?.role).toBe("admin"); + const reconciled = await findUserById(founder.user.id); + expect(reconciled?.role).toBe("super_admin"); + expect(reconciled?.banned).toBe(false); + }); + + it("warns even when the action itself changes nothing", async () => { + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); + resetAuthForTests(); + + const founder = await signInAsNew({ + email: "founder@cornell.edu", + role: "user", + name: "Fou Nder", + }); + setMockHeaders({ cookie: founder.cookie }); + const target = await seedUser({ email: "student@cornell.edu", role: "admin" }); + audit.failing = true; + + // "admin → admin" writes no event of its own, so the only gap in the trail + // is the reconciliation's — and it is still a gap. + expect(await setUserRole({ userId: target.id, role: "admin" })).toEqual({ + ok: true, + role: "admin", + warning: "audit_unavailable", + }); + expect((await findUserById(founder.user.id))?.role).toBe("super_admin"); + }); +}); diff --git a/v5/src/app/admin/users/actions.ts b/v5/src/app/admin/users/actions.ts index 15631c6..883ccb9 100644 --- a/v5/src/app/admin/users/actions.ts +++ b/v5/src/app/admin/users/actions.ts @@ -62,14 +62,17 @@ export async function setUserRole(input: { }): Promise { const gate = await authorize(); if (!gate.ok) return gate; - const { identity } = gate; + // `gateWarning` is the reconciliation's own audit gap, if it had one. It + // rides on every success below, including the ones that change nothing here: + // the caller's row still moved. + const { identity, warning: gateWarning } = gate; if (!isOneOf(ROLES, input.role)) return { ok: false, error: "invalid_role" }; const role: Role = input.role; const target = await findUserById(input.userId); if (!target) return { ok: false, error: "unknown_user" }; - if (target.role === role) return { ok: true, role }; + if (target.role === role) return { ok: true, role, ...warn(gateWarning) }; const protection = await demotionProtection(target, role); if (protection) return { ok: false, error: protection }; @@ -100,7 +103,7 @@ export async function setUserRole(input: { }); revalidatePath(ADMIN_USERS_PATH); - return { ok: true, role, ...(recorded ? {} : { warning: AUDIT_WARNING }) }; + return { ok: true, role, ...warn(gateWarning, recorded) }; } /** @@ -121,11 +124,13 @@ export async function setUserBanned(input: { }): Promise { const gate = await authorize(); if (!gate.ok) return gate; - const { identity } = gate; + const { identity, warning: gateWarning } = gate; const target = await findUserById(input.userId); if (!target) return { ok: false, error: "unknown_user" }; - if (target.banned === input.banned) return { ok: true, banned: input.banned }; + if (target.banned === input.banned) { + return { ok: true, banned: input.banned, ...warn(gateWarning) }; + } if (input.banned) { // Self-ban first: the plugin refuses it too, but with an error code the @@ -174,13 +179,15 @@ export async function setUserBanned(input: { return { ok: true, banned: input.banned, - ...(recorded ? {} : { warning: AUDIT_WARNING }), + ...warn(gateWarning, recorded), }; } // ── The shared preamble ───────────────────────────────────────────── -type Gate = { ok: true; identity: Identity } | { ok: false; error: AdminActionError }; +type Gate = + | { ok: true; identity: Identity; warning?: AdminActionWarning } + | { ok: false; error: AdminActionError }; /** * Resolve the caller, bound their attempts, and check `users.manage`. @@ -212,8 +219,9 @@ async function authorize(): Promise { if (identity.role === "anonymous") return { ok: false, error: "not_signed_in" }; if (!can(identity, "users.manage")) return { ok: false, error: "not_permitted" }; + let reconciliation; try { - await reconcileSuperAdminFloor(identity); + reconciliation = await reconcileSuperAdminFloor(identity); } catch (err) { // The write that follows depends on this having landed, so reporting // "failed" is the honest answer — better than letting the plugin refuse @@ -222,7 +230,14 @@ async function authorize(): Promise { return { ok: false, error: "failed" }; } - return { ok: true, identity }; + // The reconciliation may have promoted this caller — and lifted a ban — with + // no trail. That rides back as a warning on whatever the action goes on to + // do, because refusing here would deny a change the database has kept. + return { + ok: true, + identity, + ...(reconciliation.audited ? {} : { warning: AUDIT_WARNING }), + }; } /** @@ -299,6 +314,23 @@ const AUDIT_WARNING: AdminActionWarning = "audit_unavailable"; * the page says so, and it also says the change was not recorded. The console * line is the operator's copy — it is the only place the event now exists. */ +/** + * The warning half of a successful result, or nothing. + * + * Two audit writes can go missing on one action — the floor reconciliation's, + * before the action ran, and the action's own — and there is one warning for + * both, because the admin's question is the same either way: *did the trail + * record this?* Spread into the result so a success without a gap carries no + * `warning` key at all. + */ +function warn( + gateWarning: AdminActionWarning | undefined, + recorded = true +): { warning?: AdminActionWarning } { + const warning = gateWarning ?? (recorded ? undefined : AUDIT_WARNING); + return warning ? { warning } : {}; +} + async function record(event: NewAuditEvent): Promise { try { await recordAuditEvent(event); diff --git a/v5/src/app/api/uploads/route.test.ts b/v5/src/app/api/uploads/route.test.ts index b1d0b18..4008c1f 100644 --- a/v5/src/app/api/uploads/route.test.ts +++ b/v5/src/app/api/uploads/route.test.ts @@ -318,12 +318,18 @@ describe("POST /api/uploads — who may ask for a public URL", () => { } }); - it("refuses before reading the file, so the refusal costs nothing", async () => { + it("refuses on the permission, not on the size", async () => { const res = await POST( uploadRequest(imageFile(19 * 1024 * 1024), { kind: "resource", cookie: null }) ); - // 401 rather than the 400 the oversize check would give: the caller is - // told they may not ask, without the route reading 19 MB to find out. + // 401 rather than the 400 the oversize check would give: the caller is told + // they may not ask at all, which is the more useful of the two answers. + // + // It is not free, though. `req.formData()` has already parsed the body by + // the time the permission is checked, so an anonymous caller can still make + // the server read 19 MB; only the 15/min-per-IP limiter bounds that. Nothing + // is stored and no URL comes back, which is what the check is for — moving + // it ahead of the parse would be a separate change. expect(res.status).toBe(401); }); diff --git a/v5/src/components/ProjectSubmitForm.tsx b/v5/src/components/ProjectSubmitForm.tsx index 4ca880c..dae5b28 100644 --- a/v5/src/components/ProjectSubmitForm.tsx +++ b/v5/src/components/ProjectSubmitForm.tsx @@ -149,6 +149,11 @@ export function ProjectSubmitForm({ tools }: ProjectSubmitFormProps) { // pictures is still worth having (Article 4). throw new Error(t("uploadsUnavailable")); } + // A photo picked while signed out — routine now that an unreachable + // `/api/identity` leaves the form up rather than the sign-in wall. + // The route answers in English; every other string on this page is in + // the reader's language, so the translated one wins here too. + if (res.status === 401) throw new Error(t("signInRequiredError")); if (!res.ok) { const data = (await res.json().catch(() => null)) as | { error?: string } diff --git a/v5/src/lib/auth/floor-role.test.ts b/v5/src/lib/auth/floor-role.test.ts index 074458f..5c4c222 100644 --- a/v5/src/lib/auth/floor-role.test.ts +++ b/v5/src/lib/auth/floor-role.test.ts @@ -59,7 +59,7 @@ describe("reconcileSuperAdminFloor", () => { const person = await seedUser({ email: "founder@cornell.edu", role: "user" }); vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); - expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(true); + expect(await reconcileSuperAdminFloor(identityFor(person))).toEqual({ changed: true, audited: true }); expect(await roleOf(person.id)).toBe("super_admin"); }); @@ -67,7 +67,7 @@ describe("reconcileSuperAdminFloor", () => { const person = await seedUser({ email: "founder@cornell.edu", role: "super_admin" }); vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); - expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(false); + expect(await reconcileSuperAdminFloor(identityFor(person))).toEqual({ changed: false, audited: true }); expect(await listAuditEvents()).toEqual([]); }); @@ -84,7 +84,7 @@ describe("reconcileSuperAdminFloor", () => { }); vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); - expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(true); + expect(await reconcileSuperAdminFloor(identityFor(person))).toEqual({ changed: true, audited: true }); const row = await rowOf(person.id); expect(row?.banned).toBe(false); @@ -123,7 +123,7 @@ describe("reconcileSuperAdminFloor", () => { }); vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); - expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(true); + expect(await reconcileSuperAdminFloor(identityFor(person))).toEqual({ changed: true, audited: true }); expect(await roleOf(person.id)).toBe("super_admin"); expect((await rowOf(person.id))?.banned).toBe(false); @@ -151,7 +151,7 @@ describe("reconcileSuperAdminFloor", () => { }); vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); - expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(false); + expect(await reconcileSuperAdminFloor(identityFor(person))).toEqual({ changed: false, audited: true }); expect((await rowOf(person.id))?.banned).toBe(true); }); @@ -159,14 +159,14 @@ describe("reconcileSuperAdminFloor", () => { const person = await seedUser({ email: "someone@cornell.edu", role: "user" }); vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); - expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(false); + expect(await reconcileSuperAdminFloor(identityFor(person))).toEqual({ changed: false, audited: true }); expect(await roleOf(person.id)).toBe("user"); }); it("does nothing at all when no floor is configured", async () => { const person = await seedUser({ email: "founder@cornell.edu", role: "user" }); - expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(false); + expect(await reconcileSuperAdminFloor(identityFor(person))).toEqual({ changed: false, audited: true }); expect(await roleOf(person.id)).toBe("user"); }); @@ -176,7 +176,7 @@ describe("reconcileSuperAdminFloor", () => { const person = await seedUser({ email: "outsider@example.com", role: "user" }); vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "outsider@example.com"); - expect(await reconcileSuperAdminFloor(identityFor(person))).toBe(false); + expect(await reconcileSuperAdminFloor(identityFor(person))).toEqual({ changed: false, audited: true }); expect(await roleOf(person.id)).toBe("user"); }); @@ -190,14 +190,14 @@ describe("reconcileSuperAdminFloor", () => { name: null, rateLimitKey: "ip:abc", }; - expect(await reconcileSuperAdminFloor(anonymous)).toBe(false); + expect(await reconcileSuperAdminFloor(anonymous)).toEqual({ changed: false, audited: true }); }); it("does nothing when the id names no row", async () => { vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", "founder@cornell.edu"); const ghost = identityFor({ id: "deleted-mid-request", email: "founder@cornell.edu" }); - expect(await reconcileSuperAdminFloor(ghost)).toBe(false); + expect(await reconcileSuperAdminFloor(ghost)).toEqual({ changed: false, audited: true }); expect(await listAuditEvents()).toEqual([]); }); }); diff --git a/v5/src/lib/auth/floor-role.ts b/v5/src/lib/auth/floor-role.ts index 1e0bc0b..01d49dd 100644 --- a/v5/src/lib/auth/floor-role.ts +++ b/v5/src/lib/auth/floor-role.ts @@ -2,7 +2,7 @@ import "server-only"; import { eq } from "drizzle-orm"; -import { recordAuditEvent } from "../data/audit"; +import { recordAuditEvent, type NewAuditEvent } from "../data/audit"; import { findUserById } from "../data/users"; import { getDb } from "../db/client"; import { user } from "../db/schema/index"; @@ -61,26 +61,46 @@ import { isSuperAdminFloor } from "./super-admins"; * comes off the row, the recovered director can use the session they already * have and nothing else. * - * Returns true when a row was changed. A no-op — and one cheap query — for - * everybody else, which is every caller in a deployment with no floor set. + * Reports what happened rather than returning a bare flag, because the two + * halves fail independently: `changed` says whether a row moved, `audited` + * whether the trail records it. * - * Throws on a database failure. The caller is about to perform a write that - * depends on this having happened, so a silent failure here would surface as - * the same unexplained `failed` this function exists to remove. + * Throws on a failure of the row UPDATE itself. The caller is about to perform + * a write that depends on this having happened, so a silent failure there would + * surface as the same unexplained `failed` this function exists to remove. + * + * It does **not** throw when only the audit write fails, and that asymmetry is + * the whole point. The UPDATE has already committed by then — a promotion, and + * possibly a ban lifted — so throwing would hand the caller `failed` for a + * change the database has kept, which is the "nothing was changed" lie Article 4 + * forbids. The gap travels back as `audited: false` and reaches the admin as a + * warning on a success, exactly as `actions.ts` treats its own audit writes. */ -export async function reconcileSuperAdminFloor(identity: Identity): Promise { - if (!identity.userId) return false; - if (!isSuperAdminFloor(identity.email)) return false; +export type FloorReconciliation = { + /** True when a row was changed. */ + changed: boolean; + /** False when a change landed but the audit trail did not record it. */ + audited: boolean; +}; + +/** Nothing to do — and so nothing to record. */ +const UNCHANGED: FloorReconciliation = { changed: false, audited: true }; + +export async function reconcileSuperAdminFloor( + identity: Identity +): Promise { + if (!identity.userId) return UNCHANGED; + if (!isSuperAdminFloor(identity.email)) return UNCHANGED; const stored = await findUserById(identity.userId); // No row (deleted mid-request): nothing to write. - if (!stored) return false; + if (!stored) return UNCHANGED; const promote = stored.role !== "super_admin"; const lift = stored.banned; // Already correct — the common case by far, once the first reconciliation // has happened. - if (!promote && !lift) return false; + if (!promote && !lift) return UNCHANGED; const db = await getDb(); await db @@ -98,8 +118,10 @@ export async function reconcileSuperAdminFloor(identity: Identity): Promise { + try { + await recordAuditEvent(event); + return true; + } catch (err) { + console.error("[auth/floor] audit write failed after the row changed", err); + return false; + } } From 57d7ea3e71e7f06e667c3b973120a187b1460e55 Mon Sep 17 00:00:00 2001 From: Isaac S Date: Tue, 22 Sep 2026 17:19:46 -0400 Subject: [PATCH 4/4] =?UTF-8?q?Amendment:=20=C2=A73.4=20settled,=20Phase?= =?UTF-8?q?=206's=20engine=20confirmed,=20the=20plan=20resized?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor beats a ban, decided rather than left open: the alternative makes the documented recovery a dead end, and the cost is already held by anyone who can deploy. The one case the environment variable cannot rescue — a ban applied before the address joined the list, whose sessions are already gone — is written down rather than fixed. Phase 6 keeps the Workflow SDK. eve was considered and rejected: it consumes the same SDK rather than replacing it, and it wants ai@7, Node 24 and an HTTP target for its evals, none of which this app can give it. The phase is sized for Hobby's 300s step ceiling — four searches and four fetches, two steps, a 240s abort, 25 items a batch — and carries the two traps found while checking: mapWithConcurrency diverges on replay, and vi.mock cannot reach step code. Phase 7 leaves the plan (Isaac and Luis, not code) and Phase 9 waits until the app is otherwise good. Phase 8 stays, and still needs its two open questions answered. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE --- .../2026-09-14-v5-data-platform-design.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/docs/specs/2026-09-14-v5-data-platform-design.md b/docs/specs/2026-09-14-v5-data-platform-design.md index 7ae74d7..35f3bc8 100644 --- a/docs/specs/2026-09-14-v5-data-platform-design.md +++ b/docs/specs/2026-09-14-v5-data-platform-design.md @@ -1254,3 +1254,110 @@ write — both are proved at unit and component level only. **Status.** Accepted, except the §3.4 sentence, which is open. Everything else is a detail the spec left to implementation. + +### 2026-09-22 — §3.4 settled, the floor's own audit gap closed, and Phase 6's engine confirmed + +**§3.4 is settled: the environment variable wins.** The sentence left open by the previous +amendment — §3.4 says both "resolves as `super_admin` whatever its row says" and "a banned user +resolves to anonymous", which conflict when a floor address is banned — is decided in favour of +the floor. A banned address named in `AUTH_SUPER_ADMIN_EMAILS` resolves `super_admin`, and +`reconcileSuperAdminFloor` lifts the ban off the row on that person's first admin write. + +The reason is that the alternative is a dead end. The floor exists so the lab can always recover; +if a ban outranked it, the documented recovery — add the address, redeploy — would leave the +person still locked out, with no UI able to lift the ban and a manual `UPDATE` the only way +back. The cost is that anyone who can edit the production environment can un-ban themselves, +which is already true of anyone who can deploy, and who could reach the database directly +regardless. Decided by Isaac, 2026-09-22. + +**One hole remains, and it is narrow.** `auth.api.banUser` deletes the target's sessions, so if a +ban was applied through the app *before* the address was added to the floor, there is no session +left to carry the override and `session.create.before` refuses a fresh sign-in with +`BANNED_USER`. The app refuses to ban an address already on the floor, so reaching this state +takes a manual `UPDATE`, a restored backup, or a late addition to the list. Recovery there is +`UPDATE "user" SET banned = false` by hand. Documented rather than fixed: closing it means +running ahead of a plugin hook that better-auth pushes in front of the app's own. + +**The floor's own audit writes are guarded now.** Adversarial verification of `3c76839` found the +guard it added to `app/admin/users/actions.ts` missing one function away. +`reconcileSuperAdminFloor` committed its row `UPDATE` and then wrote two audit events unguarded, +so an unreachable `audit_events` threw past a committed change; `authorize()` caught it and +returned `failed`, which the page renders as "That did not save. Nothing was changed" — over a +row that had just been promoted and un-banned. A ban lifted with no trail, reported as nothing +having happened, which is the Article 4 lie in its purest form. + +The function now returns `{ changed, audited }` rather than a bare boolean and guards its audit +writes the way `actions.ts` guards its own. The gap travels back through `authorize()` as the +existing `audit_unavailable` warning, so both halves of a two-write action answer the admin's one +question — *did the trail record this* — with one warning. Only the row `UPDATE` itself still +throws. `actions.audit.test.ts` covers both shapes and both were confirmed red against the +unguarded version. + +**Phase 6's engine is confirmed: the Workflow SDK, as §3.7 specifies.** Re-decided rather than +assumed, because the question was reopened. Every API name in §3.7 is still current against +`workflow@4.8.9`, and the composition was verified by building a scratch app on this project's +exact stack — Next 16.1.6, Turbopack, `cacheComponents: true`, `next-intl` — where +`withWorkflow(withNextIntl(nextConfig))` compiles clean. Three corrections to §3.7: +`@workflow/world-vercel` is never installed (it is selected automatically); `@workflow/world-postgres` +needs a long-lived polling worker and is a real escape hatch rather than a config flip; and +`maxRetries` is a property on the step function (`researchItem.maxRetries = 2`), not an option. + +**eve was considered and rejected.** eve is a consumer of the Workflow SDK — "every session runs +as one durable workflow" — not an alternative to it, so adopting it would mean taking this same +layer plus an agent runtime, a session model and a second deploy surface (it runs as a peer Nitro +service, not a library). Three hard blockers independent of that: it requires `ai@^7` as a +non-optional peer dependency against this app's `ai@^6`, forcing an AI SDK major upgrade across +the live chat surface for a background job; it requires Node 24; and its eval runner always +targets an HTTP URL in a separate process, which Article 3's "every test passes with no +environment variables and no network" cannot accommodate. It is also in preview. Worth +revisiting only if the assistant itself ever becomes a durable multi-channel agent. + +**Phase 6 is sized for the Hobby plan.** The binding constraint is not Workflow but the Function +duration behind each step: Hobby caps it at 300s with no extension, and a model call with eight +web searches and eight fetches can exceed that. Decided by Isaac, 2026-09-22: stay on Hobby and +engineer around it. + +- **Four searches and four fetches per item**, not eight and eight. Halves the cost, halves what a + retry re-buys, and fits inside 300s with room. Thin results surface as low confidence, which is + the behaviour gate §5.4 already describes — never as an invented answer. +- **Two steps per item** — search, then fetch and verify — so neither alone approaches the ceiling. +- **A 240s `AbortSignal` inside each step**, so a slow item fails cleanly into `research_error` + rather than being killed mid-flight by the platform. +- **25 items per batch**, matching the per-request limit §5.4 already sets, rather than 100. + +**Two things Phase 6 must not do.** `mapWithConcurrency` (`src/lib/capabilities/intake.ts`) must +not move into the workflow function: it is a shared-cursor worker pool whose index claims depend +on completion order, so a replay can issue a different sequence of step calls and diverge. The +chunked `Promise.allSettled` in §3.7's sketch is correct and is deterministic. And `vi.mock()` +does not reach step code — `@workflow/vitest` loads steps from a pre-built esbuild bundle through +native `import()`, outside Vite's module graph — so this project's `vi.mock("ai")` pattern must +become an MSW handler on `api.anthropic.com` at that tier. MSW *does* reach step code, verified, +so the no-network guarantee holds. Testing steps and the workflow function as plain functions in +the existing config needs no new infrastructure and covers everything except retry semantics. + +**A scope correction to §3.7's "reused code".** Today's `research_tool` makes no model call at +all — the chat model does the searching with its native tools, and `research_tool` only dedupes, +verifies links and scores confidence. So `verifyResourceLinks` and `confidence.ts` genuinely +move, but the server-side research prompt and its `generateText` call are **new code**. Phase 6 is +larger than "lift and shift". + +**`research_error` is the diagnosis record, not the dashboard.** vercel/workflow#3373 (run history +on Next 16 with Turbopack) is still open, and Hobby retains run history for one day. §3.7 already +says the column is the record; build as though the dashboard does not exist. + +**Phase plan changes.** Decided by Isaac, 2026-09-22: **Phase 7 leaves the build plan** — Isaac and +Luis will review the imported inventory on their own time, and it was never code. **Phase 9 is +deferred** until the app is otherwise in good shape, then layered on; English-only keys with the +`withEnglishFallback` behaviour remain correct in the meantime. Phase 8, the Notion mirror, stays. +Open questions 4 and 5 — reporter names in the mirror, and whether the mirror key is derived from +`AUTH_SECRET` — are still unanswered and are due before Phase 8. + +**Residual risks accepted, not fixed.** A lost audit event survives only as a `console.error`: no +retry, no outbox, so the gap in `audit_events` is invisible to anyone reading the table later. +`report_issue` still reports photo loss only when *every* photo is lost, where `POST /api/projects` +now reports partial loss — the same Article 4 hole, on the other side, and worth closing when the +maintenance path is next touched. `POST /api/uploads` parses the multipart body before checking the +permission, so an anonymous caller can make the server read up to 18 MB before its 401; nothing is +stored and no URL is returned, and the 15/min-per-IP limiter is what bounds it. + +**Status.** Accepted. §3.4 is no longer open.