- README rewritten for the real Next.js + Payload + Postgres stack (old one described the dead static prototype) - Logo + 4 live screenshots (home, articles, post, subscription) - Arabic flow explanations with mermaid diagrams: visitor journey, content pipeline, request path - Architecture table, quick start, API reference, security summary, deploy commands - AGENT.md: orientation for future AI agents (data model, trust rules, rate limits, routes, env, deploy, house rules) - GLOSSARY.md and docs/adr committed (linked from README)
48 lines
2.5 KiB
Markdown
48 lines
2.5 KiB
Markdown
# 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.
|