Skip to main content

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

  1. Start local Supabase and apply all migrations with pnpm supabase:reset.

  2. Start the canonical API with pnpm dev:auth on port 9001.

  3. Configure apps/landing/.env.local from its example and run pnpm landing:dev on port 3001.

  4. Dry-run the guarded owner lookup:

    pnpm.cmd cms:bootstrap-owner -- --email owner@example.com --project-ref local
  5. Re-run with --apply only 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_ENABLED gates every private endpoint.
  • EXEPERT_SHARED_COOKIE_ENABLED changes the prefix to exepert_shared and, in production only, scopes the secure cookie to exepert.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, and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY only.
  • The root API additionally requires SUPABASE_SERVICE_ROLE_KEY for direct, non-upsert signed uploads, the enforced ten-minute CMS acceptance window, and media promotion, plus a random 32+ character CMS_CRON_SECRET. Neither value may enter browser code.
  • Turnstile must allow www.exepert.com before email authentication is enabled there. Google keeps the callback https://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.

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.