Admin
/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.
-
Set the two secrets.
pnpm cf:setupgenerates and uploads them; by hand, or to rotate one:Terminal pnpm cf:secrets ADMIN_SECRET TOKEN_SECRETEach prompt ends with
✔ <n> characters received — setting it on <worker>… done.ADMIN_SECRETneeds 32 characters or more andTOKEN_SECRETa different value;node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"makes one. -
Name the operator.
site.config.ts admin: { email: 'you@acme.com', sessionHours: 12 },emailreceives the new-submission notification and the daily digest; unset, both go tosite.contactEmail.pnpm check:configanswerssite.config.ts OK — <name> (<url>). -
Deploy and sign in.
Terminal pnpm deployOpen
https://<your domain>/admin/login/and paste the secret. You land on/admin/?ok=signed-inwith 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.
Verify
Section titled “Verify”| 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 |
How it works
Section titled “How it works”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.
Sessions and the audit log
Section titled “Sessions and the audit log”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.
The configuration report
Section titled “The configuration report”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. |
The hourly jobs
Section titled “The hourly jobs”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.
When it is off or degraded
Section titled “When it is off or degraded”| 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 |
Configuration
Section titled “Configuration”| 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 |
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