# mod-cms — Deploy & ops checklist

Audience: operator provisioning mod-cms Cloudflare resources and validating CI delivery. Run top-to-bottom
for a fresh environment. Production deploys MUST run through `.github/workflows/delivery.yml`.
§2 (media nosniff) is REQUIRED before the media library serves real uploads.

## 1. Worker + bindings
- A successful `Main Gate` triggers the `Delivery` workflow, which deploys the exact tested SHA from `wrangler.toml`
  (SESSION KV, the **four** rate-limit bindings — `ADMIN_LOGIN_LIMITER` + `ADMIN_API_LIMITER` for the
  admin surface, `COMMENTS_LIMITER` for public comment POST, `FORMS_LIMITER` for public contact form
  POST — the `MEDIA_BUCKET` R2 bucket, `[vars]`).
- NEVER run local `wrangler deploy`; developer machines MUST NOT retain production deploy credentials.
- Secrets (NEVER in `wrangler.toml`): `wrangler secret put DATABASE_URL` plus the auth secrets
  (`AUTH_PEPPER`, `AUTH_SESSION_SECRET`) and the admin/service-token secrets the auth engine expects.
  Seed the first admin with `scripts/seed-admin.ts`.

## 2. Media bucket public-serving hardening (REQUIRED — security-guard P2)
Media objects are served from the **direct public R2 bucket** at `MEDIA_PUBLIC_BASE_URL`
(`wrangler.toml [vars]`). The upload route stores the **detected** Content-Type (magic-byte sniffed,
never the client-declared value) — that is the primary content-type-confusion defense.
`X-Content-Type-Options: nosniff` is the required second layer. For a direct public bucket this is
**serving-layer config, not Worker code**, so it MUST be set on the media custom domain before the
bucket goes public. Realize it ONE of two ways on the zone serving `MEDIA_PUBLIC_BASE_URL`:

**A. Dashboard (Transform Rule):** Rules → Transform Rules → Modify Response Header → Create →
match `Hostname equals <media-host>` → action `Set static` `X-Content-Type-Options` = `nosniff` → Deploy.

**B. CF API (scriptable):** PUT the zone's response-header transform ruleset entrypoint:
```bash
curl -X PUT \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets/phases/http_response_headers_transform/entrypoint" \
  -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"rules":[{"action":"rewrite","action_parameters":{"headers":{"X-Content-Type-Options":{"operation":"set","value":"nosniff"}}},"expression":"(http.host eq \"<media-host>\")","description":"media nosniff (security-guard P2)"}]}'
```

**Verify (MUST show the header before the bucket is public):**
```bash
curl -sI "$MEDIA_PUBLIC_BASE_URL/<any-existing-key>" | grep -i x-content-type-options
# → x-content-type-options: nosniff
```
Do NOT mark the media library shippable until this returns the header.

## 3. Scheduled-publish — Durable Object alarm (in-process, no separate worker)
Scheduled publishing runs as a **Durable Object alarm INSIDE the main `mod-cms-preview` Worker** —
there is **no separate worker and no Cloudflare cron trigger** (DO alarms are free, self-reschedule,
survive eviction, and do **not** count against the per-account cron-trigger cap). The `ScheduledPublishDO`
(`src/do/scheduled-publish.ts`) runs the same `runScheduledPublish` → `promoteScheduled` sweep
(`src/workers/cron/run.ts`, reused) every five minutes; its `alarm()` always reschedules `+5min` in a
`finally` block (fail-closed, no retry storm), so scheduled posts go live within ≤5 min of their
`publishedAt`.

**One shared secret + one CI URL:** set `CRON_DO_SECRET` on the main Worker and as a GitHub
`production` environment secret. Set `MOD_CMS_ARM_URL` in that GitHub environment to the Worker URL.
Use a 32-byte random `CRON_DO_SECRET`; NEVER commit or retain it in a developer shell profile.
`DATABASE_URL` is the **same secret the main Worker already has** (§1) — the DO reads it via `getFullDb`.
No second `DATABASE_URL` to set.

**Deploy + arm is one workflow:** `Delivery` runs `wrangler deploy` then `scripts/arm-after-deploy.mjs`,
which POSTs `/cron-arm` (header `x-mod-cron-secret`, `Content-Type:
application/json` — load-bearing, Astro `checkOrigin` 403s form-type/no-type POSTs) to set the first
alarm; the endpoint returns `armed` and the alarm then self-perpetuates. The arm is idempotent
(`getAlarm() === null` guard) — re-running deploy never double-schedules.

