Skip to content

Admin

Markdown

/admin/ is the review queue and the state of the deployment: what is waiting, what is published, and what the configuration still lacks. It is a Worker route behind one secret, ADMIN_SECRET, on the DB binding that Go live creates.

  1. Set the two secrets. pnpm cf:setup generates and uploads them; by hand, or to rotate one:

    Terminal
    pnpm cf:secrets ADMIN_SECRET TOKEN_SECRET

    Each prompt ends with ✔ <n> characters received — setting it on <worker>… done. ADMIN_SECRET needs 32 characters or more and TOKEN_SECRET a different value; node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" makes one.

  2. Name the operator.

    site.config.ts
    admin: { email: 'you@acme.com', sessionHours: 12 },

    email receives the new-submission notification and the daily digest; unset, both go to site.contactEmail. pnpm check:config answers site.config.ts OK — <name> (<url>).

  3. Deploy and sign in.

    Terminal
    pnpm deploy

    Open https://<your domain>/admin/login/ and paste the secret. You land on /admin/?ok=signed-in with the banner “Signed in.”

On a fresh clone, pnpm dev copies .dev.vars.example to .dev.vars and migrates a local database, so http://localhost:4321/admin/login/ accepts the ADMIN_SECRET in that file — a placeholder every copy shares, which /admin/ reports as an error until pnpm cf:setup replaces it.

What to check Where You should see
Sign-in /admin/login/ the form takes the secret; a wrong one says “That is not the admin secret.”
The report /admin/ a “Configuration” card with only what you left for later — or no card
The jobs Workers & Pages → your Worker → Logs one jobs: … line an hour

Login is one field. With ADMIN_SECRET missing or short the form is disabled and says “Not configured”. A wrong value leaves a login_fail audit row. ?next= survives the sign-in when it is a same-site path.

The dashboard stacks the publishing bar (Publishing on approval), the configuration report, the queue numbers, a “Needs a look” box for stale checkouts and mail that gave up, then the queue: approvable rows only, featured tier first, then express, then the free queue, oldest first. Approve is a one-click form; Reject… links to the listing, because a rejection needs a reason. “Awaiting payment” is a collapsed group you can see but not approve. The page is a fixed set of queries that a Workers test counts against DASHBOARD_QUERY_BUDGET: the free plan gives a request 50 D1 queries and 10 ms of CPU.

Listings browses everything through a GET form, so ?q=&status=&tier=&page= is a URL you can bookmark; q matches name, slug and URL; 50 rows a page.

One listing (/admin/listings/<id>/) is a preview — the real detail component through the real normaliser, so a category removed from site.config.ts reads “This row cannot be rendered yet” — beside one form per decision: approve, with an optional slug; reject, with a reason of at least three characters, enforced in the data layer too, emailed verbatim; edit the reviewable fields and a private note; feature, extend or unfeature; Mark as paid by hand, which writes a payments row with a manual: id; Resend the status email, which queues a fresh message for the row’s current state and flushes the outbox. Delete goes through a confirmation page, removes the row and its R2 objects (R2 is the only object store), and refuses a paid row: money changed hands, so reject it instead and the record survives.

Featured lists what is pinned and what has expired but not been demoted; the build already unfeatures those, and Demote now or the hourly job drops the tier. Stats shows submissions this month, pending, subscribers, unfinished checkouts, revenue by month, and the top outbound clicks of 30 days.

No auth provider: one admin, one secret, one signed cookie. zd_admin is signed with TOKEN_SECRET (never ADMIN_SECRET), carries its expiry inside the signature, and lasts admin.sessionHours. There is no session store: sign-out is a POST from the top bar, and rotating TOKEN_SECRET ends every session. Each write — approve, reject, update, set_featured, unset_featured, mark_paid, resend_email, delete — is its own audit_log row (actor, action, target, JSON detail), so the log says what happened. A failed login stores a salted hash of the address, never the address. The rest is on Security.

configGaps() compares site.config.ts with the secrets the Worker holds. An error is something switched on that cannot work now; a warning is a deliberate but lossy setting. Each row carries the command that closes it, and a correct free-plan site with console mail and manual publishing shows warnings and no errors. Read it after every deploy: each state is silent otherwise.

Severity Example
error Missing secret DEPLOY_HOOK_URL — run: wrangler secret put DEPLOY_HOOK_URL (local: add to .dev.vars). Approvals will not trigger a build.
warning providers.rebuild is "none": approved listings stay off the site until someone publishes.

One cron trigger, 0 * * * * in wrangler.jsonc (the free plan allows five per account), runs runJobs() in this order: expireFeatured demotes listings whose paid window closed and asks for one rebuild; adminDigest queues the daily reminder at queue.digestHourUtc while anything waits; flushOutbox sends up to 20 queued messages one at a time, five attempts each; retryRebuilds refires the oldest failed build request and folds the rest into it; purgeUnpaid deletes unpaid submissions older than limits.unpaidTtlDays, rows before R2 objects; sweepAuth, with features.accounts, deletes expired sessions and sign-in links. A job that throws is counted and the rest still run. Nothing is load-bearing: featured status is recomputed at every build, mail waits, a failed rebuild is a banner with a button. The tick writes one line to Workers Logs (observability is on in wrangler.jsonc; pnpm exec wrangler tail streams it):

jobs: expireFeatured=0 flushOutbox=2 retryRebuilds=0 purgeUnpaid=0 adminDigest=0 (cron=0 * * * *, in 41ms)

skipped: no DB binding, emailFailures=<n> or errors: … join the tail only when there is something to say. Locally a tick runs against the built Worker — pnpm build, then pnpm exec wrangler dev, then curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+*+*+*+*"; under pnpm dev that URL is the site’s 404 page. The review queue has the details.

State Visitor sees /admin/ reports Fix
No Worker (static-only deploy) /admin/ is a 404 Go live
No cookie 302 to /admin/login/?next=… sign in
ADMIN_SECRET missing or short the login form is disabled error pnpm cf:secrets ADMIN_SECRET
TOKEN_SECRET missing every login refused error: nothing can be signed pnpm cf:secrets TOKEN_SECRET
A secret is the example value login works error: “still the value from .dev.vars.example” generate a value, upload it
triggers missing from wrangler.jsonc nothing expires, mail waits no jobs: line restore the cron, deploy
Field Default What it changes
admin.email recipient of the notification and the digest; falls back to site.contactEmail; nothing with providers.adminNotify: 'off'
admin.sessionHours 12 the cookie’s lifetime, 1 to 720 hours

See admin and providers.

  • Directorysrc/
    • Directorypages/admin/
    • Directorytemplates/admin/
    • Directorycomponents/admin/ ConfigGaps, RebuildBar, QueueTable, ListingForms
    • layouts/Admin.astro
    • actions/admin.ts
    • middleware.ts
    • Directoryserver/
      • admin-auth.ts
      • audit.ts
      • queries.ts
      • secrets.ts configGaps()
      • jobs.ts runJobs()
    • worker.ts scheduled()
  • scripts/dev-setup.ts
  • wrangler.jsonc the cron trigger