diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md index bc54672..b1e92bb 100644 --- a/docs/LAUNCH_READINESS.md +++ b/docs/LAUNCH_READINESS.md @@ -30,32 +30,48 @@ None outstanding. ## Deploy checklist -Applies when the site is connected to Netlify / production hosting. Nothing here is actionable until that connection exists; kept here so nothing gets forgotten at cutover. +Applies when the app is deployed to **Dokploy on Hetzner**. Build is via the in-repo `Dockerfile` (`node:20-alpine`, runs `node .output/server/index.mjs` on port 3000); Dokploy autodetects it. Traefik (Dokploy's reverse proxy) handles SSL; `oidc-provider.ts:194` and the rate-limit middleware already trust `X-Forwarded-Proto` / `X-Forwarded-For`. + +### One-time host setup + +- [ ] **Provision the Dokploy app** pointing at this repo. Build context: repo root. Default Dockerfile. Container port: `3000`. +- [ ] **Set env vars in the Dokploy UI** (full list below). The `validate-env.js` Nitro plugin fails fast at boot if `MONGODB_URI` / `JWT_SECRET` / `RESEND_API_KEY` / `HELCIM_API_TOKEN` are missing — container refuses to start, so misconfig surfaces immediately in logs. +- [ ] **`BASE_URL` must exactly match the public origin** (e.g. `https://ghostguild.org`, no trailing slash). The `/api/helcim/customer` origin check at `server/api/helcim/customer.post.js:11-15` does exact-match comparison against the `Origin` header — if `BASE_URL` is wrong or unset, signup 403s. +- [ ] **`NODE_ENV=production`** must be set. Without it: `Secure` cookie flag, HSTS, and CSP all silently no-op. +- [ ] **Add a Dokploy Scheduled Task** for daily reconciliation. Command: + ``` + curl -fsS -X POST "$BASE_URL/api/internal/reconcile-payments" -H "X-Reconcile-Token: $NUXT_RECONCILE_TOKEN" + ``` + Schedule: `0 4 * * *` (or any time of day). The Nitro route does the heavy lifting (Mongo iteration, Helcim API, retries) — the scheduler just wakes it up. + +### Cutover - [ ] Push local `main` to `origin/main`. - [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.** Idempotent; dry-run on local counted 34 members. Requires `MONGODB_URI` in env. The script writes `contributionAmount` (Number) derived from existing `contributionTier` (String) on every Member doc; the old field is left intact for a window. -- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` in production env. -- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in production env. -- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic** to backfill Payment records for pre-existing members. Idempotent (unique `helcimTransactionId`); safe to re-run as a nightly reconciliation job post-launch. +- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` in Dokploy env. +- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy env. +- [ ] **Set `NUXT_RECONCILE_TOKEN`** to any 32+ char random string. Shared secret between the Dokploy scheduled task and `/api/internal/reconcile-payments`. +- [ ] Deploy. +- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic** to backfill Payment records for pre-existing members. Idempotent (unique `helcimTransactionId`); the daily Dokploy cron picks it up from there. - [ ] **Prod audit for pre-fix series-pass bypass registrations.** Fixed in `f34b062` + `4e1888a` (2026-04-20). Before that, child events of pass-only series (`tickets.requiresSeriesTicket=true && tickets.allowIndividualEventTickets=false`) accepted drop-in registrations from non-pass-holders. For every such series, list its child-event `registrations` where the registrant is not in the parent series' pass-holder list, filter to `registeredAt < 2026-04-20`, and decide per-case: grandfather (keep + notify), refund + unregister, or silently unregister. Local Mongo was scrubbed of 2 such rows on 2026-04-20; prod was intentionally untouched. - [ ] **Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303.** We send our own CRA-safe confirmation via Resend (`server/emails/paymentConfirmation.js`) triggered from `upsertPaymentFromHelcim`; leaving Helcim's default on = duplicate emails. -- [ ] **Run one real test charge on staging** via the cloudflared tunnel and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing). -- [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the prod env var. The token was previously exposed in `window.__NUXT__` payload until today's deploy. -- [ ] **Set NUXT_RECONCILE_TOKEN** in production env (any 32+ char random string). Used as shared secret between Netlify scheduled function and the internal reconcile route. -- [ ] **Verify Netlify scheduled function `reconcile-payments` is enabled** in the Netlify dashboard. Schedule: daily. +- [ ] **Run one real test charge against the deployed app** and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing). +- [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the Dokploy env var. The token was previously exposed in `window.__NUXT__` payload until commit `208638e`. +- [ ] **Trigger the daily reconcile task once manually** in Dokploy to confirm scheduled task + token are wired correctly. Expect a `[reconcile] done {...}` log line. -**Env vars required in production (reference):** +**Env vars required in Dokploy (reference):** +- `NODE_ENV=production` +- `BASE_URL` (exact public origin, no trailing slash) - `MONGODB_URI` - `JWT_SECRET` (or `NUXT_JWT_SECRET` — the `NUXT_` variant wins) - `RESEND_API_KEY` - `HELCIM_API_TOKEN` -- `NUXT_HELCIM_MONTHLY_PLAN_ID` -- `NUXT_HELCIM_ANNUAL_PLAN_ID` -- `SLACK_BOT_TOKEN` -- `BASE_URL` -- `OIDC_COOKIE_SECRET` +- `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` +- `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` - `NUXT_PUBLIC_HELCIM_PORTAL_URL` -- `NUXT_RECONCILE_TOKEN` +- `NUXT_RECONCILE_TOKEN` (32+ char random string) +- `SLACK_BOT_TOKEN` +- `OIDC_COOKIE_SECRET` --- diff --git a/netlify.toml b/netlify.toml deleted file mode 100644 index 9c0d9fd..0000000 --- a/netlify.toml +++ /dev/null @@ -1,13 +0,0 @@ -[build] - command = "npm run build" - publish = "dist" - -[functions] - directory = "netlify/functions" - -# Daily reconciliation cron — invokes the protected Nitro route to upsert -# Payment docs from Helcim transactions. Schedule is also pinned inline in -# netlify/functions/reconcile-payments.mjs (Netlify accepts either; both is -# harmless). -[functions."reconcile-payments"] - schedule = "@daily" diff --git a/netlify/functions/reconcile-payments.mjs b/netlify/functions/reconcile-payments.mjs deleted file mode 100644 index 57994bc..0000000 --- a/netlify/functions/reconcile-payments.mjs +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Netlify scheduled function — daily reconciliation of Helcim payments. - * - * Calls the protected Nitro route `/api/internal/reconcile-payments` with the - * shared-secret header. Heavy lifting (Mongo queries, Helcim API calls, retry - * logic) lives in the Nitro handler so it can use auto-imported utils. - * - * Required env (set in Netlify dashboard): - * - URL (set automatically by Netlify) - * - RECONCILE_TOKEN (must match NUXT_RECONCILE_TOKEN in Nitro runtime config) - * - * Schedule: @daily (00:00 UTC). Also pinned in netlify.toml. - */ - -export default async () => { - const url = `${process.env.URL}/api/internal/reconcile-payments` - const token = process.env.RECONCILE_TOKEN - - if (!token) { - const msg = '[reconcile] RECONCILE_TOKEN not configured; aborting' - console.error(msg) - return new Response(msg, { status: 500 }) - } - - const res = await fetch(url, { - method: 'POST', - headers: { 'X-Reconcile-Token': token } - }) - const body = await res.text() - if (!res.ok) { - console.error('[reconcile] route failed', res.status, body) - return new Response(body, { status: res.status }) - } - console.log('[reconcile] ok', body) - return new Response(body, { status: 200 }) -} - -export const config = { - schedule: '@daily' -}