# Admin

> The /admin/ area page by page — login, the dashboard and its configuration report, listings, one listing's actions, featured, stats, and the hourly jobs.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/submissions/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](https://zerodirs.com/docs/deploy/go-live/) creates.

> **Free edition**
>
> The free edition has no Worker and no `src/pages/admin/`. Nothing on this page exists there.

## Setup

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

   ```sh title="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.

   ```ts title="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.

   ```sh title="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.

## 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

**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](https://zerodirs.com/docs/submissions/publishing/)),
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

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](https://zerodirs.com/docs/run/security/#admin-authentication).

### 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

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):

```text
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](https://zerodirs.com/docs/submissions/queue/) has the
details.

## When it is off or degraded

| State | Visitor sees | `/admin/` reports | Fix |
| --- | --- | --- | --- |
| No Worker (static-only deploy) | `/admin/` is a 404 | — | [Go live](https://zerodirs.com/docs/deploy/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

| 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`](https://zerodirs.com/docs/configure/reference/#admin) and [`providers`](https://zerodirs.com/docs/configure/reference/#providers).

## Files

- src/
  - pages/admin/
  - templates/admin/
  - components/admin/ ConfigGaps, RebuildBar, QueueTable, ListingForms
  - layouts/Admin.astro
  - actions/admin.ts
  - middleware.ts
  - server/
    - 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

## Related

- [The review queue](https://zerodirs.com/docs/submissions/queue/) — What arrives, in what order, and what submitters are told.
- [Publishing on approval](https://zerodirs.com/docs/submissions/publishing/) — The build an approval asks for.
- [Security](https://zerodirs.com/docs/run/security/) — The cookie in depth, Cloudflare Access, the WAF rule.
- [Environment and secrets](https://zerodirs.com/docs/deploy/environment/) — Where the two secrets live.
