EXEPERT Blog CMS
The landing blog uses Better Auth as its only identity authority and the
existing vsfajebxloenaertaten Supabase project as its only data plane. Public
pages read immutable public.blog_article* projections. All working copies,
revisions, membership, assets and publication jobs live in app_private and
are reachable only through the canonical app.exepert.com API.
Local workflow
-
Start local Supabase and apply all migrations with
pnpm supabase:reset. -
Start the canonical API with
pnpm dev:authon port 9001. -
Configure
apps/landing/.env.localfrom its example and runpnpm landing:devon port 3001. -
Dry-run the guarded owner lookup:
pnpm.cmd cms:bootstrap-owner -- --email owner@example.com --project-ref local -
Re-run with
--applyonly after the command reports exactly one verified Better Auth principal. The script is idempotent and aborts if the target or single-owner invariant does not match.
Security boundary
CMS_ENABLEDgates every private endpoint.EXEPERT_SHARED_COOKIE_ENABLEDchanges the prefix toexepert_sharedand, in production only, scopes the secure cookie toexepert.com. This causes the intentional one-time sign-in cutover.- Exact trusted origins are
https://app.exepert.com,https://www.exepert.com, and the documented loopback ports in development. - The landing deployment contains
EXEPERT_API_ORIGIN,NEXT_PUBLIC_SUPABASE_URL, andNEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEYonly. - The root API additionally requires
SUPABASE_SERVICE_ROLE_KEYfor direct, non-upsert signed uploads, the enforced ten-minute CMS acceptance window, and media promotion, plus a random 32+ characterCMS_CRON_SECRET. Neither value may enter browser code. - Turnstile must allow
www.exepert.combefore email authentication is enabled there. Google keeps the callbackhttps://app.exepert.com/api/auth/callback/google.
Supabase Storage fixes the lifetime of createSignedUploadUrl tokens at the
platform level. EXEPERT therefore records its own ten-minute acceptance
deadline: after it expires, the private object is deleted and cannot be
normalized, attached, or published. Upload tokens are non-upsert so a
completed normalized object cannot be replaced with the original token.
Publishing model
Autosave writes canonical Tiptap JSON with optimistic expectedVersion
checks. Validation rejects unknown nodes/marks, unsafe URLs, oversized content,
and unready assets. Publishing normalizes draft images to metadata-free WebP,
copies them to a versioned public path, and atomically replaces the selected
public database snapshot. Editing a published post changes only the working
copy until Publish update is selected.
New working copies default to the named byline Akmal Alif. A nullable byline
on an older working copy remains meaningful: the editor and public projection
render it as EXEPERT Editorial. Revision snapshots created before the byline
column existed do not erase the current byline when restored.
Featured articles
The public blog_featured_slot projection stores at most three unique,
published article IDs at positions 1 through 3. Anonymous and authenticated
browser roles have read-only access. The CMS backend validates all candidates
before replacing the order in one transaction. The curator also submits the
order it originally loaded, so a stale browser tab receives a recoverable
conflict instead of overwriting another editor. Unpublishing or archiving a
featured article uses the same transaction lock, removes that slot and compacts
the remaining positions without rewriting slots for unrelated articles.
Editors curate the order from CMS Posts → Featured posts. Before the additive migration is present, that panel reports itself unavailable and the public blog safely falls back to the three newest published articles. Cover metadata is resolved in a single batch for the feature cards and archive rows; missing covers use the EXEPERT pattern rather than a broken image.
Scheduled jobs use UTC in storage and Malaysia time in the editor. The guarded
production setup stores cms_publish_url and cms_cron_secret in Supabase
Vault, then registers a once-per-minute Cron call to
app_private.invoke_cms_publisher(). Each request leases at most one job;
failed jobs retry with backoff up to five attempts.
Production rollout gate
Do not apply this sequence implicitly. Back up and record row counts, apply the additive migration, run database lint/advisors, bootstrap the sole owner, deploy the root API with CMS disabled, deploy landing/proxies, configure Turnstile, Vercel, Vault and Cron, then enable the cookie cutover and CMS. Sign in once and publish/archive a disposable scheduled article before announcing availability.
Rollback disables both flags and promotes the last known-good deployments. Keep additive tables, drafts, media and principal membership; never rewrite an applied migration or drop data as rollback.
For the compact blog release, apply
20260907035505_compact_blog_featured_byline.sql only after the feature-branch
preview and a dedicated local Supabase reset have passed. The migration adds a
nullable private byline, its validation constraints, and the read-only featured
projection. It backfills only the three approved current articles and seeds
their initial display order.
Immediately before the hosted migration, capture the protected post's private and public status, version, publication version, timestamps, and a SHA-256 hash of each JSONB content value using its complete UUID. Repeat the same read-only query after migration, deployment, and final verification, and retain both outputs with the release evidence. A mismatch is a stop condition; do not merge, publish, or attempt an automatic repair.
Production rollback does not drop blog_featured_slot or the byline column.
Roll back the application deployment, leave the additive schema in place, and
restore the prior featured order through the CMS only if the application
release itself must be reverted.