Schema hardening pass. After reading the onboarding/edit forms I
reconsidered the original 'migrate String? -> DateTime?' suggestion:
the bornDate/passedDate placeholders are 'нпр. 1960' and the column
deliberately accepts imprecise values like '1960', 'early 1990s',
'12 May 1960'. Forcing DateTime? would silently break that feature
and require users to enter exact dates they often don't know. The
responsible fix is to keep the free-text semantics but bound the
column length and document the intent in a schema comment. Future
structured-date queries should add a parallel DateTime? column.
Schema changes (prisma/schema.prisma):
- @db.VarChar bounds added to every string column, sized to match
the existing client-side maxLength / app-constant caps:
User.subdomain -> 32, User.title -> 100, User.description -> 2000,
User.bornDate/passedDate -> 50, User.email -> 255, etc.
Image.url/key -> 255, Image.id/userId -> 30.
AdminUser.username -> 50, passwordHash -> 100.
Code.code -> 12, usedByUserId -> 100 (matches Clerk userId scale).
- Image.key: added @@index('Image_key_idx') to support the
/api/image lookup we tightened in Phase 1 (currently a scan).
- updatedAt added to Image, AdminUser, Code (previously only User).
- Inline schema comment on Code.createdById documenting the existing
onDelete: Restrict behaviour (Prisma default) — the migration does
not change it, just makes the audit-trail intent explicit.
Migration:
- 20260802000000_phase6_schema_hardening/migration.sql: explicit
ALTER TABLE ... SET DATA TYPE VARCHAR(N) statements for every
bounded column; ADD COLUMN updatedAt with DEFAULT CURRENT_TIMESTAMP
(so existing rows back-fill immediately); CREATE INDEX for the
Image.key column; a defensive LEFT() truncation UPDATE for any rows
whose bornDate/passedDate exceed 50 chars (the inputs always capped
at 50 on the client side, so this is belt-and-braces). Will be
applied on next deploy via 'prisma migrate deploy'.
Seed:
- prisma/seed.ts: upserts the SUPER_ADMIN row from env
SUPER_ADMIN_USERNAME + SUPER_ADMIN_PASSWORD_HASH. This resolves
the Phase 1 problem where an env-only super-admin had no row in
AdminUser, which meant the Code.createdById FK prevented them from
ever creating codes. The seed is idempotent and safe to run on
every boot (uses upsert).
- package.json: 'prisma.seed' wired to 'tsx prisma/seed.ts'.
- 'db:seed' npm script also added for manual provisioning.
- tsx added as devDependency (executes TypeScript straight from disk
without pre-build).
Establishes the test suite. The repo previously had zero tests and no
test framework installed.
Tooling:
- Vitest 2 added as devDependency. Chosen for ESM-native + TypeScript
out-of-the-box, no Babel/ts-node, and fast cold starts.
- vitest.config.ts sets environment=node and wires the '@/'
path alias so tests can import app modules by the same path the
app uses.
- package.json scripts: 'test' (vitest run, CI-friendly) and
'test:watch'; also adds the long-missing 'typecheck' wrapper for
'tsc --noEmit'.
- tsconfig.json now excludes *.test.ts from the app's build graph so
the production bundle doesn't pull in test files (the editor still
type-checks them via vitest).
Tests:
- admin-session.test.ts (8 cases): sign/verify round-trip for ADMIN
and SUPER_ADMIN, tampered payload rejection (privilege-escalation
attempt — should be rejected because the HMAC no longer matches),
tampered signature rejection, missing-separator token, invalid
base64/JSON payload, and cookie option flags (httpOnly, sameSite,
path, secure under NODE_ENV=test vs production).
- rate-limit.test.ts (4 cases): basic token bucket within window,
independent key tracking, refill after window elapses (fake
timers), and remaining-counter accounting.
12 tests, all green.
Security hardening covering credentials, brute-force protection, CSRF,
TOCTOU races, upload validation, and migration failure handling.
Secret rotation is deferred; the existing secrets in .env will be
rotated in a later phase. This phase reduces the attack surface and
removes the most exploitable issues.
Removed:
- Hardcoded 'super'/'admin' super-admin credentials in
src/app/api/admin/login/route.ts. Username/hash are now loaded from
env (SUPER_ADMIN_USERNAME / SUPER_ADMIN_PASSWORD_HASH) and verified
via bcrypt like regular admins.
Added:
- src/lib/config.ts: single source of truth for APP_DOMAIN,
APP_URL, subdomain regex/lengths, validation bounds, admin password
policy, image-key allow-list regex, and super-admin env credentials.
- src/lib/rate-limit.ts: in-memory LRU (via lru-cache) rate limiters —
admin login (5/min), validate-code (20/min), check-subdomain
(60/min), upload (10/min) — keyed by client IP, returning 429 with
X-RateLimit-* headers.
- requireAdmin / requireAdminPost / requireSuperAdminPost guards in
src/lib/admin-session.ts. All admin mutating routes now enforce
same-origin (Origin/Referer/Host check against NEXT_PUBLIC_APP_URL)
before running — CSRF protection for the custom admin auth layer.
- Logout now imports COOKIE_NAME_ADMIN instead of hardcoding the string.
- Server-side magic-byte detection for image uploads (no new dep) —
rejects spoofed Content-Type. GIF removed from allowed types.
- /api/publish is now transactional (prisma.) with
explicit P2002 → 409 handling for subdomain collisions.
- /api/image validates the key against an allow-list regex and returns
Macedonian error messages (was the only English-localized file).
- /api/validate-code enforces a 12-hex-char pattern and uses
updateMany with usedByUserId=null guard to make the claim atomic.
- /api/check-subdomain validates the slug against the shared regex
before hitting the DB and returns a short private Cache-Control.
- Admin password minimum length bumped from 6 to 12 with letter+digit
complexity requirement across change-password, users POST and
users/[id] PUT.
Changed:
- scripts/start.sh: prisma migrate deploy failures now exit non-zero
instead of silently continuing (prevents schema drift in prod).
- .env.example: documents SUPER_ADMIN_USERNAME /
SUPER_ADMIN_PASSWORD_HASH with example bcrypt-hash generation.
- package.json: lru-cache added as direct dependency (already present
transitively, promoted to explicit).