- No file exceeds 400 lines
-
npm run buildpasses clean -
npm run lintpasses clean - No TypeScript errors (
npx tsc --noEmit)
- All write/delete API routes return 401 when unauthenticated (see curl below)
-
/admininaccessible to unauthenticated users
- Draft posts not visible on public blog
- Future-dated posts hidden until the date is reached
- Past-date posts show the correct date
- Postgres rows stay consistent after every write/delete (posts/pages/media/files)
-
SUPABASE_URL+SUPABASE_SERVICE_ROLE_KEYset in the target Vercel environment - Deleting a media item moves it to Trash (it leaves the library but is NOT gone — the blob + every variant stay until purged from Trash)
- After publishing a post: detail page at
/{slug}shows the new post - After publishing a post: home page list updates (may need one refresh for ISR)
- After saving settings: header/title/theme updates on next page load
- Editing a post slug: old slug returns 404, new slug works
- Deleting a post: slug returns 404, removed from home list
-
BLOB_READ_WRITE_TOKENformat isvercel_blob_rw_<storeId>_<secret>—blobUrl()will throw at runtime if the token is malformed or missing
- Home
/page/2works;/page/1and out-of-range/page/999return 404 -
/category/<x>/page/2and/tag/<x>/page/2work; page links carry no?query - Blog list shows reading time when the readingTime feature is on
- Uploading several images at once: ALL appear (no dropped entries)
- Re-uploading a same-named file creates
name-2, never errors - Dragging image(s) into the editor inserts every one, in order
- Image upload works, appears in media library immediately (original + thumb)
- An inserted image ALWAYS shows on the post (even before variants exist — it falls
back to a plain
<img>of the original;<picture>only appears once variants exist) - On save, jpg/png get
-1024/-1600AVIF+WebP variants; post renders<picture> - Browser is served AVIF where supported (DevTools → Network on a post)
- "Check unused" badges media referenced by no post/page/settings/revision (read-only, deletes nothing); the "show unused only" filter appears when any are found
- Deleting an image moves it to Trash (blob kept); purging it from Trash removes the original + thumbnail + every variant
- Favicon / app icon upload accepts
.icoand lands infiles/(not the media grid) - Post publish works, appears on blog immediately
- Deleting a post/page/media/file moves it to Admin → Trash; it leaves the live site/library
- Restore returns the item to live (post/page reappears on its URL + lists)
- "Delete permanently" / "Empty trash" purges (posts: row+revisions; media/files: row+blobs)
- Nothing in Trash disappears on its own (no auto-purge)
- A published post linking a trashed image still renders that image (blob kept until purge)
- MCP toggle OFF (Admin → Settings → Advanced) →
GET /api/mcpreturns 401 - Generate a token: shown once, appears in the list (name + prefix), max 5 enforced, delete works
- With the toggle ON, a
Authorization: Bearer <token>MCP client lists/creates/updates posts -
update_settingsonly changes title/description/showDescription (sensitive settings refused) -
/.well-known/oauth-protected-resource+/.well-known/oauth-authorization-serverreturn JSON
- One-time: Google Drive API enabled on the
AUTH_GOOGLE_IDCloud project +https://<domain>/api/backup/callbackadded as an Authorized redirect URI - "Connect Google Drive" → consent → returns to Settings → Advanced showing connected
- "Back up now" creates a
.tar.gzin the Drivequire-backupsfolder; it appears in the list with a size - The refresh token never appears in the client:
GET /api/settings/ page source has no Drive token (only enabled/interval/keep) - Retention: with N snapshots >
keep, a new run prunes to the newestkeep - Restore (on a throwaway/staging site) replaces content from the snapshot; a pre-restore snapshot is created first
- Toggle OFF (or disconnect) → the cron no longer creates snapshots
-
node scripts/docker/gen-keys.mjs >> .env.dockerthendocker compose up -d --buildboots the full no-cloud stack (app + db + rest + cron); image builds with no backend env, and no compose${...}interpolation warnings appear (every value is read from.env.docker) - First boot applies
scripts/schema.sql+docker/initdbroles/grants;service_roleJWT reaches every table (sign in, create/list a post) — no PostgREST permission errors inrestlogs - Text survives a restart:
docker compose down && up -dkeeps posts (volume./data/postgres) - Analytics dashboard loads (the
analytics_summary/analytics_totalsRPCs resolve via PostgREST) -
STORAGE_DRIVER=local: uploading an image writes under the/app/uploadsvolume and renders at/uploads/...(original + thumb + variants); it survivesdocker compose down && up - Large upload (>4.5 MB) succeeds — the browser posts to
/api/media/upload(no serverless cap) - Deleting then purging media removes the files from the volume (no orphaned binaries)
- The cron sidecar reaches
/api/cronhourly with theCRON_SECRETbearer (keep-alive + sweep) - Backup "Back up now" produces a
.tar.gzwhoseblob/holds the volume's files (driver read) - OG card: a post's featured image + custom font load (absolute
<SITE_URL>/uploads/...URLs) - The Vercel deploy is unaffected: no
STORAGE_DRIVERset there → still Vercel Blob
- Desktop: sticky left sidebar with icons; active route highlighted; controls pinned at the bottom
- Collapse toggle → icon-only rail; state persists across navigation (localStorage); tooltips show
- Mobile: hamburger opens a drawer with the same links + controls (always icon+label)
- Settings + editor save bars sit to the right of the sidebar (not under it) at any collapse state
- Header rows align on one line: every item (incl. the wordmark) is an
h-9/h-10items-centerbox; the row isitems-center(neveritems-baseline) - Sibling controls share ONE class constant — admin bar
ADMIN_NAV(components/admin/headerActions.ts), public icon buttonsICON_BTN(components/ui/iconButton.ts); grep the literal class string to catch a new hand-rolled copy - Public reading UI uses theme tokens only (
bg-bg/text-text/text-meta/border-rule…), no hardcodedneutral-*/hex/white/black - One
<hr>divider style (global 50% left rule); no bespokeborder-tdividers; nouppercase - Palette switch + light/dark/system/by-time both apply instantly with no FOUC on reload
- No hardcoded font sizes on the PUBLIC site:
grep -rE "text-\[|text-(xs|sm|base|lg|xl|[2-6]xl)\b" src/components/blog 'src/app/(blog)'returns only the brand wordmark (text-lg) + 404 numeral (text-6xl); everything else uses.fs-h*/.t-small/.proserole vars -
globals.css :root--fs-*/--lh-*/--ls-*defaults EXACTLY mirrorDEFAULT_TYPOGRAPHYinlib/themes.ts(fresh install must match a saved-default site) - Settings → Appearance → text sizes: editing a role updates the public site after save + reload; "reset to default" returns to the tuned scale
- List-card titles (H2) read as headings, not banners; single-post/page/category titles (H1) step up
- One typeface everywhere on the reading site — code blocks render in the site font, not monospace
- Custom font: uploading per weight (400/500/600/700) registers
@font-face; bold/headings render with the real weight (faux-bold is disabled); removing all weights falls back to Inter
# All should return {"success":false,"error":"Unauthorized"} with 401
curl -s -X POST localhost:3000/api/posts -d '{}'
curl -s -X PUT localhost:3000/api/posts/test -d '{}'
curl -s -X DELETE localhost:3000/api/posts/test
curl -s -X POST localhost:3000/api/media/upload
curl -s -X DELETE "localhost:3000/api/media/by?url=x"
curl -s -X POST localhost:3000/api/files/upload
curl -s localhost:3000/api/media/unused # GET, owner-only audit- Model = ISR pages + full purge on save. After an admin save, a plain reload of the
public page must show the change (the save calls
revalidatePath('/', 'layout')). npm run buildshould show/and/[slug]as○/●(ISR), admin asƒ(dynamic). If/[slug]isƒ, the Blob reads got set tono-storeagain (that breaks ISR).- Do NOT add
unstable_cacheback orcacheComponents: true. Do NOT setblob.tsreads tocache: 'no-store'— keep{ next: { revalidate } }so pages stay ISR-eligible. - The "Clear all cache" button must purge + warm (returns
{ warmed }). - Change blog settings (e.g. background color) → reload public site shows it immediately.