# Per-page moderator access

Written 2026-09-23 from Nash's brief: "Any of the moderator pages is not available by default to anyone. The main admin can invite a specific user with his email to a specific page, and he has access to that page. He cannot invite anyone else, only the admin can… Maybe I want a guy from marketing to deal with the leads, one with the pilot cars, one with the brokers. Two guys on the AI feedback. And I can do it from the page itself — add a moderator, put the email, send the invite, it gives him access."

## The model

A **surface** is one moderator page plus the API routes behind it. Access is granted per surface, per person, by the platform admin only.

| Surface | Page | Delegatable |
|---|---|---|
| `moderation` | Moderator Dashboard | yes |
| `broker_leads` | Freight Broker Leads | yes |
| `pilot_invitations` | Pilot Invitation Manager | yes |
| `email_templates` | Email Templates | yes |
| `email_health` | Email Health | yes |
| `company_review` | Company Review | yes |
| `intake_security` | Intake Security | yes |
| `users` | Users | **no** — it hands out roles, passwords and MFA resets |

Rules: the platform admin opens everything. Anyone else opens exactly the surfaces granted to them. A moderator can never grant, revoke or even see the access list. A customer sees no moderator pages at all and is redirected to their dashboard, with the refusal logged as `moderator_access_denied`.

## Where things live

| Piece | Path |
|---|---|
| Surface registry + rules | `src/lib/domain/moderator-access.ts` · tests `tests/moderator-access.test.ts` |
| Grants, invitations, audit | `src/lib/data/moderator-access.ts` |
| Page and API gate | `src/lib/auth/surface-guard.ts` (`requireSurface`, `requireSurfaceApi`) |
| Access card ("Add moderator") | `src/components/app/moderator-access-card.tsx` |
| Grant / revoke / resend API | `src/app/api/admin/moderators/` |
| Emailed-code sign-in | `src/app/api/auth/email-code/` + the login page |
| Invitation template | `moderator_access_invitation` (transactional, account security) |
| Migration | `supabase/migrations/0032_moderator_access.sql` · `scripts/verify-migration-0032.mjs` |

## How a grant works

1. The admin opens any moderator page and expands **Who can open this page**.
2. Types an email, optionally a name and a note, and sends the invite.
3. A row goes into `moderator_grants` keyed by the email, so it works whether or not that person has an account yet. The invitation email names the page, what it lets them do, the note, and how to sign in.
4. On their first sign-in the grant is linked to the account and stamped accepted. `last_access_at` updates each time they open the page.
5. Revoking is immediate: the lookup cache for that address is cleared, so the next request is refused.

Every grant, revoke, resend and acceptance is written to `moderator_access_log` and mirrored into `security_events`.

## Signing in without a password

Invited moderators often have no `AUTH_USERS` entry. The login page offers **Email me a sign-in code instead**. A code is sent only to an address that already has an account or a grant, and the reply is identical either way so nothing leaks about who exists. Verifying the code creates or reuses a self-service account and links any grants.

## Reading the session

`getSessionUser` fills `surfaces` with the granted keys, cached for 30 seconds per address and skipped entirely for an admin. The lookup fails closed: a database that cannot answer grants nothing, and it never throws, because it runs on every request.

## Deferred

Time-limited grants, per-surface sub-permissions (read-only vs. acting), a single admin screen listing every moderator across all pages, and grants tied to a group rather than an address.
