spomeni/docs/admin.md
dimitar 9c354543fc
Some checks are pending
CI / build (push) Waiting to run
docs: Phase 7 — reconcile drift, delete scratch, rewrite admin.md
Reconcile the docs with the shipped implementation. The exploration
flagged three docs as out of sync; one was a scratch file.

- docs/test.md deleted. File content was literally the four bytes
  'aaaa' — a debugging leftover that had no business in the repo.
- docs/admin.md rewritten end-to-end. The old file still described
  the removed hardcoded super/admin credentials ('SuperAdmin will
  log in with hardcoded username: super and password:admin'). The
  new version covers roles, the dual-auth model, the
  requireAdmin/requireAdminPost/requireSuperAdminPost guard flow,
  env-based super-admin provisioning (with the bcrypt-hash generation
  command), code generation + the atomic-claim guard, the
  Code.createdById onDelete:Restrict decision, and the rate-limit
  surface — i.e. all of the Phase 1–6 decisions in one place. It
  is now the authoritative source of truth for the admin flow.
- docs/description.md (the 324-line original architecture plan) gets
  a banner at the top listing every known delta with the actual
  implementation: storage (S3 not UploadThing/Vercel Blob),
  upload MIME allow-list, dual auth (Clerk + custom HMAC), production
  deploy (Coolify/Traefik instead of Vercel), subdomain routing
  (Host header, not X-Subdomain), schema notes for the deliberate
  String? bornDate/passedDate, rate limiting + CSRF enforcement, and
  in-house QR rendering. The body of the document is preserved as
  historical design context.
- docs/adminImplem.md (the 259-line original implementation plan)
  gets a similar banner — most notable is that the cookie path is
  actually '/' not '/admin', and the rolling helpers in
  admin-session.ts supersede the per-route inline guards the doc
  describes. Body preserved as history.

No source changes — docs only.

Note: docs/db.md is left untouched per the user's instruction to defer
secret rotation to a later stage. It still contains an in-repo DB
password; that and the other .env secrets will be addressed when
rotation happens.
2026-08-02 15:15:50 +02:00

2.3 KiB

Admin flow

Roles

  • SUPER_ADMIN: provisioned from environment. Has full power — creates and deletes regular admins, generates codes, resets passwords.
  • ADMIN: created by a SUPER_ADMIN via the admin panel. Can only generate and delete their own codes.

Authentication

The admin layer uses a custom HMAC-signed cookie, separate from Clerk (which is for end-users only):

  • POST /api/admin/login — verifies credentials, sets an admin_session cookie signed with ADMIN_SESSION_SECRET.
  • POST /api/admin/logout — clears the cookie.
  • src/lib/admin-session.ts — sign/verify helpers, plus requireAdmin / requireAdminPost / requireSuperAdminPost guards that handle auth + same-origin (CSRF) checks for admin API routes.
  • All admin POST routes run through requireAdminPost (or requireSuperAdminPost), which enforces Origin/Referer/Host matching NEXT_PUBLIC_APP_URL.

Super-admin provisioning

Username and bcrypt-hashed password are read from env:

  • SUPER_ADMIN_USERNAME (default super)
  • SUPER_ADMIN_PASSWORD_HASH — bcrypt hash, NOT plaintext. Generate with:
    node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))"
    

Because Code.createdById is a non-nullable FK to AdminUser, the env super-admin also needs a row in AdminUser. Provisioning is handled by prisma/seed.ts, which upserts the SUPER_ADMIN row from the same env values. Run npm run db:seed after starting the DB (or the container's start.sh will keep migrations up to date on boot; seed is run manually or via CI as needed).

Codes

  • A Code is a 12-char uppercase hex string generated by POST /api/admin/codes.
  • Users validate a code via POST /api/validate-code. The claim is atomic (uses updateMany with usedByUserId: null guard) so a code can't be claimed by two concurrent requests.
  • Code.createdById is a non-nullable FK to AdminUser with onDelete: Restrict (Prisma default). You cannot delete an admin who has issued codes — they audit-protect the code trail.

Rate limiting + brute-force defence

  • /api/admin/login: 5 attempts/min per IP (adminLoginLimiter).
  • /api/validate-code: 20 attempts/min per IP (validateCodeLimiter).
  • Other admin mutating routes are also CSRF-guarded.
  • All limiters live in src/lib/rate-limit.ts.