Admin and comments
The admin surface (`/admin`, `/admin/posts`, `/admin/posts/new`, `/admin/posts/:id/edit`, `/admin/categories`, `/admin/comments`) is a real React app — see [`src/app/admin/`](https
Tiếng Việt: docs/vi/admin-and-comments.md
Current status: implemented and tested. The sign-in boundary, CRUD contract, and comment moderation described below exist in source and are covered by unit/contract tests (see tests/server/auth.test.ts and tests/server/comments.test.ts). A real admin account has been bootstrapped against the provisioned Neon database. The compiled-browser gate verifies the end-to-end publish and moderation path; exploratory testing of every admin screen remains an operator responsibility.
Admin sign-in model
- Authentication uses Better Auth with exactly one admin identity — see
src/server/auth.tsandsrc/server/auth-policy.ts. - There is no public sign-up, no self-registration, and no email verification or password-reset flow.
disableSignUpis set on the Better Auth policy, and the only account that can ever exist is created by the operator runningnpm run db:bootstrap(see Getting started). Running bootstrap attests that the operator controls the identity behindADMIN_EMAIL— there is no automated verification step, and none is invented by this documentation. - Sessions expire after 8 hours and refresh after 1 hour of use. Cookies are marked
Secureautomatically wheneverBETTER_AUTH_URLis anhttps://origin. - Every mutation carries its own server-side role check (
isAdminUser) insrc/server/router.ts— hiding admin controls in the React UI is never treated as authorization on its own. - Sign-in itself is rate-limited: 15 attempts per 15 minutes, tracked separately by hashed IP and hashed email.
Category and post CRUD contract
The admin surface (/admin, /admin/posts, /admin/posts/new, /admin/posts/:id/edit, /admin/categories, /admin/comments) is a real React app — see src/app/admin/ — talking to the /api/admin/* routes:
- Post fields, validation, and the slug/cover/SEO/draft-publish workflow are documented in Markdown editor.
- Category delete is rejected with
409 Conflictwhen posts still reference it, unless a deliberate reassignment/archive step is taken first. - Post and category delete archive by default — the "Archive" action in the UI sets
isArchived/status: archivedrather than removing the row. There is no hard-delete button for posts or categories in the current admin UI. - Every write records an audit event (actor, action, entity, before/after summary, request ID) per the
audit_eventtable insrc/server/schema.ts. - Updates use optimistic concurrency via
expectedUpdatedAt; a stale write is rejected as a409conflict rather than silently overwriting a concurrent edit.
Anonymous comments
Comments never require an account. A reader supplies only an email address and a message:
| Field | Rule |
|---|---|
email | Required, valid email, up to 320 characters. Stored encrypted (emailCiphertext, AES-256-GCM) plus a hash (emailHash) used for deduplication and rate limiting — never returned to public clients. |
body | Required, 1–5,000 characters. |
formToken | A server-issued, HMAC-signed token (GET /api/comments/token) valid for 15 minutes; submissions with a missing, expired, or invalid token are rejected. |
honeypot | An optional hidden field, up to 200 characters. A filled-in honeypot marks the submission as spam — it is one signal among several, never the only check. |
See src/server/comment-contract.ts for the exact validation and src/server/comment-policy.ts / src/server/comments.ts for the moderation service.
Moderation state machine
Every comment enters as pending or spam — never directly approved:
pending ──► approved
└─► rejected
└─► spam
An admin moderation action carries a status, an optional reason, and the same expectedUpdatedAt optimistic-concurrency guard used for posts.
Anti-abuse layers — what's real today
- A server-issued, signed, time-boxed form token rejects stale or forged submissions.
- A honeypot field is present but never relied on alone.
- Rate limiting is atomic and database-backed (
rate_limit_buckettable), keyed by hashed IP and hashed normalized email — not a client-side counter. - Raw IP addresses and email addresses are never logged in plain text; only HMAC hashes are stored for rate limiting, and the email itself is encrypted at rest for moderator use only.
- Turnstile/CAPTCHA is not active. The environment schema accepts a
TURNSTILE_SECRET_KEYvalue, but no code path currently verifies a Turnstile token. Do not describe comments as CAPTCHA-protected until that integration exists — see Configuration.
Comment delete is real deletion
Unlike posts and categories, deleting a comment from the moderation queue permanently removes the row — there is no archive state for comments. This is an explicitly audited action, not a soft delete.
Next: Deployment.