**Build-time gates (in `scripts/patch-do-config.mjs`, after `astro build`):** the Astro adapter
regenerates `dist/server/wrangler.json` with `migrations: []` each build, so the script re-patches the
DO binding + migration, then **grep-gates** the `ScheduledPublishDO` class is in the built entry and
**route-manifest-gates** the `/cron-arm` route is registered (catches a future `_`-prefixed/dropped
route at build time, not live). A failed gate exits 1 — deploy is blocked, never silently inert.

**No cron-trigger-limit concern.** DO alarms do not consume cron-trigger slots, so the former CF
`10072` "exceeded the limit of 5 cron triggers" failure mode is gone — this path is unaffected by the
account cron cap.

## 4. Schema provisioning (REQUIRED — one idempotent step for ALL CMS-owned tables)
mod-cms ships **no in-app per-request DDL runner** (Postgres uses external migrations — `install.ts`
only auto-provisions for the sqlite/D1 dialect). On Postgres the schema is provisioned **once per
environment** by a single idempotent operator script that drives `applyFullSchema(db, 'postgres')`
(`src/lib/install.ts`). It composes every module's canonical `*MigrationSql()` / `apply*Schema()` DDL
in **FK-dependency order** and creates, idempotently (`IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`):
the auth tables (`auth_users` → `user_sessions` → `service_tokens`, FK order) via engine-exported
DDL, then the base `content_entries`, content visibility + FTS, `content_revisions`, taxonomy
(`content_terms` + `content_entry_terms`), `comments`, `form_submissions`, `notifications`,
`field_values` + `field_groups`, `menus` + `menu_items`, `media_assets`, `audit_log`, and finally
the `auth_users.status` ALTER (idempotent).

**Ordering — apply-schema FIRST, then seed the admin.** Auth tables are created by this script;
`scripts/seed-admin.ts` (§1) must run **after** so the admin INSERT lands in an already-existing
`auth_users` table.

```bash
DATABASE_URL=<branch-url> pnpm --filter @app/mod-cms exec tsx scripts/apply-schema.ts
# → full CMS schema applied (13 tables/migrations, FK-ordered, idempotent)
```

Each statement runs **split per statement** — REQUIRED because neon-http's `execute()` rejects a
multi-statement string. **Idempotent** — safe to re-run on any DB state (fresh, half-migrated, or
fully current); a second run is a clean no-op. It both **provisions a fresh DB** and **heals an
older (e.g. v0.0.1-era) branch** additively without touching existing rows. Without it the
media/audit/FTS/comments/forms/fields/menus routes fail closed (`relation "…" does not exist`); in
particular **every admin mutation writes `audit_log`**, so a missing `audit_log` 500s admin writes,
not only the audit view. This single step supersedes the former per-table apply scripts.

## 5. Real-router e2e gate (ops-gated)
`apps/mod-cms/e2e/media.spec.ts` proves Astro routes a multi-segment DELETE to the `[...key]`
catch-all (the unit tests cannot — they hand a pre-decoded `params.key`). Run it like the other
admin e2e, against a throwaway Neon branch + seeded admin:
- `.dev.vars`: `DATABASE_URL` (throwaway Neon branch with auth + media schema), `SITE_ORIGIN=http://localhost:4331`,
  the auth secrets; seed an admin (`admin@e2e.test`) via `scripts/seed-admin.ts`. `SITE_ORIGIN` MUST equal
  the Playwright baseURL or same-origin CSRF rejects the session.
- `pnpm --filter @app/mod-cms test:e2e` → `media.spec.ts` must pass (404 with `{error:{code:'not_found'}}`
  JSON, not Astro's 404 HTML).

## 6. Agent / MCP bearer access
For non-interactive admin access, mint a token in **Settings → Service tokens**. The raw token is shown
once at creation time; copy it immediately.

Pass that token to the existing admin REST API as:
```http
Authorization: Bearer <token>
```

The token issue/list/revoke routes live at [src/pages/api/admin/service-tokens.ts](./src/pages/api/admin/service-tokens.ts).
The rest of the admin API already accepts bearer auth through `requireAdmin`, including routes such as
[src/pages/api/admin/content.ts](./src/pages/api/admin/content.ts) and
[src/pages/api/admin/users.ts](./src/pages/api/admin/users.ts).

This is bearer access to the existing admin API, not a full one-click MCP tool server. A packaged
agent/MCP server flow is a separate feature and is not shipped here yet.
