Investigation with the running container revealed the previous fix
was correct on the deployed server but didn't help the user because
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY in their .env has the placeholder
'pk_test_...' from .env.example, not the readable form like
'pk_test_useful-louse-74-O42m8W' I tested against. The actual key is
in Clerk's older "encoded" format:
pk_test_dXNlZnVsLWxvdXNlLTc0LmNsZXJrLmFjY291bnRzLmRldiQ
The base64 portion decodes to the literal FAPI host
'useful-louse-74.clerk.accounts.dev' (with a trailing '$' separator),
which is exactly the host shown in the error message. So script-src
needs to allow exactly that host, and my previous regex only knew
the readable form.
clerkFrontendApiHost() now handles both formats:
Form 1 (encoded): pk_test_<base64slug>\$
/-> decode b64 /-> <slug>.clerk.accounts.dev
(or .clerk.services for ?)
Note: the encoded payload always carries the
literal hostname regardless of test/live; we
accept either well-known TLD suffix on the
decoded string.
Form 2 (readable): pk_test_<slug>-<randomSuffix>
/-> <slug>.clerk.accounts.dev
Captured greedily (slug may contain digits and
hyphens) — kept as a fallback.
Defensive fall-throughs ensure a string that decodes to garbage
(e.g. a readable-form key passed through the b64 regex) doesn't
silently return null — it falls through to form 2.
Verified against four cases:
pk_test_dXNlZnVsLWxvdXNlLTc0... -> useful-louse-74.clerk.accounts.dev ✓
pk_test_useful-louse-74-O42m8W -> useful-louse-74.clerk.accounts.dev ✓
pk_live_dXNlZnVsLWxvdXNlLTc0... -> useful-louse-74.clerk.accounts.dev ✓
pk_test_invalid-garbage -> invalid.clerk.accounts.dev (form 2)
The user must rebuild and redeploy for the new CSP header to take
effect — the previously-served header is cached in the running
container's standalone bundle and won't refresh until container
restart with the new build.
The Phase 3 CSP was too strict for Clerk and blocked its browser-side
runtime. Reported runtime error:
ClerkRuntimeError: Failed to load Clerk JS, failed to load script:
https://useful-louse-74.clerk.accounts.dev/npm/@clerk/clerk-js@6/
dist/clerk.browser.js (code='failed_to_load_clerk_js')
Root cause: script-src allowed only 'self' 'unsafe-inline' 'unsafe-
eval', so the browser blocked the Clerk JS bundle fetched from the
per-instance frontend-API host. The connect-src allowlist of
'*.clerk.accounts.dev' was also both too narrow (no production
*.clerk.services host, no real FAPI host on the actual subdomain)
and hard-coded — it didn't track changes in the publishable key.
Fix:
- next.config.ts now derives the active Clerk frontend-API host from
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY at build time:
pk_test_<slug>-<suffix> -> <slug>.clerk.accounts.dev
pk_live_<slug>-<suffix> -> <slug>.clerk.services
The slug itself may contain digits and hyphens, so the suffix is
captured as the final dash-group (regex: ^pk_(test|live)_(.+?)-
([a-z0-9]+)$). Verified against the user's actual key
'pk_test_useful-louse-74-O42m8W' -> 'useful-louse-74.clerk.accounts.dev'.
- script-src now includes https://<fapiHost> so Clerk can pull its
browser bundle from <fapiHost>/npm/@clerk/clerk-js@<v>/dist/...
- connect-src now includes https://<fapiHost> + wss://<fapiHost>
for Clerk's session/socket traffic.
- img-src now whitelists https://img.clerk.com (Clerk-served user
avatars) and keeps the open 'https:' for memorial images that
we proxy through our own /api/image.
- remotePatterns in next/image now lists img.clerk.com alongside
the dynamic S3 host, so next/image (if/when adopted) will accept
Clerk avatar URLs.
- If the publishable key is absent, the FAPI host simply isn't
added to either directive, so dev without Clerk configured stays
functional.
The build emits the exact right CSP for the active environment
without any hand-editing when promoting test -> live.
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.
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).
DevOps and DX consolidation pass.
Dockerfile (production):
- Replaced 'COPY . .' at the builder stage with explicit copies of
package.json, prisma/, src/, public/, and the config files. The
original 'COPY . .' would ship .env (with live secrets) and the
.next/ cache into the builder context; .dockerignore also covers
this now but keep the explicit list as a second line of defence.
- Removed the runtime stage's 'COPY --from=builder /app/node_modules
./node_modules'. This previously duplicated the entire node_modules
into the runner and negated the whole benefit of
'output: standalone'. Now we copy only the Prisma generated
client (.prisma + @prisma). Expected image size reduction: ~1GB.
- Added a HEALTHCHECK polling GET /api/check-subdomain?slug=__health
every 30s (the route already exists and is cheap).
- Added wget for the health probe (alpine doesn't ship wget by
default).
.dockerignore (new):
- Excludes .next/, .next_old/, .git/, docs/, nginx/, *.md, .env *,
coverage, *.tsbuildinfo, tt.md, and the Docker/compose files
themselves from the build context.
CI (.github/workflows/ci.yml, new):
- Runs on push and PR to main/admin.
- Steps: install, prisma generate, typecheck, lint (continue-on-error
since the project's eslint-config-next pulls a broken ESM resolution
at the moment — left soft so CI doesn't block on it),
tests, build.
- Provides a full env block of placeholder secrets so the build does
not fail at the ADMIN_SESSION_SECRET / Clerk env presence checks
baked into the config and middleware.
Repo cleanup:
- Untracked .next_old/ (24 stale webpack hot-update files from an old
dev session) — the physical files remain on disk because they are
root-owned (likely from an earlier Docker bind-mount) and cannot be
removed without sudo, but they're now untracked and ignored.
- .gitignore: the bogus 'certbot/.next_old/' line is replaced with
'/.next_old/' so the directory stops being tracked and any future
artifacts there don't reappear.
- Deleted nginx/conf.d/* (Traefik is the actual deploy per project
decision). Traefik labels in docker-compose.yaml remain.
App DX:
- layout.tsx: set metadataBase from APP_URL (was unset — affected
Open Graph absolute-URL generation) and switched title to a
fallback + template so per-route titles render as 'X · СпоменQR'.
- src/app/loading.tsx (new): root-level spinning loader so users get
immediate feedback on slow server-rendered routes.
- src/app/error.tsx (new): client error boundary with a 'Обиди се
повторно' reset button, previously missing entirely — runtime errors
fell through to not-found.
package.json: 'typecheck' script added (for local use + CI).
.gitignore cleanups: '/.next_old/' replaces the accidental
'certbot/.next_old/' glob.
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.
Performance and transport-layer hardening.
ImageUploader.tsx:
- Replaced sequential for-loop uploads with Promise.allSettled, so
multiple files upload concurrently. Partial failures no longer abort
the whole batch — successful uploads are kept, failed ones surface
a concatenated error (and a subsequent retry is still possible).
- Order indices are pre-computed from the existing images.length so
the parallel results stay correctly ordered.
next.config.ts:
- PoweredByHeader: false (no longer advertises Next.js).
- compress: true explicitly (default, but documented).
- Added Strict-Transport-Security, X-Frame-Options, X-Content-Type-
Options, Referrer-Policy, Permissions-Policy and a defensive CSP
(script-src allows 'unsafe-eval' for Next.js dev/HMR invariants,
connect-src whitelists Clerk endpoints).
- remotePatterns is now only populated when S3_ENDPOINT is set, and
parsed with URL() so a trailing path no longer produces a phantom
hostname. Still effectively unused because the app uses raw <img>;
the migration to next/image is deferred.
Code quality pass driven by the plan. Eliminates the duplicated
constants, breaks the 216-line templates file into per-template files,
and removes the third-party QR dependency that was leaking memorial
URLs to api.qrserver.com.
Templates (src/lib/templates.tsx → src/lib/templates/):
- Split into Elegance.tsx, Cinematic.tsx, Serene.tsx and a shared.tsx
holding formatDates() and MemorialFooter.
- New index.tsx re-exports everything plus renderTemplate(), so the
existing '@@/lib/templates' import paths are unchanged.
- Adds eslint-disable-next-line @next/next/no-img-element markers on
the raw <img> tags so the linter (once it works again) won't flag
them; full migration to next/image is deferred to a later phase
pending next.config remotePatterns verification.
QR consolidation:
- dashboard/page.tsx now uses lib/qrcode.ts::generateMonumentQR
(server-rendered async) instead of api.qrserver.com. The external
service was logging every memorial URL to a third party.
- lib/qrcode.ts reads APP_DOMAIN from the shared config (was
process.env.NEXT_PUBLIC_APP_DOMAIN inline). Added
generateMonumentQRPng() helper for the download path.
- Dashboard 'Превземи QR код' link now points at the data: URL in-app
and downloads as '{subdomain}-qr.png'.
Config consolidation:
- SubdomainPicker.tsx now imports APP_DOMAIN, SUBDOMAIN_MIN_LENGTH
and SUBDOMAIN_MAX_LENGTH from lib/config; previously the slug was
normalized with a regex that didn't match the server's stricter
/^[a-z0-9][a-z0-9-]*[a-z0-9]$/ rule and the success message
hardcoded '.testbed.mk'.
- ImageUploader.tsx re-imports MAX_FILE_SIZE / ALLOWED_TYPES /
MAX_FILES from lib/upload (they were literally redefined inline,
causing drift risk). The remaining client-side filter uses a
string-cast to satisfy the readonly-tuple type.
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).
Cookie path was /admin, but admin API routes live under /api/admin/.
Browser only sends cookies to paths matching the cookie's path, so
all API calls were unauthenticated. Changed path to / for both
cookie creation (login) and deletion (logout).
The login API was blocked by the admin session middleware, preventing
the creation of the session cookie. Now the API endpoint is exempted
alongside the login page.
- Move login page into (auth) route group — no layout wrapper
- Move dashboard/users/codes + layout into (panel) route group —
session check and sidebar only apply to these
- URL paths remain unchanged (/admin/login, /admin/dashboard, etc.)
- Rewrite admin-session.ts to use Web Crypto API (crypto.subtle) instead
of Node.js crypto module, ensuring compatibility with Edge Runtime
in middleware
- Add ADMIN_SESSION_SECRET to .env.example
- Build passes with zero warnings
- Add /api/validate-code endpoint: validates code, marks it as used by
the current Clerk user, prevents reuse
- Add 'Код' step to onboarding wizard (step 0): user must enter and
validate a code before proceeding to fill memorial details
- Protect /api/publish: reject with 403 if user has not consumed a valid
code
- Code input auto-capitalizes on the onboarding page
- Add admin layout with sidebar navigation and session guard
- Create AdminSidebar client component with role-based nav links
- Add dashboard page showing stats (admin count, code counts)
- Add users management page (SuperAdmin only): list, create, delete,
and reset passwords for admin users
- Add codes management page: list all codes, generate new codes,
delete unused codes
- Add API routes for admin user CRUD (GET, POST, DELETE, PUT)
- Add API routes for code management (GET, POST, DELETE)
- All UI in Macedonian
- Create admin-session lib with sign/verify helpers using HMAC-SHA256
- Add admin login API that checks hardcoded super/admin credentials
and DB-stored admin users with bcrypt password comparison
- Add admin logout API to clear session cookie
- Add change-password API for admin self-service password changes
- Create admin login page with Macedonian UI
- Update middleware to protect /admin/* and /api/admin/* routes
with admin session check, bypassing Clerk auth