# ADR 0005 — Member trust model (server-only privilege/verification/billing fields) Date: 2026-10-09 ## Status Accepted ## Context The 2026-10-08 pentest (findings #1, #3, #4, #6) confirmed that anonymous or self-authenticated client requests could write privileged fields on `users`: 1. `POST /api/users` accepted client-supplied `emailVerified`, `role`, `onboarded`, `stripeCustomerId`, `verificationToken` — e.g. instant self-verified admin. 2. Self-`PATCH /api/users/{id}` accepted the same fields on update. 3. Per-account login lockout (no time-box) let an attacker lock the admin out of the site indefinitely with wrong-password floods. ## Decision - `role`, `emailVerified`, `verificationToken`, `stripeCustomerId`, `onboarded` are stripped from every **untrusted write** in `Users.beforeChange` (`web/src/collections/Users.ts`). Untrusted = no authenticated admin **and** no `context.systemActivation === true`. On create, the hook forces `role: 'free'`, `emailVerified: false`, `onboarded: false`. - Server-side writers opt back in with `context: { systemActivation: true }` on their local-API `payload.update`/`create` calls. Enumerated writers: `verify-email`, `resend-verification`, `subscribe`, `stripe/webhook` (×2), `payment-success`, `seed`. **Omitting the context silently no-ops the write (the field is deleted) — that is the intended failure mode**, so a missing context is caught in QA rather than leaking privilege. - Login lockout is per-account, time-boxed: 5 failed attempts → 15-minute lock (`auth: { maxLoginAttempts: 5, lockTime: 15 * 60 * 1000 }`). - `role === 'admin'` accounts are **exempt from lockout** (owner decision 2026-10-09): a `beforeOperation` hook on `login` pre-clears `lockUntil`/`loginAttempts` via `db.updateOne` (which, like Payload's own `incrementLoginAttempts`, bypasses hooks). Free/VIP accounts keep the normal 5/15-min lockout. - Commenting requires a verified email; admins are exempt (see the 403 gate in `api/posts/[slug]/comments`). ## Consequences - Every future server-side writer of these fields MUST pass `context.systemActivation`; a writer that forgets it silently loses its write. - The admin password has **no brute-force throttle** — compensate with a strong password. If that posture changes, delete the `beforeOperation` hook and the account falls back to 5/15-min lockout. - Client-sent profile fields (`fullName`, `termsVersion`, `interest`, `level`, `goal`, `prefs`) stay writable; only privilege/verification/billing fields are protected.