# Production checklist (Descrybe v2) Actionable go-live checklist for **Descrybe v2** (Go API + SvelteKit + PostgreSQL). Check every box before cutting traffic. **Migrator note:** Schema migrations use **goose** on `apps/api/sql/schema/*.sql`. Do **not** use Drizzle Kit here — that belongs to the legacy Next.js repo (`descrybe`). | Tool | Role | How | |------|------|-----| | **goose** | Apply Postgres schema | `make migrate` / `.\scripts\migrate.ps1` → `goose -dir sql/schema postgres "$DATABASE_URL" up` | | **sqlc** | Generate Go query code after schema changes | run as part of migrate scripts | | **cmd/migrator** | One-time MySQL → Postgres **data** cutover | separate from goose; see [cutover.md](cutover.md) | | drizzle-kit | **N/A for v2** | legacy repo only | Pinned invoke (scripts): `github.com/pressly/goose/v3/cmd/goose@v3.24.3`. Related docs: [production-readiness.md](production-readiness.md), [ops-runtime.md](ops-runtime.md), [go-live-checklist.md](go-live-checklist.md), [cutover.md](cutover.md). **Scorecard (2026-08-09):** **Product (code) 10/10** · **Cutover (ops) NO-GO / 9/10** (hard live left: SMTP + Stripe; Clerk removed from product — not a cutover blocker) — see [production-readiness.md](production-readiness.md#executive-verdict). Product evidence includes mobile browser **PASS** and `npm run check` **GREEN** ([7c018ec6](7c018ec6-ffd1-46b4-8717-fbe52c9e9913)). Unchecked **OPS OPEN** boxes do not reduce Product. **Checkbox legend** | Mark | Meaning | |------|---------| | `[x]` **CODE DONE** | Shipped in repo (tooling/API/UI/schema/examples). Not an ops clear. | | `[x]` **LOCAL DONE** | Proven on local stack / dry-run rehearsal only. Not a production-host clear. | | `[ ]` **OPS OPEN** | Operator must run/prove on the **target/cutover** env (or live SMTP/Stripe). | **Still OPS OPEN (do not check off here):** live SMTP (**#4**), live Stripe+webhooks (**#5**). Clerk is removed from the product — `#1` `@legacy.local` hygiene is optional data cleanup, not a hard NO-GO. **Operator NO-GO gate (do not duplicate):** sequence the **10** cutover blockers with dry-run commands in [cutover.md — NO-GO blockers gate](cutover.md#no-go-blockers-gate-sequenced-dry-runs). Canonical blocker list: [production-readiness.md](production-readiness.md#remaining-blockers-production-cutover--ops--true-open-items). This checklist keeps deploy/orphan/metrics/edge detail (§§0–1 / 1b / 1d / 1f) plus shipped cutover tooling below (§§T / 1e / 4b / 6). --- ## T. Shipped cutover tooling (code vs ops) Continuation cutover-prep wave shipped **operator tooling** only. Presence of these boxes as `[x]` does **not** clear DNS/cutover. ### T1. Forgot-password (P1-18) Design: [forgot-password.md](forgot-password.md). - [x] **CODE DONE:** Goose `041_password_reset_tokens` + `POST /api/auth/forgot-password` + `POST /api/auth/reset-password` + `/forgot-password` / `/reset-password` (i18n); reuses `mail.Send` + `EMAIL_DRY_RUN`. - [x] **CODE DONE:** Admin set-password re-issue remains for migration cohort (`must_set_password` / migrator invites) — not replaced by forgot-password. - [ ] **OPS OPEN:** Goose through **042** applied on the production host (see §1 / §4). - [ ] **OPS OPEN:** Live SMTP + one forgot-password → reset → login smoke (dry-run / `EMAIL_DRY_RUN` first; cutover blocker **#4**). ### T2. Migrator operator flags Full flag table: [cutover.md — Migrator flags](cutover.md#migrator-flags). Sequenced dry-runs: [NO-GO gate](cutover.md#no-go-blockers-gate-sequenced-dry-runs). - [x] **CODE DONE:** `@legacy.local` hygiene — `-list-legacy-emails` / `-export-legacy-emails` / `-patch-emails` (`-dry-run` or `-confirm`; never overwrites real emails). - [x] **CODE DONE:** Membership promote — `-list-member-memberships` / `-promote-company-admins` (`-dry-run` or `-confirm`; unscoped promote refused; **never** promotes A1 `a1=true`; `-company-id` alone scopes). - [x] **CODE DONE:** Plan gaps — `-list-companies-without-plans` / `-assign-missing-plans` + `-plan-name` (`-dry-run` or `-confirm`); ETL `-fallback-plan-name`. - [x] **CODE DONE:** Set-password / demo helpers — `-issue-set-password-invites`, `-set-password`, `-ensure-demo`; offline `-fixture` dry-run (not a substitute for MySQL dry-run). - [x] **LOCAL DONE:** Promote + plan-gap **list/dry-run** rehearsal on local Postgres (A1 promote skipped; Demo already admin via `seed-demo`; assign `-confirm` not claimed on fixture-heavy local DBs). Script: `node scripts/cutover-local-rehearsal.mjs` / `make cutover-rehearsal`. - [x] **CODE DONE:** Clerk removed from product auth — no live Clerk dependency. Optional `@legacy.local` hygiene tooling remains for residual rows (not a hard NO-GO). - [ ] **OPS OPEN:** Cutover-DB promote/assign **`-confirm`** after reviewed dry-run (do not invent DSNs / mutate A1). Live SMTP/Stripe stay **#4–#5**. ### T3. Admin diagnostics (presence flags) - [x] **CODE DONE:** `GET /api/admin/diagnostics` + `/admin/diagnostics` — Stripe/mail **presence** only (no secrets/DSNs/keys in payload). - [x] **CODE DONE:** Useful `config.*` / check fields: `smtp_enabled`, `email_dry_run`, `smtp_host_set`, `stripe_secret_set`, `stripe_webhook_secret_set`, `stripe_mock`, `stripe_mock_rejected_in_prod`; check `stripe.secret_key_set` / `webhook_secret_set` / `mock`. - [ ] **OPS OPEN:** Platform admin opens `/admin/diagnostics` on target env; flags match intended prod config (live SMTP/Stripe proof still cutover **#4–#5**). --- ## 0. Web adapter / deploy shape (code-decided) **Decided:** `@sveltejs/adapter-node@5.5.7` (`apps/web/svelte.config.js`). Not `adapter-static` — SSR hooks and server routes are in use (`hooks.server.ts`, `+page.server.ts`, `+server.ts`). Details: [production-readiness.md](production-readiness.md#deploy-note-sveltekit-adapter-decided). - [x] Adapter pinned in repo (`adapter-node`; `adapter-auto` removed). - [x] **CODE DONE:** Production start/build scripts — `apps/web` `build` + `start` (`node build`); root `npm run build` (web+api) and `npm run start:web`. - [x] **LOCAL DONE:** `npm run cutover:deploy-check` / `node scripts/cutover-deploy-check.mjs` with `HEALTH_BASE_URL=http://127.0.0.1:28471` — goose **039–042**, `/readyz` worker ok, adapter-node host gates (local stack only). - [ ] **OPS OPEN:** Production host runs the Node build output (`npm run start:web` / `node build` in `apps/web`) — not a static CDN-only site. - [ ] **OPS OPEN:** Reverse proxy serves dashboard `WEB_ORIGIN` to that Node process; API remains on its own origin/`PUBLIC_API_URL`. - [ ] **OPS OPEN:** Web process gets SIGTERM drain comparable to API (rolling deploys). **Build + start (host):** ```bash npm run build --workspace=web # or: npm run build (web + go build ./...) npm run start:web # node build (adapter-node output); requires prior build ``` **Operator check (read-only, no infra changes):** after schema+worker are up on the target env: ```bash npm run cutover:deploy-check # equivalent: node scripts/cutover-deploy-check.mjs # code-only (no DB/API): npm run cutover:deploy-check:code # optional on node invoke: --skip-goose | --skip-readyz # optional: HEALTH_BASE_URL=http://127.0.0.1:28471 (overrides PUBLIC_API_URL for /readyz) ``` This verifies goose **039–042** applied, `/readyz` `checks.worker=ok`, and adapter-node host gates (pin, `svelte.config.js` import, build/start scripts). Host process deploy remains ops. --- ## 1. Health / readiness probes | Probe | Path | Expect | Auth | |-------|------|--------|------| | Liveness | `GET /healthz` | `200` `{ "status": "ok", … }` | none | | Readiness | `GET /readyz` | `200` `{ "status": "ready", … }` or **503** if DB down **or** worker heartbeat missing/stale (>60s) | none | `/readyz` checks Postgres ping **and** `worker_heartbeats` for worker_id `processing` (`jobs.DefaultHeartbeatStaleAfter` = **60s**). Payload includes `checks.database`, `checks.worker`, `checks.queue`, and `worker_last_seen_age_s` when a heartbeat row exists (on-call triage field — see §1d). **Local / API-only:** running `cmd/api` without `cmd/worker` → `/readyz` returns **503** (`worker heartbeat missing` or `stale`). That is expected — not a false healthz failure (`/healthz` stays 200). **Deploy order (required):** 1. goose up through **`042_user_session_version`** (includes **`039_worker_heartbeats`** + **`040_job_hotpath_indexes`** — heartbeat table must exist before worker touch / API probe; **041** for self-serve forgot-password; **042** for cookie `session_version` revoke-on-reset). 2. Restart **worker** (`cmd/worker`) so it upserts heartbeats. 3. Then expect `/readyz` **200** (API restart alone is not enough if the table is new or the worker was never started). - [ ] Load balancer / k8s **liveness** → `/healthz` (no DB / no worker). - [ ] Load balancer / k8s **readiness** → `/readyz` (DB + fresh worker heartbeat ≤60s; **503** if pool missing, ping fails, or worker missing/stale). - [ ] Probes stay reachable under `MAINTENANCE_MODE` (middleware short-circuits only non-probe traffic). - [ ] Smoke from outside the VPC (API **and** worker running): ```bash curl -sS -o /dev/null -w "%{http_code}\n" https://YOUR_API_HOST/healthz curl -sS https://YOUR_API_HOST/readyz make health ``` --- ## 1b. Orphan processed cleanup (one-shot ops) Sibling **97a2be08** shipped admin report/cleanup. After ETL/cutover data is loaded, platform admins run **once** on the **cutover** DB: 1. Open **`/admin/orphan-processed`** (Ops nav) — loads the report (no delete). Or `GET /api/admin/jobs/orphan-processed`. 2. Review counts/samples. Optional: **Dry-run cleanup** (POST without confirm) to re-check without deleting. 3. If orphans > 0: **Confirm delete…** in the UI (sends `{"confirm":true}`), or `POST /api/admin/jobs/orphan-processed-cleanup` with `confirm=true`. Without confirm, POST is report-only. - [x] **CODE DONE:** Admin report/cleanup API + UI (`/admin/orphan-processed`; dry-run vs `confirm=true`). - [x] **LOCAL DONE:** Report-only / dry-run path on local stack (`cutover-local-rehearsal.mjs` orphan step — never passes `confirm=true` by default). - [ ] **OPS OPEN:** Report run on cutover DB; samples reviewed. - [ ] **OPS OPEN:** Confirmed cleanup on cutover DB if needed; re-report shows zero (or accepted residual). - [ ] Understood: this does **not** by itself make production GO (and does not clear SMTP/Stripe). --- ## 1c. Durable feed sync (worker-backed) Sibling **014e2507**: sync is durable via `feed_sync_jobs` + `cmd/worker` claim (`FOR UPDATE SKIP LOCKED`). Dashboard: **202** on `POST /api/feeds/{id}/sync`, then poll `GET /api/feeds/{id}/sync-jobs/{jobID}` (`apps/web` `pollFeedSyncJob`). API does **not** run unbound sync goroutines. - [ ] Worker process running (same requirement as `/readyz` heartbeat). - [ ] Smoke: trigger feed sync → 202 → job reaches `completed` / terminal via poll (or Sync History). - [x] LISTEN wake + stuck `running` reclaim + same-feed pending dedupe **shipped** (2f1019f2 / 014e2507) — not open residuals. Worker still required for completion; load soak / multi-replica edge caps remain Cutover/ops (not Product code gaps). --- ## 1d. Metrics scrape + alerts (cutover) Product code ships gated Prometheus text at `GET /metrics` (`metrics.Gate`: loopback or `METRICS_PUBLIC=1` in production). **Example** scrape + alert files are **CODE DONE** under [`deploy/prometheus/`](../deploy/prometheus/); wiring live targets / Alertmanager remains **OPS OPEN**. Details + starter PromQL: [production-readiness.md](production-readiness.md#ops-prometheus-scrape). **Topology:** scrape API on `HTTP_ADDR` for HTTP RED; scrape worker on `METRICS_ADDR` (e.g. `127.0.0.1:9091`) for sync series. Deny `/metrics` on the public VIP. Keep `METRICS_PUBLIC` unset unless scraping a private VIP. | Job | Target | Series | |-----|--------|--------| | `descrybe-api` | API private/mesh `:HTTP_ADDR` `/metrics` | `http_requests_total`, `http_request_duration_seconds` | | `descrybe-worker` | Worker `METRICS_ADDR` `/metrics` | `sync_duration_seconds`, `sync_failures_total` | | `descrybe-readyz` | blackbox → private `GET /readyz` | `probe_success` (worker missing/stale → HTTP **503**; age not a Prom series) | **On-call worker freshness (not a Prom series):** `/metrics` does not expose heartbeat age. Triage via `GET /readyz`: `checks.worker` (`ok` / `missing` / `stale`) and `worker_last_seen_age_s` when a heartbeat row exists (seconds since last `processing` touch; stale after **60s** = `jobs.DefaultHeartbeatStaleAfter`). Same age field is printed by `node scripts/cutover-deploy-check.mjs`. Probe details: §1 above. Example alert: `DescrybeWorkerHeartbeatStale` in [`alerts.example.yml`](../deploy/prometheus/alerts.example.yml). - [x] **CODE DONE:** Gated `GET /metrics` (API + optional worker `METRICS_ADDR`) + Gate unit coverage. - [x] **CODE DONE:** Example scrape + alert rules (no secrets): [`deploy/prometheus/scrape.example.yml`](../deploy/prometheus/scrape.example.yml), [`deploy/prometheus/alerts.example.yml`](../deploy/prometheus/alerts.example.yml), [`deploy/prometheus/README.md`](../deploy/prometheus/README.md). - [ ] **OPS OPEN:** Prometheus (or equivalent) scrapes API `/metrics` from loopback/sidecar/mesh — not the public internet (replace placeholder targets). - [ ] **OPS OPEN:** Worker has `METRICS_ADDR` bound privately; job `descrybe-worker` scrapes it (sync kinds: `feed`, `woocommerce*`, `shopify*`). - [ ] **OPS OPEN:** Public edge **denies** `/metrics` (defense-in-depth even if Gate holds). - [ ] **OPS OPEN:** Alerts loaded + routed (tune thresholds): API 5xx rate, API p99 latency, `sync_failures_total` increase, sync p95 duration, scrape `up==0`, plus blackbox/HTTP probe on `/readyz` (worker missing/stale >60s). - [ ] **OPS OPEN:** On-call can open `/metrics` via private path only; no scrape secrets committed. - [ ] **OPS OPEN:** On-call runbook uses `/readyz` `worker_last_seen_age_s` + `checks.worker` for worker-down triage (not `/metrics`). --- ## 1e. Admin diagnostics smoke (ops) **CODE DONE** surface is §T3. This section is **OPS OPEN** only. - [ ] Platform session: open `/admin/diagnostics` (or `GET /api/admin/diagnostics`) — overall status + checks load. - [ ] Confirm mail presence: `config.smtp_enabled` / `email_dry_run` / `smtp_host_set` match host intent (no secret values visible). - [ ] Confirm Stripe presence: `config.stripe_secret_set` / `stripe_webhook_secret_set` / `stripe_mock` (prod expects mock off + secrets set when billing on). - [ ] Understood: green presence flags ≠ live Checkout/webhook or live mail delivery proof. --- ## 1f. Multi-replica edge rate limits (cutover #8) **CODE DONE:** in-process HTTP limiters + `RATE_LIMIT_REPLICAS` ceil-division (`apps/api/internal/httpapi/ratelimit.go`). No Redis shared store in-repo (`RATE_LIMIT_BACKEND` forced to `memory`). Snippets: [deploy/examples/edge-rate-limit.md](../deploy/examples/edge-rate-limit.md). Runbook: [cutover.md §8](cutover.md#8-multi-replica-edge-rate-limits). **Env contract (when API replicas N>1):** | Var | Value | Notes | |-----|-------|-------| | `RATE_LIMIT_REPLICAS` | `N` | Divides **HTTP middleware only**; not lockout / StartLimiter / AI / email | | `RATE_LIMIT_MULTI_REPLICA` | `true` | Ops ack; boots with warn | | `TRUSTED_PROXIES` | hop-1 edge CIDRs/IPs | Required so IP RPM keys on client, not proxy | | `RATE_LIMIT_BACKEND` | `memory` (effective) | `redis`/`postgres` requested → warn + memory | - [x] **CODE DONE:** `rateLimitEffectiveCap` + boot warn (`ShouldWarnRateLimits` / `RateLimitWarningMessage`). - [x] **CODE DONE:** Edge nginx/Caddy example with shared zones ([edge-rate-limit.md](../deploy/examples/edge-rate-limit.md)). - [ ] **OPS OPEN:** If **N=1**, leave defaults — edge optional. - [ ] **OPS OPEN:** If **N>1**, set env contract on every API replica + apply **one** shared edge config in front of all replicas. - [ ] **OPS OPEN:** Confirm boot warn appears; spot-burst auth/public and expect edge and/or app **429** (not silent N× budget). - [ ] Understood: `RATE_LIMIT_REPLICAS` alone is **not** a substitute for edge hard global RPM. --- ## 2. Graceful shutdown / process config - [ ] API (`cmd/api`) receives **SIGTERM** / **SIGINT** and drains with `Shutdown` (~10s timeout). - [ ] Worker (`cmd/worker`) uses `signal.NotifyContext` and stops claiming new jobs on cancel. - [ ] Orchestrator drain window ≥ shutdown timeout. - [ ] `UPLOAD_DIR` / feed artifacts on **persistent** disk (or object storage), not ephemeral container FS only. - [ ] Behind a LB: idle timeout lower than server keep-alive (or raise keep-alive) to avoid 502s on rolling deploys. --- ## 3. Logging without PII - [ ] API uses `log/slog` JSON via `internal/logredact.NewJSONHandler` (emails, Bearer tokens, `api_key=` / secrets, Stripe `sk_*` / `whsec_`, DB URLs with credentials → `[REDACTED]`). - [ ] Worker / stdlib `log` output is wrapped with `logredact.Writer`. - [ ] Never log raw Stripe webhook bodies, Woo/Shopify tokens, full `Authorization` headers, or marketing recipient addresses. - [ ] Production log level stays `info` (or quieter); avoid debug dumps of request bodies. - [ ] Spot-check after deploy: no emails/API keys in fresh log lines. ```bash cd apps/api && go test ./internal/logredact/ ./internal/httpapi/ -count=1 ``` --- ## 4. Database schema migrations (goose) Apply schema with goose only: ```bash # Git Bash / WSL / macOS / Linux export DATABASE_URL='postgres://…' make migrate # Windows PowerShell $env:DATABASE_URL = 'postgres://…' .\scripts\migrate.ps1 # Equivalent direct invoke (apps/api) go run github.com/pressly/goose/v3/cmd/goose@v3.24.3 \ -dir sql/schema postgres "$DATABASE_URL" up ``` SQL files under `apps/api/sql/schema/` (goose `+goose Up` / `+goose Down`): | File | Purpose | |------|---------| | `001_platform.sql` | Companies, users, sessions, memberships, plans | | `002_catalog.sql` | Categories, attributes, products | | `003_feeds.sql` | Feeds / export feeds | | `004_processing.sql` | Processing jobs / costs | | `005_woocommerce.sql` | Woo configs | | `006_feed_sync.sql` | Feed sync columns | | `007_standard_fields.sql` / `008_…` | Standard fields | | `009_processing_step_progress.sql` | Step progress | | `010_woo_orders_reviews.sql` | Orders / reviews | | `011_email_campaigns.sql` … `014_…` | Email / unsub | | `015_ai_providers.sql` | AI BYOK providers | | `016_stripe_billing.sql` | Stripe billing | | `017_shopify.sql` | Shopify connector | | `018_…` … `038_…` | Indexes, support, sales, i18n, etc. | | `039_worker_heartbeats.sql` | Worker heartbeat rows for `/readyz` (sibling **63ee62c5**) | | `040_job_hotpath_indexes.sql` | Claim/list composites for `feed_sync_jobs` / `processing_jobs` / `processing_job_products` | | `041_password_reset_tokens.sql` | Self-serve forgot-password reset tokens (`password_reset_tokens`) | | `042_user_session_version.sql` | `users.session_version` for revoke-on-password-reset cookie sessions | - [x] **CODE DONE:** Goose migrations through **`042_user_session_version.sql`** exist in repo (incl. **039** / **040** / **041**); one-command apply `npm run migrate`. - [x] **CODE DONE:** Local/dev starts API+worker together (`npm run dev` / `npm run dev:backend`) so `/readyz` can green. - [x] **LOCAL DONE:** Local goose through **042** + worker heartbeat + `cutover-deploy-check` PASS (see §0). - [ ] **OPS OPEN:** Backup DB before migrate. - [ ] **OPS OPEN:** Run goose against the **production** `DATABASE_URL` in a maintenance window (or verify already applied). - [ ] **OPS OPEN:** Confirm goose version table includes versions through **042**. - [ ] **OPS OPEN:** After `039`/`040`/`041`/`042`: **restart worker**, then confirm `/readyz` → 200 (API-only without worker stays **503**). - [ ] After schema changes, regenerate sqlc (`make migrate` / migrate scripts do this). - [ ] Do **not** run drizzle-kit against this database. - [ ] MySQL → PG **data** load is `cmd/migrator` (separate checklist in [cutover.md](cutover.md)) — not a substitute for goose. --- ## 4b. Migrator data cutover (ops; tooling CODE/LOCAL) Operator flags are **CODE DONE** (§T2); local list/dry-run rehearsal is **LOCAL DONE**. Cutover-DB execution + SMTP stay **OPS OPEN** — full commands in [cutover.md](cutover.md#2-run-migrator). - [x] **CODE DONE / LOCAL DONE:** Offline fixture dry-run + local list/dry-run inventory (promote/plans; no blind `-confirm`) — §T2. - [ ] **OPS OPEN:** MySQL dry-run with real `MIGRATE_MYSQL_DSN` (report + id-map; no migrated row writes). - [ ] **OPS OPEN:** Live load after freeze + reviewed dry-run. - [ ] **OPS OPEN:** Post-load: optional `@legacy.local` hygiene (not hard NO-GO) → `#2` promote admins → `#3` assign missing plans (dry-run then `-confirm`). - [ ] **OPS OPEN:** Set-password invites re-issued **after** email repair (`-issue-set-password-invites` / mailhooks) — live SMTP is **#4**. --- ## 5. Environment (`.env.example`) - [ ] Copy **root** `.env.example` → host secret store / local untracked **root** `.env` only. Do **not** maintain a second `apps/api/.env`. - [ ] **Bootstrap (required for a working process):** `DATABASE_URL`, `APP_ENV`, `HTTP_ADDR`, `WEB_ORIGIN`, `PUBLIC_API_URL`, `SESSION_SECURE` (production), `APP_ENCRYPTION_KEY` (or `CREDENTIALS_ENCRYPTION_KEY`), `TOKEN_SIGNING_SECRET`. Local defaults: [README.md](../README.md#environment-one-file). - [ ] **Stripe / EPREL / feed private-URL allowlist:** configure in **`/admin/settings`** (`GET/PUT /api/admin/settings` → `values.*` such as `stripe.secret_key`, `eprel.*`, `feeds.private_url_allowlist`). Process env is an **optional fallback** — production does **not** require Stripe secrets at boot. - [x] **CODE DONE:** Mail send path for platform invites + forgot-password (`internal/mail`, `EMAIL_DRY_RUN` / `ApplyDryRun`). - [ ] **OPS OPEN:** **Platform invite / forgot-password SMTP** — `SMTP_*` or platform admin mail settings for live sends; prove delivery (cutover **#4**) — see [ops-runtime.md](ops-runtime.md). - [ ] **Do not put day-to-day product secrets in `.env`.** Configure in the dashboard after login: - Platform Stripe / EPREL / feed allowlist → `/admin/settings` - AI → `/integrations/ai` - Marketing email → `/integrations/email` - Stores → `/stores` (and Woo/Shopify sibling routes) - [ ] Secrets from vault/CI only — never commit real values. No tmp placeholder env files. --- ## 6. Auth & public surface - [x] **CODE DONE:** Self-serve forgot-password / reset (goose **041** + API/UI) — §T1. - [ ] **OPS OPEN:** Dashboard CSRF + session cookies secure on HTTPS. - [ ] **OPS OPEN:** Forgot-password live mail + reset login smoke (after SMTP proven). - [ ] `/api/v1/*` uses API keys; Stripe webhooks verify signatures when secret set. - [ ] Export-feed public URLs intentional; rotate leaked feed IDs if needed. - [ ] SSRF guards enabled for feed / Woo / Shopify HTTP clients. --- ## 7. Post-deploy smoke - [ ] `GET /healthz` → 200 - [ ] `GET /readyz` → 200 with `status=ready` **and** `checks.worker=ok` (worker process running; not API-only) - [ ] Login works against prod session config - [ ] **OPS OPEN:** Forgot-password smoke (if SMTP live): request reset → email → set password → login - [ ] **OPS OPEN:** Admin diagnostics presence flags look right (§1e) — no secrets in payload - [ ] One product process job + one export feed generate - [ ] One feed sync: **202** + poll to terminal status (worker claiming `feed_sync_jobs`) - [ ] Worker claiming jobs (processing / feed sync / Woo / billing tick) - [ ] Orphan-processed report (+ cleanup if needed) — section 1b - [ ] Stripe webhook reachable (if billing on) - [ ] No emails/API keys in fresh log lines ```bash cd apps/api && go build ./... && go test ./... curl -sf https://YOUR_API_HOST/healthz && curl -sf https://YOUR_API_HOST/readyz ``` --- ## 8. Rollback - [ ] Previous API/web image tags known and redeployable. - [ ] DB backup from pre-migrate retained. - [ ] Feature / env kills: `stripe.mock` (admin settings) / `STRIPE_MOCK`, `EMAIL_DRY_RUN`, `MAINTENANCE_MODE`, `READ_ONLY_MODE` understood by on-call. --- ## Quick owner commands ```bash # Combined cutover deploy gates (goose 039–042 + /readyz worker + adapter-node host gates) npm run cutover:deploy-check # equivalent: node scripts/cutover-deploy-check.mjs # code-only adapter/scripts gate: npm run cutover:deploy-check:code # Local dry-run rehearsal (no SMTP / Stripe / -confirm writes) node scripts/cutover-local-rehearsal.mjs # or: make cutover-rehearsal # Schema migrator status only (goose — not Drizzle) cd apps/api go run github.com/pressly/goose/v3/cmd/goose@v3.24.3 -dir sql/schema postgres "$DATABASE_URL" status # Probes (local; npm run health also works) curl -sS http://127.0.0.1:28471/healthz curl -sS http://127.0.0.1:28471/readyz # Admin diagnostics presence flags (platform session cookie / API key as used in env) # curl -sS -H "Cookie: …" http://127.0.0.1:28471/api/admin/diagnostics | head # Migrator read-only inventory examples (OPS OPEN; from apps/api) # go run ./cmd/migrator -list-legacy-emails -postgres "$DATABASE_URL" # go run ./cmd/migrator -list-companies-without-plans -postgres "$DATABASE_URL" # go run ./cmd/migrator -list-member-memberships -postgres "$DATABASE_URL" # Redaction + health unit tests go test ./internal/logredact/ ./internal/httpapi/ -count=1 ```