From a7fed5d0449f5df90df49f3f210f6faaf0a3c793 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 13:57:01 -0700 Subject: [PATCH 01/17] =?UTF-8?q?docs:=20sprint=206=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20dockerized=20stack=20+=20pre-deploy=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope decided with Steve: prod-style images via turbo prune, full-stack compose behind a stack profile, CI e2e against containers, webhook hardening (#8), rate-limiter memory (#10), offer-id validation (#13), polish + test-hardening sweep (#11, #14). Cloud accounts and Sentry deferred. Co-Authored-By: Claude Fable 5 --- ...-07-08-sprint-6-docker-hardening-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-08-sprint-6-docker-hardening-design.md diff --git a/docs/superpowers/specs/2026-07-08-sprint-6-docker-hardening-design.md b/docs/superpowers/specs/2026-07-08-sprint-6-docker-hardening-design.md new file mode 100644 index 0000000..03f8fab --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-sprint-6-docker-hardening-design.md @@ -0,0 +1,183 @@ +# Sprint 6 Design: Dockerized Stack & Pre-Deploy Hardening + +**Date:** 2026-07-08 +**Theme:** Ship-shape — the whole platform runs as production-style containers +with one command, and every "must land before deploy" issue is closed, so the +artifact is genuinely exposable. +**Parent spec:** `2026-07-06-promocean-design.md` §5b (hosting — platform +decision deliberately deferred; local Docker chosen for now) +**Branch:** `sprint-6-docker-hardening` (off `main` at the PR #16 merge) + +## 1. Scope + +In scope (decided with Steve, 2026-07-08): + +1. **Prod-style Docker images** for `api`, `cms`, `demo` — multi-stage + `turbo prune --docker` builds, slim runtimes +2. **Full-stack compose** behind a `stack` profile (dev flow unchanged), + healthcheck-gated ordering, documented env contract (including the missing + `apps/api/.env.example`) +3. **CI builds the images and runs e2e against the compose stack** (replacing + the hand-rolled service boot in `ci.yml`) +4. **Issue #8** — webhook delivery hardening (all seven items; ~2 tasks) +5. **Issue #10** — rate-limiter bucket eviction + negative auth-cache cap +6. **Issue #13** — offer-id validation on impression/click routes +7. **Issues #11 + #14** — fast-follow polish + test-hardening sweep + +Out of scope: cloud deploy accounts (Fly/Railway/Vercel), Sentry (needs a +DSN), first npm publish (needs NPM_TOKEN), remaining v1.x roadmap features. + +## 2. Decisions and rationale + +| Decision | Choice | Why | +|---|---|---| +| Hosting target | Local Dockerized stack, no cloud accounts | Steve's call (pivot from the spec's Fly/Railway spike). The images + compose double as the self-host artifact; any future cloud move takes Dockerfiles as-is. | +| Image build strategy | `turbo prune --docker` multi-stage | Turborepo's canonical monorepo-Docker pattern; per-app lockfile subsets cache layers well. Rejected: `pnpm deploy` (fussier with workspace: protocol); single mono-image (huge, no per-app caching). | +| Compose shape | Extend existing `docker-compose.yml` with `cms`/`api`/`demo` services behind a `stack` profile | `docker compose up -d postgres` dev flow stays byte-identical; `--profile stack` opts into the full stack. | +| CI depth | Build images AND run Playwright against the compose stack | Images and compose file can't rot when the merge gate exercises them. Rejected: build-only (a compiling image can still be broken at runtime). | +| Demo build-time env | `NEXT_PUBLIC_*` as Docker build args with localhost-stack defaults | Next bakes `NEXT_PUBLIC_*` into the browser bundle at build time; runtime env cannot change them. `PROMOCEAN_SECRET_KEY` stays runtime-only (server component). | +| Migrations | Keep running at api boot | Single-instance semantics; already how dev/CI work. Documented limitation: multi-replica rollout needs a migration job (future cloud sprint). | +| Sentry | Deferred | Needs a DSN from an account that doesn't exist; contradicts the local-only pivot. pino remains the observability story. | + +## 3. Architecture + +### 3.1 Images + +Three Dockerfiles (`apps/api/Dockerfile`, `apps/cms/Dockerfile`, +`apps/demo/Dockerfile`), each multi-stage on `node:22-alpine`. The base image +is an implementation default, not a contract: if native deps fight musl +(Strapi's `sharp` is the known risk), that app's Dockerfile falls back to +`node:22-slim` (glibc) — document the choice in the Dockerfile. Healthchecks +must use tools present in the final image (busybox `wget` on alpine, or a +node one-liner) — no assuming `curl`. + +Stages: + +1. **prune** — `turbo prune --docker` produces `out/json` (manifests + + lockfile subset) and `out/full` (source). +2. **install+build** — `pnpm install --frozen-lockfile` on the json layer + (cache-friendly), copy full source, `turbo run build` for the app. +3. **runtime** — slim stage with only the built output and production deps; + non-root user; `NODE_ENV=production`. + +Entrypoints: api `node dist/index.js` (runs migrations at boot — single +instance); cms `strapi start` with the admin panel built at image build; demo +Next `output: 'standalone'` → `node server.js`. The demo Dockerfile accepts +`NEXT_PUBLIC_PROMOCEAN_KEY` / `NEXT_PUBLIC_PROMOCEAN_API` build args +(defaults: the seeded demo pk key and `http://localhost:3001` — correct for +the local stack because the browser, not the container, resolves that URL). + +### 3.2 Compose + +Existing `docker-compose.yml` grows three services under +`profiles: ["stack"]`; `postgres` stays profile-less (dev flow unchanged) and +gains a `pg_isready` healthcheck. + +- `cms`: depends_on postgres healthy; healthcheck on the Strapi HTTP port; + seeds demo data on an empty DB (`SEED_DEMO`); env from the root `.env`. +- `api`: depends_on postgres + cms healthy; healthcheck hits `/readyz` + (Sprint 4 endpoint — checks DB + config plane); `STRAPI_URL=http://cms:1337`. +- `demo`: depends_on api healthy; `PROMOCEAN_SECRET_KEY` runtime env; + server-side stats calls use `PROMOCEAN_API_URL=http://api:3001` (in-network) + while the browser uses the baked `NEXT_PUBLIC_PROMOCEAN_API=http://localhost:3001`. + +Host ports preserved: 5433 (pg), 1337 (cms), 3001 (api), 3002 (demo). Root +`.env.example` documents the full stack contract; `apps/api/.env.example` +created (`DATABASE_URL`, `STRAPI_URL`, `CONFIG_PLANE_SECRET`, `API_PORT`, +`RATE_LIMIT_PER_MINUTE`, `LOG_LEVEL`). + +### 3.3 CI + +`ci.yml`'s e2e job: build the three images (layer cache via GitHub Actions +cache), `docker compose --profile stack up -d --wait`, run the existing +Playwright suite against `localhost:3002`, dump compose logs on failure, tear +down. The unit/typecheck job is unchanged. Image builds also run on PRs so a +broken Dockerfile blocks merge. + +### 3.4 Webhook hardening (#8) + +- **Migration 0004:** `timed_event_notifications` gains `delivered_at + timestamptz` (null = claimed-but-unconfirmed) and `attempts integer`. + Dispatcher marks `delivered_at` on success; dead-letters record as today. +- **Redelivery:** each scheduler tick re-drives claims older than a grace + window (default 5 min) that are neither delivered nor dead-lettered — + at-least-once delivery restored; consumer-side dedup via the new message id. +- **Message id + replay docs:** webhook payloads gain a `messageId` (uuid) — + an additive change to the contracts webhook message schema; README + documents consumer dedup + a recommended replay-window check on the signed + `createdAt`. +- **Dead-letter retention:** scheduler sweep deletes dead letters older than a + TTL (default 30 days, env-tunable). +- **Graceful shutdown:** `apps/api/src/index.ts` handles SIGTERM/SIGINT — + stop the lifecycle scheduler (its discarded `stop()` gets wired), close the + HTTP server, end the pg pool. Containers make this real: `docker stop` + sends SIGTERM and waits 10s before SIGKILL. +- **Ended-event scan growth:** the CMS `timed-events/all` endpoint filters + `endsAt > now - grace` so historical events stop costing per-tick work. + **Window-ordering constraint:** the scan filter's grace MUST exceed the + redelivery window (scan default 60 min vs redelivery 5 min) — a re-driven + `ended` transition must still be able to resolve its event definition from + the feed. Encode both as env-tunable values and assert the ordering at + scheduler startup (warn + clamp, don't crash). +- **Documented tradeoffs:** SSRF posture (dispatcher POSTs to + customer-controlled URLs; private-IP blocking is future multi-tenant work) + and disabled-after-live events (no `ended` webhook fires; documented). + +### 3.5 Rate-limiter memory (#10) + +`apps/api/src/rate-limit.ts`: on window rollover, lazily sweep expired +buckets; cap total buckets (default 10k, env-tunable) — when full, new keys +share a single overflow bucket (still rate-limited, never unlimited, never +hard-denied). adapter-strapi: cap the +negative (`null`) auth-cache entries (bounded map, oldest-evicted) so random +invalid keys cannot grow the heap unboundedly. + +### 3.6 Offer-id validation (#13) + +Impression and click routes verify `:id` against the project's cached offer +config (`getOffers`); unknown → 404 with the standard envelope, using a +`not_found` error code (added to the contracts catalog if absent — additive). Config-plane +failure falls back to accepting (fail-open, matching ingestion's posture — +availability over strictness for pk-facing writes; documented inline). + +### 3.7 Polish + test hardening (#11, #14) + +One sweep pass: route-level warn logs and webhook logs carry `requestId` +(child loggers); `files` allowlists in contracts/sdk/widgets package.json; +`release.yml` pushes tags and runs tests before publish; a static Redoc page +served at `GET /docs` rendering `/v1/openapi.json`; log-retention note beside +the MAU-retention decision; `docs/retros/` created with a Sprint 0-5 +retro-notes stub; changeset authoring note. Tests: N-way concurrent ingestion +race loop; StrictMode-wrapped beacon test; `statsQuerySchema` and +impression optional-userId contract tests; `nameById` fallback (drop the +non-null assertion); suggester distance-2/3 boundary pair. + +## 4. Error handling + +- Compose: healthcheck-gated `depends_on` prevents connect-before-ready; + `--wait` surfaces boot failures with non-zero exit in CI. +- Shutdown: in-flight requests get a bounded drain (server.close + timeout) + before pool teardown; scheduler stops first so no new webhook work starts. +- Redelivery: idempotent — a re-driven claim that already delivered is a + no-op (delivered_at check); consumers dedup by `messageId`. +- Rate limiter at cap: new keys are still rate-limited via a shared overflow + bucket rather than unlimited (fail-safe, documented). + +## 5. Testing + +- Testcontainers: redelivery (claim, crash before deliver, sweep re-drives), + retention sweep, delivered_at marking. +- API (fakes): offer-id validation paths (known/unknown/config-failure), + rate-limiter eviction + cap behavior (unit-level with fake clock). +- adapter-strapi: negative-cache cap eviction. +- e2e: existing 3 specs run against the compose stack — locally (DoD) and in + CI. No new specs; the environment change IS the test. +- CI: image builds on PR; compose-based e2e replaces hand-rolled boot. +- #14 list lands as unit tests in their respective packages. + +## 6. Definition of done + +Clean clone + `.env` + `docker compose --profile stack up` → seeded working +stack; 3/3 Playwright specs green against the containers locally AND in CI; +`pnpm turbo run typecheck build test` green; issues #8, #10, #11, #13, #14 +closable; docs updated (root README quickstart gains the one-command boot). From 961d4857a698a333408ed82e0eb33645f03de174 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 14:04:34 -0700 Subject: [PATCH 02/17] =?UTF-8?q?docs:=20sprint=206=20implementation=20pla?= =?UTF-8?q?n=20=E2=80=94=20dockerized=20stack=20+=20pre-deploy=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten tasks, hardening-first ordering: contracts (messageId, not_found) → webhook delivery status + redelivery/retention → lifecycle plumbing (scan window, graceful shutdown) → memory bounds (#10) → offer-id validation (#13) → polish (#11) → test hardening (#14) → docker images + compose stack → CI against containers. Co-Authored-By: Claude Fable 5 --- .../2026-07-08-sprint-6-docker-hardening.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-sprint-6-docker-hardening.md diff --git a/docs/superpowers/plans/2026-07-08-sprint-6-docker-hardening.md b/docs/superpowers/plans/2026-07-08-sprint-6-docker-hardening.md new file mode 100644 index 0000000..3fe1e54 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-sprint-6-docker-hardening.md @@ -0,0 +1,172 @@ +# Promocean Sprint 6: Dockerized Stack & Pre-Deploy Hardening — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The whole platform boots as production-style containers with one command (`docker compose --profile stack up`), CI exercises those containers on every merge, and every "must land before deploy" issue (#8, #10, #11, #13, #14) is closed. + +**Architecture:** Hardening lands first (it ships inside the images), Docker second, CI last. Images are per-app multi-stage `turbo prune --docker` builds; compose gains a `stack` profile so the dev flow (`docker compose up -d postgres` + host servers) is untouched; webhook redelivery/shutdown work rides the existing dispatcher/scheduler seams in `apps/api/src/webhooks.ts`. + +**Spec:** `docs/superpowers/specs/2026-07-08-sprint-6-docker-hardening-design.md`. Branch `sprint-6-docker-hardening` off main (PR #16 merge). + +## Global Constraints + +(All prior global constraints bind: error envelope `{ error: { code, message, details? } }`, zod contracts single source of truth, TDD per task, per-package gates green before commit, known-break pattern recorded when a shared port widens.) + +Sprint-6 additions (values verbatim from the spec): +- Compose: new services under `profiles: ["stack"]`; `postgres` stays profile-less and gains a `pg_isready` healthcheck. Host ports preserved: 5433 (pg), 1337 (cms), 3001 (api), 3002 (demo). Dev flow `docker compose up -d postgres` must remain byte-identical in behavior. +- Images: multi-stage `turbo prune --docker`; base `node:22-alpine` is a default not a contract — fall back to `node:22-slim` if native deps (Strapi `sharp`) fight musl, documenting the choice in the Dockerfile. Healthchecks use tools present in the final image (busybox `wget` or a node one-liner) — never assume `curl`. Non-root user, `NODE_ENV=production`. +- Demo image: `NEXT_PUBLIC_PROMOCEAN_KEY` / `NEXT_PUBLIC_PROMOCEAN_API` are **build args** (defaults `pk_test_demo_1234567890abcdef` / `http://localhost:3001` — the browser resolves that URL, not the container). `PROMOCEAN_SECRET_KEY` is runtime-only. In-network server-side stats calls use `PROMOCEAN_API_URL=http://api:3001`. +- Webhook payloads gain `messageId` (uuid) — additive to `webhookMessageSchema`. Error catalog gains `not_found` — additive. +- Redelivery: claims older than `WEBHOOK_REDELIVERY_GRACE_MINUTES` (default 5) that are neither delivered nor exhausted get re-driven each tick; re-drive attempts capped at 5, then dead-letter + mark delivered (stop the loop). Dead letters older than `WEBHOOK_DEAD_LETTER_TTL_DAYS` (default 30) are swept. +- Ended-event scan filter: cms `timed-events/all` accepts `?endedWithinMinutes=`; the api always sends it from `TIMED_EVENT_SCAN_GRACE_MINUTES` (default 60). **Ordering constraint:** scan grace MUST exceed redelivery grace — assert at scheduler startup, warn + clamp (scan grace := max(scan, redelivery + 5)), never crash. +- Graceful shutdown: SIGTERM/SIGINT → stop scheduler first, then `server.close()` with a 10s drain timeout, then `db.$client.end()`. (`docker stop` sends SIGTERM and waits 10s.) +- Rate limiter: lazy sweep of expired buckets on window rollover; cap total buckets at `RATE_LIMIT_MAX_BUCKETS` (default 10000); at cap, new keys share a single overflow bucket (still limited, never unlimited, never hard-denied). adapter-strapi negative (`null`) auth-cache entries capped (bounded, oldest-evicted; default 1000). +- Offer-id validation: impression/click `:id` not in the project's cached offers → 404 `not_found` envelope; config-plane failure → fail-open (accept + warn), documented inline. + +--- + +### Task 1: contracts — webhook messageId + not_found code + +**Files:** Modify `packages/contracts/src/webhooks.ts`, `src/errors.ts`; test append `packages/contracts/test/contracts.test.ts`. + +**Interfaces — produces:** +```ts +// webhooks.ts: webhookMessageSchema gains messageId: z.uuid() (REQUIRED field — the dispatcher is the only producer and Task 3 updates both call sites in the same sprint; consumers dedup on it) +// errors.ts: errorCodeSchema gains 'not_found' (additive) +``` +Tests (RED first): message with messageId round-trips; message without messageId rejected; `not_found` accepted by the envelope. **Known break (record, don't patch):** requiring `messageId` breaks `apps/api` webhook tests/dispatch call sites until Task 3 — contracts' own gates green. + +Commit: `feat(contracts): webhook message id and not_found error code` + +--- + +### Task 2: core + adapter-db — delivery-status columns, redelivery/retention store methods + +**Files:** Modify `packages/core/src/ports.ts` (WebhookDeliveryStore widening), `packages/adapter-db/src/schema.ts`, `src/stores.ts`; create migration `packages/adapter-db/migrations/0004_*` (drizzle-kit generate); test `packages/adapter-db/test/webhook-delivery.test.ts` (extend). + +**Schema:** `timedEventNotifications` gains `deliveredAt: timestamp('delivered_at', { withTimezone: true })` (nullable) and `attempts: integer('attempts').notNull().default(0)`. (`fired_at` already exists — it is the claim timestamp; do NOT add another.) + +**Interfaces — produces (port additions on WebhookDeliveryStore):** +```ts +markDelivered(projectId: string, eventId: string, transition: TimedEventTransition): Promise +// sets delivered_at = now() on the claim row (idempotent — already-delivered is a no-op update) +findStaleClaims(olderThan: Date, maxAttempts: number): Promise> +// rows where delivered_at IS NULL AND fired_at < olderThan AND attempts < maxAttempts +incrementAttempts(projectId: string, eventId: string, transition: TimedEventTransition): Promise +deleteDeadLettersBefore(cutoff: Date): Promise // returns deleted count +``` +**Known break:** api fakes/tests referencing WebhookDeliveryStore stay red until Task 3. adapter-db gates green. + +Tests (Testcontainers, extend existing file): claim → markDelivered sets delivered_at (raw SQL assert); findStaleClaims returns only null-delivered rows older than cutoff and below maxAttempts (seed a delivered, a fresh, an exhausted, and a stale row); incrementAttempts increments; deleteDeadLettersBefore deletes only older rows and returns count. Migration applies on fresh DB (runMigrations in beforeAll — existing pattern). + +Commit: `feat(core,adapter-db): webhook delivery status, stale-claim lookup, dead-letter retention` + +--- + +### Task 3: api — dispatcher delivered-marking, messageId, scheduler redelivery + retention + +**Files:** Modify `apps/api/src/webhooks.ts`, `apps/api/src/routes/events.ts` (unlock webhook gains messageId), `apps/api/test/webhooks.test.ts`, `test/fakes.ts` (delivery-store fake gains the four new methods); README webhook section (consumer dedup by `messageId` + replay-window check on signed `createdAt`; SSRF posture note: dispatcher POSTs to customer URLs, private-IP blocking is future multi-tenant work; disabled-after-live events emit no `ended` message — documented). + +**Behavior:** +- `WebhookDispatcher.deliver` returns `Promise` still, but `deliverToEndpoint` outcomes are awaited via the existing `Promise.allSettled`; a new public `deliverTransition(projectId, eventId, transition, message)` wraps deliver + `markDelivered` after all endpoints settle (each endpoint either succeeded or dead-lettered — "resolved"). A crash before markDelivered leaves the claim stale → redelivery sweep finds it. The unlock path in events.ts keeps plain `deliver` (unlocks have no claim row) but adds `messageId: crypto.randomUUID()` to its message; the scheduler builds messages with `messageId` too. +- Scheduler tick additions (order): (1) normal transition scan (claim → `deliverTransition`); (2) redelivery sweep — `findStaleClaims(now - redeliveryGraceMs, 5)`, for each: `incrementAttempts`, rebuild the message from the event definition in the feed (fresh `messageId` — consumers dedup per message, the redelivery IS a new message; document this), `deliverTransition`; if the event definition is absent from the feed, `recordDeadLetter(projectId, '', claimJson, 'event definition no longer in scan window', now)` + `markDelivered` (stop the loop); if `attempts` already ≥ 5 findStaleClaims excludes it, but the 5th failure path dead-letters + marks delivered explicitly; (3) retention sweep — `deleteDeadLettersBefore(now - ttlDays)`, log count when > 0. +- `startLifecycleScheduler` opts gain `{ redeliveryGraceMinutes?: number (default 5), scanGraceMinutes?: number (default 60), deadLetterTtlDays?: number (default 30) }`; startup ordering assert: if `scanGraceMinutes <= redeliveryGraceMinutes`, `logger.warn` and clamp `scanGraceMinutes = redeliveryGraceMinutes + 5`. `index.ts` wires the three envs (`WEBHOOK_REDELIVERY_GRACE_MINUTES`, `TIMED_EVENT_SCAN_GRACE_MINUTES`, `WEBHOOK_DEAD_LETTER_TTL_DAYS`). The scanGrace value is CONSUMED in Task 4 (passed to the config plane); this task only plumbs + asserts it. + +Tests (fakes, extend webhooks.test.ts): delivered claim marked after successful dispatch; crash-sim (fake deliver throws) leaves claim unmarked; stale claim re-driven with incremented attempts and fresh messageId; unresolvable stale claim dead-lettered + marked; exhausted (attempts=5) not re-driven; retention sweep called with correct cutoff; ordering assert warns + clamps; every scheduler message carries a uuid messageId. Workspace typecheck fully green again after this task. + +Commit: `feat(api): webhook redelivery, dead-letter retention, message ids (closes #8 scope pt 1)` + +--- + +### Task 4: lifecycle plumbing — cms scan filter, adapter param, graceful shutdown + +**Files:** Modify `apps/cms/src/api/config-plane/controllers/config-plane.ts` (`timedEventsAll` handler), `packages/adapter-strapi/src/index.ts` + `src/schemas.ts` (constructor opt + query param), `packages/core/src/ports.ts` (NO change — the param rides the adapter constructor, not the port), `apps/api/src/index.ts` (shutdown + wiring); tests `packages/adapter-strapi/test/adapter.test.ts`, cms live-verify. + +**Behavior:** +- cms `timedEventsAll`: accepts `?endedWithinMinutes=`; when present and valid (positive int), filters out events with `endsAt < now - endedWithinMinutes`; absent/invalid → unfiltered (backward compatible). Live-verify with curl (filtered vs unfiltered). +- adapter-strapi: `StrapiConfigPlane` constructor opts gain `allTimedEventsEndedWithinMinutes?: number`; when set, `getAllTimedEvents` appends the query param. Test with stub fetch (param present/absent in requested URL). +- api `index.ts`: construct plane with `allTimedEventsEndedWithinMinutes: scanGraceMinutes` (same env as Task 3); **graceful shutdown** — capture `const stopScheduler = startLifecycleScheduler(...)` (currently discarded) and `const server = serve(...)`; on SIGTERM/SIGINT: `stopScheduler()`, `server.close(cb)` with a 10s `setTimeout(..., 10_000).unref()` force-exit fallback, `await db.$client.end()`, `process.exit(0)`. Log each phase. Extract as `installShutdownHandlers({ stopScheduler, server, pool, logger })` in a new `apps/api/src/shutdown.ts` so it's unit-testable with fakes (signal simulated by calling the returned handler directly; assert ordering: scheduler stopped before server.close, pool ended after). + +Tests: shutdown.ts unit test (ordering + force-exit timer unref'd, via fake timers); adapter-strapi param tests; cms curl evidence in report. Note for reviewer: readyz probe (`plane.getAllTimedEvents()`) now sees the filtered feed — fine, it checks reachability not completeness. + +Commit: `feat(cms,adapter-strapi,api): ended-event scan window and graceful shutdown (closes #8 scope pt 2)` + +--- + +### Task 5: rate-limiter + auth-cache memory bounds (#10) + +**Files:** Modify `apps/api/src/rate-limit.ts`, `packages/adapter-strapi/src/index.ts`; tests `apps/api/test/security.test.ts` (extend), `packages/adapter-strapi/test/adapter.test.ts` (extend). + +**Behavior:** +- rate-limit.ts: `createRateLimiter(limitPerMinute, opts?: { maxBuckets?: number; now?: () => number })` (now injectable for tests; default `Date.now`). On each request where the requester's own bucket rolls over (`now - windowStart >= WINDOW_MS`), ALSO sweep: iterate the map deleting every expired bucket (lazy full sweep amortized to at most once per window per active key — cheap at 10k cap; note the O(n) bound in a comment). Cap: when `buckets.size >= maxBuckets` (default 10000, env `RATE_LIMIT_MAX_BUCKETS` read in app.ts) and the key is new, use the shared literal key `'__overflow__'` bucket instead of inserting — still counted and 429-able, never unlimited, never denied outright. +- adapter-strapi: negative auth-cache bound — when `verifyKey` caches a `null` value and the count of currently-cached null entries is at `maxNegativeAuthEntries` (constructor opt, default 1000), evict the oldest null entry first (track insertion order — a Set of keyHashes for null entries alongside the existing Map suffices). Positive entries unaffected. + +Tests: sweep removes expired buckets (fake `now`, advance a window, assert internal size via behavior: flood N keys in window 1, advance, one request from a fresh key, then assert the overflow path does NOT trigger for the next new key — expose bucket count via an optional test-only accessor `_bucketCount()` documented as test-internal); at-cap new key shares overflow bucket (two new keys at cap 429 together at the shared limit); adapter-strapi: 1001st null-cached key evicts the first null entry (first key re-fetches on next verify — assert fetch call count), positive entries survive. + +Commit: `fix(api,adapter-strapi): bound rate-limiter buckets and negative auth cache (closes #10)` + +--- + +### Task 6: offer-id validation on impression/click (#13) + +**Files:** Modify `apps/api/src/routes/offers.ts`; tests `apps/api/test/offers.test.ts` (extend). + +**Behavior:** Both `POST /:id/click` and `POST /:id/impression`, after body validation: `const offers = await deps.configStore.getOffers(scope.projectId).catch(() => null)`; if `offers !== null && !offers.some((o) => o.id === offerId)` → 404 `{ error: { code: 'not_found', message: 'Unknown offer id.' } }`. `null` (config failure) → fail-open: record + `logger.warn` (availability over strictness for pk-facing writes — inline comment). Recording call unchanged. + +Tests: known id → 200 recorded; unknown id → 404, nothing recorded (assert fake untouched); config-store failure → 200 recorded (fail-open); both routes covered. + +Commit: `fix(api): validate offer id on impression and click routes (closes #13)` + +--- + +### Task 7: polish sweep (#11) + +**Files:** Modify `apps/api/src/routes/events.ts`, `src/routes/placements.ts` (route-level `logger.warn` calls become per-request child loggers: `logger.child({ requestId: c.get('requestId') })` — smallest change: build the child inline where warns occur), `apps/api/src/app.ts` (serve static Redoc page at `GET /docs`: inline HTML string embedding `` + the Redoc CDN script tag, auth-free alongside openapi.json — note: page loads the viewer from CDN in the browser; the API itself stays dependency-free), `packages/contracts/package.json`, `packages/sdk/package.json`, `packages/widgets/package.json` (add `"files": ["dist", "README.md", "LICENSE"]`), `.github/workflows/release.yml` (add `pnpm turbo run test --filter='./packages/*'` before publish; add `git push --tags` step after publish), root README (log-retention note beside the MAU-retention/erasure docs: external user IDs appear in access-log paths; erasure does not touch logs; document retention expectation), `docs/retros/README.md` (create: one-paragraph retro stubs for Sprints 0-5 sourced from the progress ledger), `.changeset/README-authoring.md` or a note in root README (changesets should list only actually-changed packages). + +Tests: existing suites stay green; new test asserts `GET /docs` returns 200 HTML without auth; a log-capture test (pino test transport or spy) asserts a route warn carries `requestId`. `npm pack --dry-run` output for the three packages captured in the report (files allowlist verification). + +Commit: `chore: fast-follow polish — request-id logs, docs page, tarball hygiene, release fixes (closes #11)` + +--- + +### Task 8: test-hardening sweep (#14) + +**Files:** Modify `packages/adapter-db/test/ingestion.test.ts` (N-way race: 8 parallel `ingestEvent` calls, distinct idempotency keys, same user/achievement target 20 → raw SQL current === 8), `packages/widgets/test/widgets.test.tsx` (render `Placement` inside ``, assert exactly one beacon call), `packages/contracts/test/contracts.test.ts` (statsQuerySchema: valid Z-datetime accepted, junk rejected, empty object accepted; impression request with userId omitted accepted), `packages/core/test/suggest.test.ts` (exact distance-2 input matches, exact distance-3 input → null — e.g. registered `['level_complete']`, input `'level_compl'` (distance 3 → null) vs `'level_complet'` (distance 2... verify actual distances when writing; construct pairs by deleting 2 vs 3 trailing chars)), `apps/api/src/routes/events.ts` (replace `nameById.get(u.achievementId)!` with `?? u.achievementId` + one test where the fake returns a newUnlock absent from increments asserting the fallback name). + +Tests ARE the deliverable; every existing suite stays green; no production behavior changes except the `nameById` fallback. + +Commit: `test: race loop, StrictMode beacon, schema coverage, suggester boundary; nameById fallback (closes #14)` + +--- + +### Task 9: Docker images + compose stack + +**Files:** Create `apps/api/Dockerfile`, `apps/cms/Dockerfile`, `apps/demo/Dockerfile`, root `.dockerignore` (node_modules, .git, dist, .next, .turbo, .superpowers, docs), root `.env.example` (full stack contract: all cms vars from apps/cms/.env.example with stack-appropriate values + `CONFIG_PLANE_SECRET`, `RATE_LIMIT_PER_MINUTE`, `TIMED_EVENT_SCAN_GRACE_MINUTES`, `WEBHOOK_REDELIVERY_GRACE_MINUTES`, `WEBHOOK_DEAD_LETTER_TTL_DAYS`, `PROMOCEAN_SECRET_KEY`), create `apps/api/.env.example` (`DATABASE_URL`, `STRAPI_URL`, `CONFIG_PLANE_SECRET`, `API_PORT`, `RATE_LIMIT_PER_MINUTE`, `RATE_LIMIT_MAX_BUCKETS`, `LOG_LEVEL`, the three webhook envs); modify `apps/demo/next.config.ts` (`output: 'standalone'`), `docker-compose.yml` (see spec §3.2: postgres healthcheck `pg_isready -U promocean`; `cms`/`api`/`demo` services under `profiles: ["stack"]`, `build:` contexts at repo root with per-app dockerfile, healthcheck-gated `depends_on` — cms waits postgres healthy, api waits postgres+cms healthy with healthcheck on `/readyz` via busybox wget, demo waits api healthy; env per spec incl. `STRAPI_URL=http://cms:1337`, `PROMOCEAN_API_URL=http://api:3001`, `DATABASE_URL=postgres://promocean:promocean@postgres:5432/promocean` — note in-network port 5432, not the 5433 host mapping). + +**Dockerfile shape (all three, adjust pkg name/entrypoint):** stage 1 `base` (node:22-alpine + `corepack enable`); stage 2 `pruner` (copy repo, `pnpm dlx turbo@ prune --docker`); stage 3 `installer` (copy `out/json` + lockfile, `pnpm install --frozen-lockfile`, copy `out/full`, `pnpm turbo run build --filter=`); stage 4 `runner` (non-root `node` user, `NODE_ENV=production`, copy built output + prod node_modules — for api copy the pruned workspace and run `node apps/api/dist/index.js`; for cms copy the built strapi app and run `pnpm --filter cms start` equivalent (`node_modules/.bin/strapi start` from apps/cms dir); for demo copy `.next/standalone` + `.next/static` + `public`, run `node apps/demo/server.js`). Demo stage 3 takes `ARG NEXT_PUBLIC_PROMOCEAN_KEY=pk_test_demo_1234567890abcdef` / `ARG NEXT_PUBLIC_PROMOCEAN_API=http://localhost:3001` exported as ENV before `next build`. If sharp/musl breaks the cms build, switch that Dockerfile's bases to `node:22-slim` and add a comment; capture the decision in the report. + +**Verification (is the test cycle for this task):** `docker compose --profile stack build` succeeds; from a wiped stack (`docker compose --profile stack down -v` — disposable local demo data, established precedent) `docker compose --profile stack up -d --wait` exits 0; `curl localhost:3001/readyz` → 200; `curl localhost:3002` → 200; seeded demo visible; `docker stop ` completes in <10s with the shutdown log phases visible in `docker logs` (graceful-shutdown live proof). Then `pnpm --filter demo e2e` against the running containers → 3/3. Capture all outputs. + +Commit: `feat: production docker images and one-command compose stack` + +--- + +### Task 10: CI against containers + README quickstart — sprint DoD + +**Files:** Modify `.github/workflows/ci.yml` (e2e job: drop the hand-rolled `pnpm start` service boots and `docker compose up -d postgres`; instead `docker compose --profile stack build` (with `docker/build-push-action`-style GHA layer cache OR plain build — prefer simple: `docker compose --profile stack build` with `cache-from: type=gha` only if straightforward via `docker buildx bake`; plain uncached build is acceptable if cache wiring fights compose — note the choice), write the CI env into a `.env` file for compose (same values the job env block has today; `NEXT_PUBLIC_*` become build args), `docker compose --profile stack up -d --wait`, keep `playwright install`, run `pnpm --filter demo e2e`, on failure `docker compose logs` dump step (`if: failure()`), teardown `docker compose --profile stack down -v`); root README (quickstart section: clone → `cp .env.example .env` → `docker compose --profile stack up` → URLs; dev-mode section unchanged, clarified as the profile-less flow). + +Note: the unit `test` job is untouched. The e2e job no longer needs node/pnpm setup for the servers, but keeps it for playwright itself. + +**DoD steps (in order):** CI-equivalent run locally (build → up --wait → e2e 3/3 → down); push branch and confirm the GitHub Actions e2e job goes green on the PR (this is the real gate — watch it); `pnpm turbo run typecheck build test` fully green locally; README quickstart followed verbatim from a clean `git clone` into a temp dir (scratchpad) to prove the one-command story. + +Commit: `ci: build images and run e2e against the compose stack; one-command quickstart docs` + +--- + +## Self-Review Notes + +- **Spec coverage:** §3.1 images ✓ (T9); §3.2 compose + env contract ✓ (T9); §3.3 CI ✓ (T10); §3.4 webhook hardening ✓ (T1 messageId, T2 storage, T3 redelivery/retention/docs, T4 scan filter + shutdown); §3.5 memory bounds ✓ (T5); §3.6 offer-id ✓ (T6, uses T1's not_found); §3.7 polish + tests ✓ (T7, T8); §4 error handling distributed (healthcheck ordering T9, shutdown drain T4, redelivery idempotence T3, overflow bucket T5); §5 testing mapped 1:1; §6 DoD = T9 verification + T10. +- **Ordering rationale:** hardening (T1-T8) before Docker (T9) so images contain final code; CI (T10) last because it needs the images. T1→T3 known-break chain (required messageId) is two tasks long and recorded. +- **Type consistency check:** `WebhookDeliveryStore` additions named identically in T2 (port+impl) and T3 (consumer+fakes): `markDelivered`, `findStaleClaims(olderThan, maxAttempts)`, `incrementAttempts`, `deleteDeadLettersBefore`. Scheduler opts (`redeliveryGraceMinutes`, `scanGraceMinutes`, `deadLetterTtlDays`) named identically in T3 (plumb+assert) and T4 (scanGrace consumed via adapter constructor opt `allTimedEventsEndedWithinMinutes`). Env names consistent across T3/T4/T9 (`WEBHOOK_REDELIVERY_GRACE_MINUTES`, `TIMED_EVENT_SCAN_GRACE_MINUTES`, `WEBHOOK_DEAD_LETTER_TTL_DAYS`, `RATE_LIMIT_MAX_BUCKETS`). +- **Deliberate choices encoded:** required (not optional) `messageId` — single-producer wire format, updated atomically within the sprint; redelivery issues a FRESH messageId per attempt (each delivery is a new message; consumer dedup is per-message, replay protection is the signed createdAt window); `/docs` loads Redoc from CDN in the browser (API stays dependency-free) — acceptable for a docs page, noted for the reviewer. +- **Compression note:** as with Sprints 2-5, test code is specified behaviorally (patterns long established); production interfaces, env names, and defaults are exact. From f0303384d35f84c5d3daa62116b6a3210896728d Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 14:17:04 -0700 Subject: [PATCH 03/17] feat(contracts): webhook message id and not_found error code Co-Authored-By: Claude Fable 5 --- packages/contracts/src/webhooks.ts | 1 + packages/contracts/test/contracts.test.ts | 32 ++++++++++++++++++++ packages/contracts/test/timed-events.test.ts | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/webhooks.ts b/packages/contracts/src/webhooks.ts index dafdec3..4b7a642 100644 --- a/packages/contracts/src/webhooks.ts +++ b/packages/contracts/src/webhooks.ts @@ -1,6 +1,7 @@ import { z } from 'zod' export const webhookMessageSchema = z.object({ + messageId: z.string().uuid(), type: z.enum(['timed_event.live', 'timed_event.ending_soon', 'timed_event.ended', 'achievement.unlocked']), data: z.record(z.string(), z.unknown()), createdAt: z.iso.datetime(), diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index 5f80efa..9246620 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -8,6 +8,7 @@ import { offerImpressionRequestSchema, offerImpressionResponseSchema, statsResponseSchema, + webhookMessageSchema, } from '../src/index.js' describe('trackEventRequestSchema', () => { @@ -143,4 +144,35 @@ describe('error codes', () => { }) expect(result.success).toBe(true) }) + it('accepts not_found error code', () => { + const result = errorEnvelopeSchema.safeParse({ + error: { code: 'not_found', message: 'Resource not found' }, + }) + expect(result.success).toBe(true) + }) +}) + +describe('webhookMessageSchema', () => { + it('accepts a message with messageId', () => { + const payload = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'timed_event.live', + data: { eventId: 'e1' }, + createdAt: '2026-07-08T10:00:00.000Z', + } + const result = webhookMessageSchema.safeParse(payload) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toEqual(payload) + } + }) + it('rejects a message without messageId', () => { + const payload = { + type: 'timed_event.live', + data: { eventId: 'e1' }, + createdAt: '2026-07-08T10:00:00.000Z', + } + const result = webhookMessageSchema.safeParse(payload) + expect(result.success).toBe(false) + }) }) diff --git a/packages/contracts/test/timed-events.test.ts b/packages/contracts/test/timed-events.test.ts index e11b028..ef26112 100644 --- a/packages/contracts/test/timed-events.test.ts +++ b/packages/contracts/test/timed-events.test.ts @@ -16,7 +16,7 @@ describe('timed event schemas', () => { expect(liveEventsResponseSchema.safeParse({ events: [{ ...event, state }] }).success).toBe(false) }) it('validates webhook messages and exports the signature header', () => { - expect(webhookMessageSchema.parse({ type: 'achievement.unlocked', data: { userId: 'u1' }, createdAt: event.startsAt }).type).toBe('achievement.unlocked') + expect(webhookMessageSchema.parse({ messageId: '550e8400-e29b-41d4-a716-446655440000', type: 'achievement.unlocked', data: { userId: 'u1' }, createdAt: event.startsAt }).type).toBe('achievement.unlocked') expect(webhookMessageSchema.safeParse({ type: 'other', data: {}, createdAt: event.startsAt }).success).toBe(false) expect(WEBHOOK_SIGNATURE_HEADER).toBe('x-promocean-signature') }) From 58ac9c69d128d50ab2199065d64275b01f6b26e4 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 14:19:51 -0700 Subject: [PATCH 04/17] fix(contracts): use zod v4 top-level z.uuid() for webhook messageId Co-Authored-By: Claude Fable 5 --- packages/contracts/src/webhooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/src/webhooks.ts b/packages/contracts/src/webhooks.ts index 4b7a642..a28d11d 100644 --- a/packages/contracts/src/webhooks.ts +++ b/packages/contracts/src/webhooks.ts @@ -1,7 +1,7 @@ import { z } from 'zod' export const webhookMessageSchema = z.object({ - messageId: z.string().uuid(), + messageId: z.uuid(), type: z.enum(['timed_event.live', 'timed_event.ending_soon', 'timed_event.ended', 'achievement.unlocked']), data: z.record(z.string(), z.unknown()), createdAt: z.iso.datetime(), From f2986a0f2eefae529b6267cfd3f886385875c785 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 14:23:37 -0700 Subject: [PATCH 05/17] feat(core,adapter-db): webhook delivery status, stale-claim lookup, dead-letter retention Co-Authored-By: Claude Fable 5 --- .../migrations/0004_fancy_electro.sql | 2 + .../migrations/meta/0004_snapshot.json | 725 ++++++++++++++++++ .../adapter-db/migrations/meta/_journal.json | 7 + packages/adapter-db/src/schema.ts | 2 + packages/adapter-db/src/stores.ts | 40 +- .../adapter-db/test/webhook-delivery.test.ts | 74 ++ packages/core/src/ports.ts | 7 + 7 files changed, 856 insertions(+), 1 deletion(-) create mode 100644 packages/adapter-db/migrations/0004_fancy_electro.sql create mode 100644 packages/adapter-db/migrations/meta/0004_snapshot.json diff --git a/packages/adapter-db/migrations/0004_fancy_electro.sql b/packages/adapter-db/migrations/0004_fancy_electro.sql new file mode 100644 index 0000000..934274d --- /dev/null +++ b/packages/adapter-db/migrations/0004_fancy_electro.sql @@ -0,0 +1,2 @@ +ALTER TABLE "runtime"."timed_event_notifications" ADD COLUMN "delivered_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "runtime"."timed_event_notifications" ADD COLUMN "attempts" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/packages/adapter-db/migrations/meta/0004_snapshot.json b/packages/adapter-db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..d42862d --- /dev/null +++ b/packages/adapter-db/migrations/meta/0004_snapshot.json @@ -0,0 +1,725 @@ +{ + "id": "d29da447-031e-40d4-96c8-450db274fa9d", + "prevId": "1c61e033-4e55-4ae3-9ed3-5510126e4368", + "version": "7", + "dialect": "postgresql", + "tables": { + "runtime.achievement_progress": { + "name": "achievement_progress", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_id": { + "name": "achievement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current": { + "name": "current", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "progress_uq": { + "name": "progress_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.events": { + "name": "events", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_idem_uq": { + "name": "events_idem_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_stats_ix": { + "name": "events_stats_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.monthly_active_users": { + "name": "monthly_active_users", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mau_uq": { + "name": "mau_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.offer_events": { + "name": "offer_events", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offer_id": { + "name": "offer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "offer_events_idem_uq": { + "name": "offer_events_idem_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"runtime\".\"offer_events\".\"kind\" = 'impression' and \"runtime\".\"offer_events\".\"idempotency_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "offer_events_stats_ix": { + "name": "offer_events_stats_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "offer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.timed_event_notifications": { + "name": "timed_event_notifications", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transition": { + "name": "transition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "event_notif_uq": { + "name": "event_notif_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transition", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.unlocks": { + "name": "unlocks", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_id": { + "name": "achievement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "unlocks_uq": { + "name": "unlocks_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unlocks_stats_ix": { + "name": "unlocks_stats_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.usage_counters": { + "name": "usage_counters", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events_count": { + "name": "events_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "usage_uq": { + "name": "usage_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.webhook_dead_letters": { + "name": "webhook_dead_letters", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "runtime": "runtime" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/adapter-db/migrations/meta/_journal.json b/packages/adapter-db/migrations/meta/_journal.json index 4bc3038..d3f3aa9 100644 --- a/packages/adapter-db/migrations/meta/_journal.json +++ b/packages/adapter-db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1783486716029, "tag": "0003_brown_pride", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1783545736622, + "tag": "0004_fancy_electro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/adapter-db/src/schema.ts b/packages/adapter-db/src/schema.ts index 58bf799..1a506e4 100644 --- a/packages/adapter-db/src/schema.ts +++ b/packages/adapter-db/src/schema.ts @@ -73,6 +73,8 @@ export const timedEventNotifications = runtime.table('timed_event_notifications' eventId: text('event_id').notNull(), transition: text('transition').notNull(), firedAt: timestamp('fired_at', { withTimezone: true }).defaultNow().notNull(), + deliveredAt: timestamp('delivered_at', { withTimezone: true }), + attempts: integer('attempts').notNull().default(0), }, (t) => [uniqueIndex('event_notif_uq').on(t.projectId, t.eventId, t.transition)]) export const webhookDeadLetters = runtime.table('webhook_dead_letters', { diff --git a/packages/adapter-db/src/stores.ts b/packages/adapter-db/src/stores.ts index 3552086..2f94af4 100644 --- a/packages/adapter-db/src/stores.ts +++ b/packages/adapter-db/src/stores.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, inArray, lte, sql } from 'drizzle-orm' +import { and, eq, gte, inArray, lt, lte, sql } from 'drizzle-orm' import type { ErasureStore, EventStore, IngestionStore, OfferMetricsStore, ProgressStore, Scope, StatsStore, TimedEventTransition, UsageStore, WebhookDeliveryStore } from '@promocean/core' import { achievementProgress, events, monthlyActiveUsers, offerEvents, timedEventNotifications, unlocks, usageCounters, webhookDeadLetters } from './schema.js' import type { Db } from './index.js' @@ -234,6 +234,44 @@ export class PgWebhookDeliveryStore implements WebhookDeliveryStore { async recordDeadLetter(projectId: string, url: string, payload: string, error: string, at: Date) { await this.db.insert(webhookDeadLetters).values({ projectId, url, payload, error, createdAt: at }) } + async markDelivered(projectId: string, eventId: string, transition: TimedEventTransition) { + await this.db.update(timedEventNotifications) + .set({ deliveredAt: sql`now()` }) + .where(and( + eq(timedEventNotifications.projectId, projectId), + eq(timedEventNotifications.eventId, eventId), + eq(timedEventNotifications.transition, transition), + )) + } + async findStaleClaims(olderThan: Date, maxAttempts: number) { + const rows = await this.db.select({ + projectId: timedEventNotifications.projectId, + eventId: timedEventNotifications.eventId, + transition: timedEventNotifications.transition, + attempts: timedEventNotifications.attempts, + }).from(timedEventNotifications) + .where(and( + sql`${timedEventNotifications.deliveredAt} is null`, + lt(timedEventNotifications.firedAt, olderThan), + lt(timedEventNotifications.attempts, maxAttempts), + )) + return rows.map((r) => ({ ...r, transition: r.transition as TimedEventTransition })) + } + async incrementAttempts(projectId: string, eventId: string, transition: TimedEventTransition) { + await this.db.update(timedEventNotifications) + .set({ attempts: sql`${timedEventNotifications.attempts} + 1` }) + .where(and( + eq(timedEventNotifications.projectId, projectId), + eq(timedEventNotifications.eventId, eventId), + eq(timedEventNotifications.transition, transition), + )) + } + async deleteDeadLettersBefore(cutoff: Date) { + const deleted = await this.db.delete(webhookDeadLetters) + .where(lt(webhookDeadLetters.createdAt, cutoff)) + .returning({ id: webhookDeadLetters.id }) + return deleted.length + } } export class PgErasureStore implements ErasureStore { diff --git a/packages/adapter-db/test/webhook-delivery.test.ts b/packages/adapter-db/test/webhook-delivery.test.ts index c60db68..49ff081 100644 --- a/packages/adapter-db/test/webhook-delivery.test.ts +++ b/packages/adapter-db/test/webhook-delivery.test.ts @@ -26,4 +26,78 @@ describe('PgWebhookDeliveryStore', () => { const { rows } = await db.$client.query(`select url, error from runtime.webhook_dead_letters where project_id='p1'`) expect(rows).toEqual([{ url: 'https://x.test/hook', error: 'server 500 after 4 attempts' }]) }) + + it('markDelivered sets delivered_at on the claim row and is idempotent', async () => { + const store = new PgWebhookDeliveryStore(db) + await store.claimTransition('p-md', 'e-md', 'live') + await store.markDelivered('p-md', 'e-md', 'live') + const { rows } = await db.$client.query( + `select delivered_at from runtime.timed_event_notifications where project_id='p-md' and event_id='e-md' and transition='live'`, + ) + expect(rows[0].delivered_at).not.toBeNull() + // Idempotent: calling again on an already-delivered row is a no-op update, not an error. + await expect(store.markDelivered('p-md', 'e-md', 'live')).resolves.toBeUndefined() + }) + + it('incrementAttempts increments the attempts counter', async () => { + const store = new PgWebhookDeliveryStore(db) + await store.claimTransition('p-ia', 'e-ia', 'live') + await store.incrementAttempts('p-ia', 'e-ia', 'live') + await store.incrementAttempts('p-ia', 'e-ia', 'live') + const { rows } = await db.$client.query( + `select attempts from runtime.timed_event_notifications where project_id='p-ia' and event_id='e-ia' and transition='live'`, + ) + expect(rows[0].attempts).toBe(2) + }) + + it('findStaleClaims returns only undelivered, aged, retryable rows', async () => { + const store = new PgWebhookDeliveryStore(db) + const old = new Date(Date.now() - 60 * 60 * 1000) // 1h ago + const recent = new Date() + const cutoff = new Date(Date.now() - 30 * 60 * 1000) // 30m ago + + // delivered: old + delivered -> excluded + await db.$client.query( + `insert into runtime.timed_event_notifications (project_id, event_id, transition, fired_at, delivered_at, attempts) values ('p-sc','delivered','live',$1,now(),0)`, + [old], + ) + // fresh: recent, undelivered -> excluded (not old enough) + await db.$client.query( + `insert into runtime.timed_event_notifications (project_id, event_id, transition, fired_at, delivered_at, attempts) values ('p-sc','fresh','live',$1,null,0)`, + [recent], + ) + // exhausted: old, undelivered, attempts >= maxAttempts -> excluded + await db.$client.query( + `insert into runtime.timed_event_notifications (project_id, event_id, transition, fired_at, delivered_at, attempts) values ('p-sc','exhausted','live',$1,null,5)`, + [old], + ) + // stale: old, undelivered, attempts < maxAttempts -> included + await db.$client.query( + `insert into runtime.timed_event_notifications (project_id, event_id, transition, fired_at, delivered_at, attempts) values ('p-sc','stale','live',$1,null,1)`, + [old], + ) + + const staleClaims = await store.findStaleClaims(cutoff, 5) + const staleForScope = staleClaims.filter((c) => c.projectId === 'p-sc') + expect(staleForScope).toEqual([{ projectId: 'p-sc', eventId: 'stale', transition: 'live', attempts: 1 }]) + }) +}) + +describe('PgWebhookDeliveryStore dead-letter retention', () => { + it('deleteDeadLettersBefore deletes only older rows and returns the count', async () => { + const store = new PgWebhookDeliveryStore(db) + const old = new Date(Date.now() - 60 * 60 * 1000) + const recent = new Date() + const cutoff = new Date(Date.now() - 30 * 60 * 1000) + + await store.recordDeadLetter('p-dl', 'https://x.test/old1', '{}', 'err', old) + await store.recordDeadLetter('p-dl', 'https://x.test/old2', '{}', 'err', old) + await store.recordDeadLetter('p-dl', 'https://x.test/recent', '{}', 'err', recent) + + const deletedCount = await store.deleteDeadLettersBefore(cutoff) + expect(deletedCount).toBe(2) + + const { rows } = await db.$client.query(`select url from runtime.webhook_dead_letters where project_id='p-dl'`) + expect(rows).toEqual([{ url: 'https://x.test/recent' }]) + }) }) diff --git a/packages/core/src/ports.ts b/packages/core/src/ports.ts index 98d9ee8..6fcf949 100644 --- a/packages/core/src/ports.ts +++ b/packages/core/src/ports.ts @@ -79,6 +79,13 @@ export interface StatsStore { export interface WebhookDeliveryStore { claimTransition(projectId: string, eventId: string, transition: TimedEventTransition): Promise recordDeadLetter(projectId: string, url: string, payload: string, error: string, at: Date): Promise + /** Sets delivered_at = now() on the claim row. Idempotent: already-delivered is a no-op update. */ + markDelivered(projectId: string, eventId: string, transition: TimedEventTransition): Promise + /** Rows where delivered_at IS NULL AND fired_at < olderThan AND attempts < maxAttempts. */ + findStaleClaims(olderThan: Date, maxAttempts: number): Promise> + incrementAttempts(projectId: string, eventId: string, transition: TimedEventTransition): Promise + /** Deletes dead letters created before cutoff. Returns the number deleted. */ + deleteDeadLettersBefore(cutoff: Date): Promise } export interface ErasureStore { From 419760f657d0fa1be474858076a3ff1d94b1575b Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 14:28:07 -0700 Subject: [PATCH 06/17] fix(adapter-db): markDelivered is value-stable idempotent; discriminating test Co-Authored-By: Claude Fable 5 --- packages/adapter-db/src/stores.ts | 5 +++-- packages/adapter-db/test/webhook-delivery.test.ts | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/adapter-db/src/stores.ts b/packages/adapter-db/src/stores.ts index 2f94af4..e1354c2 100644 --- a/packages/adapter-db/src/stores.ts +++ b/packages/adapter-db/src/stores.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, inArray, lt, lte, sql } from 'drizzle-orm' +import { and, eq, gte, inArray, isNull, lt, lte, sql } from 'drizzle-orm' import type { ErasureStore, EventStore, IngestionStore, OfferMetricsStore, ProgressStore, Scope, StatsStore, TimedEventTransition, UsageStore, WebhookDeliveryStore } from '@promocean/core' import { achievementProgress, events, monthlyActiveUsers, offerEvents, timedEventNotifications, unlocks, usageCounters, webhookDeadLetters } from './schema.js' import type { Db } from './index.js' @@ -241,6 +241,7 @@ export class PgWebhookDeliveryStore implements WebhookDeliveryStore { eq(timedEventNotifications.projectId, projectId), eq(timedEventNotifications.eventId, eventId), eq(timedEventNotifications.transition, transition), + isNull(timedEventNotifications.deliveredAt), )) } async findStaleClaims(olderThan: Date, maxAttempts: number) { @@ -251,7 +252,7 @@ export class PgWebhookDeliveryStore implements WebhookDeliveryStore { attempts: timedEventNotifications.attempts, }).from(timedEventNotifications) .where(and( - sql`${timedEventNotifications.deliveredAt} is null`, + isNull(timedEventNotifications.deliveredAt), lt(timedEventNotifications.firedAt, olderThan), lt(timedEventNotifications.attempts, maxAttempts), )) diff --git a/packages/adapter-db/test/webhook-delivery.test.ts b/packages/adapter-db/test/webhook-delivery.test.ts index 49ff081..130f8e6 100644 --- a/packages/adapter-db/test/webhook-delivery.test.ts +++ b/packages/adapter-db/test/webhook-delivery.test.ts @@ -31,12 +31,21 @@ describe('PgWebhookDeliveryStore', () => { const store = new PgWebhookDeliveryStore(db) await store.claimTransition('p-md', 'e-md', 'live') await store.markDelivered('p-md', 'e-md', 'live') - const { rows } = await db.$client.query( + const { rows: rows1 } = await db.$client.query( `select delivered_at from runtime.timed_event_notifications where project_id='p-md' and event_id='e-md' and transition='live'`, ) - expect(rows[0].delivered_at).not.toBeNull() + expect(rows1[0].delivered_at).not.toBeNull() + const deliveredAt1 = rows1[0].delivered_at // Idempotent: calling again on an already-delivered row is a no-op update, not an error. - await expect(store.markDelivered('p-md', 'e-md', 'live')).resolves.toBeUndefined() + // Wait a bit to ensure any clock advancement would be visible if idempotency failed. + await new Promise((resolve) => setTimeout(resolve, 20)) + await store.markDelivered('p-md', 'e-md', 'live') + const { rows: rows2 } = await db.$client.query( + `select delivered_at from runtime.timed_event_notifications where project_id='p-md' and event_id='e-md' and transition='live'`, + ) + const deliveredAt2 = rows2[0].delivered_at + // Timestamp must be unchanged (exact equality) — the second call was a true no-op. + expect(deliveredAt2.getTime()).toBe(deliveredAt1.getTime()) }) it('incrementAttempts increments the attempts counter', async () => { From ada5d67cbc8e670461a8ab9328fc1d22838d547f Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 14:38:39 -0700 Subject: [PATCH 07/17] feat(api): webhook redelivery, dead-letter retention, message ids (closes #8 scope pt 1) Co-Authored-By: Claude Fable 5 --- README.md | 40 +++++ apps/api/src/index.ts | 9 +- apps/api/src/routes/events.ts | 2 + apps/api/src/webhooks.ts | 123 +++++++++++++-- apps/api/test/webhooks.test.ts | 264 ++++++++++++++++++++++++++++++--- 5 files changed, 405 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 5429a01..8065c6b 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,46 @@ or unset and any event type is accepted, no enforcement — this is the default for projects that haven't opted in. The seeded demo project registers `lesson_completed` and `profile_completed`. +## Webhooks + +The api dispatches signed `POST` webhooks for `timed_event.live` / +`timed_event.ending_soon` / `timed_event.ended` (fired by a 30s lifecycle +scheduler as events cross those thresholds) and `achievement.unlocked` +(fired inline from `POST /v1/events` when a track call unlocks an +achievement). Every message carries a `messageId` (a uuid) — **consumers +must dedup by `messageId`, not by event/transition**: a redelivery of a +timed-event transition is sent as a brand-new message with a fresh +`messageId`, not a retry of the original one. Also verify the +`x-promocean-signature` HMAC header and check the signed `createdAt` +against a replay window (e.g. reject anything older than a few minutes) — +both belong in your consumer regardless of transport. + +Timed-event delivery is claim-then-mark: the scheduler claims a transition +once, delivers it to every enabled endpoint (each endpoint independently +retries transient failures and is dead-lettered on permanent failure), then +marks the claim delivered. If the process crashes between delivering and +marking, the claim is left stale and a later tick's **redelivery sweep** +re-drives it (incrementing an attempt counter, capped at 5 attempts) with a +freshly built message and a new `messageId`, as above. A **retention +sweep** on the same tick purges dead letters older than +`WEBHOOK_DEAD_LETTER_TTL_DAYS` (default 30). A disabled event that was +never observed live emits no `ended` message — disabling before an event +ever went live means no lifecycle transition ever fired for it. + +The dispatcher `POST`s directly to whatever URL a project configures as a +webhook endpoint. There is currently no SSRF protection (e.g. blocking +private/internal IP ranges) — treat endpoint URLs as trusted input for now. +Blocking requests to private IP ranges is required before this becomes a +multi-tenant, self-service feature and is tracked as future work. + +Scheduler tuning (all optional, read once at process start): + +| Env var | Default | Purpose | +| --- | --- | --- | +| `WEBHOOK_REDELIVERY_GRACE_MINUTES` | `5` | How long a claimed-but-undelivered transition sits before the redelivery sweep re-drives it. | +| `TIMED_EVENT_SCAN_GRACE_MINUTES` | `60` | How far back the config-plane scan window looks for timed events. Must exceed the redelivery grace (a shorter scan window would let events drop out of the feed before a stale claim could ever be redriven) — if misconfigured, the scheduler logs a warning at startup and clamps it to `WEBHOOK_REDELIVERY_GRACE_MINUTES + 5`. | +| `WEBHOOK_DEAD_LETTER_TTL_DAYS` | `30` | Dead letters older than this are purged by the retention sweep. | + ## Publishing MIT packages (`@promocean/contracts`, `@promocean/sdk`, `@promocean/widgets`) publish via a two-step manual flow: diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 83c4994..cb56366 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -13,7 +13,14 @@ const plane = new StrapiConfigPlane({ }) const webhookDeliveryStore = new PgWebhookDeliveryStore(db) const webhooks = new WebhookDispatcher({ configStore: plane, deliveryStore: webhookDeliveryStore }) -startLifecycleScheduler({ configStore: plane, deliveryStore: webhookDeliveryStore, dispatcher: webhooks }) +startLifecycleScheduler({ + configStore: plane, + deliveryStore: webhookDeliveryStore, + dispatcher: webhooks, + redeliveryGraceMinutes: Number(process.env.WEBHOOK_REDELIVERY_GRACE_MINUTES ?? 5), + scanGraceMinutes: Number(process.env.TIMED_EVENT_SCAN_GRACE_MINUTES ?? 60), + deadLetterTtlDays: Number(process.env.WEBHOOK_DEAD_LETTER_TTL_DAYS ?? 30), +}) const app = createApp({ configStore: plane, apiKeyStore: plane, diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 6085478..0c69292 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import { Hono } from 'hono' import { trackEventRequestSchema, type TrackEventResponse } from '@promocean/contracts' import { activeMultiplier, evaluateEvent, suggestEventType, type Scope } from '@promocean/core' @@ -64,6 +65,7 @@ export function eventsRoute(deps: AppDeps) { if (unlocks.length > 0 && deps.webhooks) { void deps.webhooks .deliver(scope.projectId, { + messageId: randomUUID(), type: 'achievement.unlocked', data: { userId, environment: scope.environment, unlocks }, createdAt: unlocks[0]!.unlockedAt, diff --git a/apps/api/src/webhooks.ts b/apps/api/src/webhooks.ts index bd9d3db..80c1be1 100644 --- a/apps/api/src/webhooks.ts +++ b/apps/api/src/webhooks.ts @@ -1,10 +1,12 @@ -import { createHmac } from 'node:crypto' +import { createHmac, randomUUID } from 'node:crypto' import type { Logger } from 'pino' import { WEBHOOK_SIGNATURE_HEADER, type WebhookMessage } from '@promocean/contracts' -import { timedEventState, type ConfigStore, type TimedEventTransition, type WebhookDeliveryStore, type WebhookEndpointDefinition } from '@promocean/core' +import { timedEventState, type ConfigStore, type TimedEventDefinition, type TimedEventTransition, type WebhookDeliveryStore, type WebhookEndpointDefinition } from '@promocean/core' import { logger as rootLogger } from './logger.js' const BASE_BACKOFF_MS = 250 +/** A stale claim is redriven up to this many times before findStaleClaims excludes it for good. */ +const MAX_REDELIVERY_ATTEMPTS = 5 export class WebhookDispatcher { private configStore: ConfigStore @@ -42,6 +44,24 @@ export class WebhookDispatcher { ) } + /** + * Delivers a timed-event transition message, then marks the claim delivered. `deliver` + * never throws — by the time it settles, every endpoint has either succeeded or been + * dead-lettered, so the claim is "resolved" and safe to mark. If this process crashes + * between `deliver` settling and `markDelivered` completing — or `deliver` itself throws, + * e.g. under test — `markDelivered` never runs and the claim is left stale for the + * redelivery sweep to pick up on a later tick. + */ + async deliverTransition( + projectId: string, + eventId: string, + transition: TimedEventTransition, + message: WebhookMessage, + ): Promise { + await this.deliver(projectId, message) + await this.deliveryStore.markDelivered(projectId, eventId, transition) + } + private async deliverToEndpoint(projectId: string, endpoint: WebhookEndpointDefinition, rawBody: string): Promise { const signature = createHmac('sha256', endpoint.secret).update(rawBody).digest('hex') let lastError: unknown @@ -95,41 +115,114 @@ function reachedTransitions(state: ReturnType): TimedEve } } +/** Builds a fresh transition message. Called with a new messageId on every delivery attempt — + * including redeliveries, which consumers must treat as a distinct message to dedup against. */ +function buildTransitionMessage( + event: TimedEventDefinition & { projectId: string }, + transition: TimedEventTransition, + now: Date, +): WebhookMessage { + return { + messageId: randomUUID(), + type: `timed_event.${transition}`, + data: { + eventId: event.id, + name: event.name, + startsAt: event.startsAt.toISOString(), + endsAt: event.endsAt.toISOString(), + multiplier: event.multiplier, + }, + createdAt: now.toISOString(), + } +} + export function startLifecycleScheduler(opts: { configStore: ConfigStore deliveryStore: WebhookDeliveryStore dispatcher: WebhookDispatcher intervalMs?: number + /** How long a claimed-but-undelivered transition sits before the redelivery sweep re-drives it. Default 5. */ + redeliveryGraceMinutes?: number + /** Consumed by the config-plane scan window (Task 4); asserted here so it always exceeds + * redeliveryGraceMinutes — a scan window shorter than the redelivery grace would let events + * drop out of the feed before a stale claim could ever be redriven. Default 60. */ + scanGraceMinutes?: number + /** Dead letters older than this are purged by the retention sweep. Default 30. */ + deadLetterTtlDays?: number logger?: Logger }): () => void { const { configStore, deliveryStore, dispatcher, intervalMs = 30_000 } = opts const logger = opts.logger ?? rootLogger.child({ component: 'webhooks' }) + const redeliveryGraceMinutes = opts.redeliveryGraceMinutes ?? 5 + const deadLetterTtlDays = opts.deadLetterTtlDays ?? 30 + let scanGraceMinutes = opts.scanGraceMinutes ?? 60 + if (scanGraceMinutes <= redeliveryGraceMinutes) { + const clampedScanGraceMinutes = redeliveryGraceMinutes + 5 + logger.warn( + { scanGraceMinutes, redeliveryGraceMinutes, clampedScanGraceMinutes }, + 'lifecycle scheduler: scanGraceMinutes must exceed redeliveryGraceMinutes; clamping', + ) + scanGraceMinutes = clampedScanGraceMinutes + } + // scanGraceMinutes is plumbed + validated here; Task 4 passes it to the config-plane scan window. + + const redeliveryGraceMs = redeliveryGraceMinutes * 60_000 + const tick = async () => { + const now = new Date() + + // Phase 1: normal transition scan — claim newly-reached transitions and deliver them. try { const events = await configStore.getAllTimedEvents() - const now = new Date() for (const event of events) { const state = timedEventState(event, now) const transitions = reachedTransitions(state) for (const transition of transitions) { const claimed = await deliveryStore.claimTransition(event.projectId, event.id, transition) if (!claimed) continue - await dispatcher.deliver(event.projectId, { - type: `timed_event.${transition}`, - data: { - eventId: event.id, - name: event.name, - startsAt: event.startsAt.toISOString(), - endsAt: event.endsAt.toISOString(), - multiplier: event.multiplier, - }, - createdAt: now.toISOString(), - }) + await dispatcher.deliverTransition(event.projectId, event.id, transition, buildTransitionMessage(event, transition, now)) + } + } + } catch (err) { + logger.error({ err }, 'lifecycle scheduler: transition scan failed') + } + + // Phase 2: redelivery sweep — re-drive claims left stale by a crash before markDelivered. + try { + const events = await configStore.getAllTimedEvents() + const eventByKey = new Map(events.map((event) => [`${event.projectId}:${event.id}`, event])) + const staleClaims = await deliveryStore.findStaleClaims(new Date(now.getTime() - redeliveryGraceMs), MAX_REDELIVERY_ATTEMPTS) + for (const claim of staleClaims) { + await deliveryStore.incrementAttempts(claim.projectId, claim.eventId, claim.transition) + const event = eventByKey.get(`${claim.projectId}:${claim.eventId}`) + if (!event) { + // The event definition scrolled out of the scan window (or was deleted) before we + // could redrive it — nothing left to rebuild the message from. Dead-letter it and + // stop retrying rather than leaving it stale forever. + await deliveryStore.recordDeadLetter( + claim.projectId, + '', + JSON.stringify(claim), + 'event definition no longer in scan window', + now, + ) + await deliveryStore.markDelivered(claim.projectId, claim.eventId, claim.transition) + continue } + await dispatcher.deliverTransition(claim.projectId, claim.eventId, claim.transition, buildTransitionMessage(event, claim.transition, now)) } } catch (err) { - logger.error({ err }, 'lifecycle scheduler: tick failed') + logger.error({ err }, 'lifecycle scheduler: redelivery sweep failed') + } + + // Phase 3: retention sweep — purge old dead letters. + try { + const cutoff = new Date(now.getTime() - deadLetterTtlDays * 24 * 60 * 60 * 1000) + const deleted = await deliveryStore.deleteDeadLettersBefore(cutoff) + if (deleted > 0) logger.info({ deleted }, 'lifecycle scheduler: retention sweep purged dead letters') + } catch (err) { + logger.error({ err }, 'lifecycle scheduler: retention sweep failed') } } diff --git a/apps/api/test/webhooks.test.ts b/apps/api/test/webhooks.test.ts index 077f607..f68fb21 100644 --- a/apps/api/test/webhooks.test.ts +++ b/apps/api/test/webhooks.test.ts @@ -1,5 +1,6 @@ import { createHmac } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Logger } from 'pino' import { WEBHOOK_SIGNATURE_HEADER, type WebhookMessage } from '@promocean/contracts' import type { ConfigStore, TimedEventDefinition, WebhookDeliveryStore, WebhookEndpointDefinition } from '@promocean/core' import { WebhookDispatcher, startLifecycleScheduler } from '../src/webhooks.js' @@ -19,6 +20,12 @@ function makeDeliveryStore() { recordDeadLetter: async (projectId, url, payload, error, at) => { deadLetters.push({ projectId, url, payload, error, at }) }, + // Safe no-op defaults for tests that don't exercise redelivery/retention directly — + // individual tests below override whichever of these they need to assert on. + markDelivered: async () => {}, + findStaleClaims: async () => [], + incrementAttempts: async () => {}, + deleteDeadLettersBefore: async () => 0, } return { deliveryStore, deadLetters } } @@ -37,6 +44,7 @@ function makeConfigStore(opts: { } const message: WebhookMessage = { + messageId: '11111111-1111-4111-8111-111111111111', type: 'achievement.unlocked', data: { userId: 'u1', environment: 'test', unlocks: [] }, createdAt: '2026-07-06T00:00:00.000Z', @@ -135,21 +143,59 @@ describe('WebhookDispatcher.deliver — group B (failure handling)', () => { }) }) +describe('WebhookDispatcher.deliverTransition — group B2 (delivered-marking)', () => { + it('marks the claim delivered once every endpoint has resolved (succeeded or dead-lettered)', async () => { + const { deliveryStore } = makeDeliveryStore() + const marked: Array<[string, string, string]> = [] + deliveryStore.markDelivered = async (projectId, eventId, transition) => { marked.push([projectId, eventId, transition]) } + const configStore = makeConfigStore({ endpoints: [endpointA, endpointB] }) + const fetchImpl = vi.fn().mockImplementation((url: string) => { + if (url === endpointA.url) return Promise.resolve(new Response('', { status: 400 })) // dead-lettered + return Promise.resolve(new Response('', { status: 200 })) // succeeded + }) + const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl }) + + await dispatcher.deliverTransition('p1', 'e1', 'live', { ...message, type: 'timed_event.live' }) + + expect(marked).toEqual([['p1', 'e1', 'live']]) + }) + + it('leaves the claim unmarked when deliver itself throws (simulated crash before markDelivered)', async () => { + const { deliveryStore } = makeDeliveryStore() + const marked: unknown[] = [] + deliveryStore.markDelivered = async () => { marked.push(true) } + const configStore = makeConfigStore({ endpoints: [endpointA] }) + const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl: vi.fn() }) + vi.spyOn(dispatcher, 'deliver').mockRejectedValue(new Error('simulated crash')) + + await expect(dispatcher.deliverTransition('p1', 'e1', 'live', { ...message, type: 'timed_event.live' })).rejects.toThrow('simulated crash') + + expect(marked).toEqual([]) + }) +}) + const mkEvent = (over: Partial = {}): TimedEventDefinition & { projectId: string } => ({ id: 'e1', projectId: 'p1', name: 'Summer Sale', description: null, startsAt: new Date('2026-07-01T00:00:00Z'), endsAt: new Date('2026-07-31T00:00:00Z'), endingSoonMinutes: 60, multiplier: 2, enabled: true, ...over, }) -describe('startLifecycleScheduler — group C', () => { +type FakeDispatcher = { deliver: ReturnType; deliverTransition: ReturnType } & WebhookDispatcher + +function fakeDispatcher(deliverTransitionImpl?: (...args: unknown[]) => Promise): FakeDispatcher { + return { + deliver: vi.fn(async () => {}), + deliverTransition: vi.fn(deliverTransitionImpl ?? (async () => {})), + } as unknown as FakeDispatcher +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + +describe('startLifecycleScheduler — group C (transition scan)', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) - function fakeDispatcher() { - return { deliver: vi.fn(async () => {}) } as unknown as { deliver: ReturnType } & WebhookDispatcher - } - - it('claims and fires the live transition exactly once across two ticks', async () => { + it('claims and fires the live transition exactly once across two ticks, with a uuid messageId', async () => { vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) // well inside live window, not ending soon const event = mkEvent() const configStore = makeConfigStore({ allTimedEvents: [event] }) @@ -158,12 +204,15 @@ describe('startLifecycleScheduler — group C', () => { const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) await vi.advanceTimersByTimeAsync(1000) - expect(dispatcher.deliver).toHaveBeenCalledTimes(1) - expect(dispatcher.deliver.mock.calls[0][0]).toBe('p1') - expect(dispatcher.deliver.mock.calls[0][1]).toMatchObject({ type: 'timed_event.live' }) + expect(dispatcher.deliverTransition).toHaveBeenCalledTimes(1) + expect(dispatcher.deliverTransition.mock.calls[0][0]).toBe('p1') + expect(dispatcher.deliverTransition.mock.calls[0][1]).toBe('e1') + expect(dispatcher.deliverTransition.mock.calls[0][2]).toBe('live') + expect(dispatcher.deliverTransition.mock.calls[0][3]).toMatchObject({ type: 'timed_event.live' }) + expect(dispatcher.deliverTransition.mock.calls[0][3].messageId).toMatch(UUID_RE) await vi.advanceTimersByTimeAsync(1000) - expect(dispatcher.deliver).toHaveBeenCalledTimes(1) // already claimed, no re-fire + expect(dispatcher.deliverTransition).toHaveBeenCalledTimes(1) // already claimed, no re-fire stop() }) @@ -179,9 +228,11 @@ describe('startLifecycleScheduler — group C', () => { const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) await vi.advanceTimersByTimeAsync(1000) - expect(dispatcher.deliver).toHaveBeenCalledTimes(2) - expect(dispatcher.deliver.mock.calls[0][1]).toMatchObject({ type: 'timed_event.live' }) - expect(dispatcher.deliver.mock.calls[1][1]).toMatchObject({ type: 'timed_event.ending_soon' }) + expect(dispatcher.deliverTransition).toHaveBeenCalledTimes(2) + expect(dispatcher.deliverTransition.mock.calls[0][3]).toMatchObject({ type: 'timed_event.live' }) + expect(dispatcher.deliverTransition.mock.calls[1][3]).toMatchObject({ type: 'timed_event.ending_soon' }) + // fresh messageId per message, even within the same tick + expect(dispatcher.deliverTransition.mock.calls[0][3].messageId).not.toBe(dispatcher.deliverTransition.mock.calls[1][3].messageId) stop() }) @@ -196,7 +247,7 @@ describe('startLifecycleScheduler — group C', () => { const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) await vi.advanceTimersByTimeAsync(1000) - expect(dispatcher.deliver).not.toHaveBeenCalled() + expect(dispatcher.deliverTransition).not.toHaveBeenCalled() stop() }) @@ -209,11 +260,11 @@ describe('startLifecycleScheduler — group C', () => { const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) await vi.advanceTimersByTimeAsync(1000) - expect(dispatcher.deliver).toHaveBeenCalledTimes(1) + expect(dispatcher.deliverTransition).toHaveBeenCalledTimes(1) stop() await vi.advanceTimersByTimeAsync(10_000) - expect(dispatcher.deliver).toHaveBeenCalledTimes(1) // no further ticks after stop + expect(dispatcher.deliverTransition).toHaveBeenCalledTimes(1) // no further ticks after stop }) it('tick failures never throw out of the interval (catch-all)', async () => { @@ -225,8 +276,187 @@ describe('startLifecycleScheduler — group C', () => { const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) await vi.advanceTimersByTimeAsync(1000) // must not throw / reject - expect(dispatcher.deliver).not.toHaveBeenCalled() + expect(dispatcher.deliverTransition).not.toHaveBeenCalled() + stop() + }) +}) + +describe('startLifecycleScheduler — group C2 (redelivery sweep)', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('calls findStaleClaims with the redelivery-grace cutoff and a maxAttempts of 5 (exhausted claims are excluded by the store)', async () => { + vi.setSystemTime(new Date('2026-07-15T00:10:00Z')) + const configStore = makeConfigStore() + const { deliveryStore } = makeDeliveryStore() + const findStaleClaims = vi.fn().mockResolvedValue([]) + deliveryStore.findStaleClaims = findStaleClaims + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000, redeliveryGraceMinutes: 5 }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(findStaleClaims).toHaveBeenCalledTimes(1) + const [olderThan, maxAttempts] = findStaleClaims.mock.calls[0] as [Date, number] + expect(maxAttempts).toBe(5) + // tick fires 1000ms (intervalMs) after the system time set above + expect(olderThan).toEqual(new Date('2026-07-15T00:05:01Z')) + }) + + it('re-drives a stale claim: increments attempts, delivers a rebuilt message, and marks it delivered', async () => { + vi.setSystemTime(new Date('2026-07-15T00:10:00Z')) + const event = mkEvent() + const configStore = makeConfigStore({ allTimedEvents: [event], endpoints: [endpointA] }) + const { deliveryStore } = makeDeliveryStore() + deliveryStore.claimTransition = async () => false // already claimed by an earlier tick + const incremented: unknown[] = [] + deliveryStore.incrementAttempts = async (projectId, eventId, transition) => { incremented.push([projectId, eventId, transition]) } + const marked: unknown[] = [] + deliveryStore.markDelivered = async (projectId, eventId, transition) => { marked.push([projectId, eventId, transition]) } + deliveryStore.findStaleClaims = vi.fn() + .mockResolvedValueOnce([{ projectId: 'p1', eventId: 'e1', transition: 'live', attempts: 2 }]) + .mockResolvedValue([]) + const fetchImpl = vi.fn().mockResolvedValue(new Response('', { status: 200 })) + const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl }) + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(incremented).toEqual([['p1', 'e1', 'live']]) + expect(fetchImpl).toHaveBeenCalledTimes(1) + const rawBody = (fetchImpl.mock.calls[0][1] as RequestInit).body as string + const body = JSON.parse(rawBody) + expect(body.type).toBe('timed_event.live') + expect(body.messageId).toMatch(UUID_RE) + expect(marked).toEqual([['p1', 'e1', 'live']]) + }) + + it('rebuilds the message with a fresh messageId on every redelivery attempt', async () => { + vi.setSystemTime(new Date('2026-07-15T00:10:00Z')) + const event = mkEvent() + const configStore = makeConfigStore({ allTimedEvents: [event] }) + const { deliveryStore } = makeDeliveryStore() + deliveryStore.claimTransition = async () => false + deliveryStore.findStaleClaims = async () => [{ projectId: 'p1', eventId: 'e1', transition: 'live', attempts: 1 }] + const messageIds: string[] = [] + const dispatcher = fakeDispatcher(async (..._args: unknown[]) => { + const msg = _args[3] as WebhookMessage + messageIds.push(msg.messageId) + }) + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) stop() + + expect(messageIds).toHaveLength(2) + expect(messageIds[0]).toMatch(UUID_RE) + expect(messageIds[1]).toMatch(UUID_RE) + expect(messageIds[0]).not.toBe(messageIds[1]) + }) + + it('dead-letters and marks delivered an unresolvable stale claim (event definition no longer in the feed)', async () => { + vi.setSystemTime(new Date('2026-07-15T00:10:00Z')) + const configStore = makeConfigStore({ allTimedEvents: [] }) + const { deliveryStore, deadLetters } = makeDeliveryStore() + const marked: unknown[] = [] + deliveryStore.markDelivered = async (projectId, eventId, transition) => { marked.push([projectId, eventId, transition]) } + deliveryStore.findStaleClaims = async () => [{ projectId: 'p1', eventId: 'gone-1', transition: 'ended', attempts: 3 }] + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(dispatcher.deliverTransition).not.toHaveBeenCalled() + expect(deadLetters).toHaveLength(1) + expect(deadLetters[0]).toMatchObject({ projectId: 'p1', url: '', error: 'event definition no longer in scan window' }) + expect(JSON.parse(deadLetters[0].payload)).toEqual({ projectId: 'p1', eventId: 'gone-1', transition: 'ended', attempts: 3 }) + expect(marked).toEqual([['p1', 'gone-1', 'ended']]) + }) +}) + +describe('startLifecycleScheduler — group C3 (retention sweep)', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('deletes dead letters older than deadLetterTtlDays using the correct cutoff, and logs when count > 0', async () => { + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const configStore = makeConfigStore() + const { deliveryStore } = makeDeliveryStore() + const deleteDeadLettersBefore = vi.fn().mockResolvedValue(3) + deliveryStore.deleteDeadLettersBefore = deleteDeadLettersBefore + const dispatcher = fakeDispatcher() + const info = vi.fn() + const testLogger = { warn: vi.fn(), error: vi.fn(), info } as unknown as Logger + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000, deadLetterTtlDays: 30, logger: testLogger }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(deleteDeadLettersBefore).toHaveBeenCalledTimes(1) + const [cutoff] = deleteDeadLettersBefore.mock.calls[0] as [Date] + // tick fires 1000ms (intervalMs) after the system time set above + expect(cutoff).toEqual(new Date('2026-06-15T00:00:01Z')) + expect(info).toHaveBeenCalledWith(expect.objectContaining({ deleted: 3 }), expect.any(String)) + }) + + it('does not log when nothing was deleted', async () => { + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const configStore = makeConfigStore() + const { deliveryStore } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + const info = vi.fn() + const testLogger = { warn: vi.fn(), error: vi.fn(), info } as unknown as Logger + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000, logger: testLogger }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(info).not.toHaveBeenCalled() + }) +}) + +describe('startLifecycleScheduler — group C4 (scan/redelivery grace ordering assert)', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('warns and clamps scanGraceMinutes when it does not exceed redeliveryGraceMinutes', async () => { + const configStore = makeConfigStore() + const { deliveryStore } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + const warn = vi.fn() + const testLogger = { warn, error: vi.fn(), info: vi.fn() } as unknown as Logger + + const stop = startLifecycleScheduler({ + configStore, deliveryStore, dispatcher, intervalMs: 1000, logger: testLogger, + redeliveryGraceMinutes: 10, scanGraceMinutes: 10, + }) + stop() + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + { scanGraceMinutes: 10, redeliveryGraceMinutes: 10, clampedScanGraceMinutes: 15 }, + expect.any(String), + ) + }) + + it('does not warn when scanGraceMinutes already exceeds redeliveryGraceMinutes', async () => { + const configStore = makeConfigStore() + const { deliveryStore } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + const warn = vi.fn() + const testLogger = { warn, error: vi.fn(), info: vi.fn() } as unknown as Logger + + const stop = startLifecycleScheduler({ + configStore, deliveryStore, dispatcher, intervalMs: 1000, logger: testLogger, + redeliveryGraceMinutes: 5, scanGraceMinutes: 60, + }) + stop() + + expect(warn).not.toHaveBeenCalled() }) }) From 9a29cb502cfbeade09638f15f4773504a3d22dcd Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 14:55:58 -0700 Subject: [PATCH 08/17] feat(cms,adapter-strapi,api): ended-event scan window and graceful shutdown (closes #8 scope pt 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bounds the timed-event scan feed and closes the process down cleanly: - cms: timedEventsAll accepts ?endedWithinMinutes=, excluding events whose endsAt is older than the cutoff; absent/invalid -> unfiltered. - adapter-strapi: StrapiConfigPlane gains allTimedEventsEndedWithinMinutes, appending the query param to getAllTimedEvents when set. No core port change. - api: plane now shares the scan-grace env with the lifecycle scheduler; new apps/api/src/shutdown.ts installs one shared SIGTERM/SIGINT handler that stops the scheduler, drains the HTTP server (10s force-exit fallback, unref'd), closes the db pool, then exits — wired into index.ts, which now captures the previously-discarded scheduler-stop and server handles. Co-Authored-By: Claude Fable 5 --- apps/api/src/index.ts | 18 ++- apps/api/src/shutdown.ts | 57 ++++++++ apps/api/test/shutdown.test.ts | 122 ++++++++++++++++++ .../config-plane/controllers/config-plane.ts | 9 ++ packages/adapter-strapi/src/index.ts | 9 +- packages/adapter-strapi/test/adapter.test.ts | 14 ++ 6 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/shutdown.ts create mode 100644 apps/api/test/shutdown.test.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cb56366..4f2e2d7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,22 +3,28 @@ import { createDb, runMigrations, PgErasureStore, PgIngestionStore, PgOfferMetri import { StrapiConfigPlane } from '@promocean/adapter-strapi' import { createApp } from './app.js' import { logger } from './logger.js' +import { installShutdownHandlers } from './shutdown.js' import { WebhookDispatcher, startLifecycleScheduler } from './webhooks.js' const db = createDb(process.env.DATABASE_URL!) await runMigrations(db) +// Same env var (and raw value) the lifecycle scheduler below reads for its own scan window +// (Sprint 6 Task 3) — kept in sync so the config-plane feed and the scheduler agree on how +// far back "ended" events are still considered in scope. +const scanGraceMinutes = Number(process.env.TIMED_EVENT_SCAN_GRACE_MINUTES ?? 60) const plane = new StrapiConfigPlane({ baseUrl: process.env.STRAPI_URL ?? 'http://localhost:1337', configSecret: process.env.CONFIG_PLANE_SECRET!, + allTimedEventsEndedWithinMinutes: scanGraceMinutes, }) const webhookDeliveryStore = new PgWebhookDeliveryStore(db) const webhooks = new WebhookDispatcher({ configStore: plane, deliveryStore: webhookDeliveryStore }) -startLifecycleScheduler({ +const stopScheduler = startLifecycleScheduler({ configStore: plane, deliveryStore: webhookDeliveryStore, dispatcher: webhooks, redeliveryGraceMinutes: Number(process.env.WEBHOOK_REDELIVERY_GRACE_MINUTES ?? 5), - scanGraceMinutes: Number(process.env.TIMED_EVENT_SCAN_GRACE_MINUTES ?? 60), + scanGraceMinutes, deadLetterTtlDays: Number(process.env.WEBHOOK_DEAD_LETTER_TTL_DAYS ?? 30), }) const app = createApp({ @@ -32,10 +38,14 @@ const app = createApp({ webhooks, readiness: { checkDb: async () => { await db.$client.query('select 1') }, - // Cheap probe: getAllTimedEvents() hits a single, cached Strapi endpoint. + // Cheap probe: getAllTimedEvents() hits a single, cached Strapi endpoint. Post-Task-4 this + // sees the ended-event-filtered feed, not the full history — fine, it only checks + // reachability, not completeness. checkConfigPlane: async () => { await plane.getAllTimedEvents() }, }, }) const port = Number(process.env.API_PORT ?? 3001) -serve({ fetch: app.fetch, port }) +const server = serve({ fetch: app.fetch, port }) logger.info({ port }, 'promocean api listening') + +installShutdownHandlers({ stopScheduler, server, pool: db.$client, logger }) diff --git a/apps/api/src/shutdown.ts b/apps/api/src/shutdown.ts new file mode 100644 index 0000000..493179d --- /dev/null +++ b/apps/api/src/shutdown.ts @@ -0,0 +1,57 @@ +import type { ServerType } from '@hono/node-server' +import type { Db } from '@promocean/adapter-db' +import type { Logger } from 'pino' + +/** How long server.close() is given to finish draining in-flight requests before the + * process force-exits. Prevents a stuck connection from hanging shutdown forever. */ +const FORCE_EXIT_MS = 10_000 + +export interface InstallShutdownHandlersOptions { + /** Stops the lifecycle scheduler's interval (the function `startLifecycleScheduler` returns). */ + stopScheduler: () => void + server: ServerType + pool: Db['$client'] + logger: Logger +} + +/** + * Registers one shared SIGTERM/SIGINT handler that shuts the process down in order: + * stop the lifecycle scheduler, close the HTTP server (falling back to a forced exit if + * close hangs past FORCE_EXIT_MS), close the db pool, then exit 0. Logs each phase. + * + * Returns the handler itself so tests can invoke it directly with fakes instead of + * sending real OS signals. + */ +export function installShutdownHandlers(opts: InstallShutdownHandlersOptions): () => Promise { + const { stopScheduler, server, pool, logger } = opts + + const shutdown = async (): Promise => { + logger.info('shutdown: stopping lifecycle scheduler') + stopScheduler() + + logger.info('shutdown: closing http server') + await new Promise((resolve) => { + const forceExitTimer = setTimeout(() => { + logger.warn('shutdown: server.close did not complete in time; forcing exit') + process.exit(1) + }, FORCE_EXIT_MS) + forceExitTimer.unref() + server.close((err) => { + clearTimeout(forceExitTimer) + if (err) logger.error({ err }, 'shutdown: error while closing http server') + resolve() + }) + }) + + logger.info('shutdown: closing db pool') + await pool.end() + + logger.info('shutdown: complete, exiting') + process.exit(0) + } + + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) + + return shutdown +} diff --git a/apps/api/test/shutdown.test.ts b/apps/api/test/shutdown.test.ts new file mode 100644 index 0000000..fe21a18 --- /dev/null +++ b/apps/api/test/shutdown.test.ts @@ -0,0 +1,122 @@ +import type { ServerType } from '@hono/node-server' +import type { Db } from '@promocean/adapter-db' +import type { Logger } from 'pino' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installShutdownHandlers } from '../src/shutdown.js' + +function fakeLogger(): Logger { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as Logger +} + +function fakeServer(onClose?: (cb: (err?: Error) => void) => void): ServerType { + return { close: vi.fn(onClose ?? ((cb: (err?: Error) => void) => cb())) } as unknown as ServerType +} + +function fakePool(onEnd?: () => Promise): Db['$client'] { + return { end: vi.fn(onEnd ?? (async () => {})) } as unknown as Db['$client'] +} + +describe('installShutdownHandlers', () => { + beforeEach(() => { + vi.spyOn(process, 'exit').mockImplementation(((): never => undefined as never)) + }) + + afterEach(() => { + vi.restoreAllMocks() + process.removeAllListeners('SIGTERM') + process.removeAllListeners('SIGINT') + }) + + it('stops the scheduler before closing the server, and ends the pool after close completes', async () => { + const order: string[] = [] + const stopScheduler = vi.fn(() => { order.push('scheduler-stopped') }) + const server = fakeServer((cb) => { + order.push('server-close-start') + cb() + order.push('server-close-callback-done') + }) + const pool = fakePool(async () => { order.push('pool-ended') }) + const logger = fakeLogger() + + const handler = installShutdownHandlers({ stopScheduler, server, pool, logger }) + await handler() + + expect(order).toEqual([ + 'scheduler-stopped', + 'server-close-start', + 'server-close-callback-done', + 'pool-ended', + ]) + expect(stopScheduler).toHaveBeenCalledTimes(1) + expect(server.close).toHaveBeenCalledTimes(1) + expect(pool.end).toHaveBeenCalledTimes(1) + expect(process.exit).toHaveBeenCalledWith(0) + }) + + it('waits for an async server.close callback before ending the pool', async () => { + const order: string[] = [] + const server = fakeServer((cb) => { + setTimeout(() => { + order.push('server-closed') + cb() + }, 0) + }) + const pool = fakePool(async () => { order.push('pool-ended') }) + const handler = installShutdownHandlers({ stopScheduler: vi.fn(), server, pool, logger: fakeLogger() }) + + await handler() + + expect(order).toEqual(['server-closed', 'pool-ended']) + }) + + it('registers the same handler function for both SIGTERM and SIGINT (no double registration)', () => { + const onSpy = vi.spyOn(process, 'on') + const handler = installShutdownHandlers({ + stopScheduler: vi.fn(), + server: fakeServer(), + pool: fakePool(), + logger: fakeLogger(), + }) + + const sigtermCall = onSpy.mock.calls.find((c) => c[0] === 'SIGTERM') + const sigintCall = onSpy.mock.calls.find((c) => c[0] === 'SIGINT') + expect(sigtermCall?.[1]).toBe(handler) + expect(sigintCall?.[1]).toBe(handler) + }) + + it('registers a force-exit timer (10s) that is unref-ed so it cannot keep the process alive', async () => { + const unref = vi.fn() + const setTimeoutSpy = vi + .spyOn(global, 'setTimeout') + .mockImplementation((() => ({ unref }) as unknown as NodeJS.Timeout) as typeof setTimeout) + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout').mockImplementation(() => {}) + + const handler = installShutdownHandlers({ + stopScheduler: vi.fn(), + server: fakeServer(), + pool: fakePool(), + logger: fakeLogger(), + }) + await handler() + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10_000) + expect(unref).toHaveBeenCalledTimes(1) + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1) + }) + + it('logs each phase', async () => { + const logger = fakeLogger() + const handler = installShutdownHandlers({ + stopScheduler: vi.fn(), + server: fakeServer(), + pool: fakePool(), + logger, + }) + await handler() + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('stopping lifecycle scheduler')) + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('closing http server')) + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('closing db pool')) + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('complete')) + }) +}) diff --git a/apps/cms/src/api/config-plane/controllers/config-plane.ts b/apps/cms/src/api/config-plane/controllers/config-plane.ts index c521923..cb9991b 100644 --- a/apps/cms/src/api/config-plane/controllers/config-plane.ts +++ b/apps/cms/src/api/config-plane/controllers/config-plane.ts @@ -78,7 +78,16 @@ export default { }, async timedEventsAll(ctx: any) { if (!configSecretOk(ctx)) return ctx.unauthorized() + // ?endedWithinMinutes=: excludes events with endsAt < now - N minutes. + // Absent or invalid (non-integer, zero, negative) -> unfiltered, for backward compatibility. + const rawParam = String(ctx.query.endedWithinMinutes ?? '') + const filters: Record = {} + if (/^[1-9][0-9]*$/.test(rawParam)) { + const cutoff = new Date(Date.now() - Number(rawParam) * 60_000) + filters.endsAt = { $gte: cutoff.toISOString() } + } const rows = await strapi.documents('api::timed-event.timed-event').findMany({ + filters, populate: ['project'], }) ctx.body = { diff --git a/packages/adapter-strapi/src/index.ts b/packages/adapter-strapi/src/index.ts index f5a43dd..0ea8b55 100644 --- a/packages/adapter-strapi/src/index.ts +++ b/packages/adapter-strapi/src/index.ts @@ -24,6 +24,9 @@ export interface StrapiConfigPlaneOptions { configSecret: string cacheTtlMs?: number fetchImpl?: typeof fetch + /** When set, getAllTimedEvents requests only events that ended within the last N minutes + * (or haven't ended yet) via `?endedWithinMinutes=`, keeping the scan feed bounded. */ + allTimedEventsEndedWithinMinutes?: number } interface CacheEntry { value: T; expires: number } @@ -145,7 +148,11 @@ export class StrapiConfigPlane implements ConfigStore, ApiKeyStore { const cached = this.allTimedEventsCache.get(key) if (cached && cached.expires > Date.now()) return cached.value try { - const res = await this.fetchImpl(`${this.opts.baseUrl}/api/config-plane/timed-events/all`, { + const url = new URL(`${this.opts.baseUrl}/api/config-plane/timed-events/all`) + if (this.opts.allTimedEventsEndedWithinMinutes !== undefined) { + url.searchParams.set('endedWithinMinutes', String(this.opts.allTimedEventsEndedWithinMinutes)) + } + const res = await this.fetchImpl(url, { headers: this.headers(), }) if (!res.ok) throw new Error(`config plane responded ${res.status}`) diff --git a/packages/adapter-strapi/test/adapter.test.ts b/packages/adapter-strapi/test/adapter.test.ts index 6aa507e..4cfae80 100644 --- a/packages/adapter-strapi/test/adapter.test.ts +++ b/packages/adapter-strapi/test/adapter.test.ts @@ -208,6 +208,20 @@ describe('StrapiConfigPlane.getAllTimedEvents', () => { const plane = makePlane(vi.fn().mockImplementation(() => ok({ events: [{ id: '2' }] }))) await expect(plane.getAllTimedEvents()).rejects.toThrow() }) + it('omits endedWithinMinutes when allTimedEventsEndedWithinMinutes is not configured', async () => { + const fetchImpl = vi.fn().mockImplementation(() => ok(allTimedEventsBody)) + const plane = new StrapiConfigPlane({ baseUrl: 'http://cms.test', configSecret: 's3cret', fetchImpl }) + await plane.getAllTimedEvents() + expect(String(fetchImpl.mock.calls[0][0])).toBe('http://cms.test/api/config-plane/timed-events/all') + }) + it('appends endedWithinMinutes when allTimedEventsEndedWithinMinutes is configured', async () => { + const fetchImpl = vi.fn().mockImplementation(() => ok(allTimedEventsBody)) + const plane = new StrapiConfigPlane({ + baseUrl: 'http://cms.test', configSecret: 's3cret', fetchImpl, allTimedEventsEndedWithinMinutes: 60, + }) + await plane.getAllTimedEvents() + expect(String(fetchImpl.mock.calls[0][0])).toBe('http://cms.test/api/config-plane/timed-events/all?endedWithinMinutes=60') + }) }) const webhookEndpointsBody = { From 7cdba54476e64dc01104ca629046560661e029c1 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 15:02:32 -0700 Subject: [PATCH 09/17] fix(api): single-source scan-grace clamp; reentrancy-safe shutdown with tested force-exit Co-Authored-By: Claude Fable 5 --- apps/api/src/index.ts | 18 +++++++---- apps/api/src/shutdown.ts | 58 ++++++++++++++++++++++------------ apps/api/src/webhooks.ts | 37 ++++++++++++++++------ apps/api/test/shutdown.test.ts | 38 ++++++++++++++++++++++ apps/api/test/webhooks.test.ts | 55 +++++++++++++++++++++++++++++++- 5 files changed, 170 insertions(+), 36 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4f2e2d7..fc75da5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,14 +4,20 @@ import { StrapiConfigPlane } from '@promocean/adapter-strapi' import { createApp } from './app.js' import { logger } from './logger.js' import { installShutdownHandlers } from './shutdown.js' -import { WebhookDispatcher, startLifecycleScheduler } from './webhooks.js' +import { WebhookDispatcher, resolveScanGraceMinutes, startLifecycleScheduler } from './webhooks.js' const db = createDb(process.env.DATABASE_URL!) await runMigrations(db) -// Same env var (and raw value) the lifecycle scheduler below reads for its own scan window -// (Sprint 6 Task 3) — kept in sync so the config-plane feed and the scheduler agree on how -// far back "ended" events are still considered in scope. -const scanGraceMinutes = Number(process.env.TIMED_EVENT_SCAN_GRACE_MINUTES ?? 60) +const redeliveryGraceMinutes = Number(process.env.WEBHOOK_REDELIVERY_GRACE_MINUTES ?? 5) +// Single-sourced (Sprint 6 Task 4 review fix): compute the effective scan-grace window once +// and hand it to BOTH the config-plane feed and the lifecycle scheduler, so they always agree +// on how far back "ended" events are still considered in scope. The scheduler's own clamp is +// kept as a backstop but is a no-op given an already-resolved value. +const scanGraceMinutes = resolveScanGraceMinutes( + Number(process.env.TIMED_EVENT_SCAN_GRACE_MINUTES ?? 60), + redeliveryGraceMinutes, + logger, +) const plane = new StrapiConfigPlane({ baseUrl: process.env.STRAPI_URL ?? 'http://localhost:1337', configSecret: process.env.CONFIG_PLANE_SECRET!, @@ -23,7 +29,7 @@ const stopScheduler = startLifecycleScheduler({ configStore: plane, deliveryStore: webhookDeliveryStore, dispatcher: webhooks, - redeliveryGraceMinutes: Number(process.env.WEBHOOK_REDELIVERY_GRACE_MINUTES ?? 5), + redeliveryGraceMinutes, scanGraceMinutes, deadLetterTtlDays: Number(process.env.WEBHOOK_DEAD_LETTER_TTL_DAYS ?? 30), }) diff --git a/apps/api/src/shutdown.ts b/apps/api/src/shutdown.ts index 493179d..1fb47ea 100644 --- a/apps/api/src/shutdown.ts +++ b/apps/api/src/shutdown.ts @@ -19,35 +19,53 @@ export interface InstallShutdownHandlersOptions { * stop the lifecycle scheduler, close the HTTP server (falling back to a forced exit if * close hangs past FORCE_EXIT_MS), close the db pool, then exit 0. Logs each phase. * + * Reentrancy-safe: SIGTERM and SIGINT share this one handler, and some environments + * deliver both during a single shutdown (e.g. a process manager sending SIGTERM followed + * by a user Ctrl-C). Without a guard, a second concurrent invocation would race the first + * — most notably calling `pool.end()` twice, where the second call rejects with an + * unhandled rejection. The first invocation's in-flight promise is cached and returned to + * every subsequent caller instead of re-running the shutdown sequence. + * * Returns the handler itself so tests can invoke it directly with fakes instead of * sending real OS signals. */ export function installShutdownHandlers(opts: InstallShutdownHandlersOptions): () => Promise { const { stopScheduler, server, pool, logger } = opts - const shutdown = async (): Promise => { - logger.info('shutdown: stopping lifecycle scheduler') - stopScheduler() - - logger.info('shutdown: closing http server') - await new Promise((resolve) => { - const forceExitTimer = setTimeout(() => { - logger.warn('shutdown: server.close did not complete in time; forcing exit') - process.exit(1) - }, FORCE_EXIT_MS) - forceExitTimer.unref() - server.close((err) => { - clearTimeout(forceExitTimer) - if (err) logger.error({ err }, 'shutdown: error while closing http server') - resolve() + let shutdownPromise: Promise | null = null + + const shutdown = (): Promise => { + if (shutdownPromise) { + logger.info('shutdown: already in progress') + return shutdownPromise + } + + shutdownPromise = (async () => { + logger.info('shutdown: stopping lifecycle scheduler') + stopScheduler() + + logger.info('shutdown: closing http server') + await new Promise((resolve) => { + const forceExitTimer = setTimeout(() => { + logger.warn('shutdown: server.close did not complete in time; forcing exit') + process.exit(1) + }, FORCE_EXIT_MS) + forceExitTimer.unref() + server.close((err) => { + clearTimeout(forceExitTimer) + if (err) logger.error({ err }, 'shutdown: error while closing http server') + resolve() + }) }) - }) - logger.info('shutdown: closing db pool') - await pool.end() + logger.info('shutdown: closing db pool') + await pool.end() + + logger.info('shutdown: complete, exiting') + process.exit(0) + })() - logger.info('shutdown: complete, exiting') - process.exit(0) + return shutdownPromise } process.on('SIGTERM', shutdown) diff --git a/apps/api/src/webhooks.ts b/apps/api/src/webhooks.ts index 80c1be1..1eb0169 100644 --- a/apps/api/src/webhooks.ts +++ b/apps/api/src/webhooks.ts @@ -136,6 +136,30 @@ function buildTransitionMessage( } } +/** + * Resolves the effective scan-grace window: it must exceed redeliveryGraceMinutes, or a + * scan window shorter than the redelivery grace would let events drop out of the feed + * before a stale claim could ever be redriven. Warns and clamps to redeliveryGraceMinutes + 5 + * when violated. + * + * Single-sourced so the config-plane feed (index.ts) and the lifecycle scheduler always + * agree on the same effective value — callers should compute this once and pass the result + * to both. `startLifecycleScheduler` also calls this internally as a backstop for callers + * that pass a raw value directly; when fed an already-resolved value it is a no-op. + */ +export function resolveScanGraceMinutes(scanGraceMinutes: number, redeliveryGraceMinutes: number, logger?: Logger): number { + if (scanGraceMinutes <= redeliveryGraceMinutes) { + const clampedScanGraceMinutes = redeliveryGraceMinutes + 5 + const log = logger ?? rootLogger.child({ component: 'webhooks' }) + log.warn( + { scanGraceMinutes, redeliveryGraceMinutes, clampedScanGraceMinutes }, + 'lifecycle scheduler: scanGraceMinutes must exceed redeliveryGraceMinutes; clamping', + ) + return clampedScanGraceMinutes + } + return scanGraceMinutes +} + export function startLifecycleScheduler(opts: { configStore: ConfigStore deliveryStore: WebhookDeliveryStore @@ -156,15 +180,10 @@ export function startLifecycleScheduler(opts: { const redeliveryGraceMinutes = opts.redeliveryGraceMinutes ?? 5 const deadLetterTtlDays = opts.deadLetterTtlDays ?? 30 - let scanGraceMinutes = opts.scanGraceMinutes ?? 60 - if (scanGraceMinutes <= redeliveryGraceMinutes) { - const clampedScanGraceMinutes = redeliveryGraceMinutes + 5 - logger.warn( - { scanGraceMinutes, redeliveryGraceMinutes, clampedScanGraceMinutes }, - 'lifecycle scheduler: scanGraceMinutes must exceed redeliveryGraceMinutes; clamping', - ) - scanGraceMinutes = clampedScanGraceMinutes - } + // Backstop clamp: index.ts computes the effective value once via resolveScanGraceMinutes + // and passes it to both the config plane and here, so this is normally a no-op. Direct + // callers (e.g. tests) that pass a raw value still get the same validation. + const scanGraceMinutes = resolveScanGraceMinutes(opts.scanGraceMinutes ?? 60, redeliveryGraceMinutes, logger) // scanGraceMinutes is plumbed + validated here; Task 4 passes it to the config-plane scan window. const redeliveryGraceMs = redeliveryGraceMinutes * 60_000 diff --git a/apps/api/test/shutdown.test.ts b/apps/api/test/shutdown.test.ts index fe21a18..a61ee91 100644 --- a/apps/api/test/shutdown.test.ts +++ b/apps/api/test/shutdown.test.ts @@ -119,4 +119,42 @@ describe('installShutdownHandlers', () => { expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('closing db pool')) expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('complete')) }) + + it('reuses the in-flight promise when invoked again before the first resolves (reentrancy guard)', async () => { + const stopScheduler = vi.fn() + // Async close so the first invocation is still in-flight when the second call happens. + const server = fakeServer((cb) => { setTimeout(() => cb(), 0) }) + const pool = fakePool() + const logger = fakeLogger() + + const handler = installShutdownHandlers({ stopScheduler, server, pool, logger }) + const first = handler() + const second = handler() + + expect(second).toBe(first) + await first + + expect(stopScheduler).toHaveBeenCalledTimes(1) + expect(server.close).toHaveBeenCalledTimes(1) + expect(pool.end).toHaveBeenCalledTimes(1) + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('already in progress')) + }) + + it('force-exits with code 1 when server.close never completes within FORCE_EXIT_MS', async () => { + vi.useFakeTimers() + try { + const server = fakeServer(() => { /* never calls back — simulates a hung close */ }) + const pool = fakePool() + const logger = fakeLogger() + + const handler = installShutdownHandlers({ stopScheduler: vi.fn(), server, pool, logger }) + void handler() + + await vi.advanceTimersByTimeAsync(10_000) + + expect(process.exit).toHaveBeenCalledWith(1) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/apps/api/test/webhooks.test.ts b/apps/api/test/webhooks.test.ts index f68fb21..e4bdeab 100644 --- a/apps/api/test/webhooks.test.ts +++ b/apps/api/test/webhooks.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Logger } from 'pino' import { WEBHOOK_SIGNATURE_HEADER, type WebhookMessage } from '@promocean/contracts' import type { ConfigStore, TimedEventDefinition, WebhookDeliveryStore, WebhookEndpointDefinition } from '@promocean/core' -import { WebhookDispatcher, startLifecycleScheduler } from '../src/webhooks.js' +import { WebhookDispatcher, resolveScanGraceMinutes, startLifecycleScheduler } from '../src/webhooks.js' import { createApp } from '../src/app.js' import { makeFakes } from './fakes.js' @@ -460,6 +460,59 @@ describe('startLifecycleScheduler — group C4 (scan/redelivery grace ordering a }) }) +describe('resolveScanGraceMinutes — group C5 (single-sourced scan-grace clamp)', () => { + it('clamps and warns when scanGraceMinutes does not exceed redeliveryGraceMinutes', () => { + const warn = vi.fn() + const testLogger = { warn, error: vi.fn(), info: vi.fn() } as unknown as Logger + + const result = resolveScanGraceMinutes(10, 10, testLogger) + + expect(result).toBe(15) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + { scanGraceMinutes: 10, redeliveryGraceMinutes: 10, clampedScanGraceMinutes: 15 }, + expect.any(String), + ) + }) + + it('passes the value through unchanged with no warning when it already exceeds redeliveryGraceMinutes', () => { + const warn = vi.fn() + const testLogger = { warn, error: vi.fn(), info: vi.fn() } as unknown as Logger + + const result = resolveScanGraceMinutes(60, 5, testLogger) + + expect(result).toBe(60) + expect(warn).not.toHaveBeenCalled() + }) + + it('feeding an already-resolved value to startLifecycleScheduler does not warn a second time (single-source wiring)', async () => { + vi.useFakeTimers() + try { + const configStore = makeConfigStore() + const { deliveryStore } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + const warn = vi.fn() + const testLogger = { warn, error: vi.fn(), info: vi.fn() } as unknown as Logger + + // Simulates index.ts: resolve once up front (this is where the single warn fires)... + const effectiveScanGrace = resolveScanGraceMinutes(10, 10, testLogger) + expect(warn).toHaveBeenCalledTimes(1) + + // ...then hand the already-resolved value to the scheduler, whose internal backstop + // clamp must be a no-op and must not warn again. + const stop = startLifecycleScheduler({ + configStore, deliveryStore, dispatcher, intervalMs: 1000, logger: testLogger, + redeliveryGraceMinutes: 10, scanGraceMinutes: effectiveScanGrace, + }) + stop() + + expect(warn).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) + describe('POST /v1/events — group D (unlock webhook wiring)', () => { const defs = [ { id: 'a1', name: 'First Lesson', description: null, artworkUrl: null, eventType: 'lesson_completed', targetCount: 1 }, From 8a0db3a0333a674d029adef3fc0bd19228649cbf Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 15:11:42 -0700 Subject: [PATCH 10/17] fix(api,adapter-strapi): bound rate-limiter buckets and negative auth cache (closes #10) The rate limiter's in-process bucket Map and the Strapi adapter's verifyKey auth cache both grew unboundedly from unique invalid tokens/keys, each leaving a permanent entry behind. Cap the rate limiter at RATE_LIMIT_MAX_BUCKETS (default 10000, env-configurable), sweeping expired buckets on each rollover and routing new keys to a shared overflow bucket once at cap (still counted and 429-able, never unlimited or denied outright). Cap the adapter's cached `null` verifyKey results at maxNegativeAuthEntries (default 1000), evicting the oldest on overflow while leaving positive entries untouched. Co-Authored-By: Claude Fable 5 --- README.md | 5 +- apps/api/src/app.ts | 4 +- apps/api/src/rate-limit.ts | 61 ++++++++++--- apps/api/test/security.test.ts | 49 +++++++++++ packages/adapter-strapi/src/index.ts | 32 ++++++- packages/adapter-strapi/test/adapter.test.ts | 93 ++++++++++++++++++++ 6 files changed, 231 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8065c6b..c1ebef2 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,10 @@ middleware so tooling can fetch the spec without a key. Every key is rate-limited independently at `RATE_LIMIT_PER_MINUTE` requests per minute (default `300`; single-instance in-memory bucket, keyed by a hash of the key), returning `429 rate_limited` with a `retry-after` header once -exceeded. Publishable keys additionally enforce an `allowedOrigins` +exceeded. The number of distinct buckets tracked is bounded by +`RATE_LIMIT_MAX_BUCKETS` (default `10000`); once at the cap, keys not yet seen +in the current window share a single overflow bucket (still counted and +429-able) rather than growing memory unboundedly. Publishable keys additionally enforce an `allowedOrigins` allowlist when one is configured on the key: requests carrying an `Origin` header not on that list are rejected with `403 origin_not_allowed` (secret keys, and requests with no `Origin` header, are exempt from this check). diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1dfb3a1..499d40a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -62,10 +62,12 @@ export interface AppDeps { export interface CreateAppOptions { rateLimitPerMinute?: number + rateLimitMaxBuckets?: number } export function createApp(deps: AppDeps, opts: CreateAppOptions = {}) { const rateLimitPerMinute = opts.rateLimitPerMinute ?? Number(process.env.RATE_LIMIT_PER_MINUTE ?? 300) + const rateLimitMaxBuckets = opts.rateLimitMaxBuckets ?? Number(process.env.RATE_LIMIT_MAX_BUCKETS ?? 10_000) const app = new Hono() app.use('*', async (c, next) => { const requestId = randomUUID() @@ -96,7 +98,7 @@ export function createApp(deps: AppDeps, opts: CreateAppOptions = {}) { app.get('/v1/openapi.json', (c) => c.body(openApiBody, 200, { 'content-type': 'application/json', 'cache-control': 'public, max-age=3600' }), ) - app.use('/v1/*', createRateLimiter(rateLimitPerMinute)) + app.use('/v1/*', createRateLimiter(rateLimitPerMinute, { maxBuckets: rateLimitMaxBuckets })) app.use('/v1/*', authMiddleware(deps.apiKeyStore)) app.route('/v1/events', eventsRoute(deps)) app.route('/v1/events', liveEventsRoute(deps)) diff --git a/apps/api/src/rate-limit.ts b/apps/api/src/rate-limit.ts index f7374a6..9bc17ef 100644 --- a/apps/api/src/rate-limit.ts +++ b/apps/api/src/rate-limit.ts @@ -2,19 +2,41 @@ import { createHash } from 'node:crypto' import type { Context, Next } from 'hono' const WINDOW_MS = 60_000 +const DEFAULT_MAX_BUCKETS = 10_000 + +// Shared bucket key for requests from keys we haven't seen this window once the +// map is at cap. Never collides with a sha256 hex digest (64 chars), so it's a +// safe sentinel alongside real per-key buckets. +const OVERFLOW_KEY = '__overflow__' interface Bucket { count: number windowStart: number } +export interface RateLimiterOptions { + /** Max distinct buckets tracked before new keys share the overflow bucket. Default 10000. */ + maxBuckets?: number + /** Injectable clock, for tests. Default `Date.now`. */ + now?: () => number +} + +export interface RateLimiterMiddleware { + (c: Context, next: Next): Promise + /** Test-only: current number of tracked buckets (including the shared overflow + * bucket, if in use). Not part of the public rate-limiter contract. */ + _bucketCount(): number +} + // Single-instance MVP: state lives in an in-process Map, so limits are per // server instance. Multi-instance (shared) rate limiting is backlog — would // need a shared store (e.g. Redis) keyed the same way. -export function createRateLimiter(limitPerMinute: number) { +export function createRateLimiter(limitPerMinute: number, opts: RateLimiterOptions = {}): RateLimiterMiddleware { const buckets = new Map() + const maxBuckets = opts.maxBuckets ?? DEFAULT_MAX_BUCKETS + const now = opts.now ?? Date.now - return async (c: Context, next: Next) => { + const middleware = (async (c: Context, next: Next) => { if (limitPerMinute <= 0) { await next() return @@ -22,23 +44,42 @@ export function createRateLimiter(limitPerMinute: number) { const header = c.req.header('authorization') ?? '' const rawKey = header.startsWith('Bearer ') ? header.slice(7) : '' - const key = createHash('sha256').update(rawKey).digest('hex') + const hashedKey = createHash('sha256').update(rawKey).digest('hex') - const now = Date.now() - let bucket = buckets.get(key) - if (!bucket || now - bucket.windowStart >= WINDOW_MS) { - bucket = { count: 0, windowStart: now } - buckets.set(key, bucket) + const nowMs = now() + const isNewKey = !buckets.has(hashedKey) + // At cap, keys we haven't seen this window share one overflow bucket instead of + // growing the map further — still counted and 429-able, never unlimited, never + // denied outright. + const effectiveKey = isNewKey && buckets.size >= maxBuckets ? OVERFLOW_KEY : hashedKey + + let bucket = buckets.get(effectiveKey) + if (!bucket || nowMs - bucket.windowStart >= WINDOW_MS) { + bucket = { count: 0, windowStart: nowMs } + buckets.set(effectiveKey, bucket) + + // Lazy sweep, piggybacked on this request's own bucket rollover: reclaim every + // expired bucket in the map. O(n) over the map per sweep, but a sweep only runs + // when *this* key's own bucket rolls over — at most once per window per active + // key — so the amortized cost stays cheap even at the 10k-bucket cap. + for (const [k, b] of buckets) { + if (k !== effectiveKey && nowMs - b.windowStart >= WINDOW_MS) buckets.delete(k) + } } bucket.count += 1 if (bucket.count > limitPerMinute) { - const retryAfterSeconds = Math.ceil((bucket.windowStart + WINDOW_MS - now) / 1000) + const retryAfterSeconds = Math.ceil((bucket.windowStart + WINDOW_MS - nowMs) / 1000) c.header('retry-after', String(Math.max(retryAfterSeconds, 1))) return c.json({ error: { code: 'rate_limited', message: 'Too many requests.' } }, 429) } await next() - } + return undefined + }) as RateLimiterMiddleware + + middleware._bucketCount = () => buckets.size + + return middleware } diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 1fdbbd1..8232682 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' +import { Hono } from 'hono' import type { AuthContext } from '@promocean/core' import { createApp } from '../src/app.js' +import { createRateLimiter } from '../src/rate-limit.js' import { makeFakes } from './fakes.js' const defs = [ @@ -54,6 +56,53 @@ describe('rate limiting', () => { }) }) +describe('rate limiter bucket bounds', () => { + function buildApp(limiter: ReturnType) { + const app = new Hono() + app.use('*', limiter) + app.get('/', (c) => c.text('ok')) + return app + } + + it('sweeps expired buckets on the requester bucket rollover instead of growing forever', async () => { + let now = 0 + const limiter = createRateLimiter(10, { now: () => now, maxBuckets: 50 }) + const app = buildApp(limiter) + + // Window 1: 5 distinct keys, one request each. + for (let i = 0; i < 5; i++) { + await app.request('/', { headers: { authorization: `Bearer key-${i}` } }) + } + expect(limiter._bucketCount()).toBe(5) + + // Advance past the window; a single request from a brand-new key rolls its own + // bucket over, which should also sweep the 5 now-expired buckets away. + now = 61_000 + await app.request('/', { headers: { authorization: 'Bearer key-new' } }) + expect(limiter._bucketCount()).toBe(1) + }) + + it('shares a single overflow bucket for new keys once at cap, still enforcing the limit (never unlimited, never hard-denied)', async () => { + let now = 0 + const limiter = createRateLimiter(1, { now: () => now, maxBuckets: 2 }) + const app = buildApp(limiter) + + // Fill the cap with two distinct keys. + await app.request('/', { headers: { authorization: 'Bearer key-a' } }) + await app.request('/', { headers: { authorization: 'Bearer key-b' } }) + expect(limiter._bucketCount()).toBe(2) + + // Two more distinct new keys arrive at cap: both land in the shared overflow + // bucket (bucket count stays bounded at cap+1), and since limitPerMinute is 1, + // the second of the two 429s alongside the first at the shared limit. + const res1 = await app.request('/', { headers: { authorization: 'Bearer key-c' } }) + const res2 = await app.request('/', { headers: { authorization: 'Bearer key-d' } }) + expect(res1.status).toBe(200) + expect(res2.status).toBe(429) + expect(limiter._bucketCount()).toBe(3) // key-a, key-b, __overflow__ + }) +}) + describe('origin enforcement', () => { it('publishable key + disallowed Origin -> 403 origin_not_allowed', async () => { const app = createApp(makeFakes(defs, pkAuth(['https://allowed.test'])), { rateLimitPerMinute: 0 }) diff --git a/packages/adapter-strapi/src/index.ts b/packages/adapter-strapi/src/index.ts index 0ea8b55..96b0116 100644 --- a/packages/adapter-strapi/src/index.ts +++ b/packages/adapter-strapi/src/index.ts @@ -27,6 +27,9 @@ export interface StrapiConfigPlaneOptions { /** When set, getAllTimedEvents requests only events that ended within the last N minutes * (or haven't ended yet) via `?endedWithinMinutes=`, keeping the scan feed bounded. */ allTimedEventsEndedWithinMinutes?: number + /** Max number of cached `null` (unknown-key) verifyKey results tracked before the + * oldest is evicted. Bounds unbounded growth from random/invalid key probing. Default 1000. */ + maxNegativeAuthEntries?: number } interface CacheEntry { value: T; expires: number } @@ -47,6 +50,12 @@ export class StrapiConfigPlane implements ConfigStore, ApiKeyStore { private achievementsCache = new Map>() private offersCache = new Map>() private authCache = new Map>() + // Insertion-ordered set of keyHashes currently cached with a `null` (unknown-key) + // verifyKey result — a Set preserves insertion order, so its first entry is always + // the oldest, letting us evict FIFO without a separate linked-list/queue structure. + // Positive (non-null) results are never tracked here and never evicted by this bound. + private nullAuthKeys = new Set() + private readonly maxNegativeAuthEntries: number private timedEventsCache = new Map>() private allTimedEventsCache = new Map>>() private webhookEndpointsCache = new Map>() @@ -55,6 +64,27 @@ export class StrapiConfigPlane implements ConfigStore, ApiKeyStore { constructor(private opts: StrapiConfigPlaneOptions) { this.ttl = opts.cacheTtlMs ?? 30_000 this.fetchImpl = opts.fetchImpl ?? globalThis.fetch + this.maxNegativeAuthEntries = opts.maxNegativeAuthEntries ?? 1000 + } + + /** Sets an authCache entry while maintaining the bounded negative-result tracking: + * evicts the oldest cached `null` entry when caching a new `null` at capacity, and + * untracks a key that transitions from a cached `null` to a positive result (so it + * can't later be evicted as if it were still a stale null). */ + private setAuthCacheEntry(keyHash: string, entry: CacheEntry) { + if (entry.value === null) { + if (!this.nullAuthKeys.has(keyHash) && this.nullAuthKeys.size >= this.maxNegativeAuthEntries) { + const oldest = this.nullAuthKeys.values().next().value + if (oldest !== undefined) { + this.nullAuthKeys.delete(oldest) + this.authCache.delete(oldest) + } + } + this.nullAuthKeys.add(keyHash) // no-op if already present; Set keeps original insertion order + } else { + this.nullAuthKeys.delete(keyHash) + } + this.authCache.set(keyHash, entry) } private headers() { @@ -256,7 +286,7 @@ export class StrapiConfigPlane implements ConfigStore, ApiKeyStore { // Deliberate fail-closed trade-off: caching `null` here overwrites even a previously-good // cached AuthContext for one TTL window if the CMS starts returning malformed bodies // (auth boundary: correctness over availability). - this.authCache.set(keyHash, { value, expires: Date.now() + this.ttl }) + this.setAuthCacheEntry(keyHash, { value, expires: Date.now() + this.ttl }) return value } catch (err) { if (cached) return cached.value diff --git a/packages/adapter-strapi/test/adapter.test.ts b/packages/adapter-strapi/test/adapter.test.ts index 4cfae80..e44444a 100644 --- a/packages/adapter-strapi/test/adapter.test.ts +++ b/packages/adapter-strapi/test/adapter.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' import { StrapiConfigPlane } from '../src/index.js' @@ -124,6 +125,98 @@ describe('StrapiConfigPlane.verifyKey', () => { }) }) +describe('StrapiConfigPlane.verifyKey negative auth-cache bound', () => { + it('evicts the oldest cached null result once at maxNegativeAuthEntries; the evicted key re-fetches', async () => { + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(new Response('', { status: 404 }))) + const plane = new StrapiConfigPlane({ + baseUrl: 'http://cms.test', configSecret: 's3cret', fetchImpl, maxNegativeAuthEntries: 3, + }) + + await plane.verifyKey('key-0') + await plane.verifyKey('key-1') + await plane.verifyKey('key-2') + expect(fetchImpl).toHaveBeenCalledTimes(3) + + // key-1 and key-2 are still cached (no extra fetches). + await plane.verifyKey('key-1') + await plane.verifyKey('key-2') + expect(fetchImpl).toHaveBeenCalledTimes(3) + + // 4th distinct null key: at cap, evicts key-0 (the oldest). + await plane.verifyKey('key-3') + expect(fetchImpl).toHaveBeenCalledTimes(4) + + // key-0 was evicted -> re-fetches. Re-caching it null now evicts key-1 (the new + // oldest at cap 3: key-1, key-2, key-3), not key-2 or key-3. + await plane.verifyKey('key-0') + expect(fetchImpl).toHaveBeenCalledTimes(5) + + // key-2 and key-3 remain cached (unaffected by key-0's re-insertion). + await plane.verifyKey('key-2') + await plane.verifyKey('key-3') + expect(fetchImpl).toHaveBeenCalledTimes(5) + }) + + it('does not evict positive entries under negative-cache eviction pressure', async () => { + const goodKeyHash = createHash('sha256').update('good-key').digest('hex') + const fetchImpl = vi.fn().mockImplementation((_url: unknown, init: { body: string }) => { + const { keyHash } = JSON.parse(init.body) as { keyHash: string } + if (keyHash === goodKeyHash) return ok(authBody) + return Promise.resolve(new Response('', { status: 404 })) + }) + const plane = new StrapiConfigPlane({ + baseUrl: 'http://cms.test', configSecret: 's3cret', fetchImpl, maxNegativeAuthEntries: 2, + }) + + await plane.verifyKey('good-key') // positive, cached + await plane.verifyKey('bad-1') // null (1/2) + await plane.verifyKey('bad-2') // null (2/2, at cap) + await plane.verifyKey('bad-3') // null: evicts bad-1, positive entry untouched + const callsSoFar = fetchImpl.mock.calls.length + expect(callsSoFar).toBe(4) + + const goodAgain = await plane.verifyKey('good-key') + expect(goodAgain).toEqual(authBody) + expect(fetchImpl).toHaveBeenCalledTimes(callsSoFar) // still cached, no extra fetch + }) + + it('drops a key from null-eviction tracking once it resolves positive, so it is not evicted like a stale null', async () => { + vi.useFakeTimers() + try { + const n0Hash = createHash('sha256').update('n0').digest('hex') + let n0Positive = false + const fetchImpl = vi.fn().mockImplementation((_url: unknown, init: { body: string }) => { + const { keyHash } = JSON.parse(init.body) as { keyHash: string } + if (keyHash === n0Hash && n0Positive) return ok(authBody) + return Promise.resolve(new Response('', { status: 404 })) + }) + const plane = new StrapiConfigPlane({ + baseUrl: 'http://cms.test', configSecret: 's3cret', fetchImpl, maxNegativeAuthEntries: 2, cacheTtlMs: 1000, + }) + + await plane.verifyKey('n0') // null, null-set: [n0] + await plane.verifyKey('n1') // null, null-set: [n0, n1] (at cap) + + // Expire n0's entry and have it resolve positive on refetch: it should drop + // out of null tracking. + vi.advanceTimersByTime(1001) + n0Positive = true + expect(await plane.verifyKey('n0')).toEqual(authBody) // null-set: [n1] + + await plane.verifyKey('n2') // null, null-set: [n1, n2] + await plane.verifyKey('n3') // null: evicts n1 (oldest remaining null), null-set: [n2, n3] + expect(fetchImpl).toHaveBeenCalledTimes(5) + + // n0's positive entry must still be cached (not evicted) within its TTL. + const callsBeforeRecheck = fetchImpl.mock.calls.length + expect(await plane.verifyKey('n0')).toEqual(authBody) + expect(fetchImpl).toHaveBeenCalledTimes(callsBeforeRecheck) + } finally { + vi.useRealTimers() + } + }) +}) + const offersBody = { offers: [{ id: 'o1', placementSlug: 'homepage-banner', headline: 'Welcome to Promocean', From f2bfdc0963cd369cb5abd05b5c43c12bfa7c66ee Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 15:17:59 -0700 Subject: [PATCH 11/17] fix(api): sweep rate-limit buckets only on true rollover, not new-key creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the lazy bucket sweep to fire only on pre-existing bucket rollover, not on new-key creation. This fixes an O(n²) CPU regression under attack (distinct random tokens): the sweep now skips new-key ramp-ups and only reclaims on legitimate rollover events. Memory remains bounded by the cap independently. Co-Authored-By: Claude Fable 5 --- apps/api/src/rate-limit.ts | 15 +++++++++++---- apps/api/test/security.test.ts | 32 +++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/apps/api/src/rate-limit.ts b/apps/api/src/rate-limit.ts index 9bc17ef..e396867 100644 --- a/apps/api/src/rate-limit.ts +++ b/apps/api/src/rate-limit.ts @@ -54,14 +54,21 @@ export function createRateLimiter(limitPerMinute: number, opts: RateLimiterOptio const effectiveKey = isNewKey && buckets.size >= maxBuckets ? OVERFLOW_KEY : hashedKey let bucket = buckets.get(effectiveKey) - if (!bucket || nowMs - bucket.windowStart >= WINDOW_MS) { + if (!bucket) { + // New key: create its bucket. No sweep — new-key creation alone does not trigger + // the lazy sweep (which only fires on true rollover of a pre-existing bucket). + bucket = { count: 0, windowStart: nowMs } + buckets.set(effectiveKey, bucket) + } else if (nowMs - bucket.windowStart >= WINDOW_MS) { + // Existing bucket has rolled over: reset it and sweep. bucket = { count: 0, windowStart: nowMs } buckets.set(effectiveKey, bucket) - // Lazy sweep, piggybacked on this request's own bucket rollover: reclaim every + // Lazy sweep, piggybacked on this key's own bucket rollover: reclaim every // expired bucket in the map. O(n) over the map per sweep, but a sweep only runs - // when *this* key's own bucket rolls over — at most once per window per active - // key — so the amortized cost stays cheap even at the 10k-bucket cap. + // on a true rollover of a pre-existing bucket — at most once per window per active + // key — so the amortized cost stays cheap even at the 10k-bucket cap. New-key + // floods never trigger it; memory stays bounded by the cap independently. for (const [k, b] of buckets) { if (k !== effectiveKey && nowMs - b.windowStart >= WINDOW_MS) buckets.delete(k) } diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 8232682..e101b9a 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -75,13 +75,39 @@ describe('rate limiter bucket bounds', () => { } expect(limiter._bucketCount()).toBe(5) - // Advance past the window; a single request from a brand-new key rolls its own - // bucket over, which should also sweep the 5 now-expired buckets away. + // Advance past the window; a request from a pre-existing key (key-0) rolls its own + // bucket over, which triggers the lazy sweep of the 5 now-expired buckets. now = 61_000 - await app.request('/', { headers: { authorization: 'Bearer key-new' } }) + await app.request('/', { headers: { authorization: 'Bearer key-0' } }) expect(limiter._bucketCount()).toBe(1) }) + it('does not sweep buckets during new-key ramp-up; sweep only fires on pre-existing-bucket rollover', async () => { + let now = 0 + const limiter = createRateLimiter(10, { now: () => now, maxBuckets: 50 }) + const app = buildApp(limiter) + + // Window 1: Add 5 distinct keys. Each is a new-key creation (no sweep). + for (let i = 0; i < 5; i++) { + await app.request('/', { headers: { authorization: `Bearer key-${i}` } }) + // Bucket count should grow monotonically; no sweep has happened. + expect(limiter._bucketCount()).toBe(i + 1) + } + + // Advance past the window. + now = 61_000 + + // Add a new key in the new window. This is also a new-key creation (no sweep). + await app.request('/', { headers: { authorization: 'Bearer key-new-a' } }) + // The old 5 buckets are still present; we're at 6. No sweep has fired. + expect(limiter._bucketCount()).toBe(6) + + // Now cause a pre-existing key (key-0) to roll over. This triggers the sweep. + await app.request('/', { headers: { authorization: 'Bearer key-0' } }) + // After the sweep, the 5 expired buckets should be gone, leaving key-0 and key-new-a. + expect(limiter._bucketCount()).toBe(2) + }) + it('shares a single overflow bucket for new keys once at cap, still enforcing the limit (never unlimited, never hard-denied)', async () => { let now = 0 const limiter = createRateLimiter(1, { now: () => now, maxBuckets: 2 }) From c01c2d5dd5730ea7d53829d4c960efae0d7a4fd7 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 15:22:57 -0700 Subject: [PATCH 12/17] fix(api): validate offer id on impression and click routes (closes #13) Co-Authored-By: Claude Fable 5 --- apps/api/src/routes/offers.ts | 19 ++++++++++++++++++ apps/api/test/offers.test.ts | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/apps/api/src/routes/offers.ts b/apps/api/src/routes/offers.ts index 5a5da1e..aae221b 100644 --- a/apps/api/src/routes/offers.ts +++ b/apps/api/src/routes/offers.ts @@ -5,6 +5,19 @@ import { } from '@promocean/contracts' import type { Scope } from '@promocean/core' import type { AppDeps } from '../app.js' +import { logger } from '../logger.js' + +// These routes are reachable with a browser-exposed publishable key, so a config-plane +// outage must not block writes: on getOffers() failure we fail open (treat the offer as +// known and record it) rather than 404, trading strict id validation for availability. +async function isKnownOffer(deps: AppDeps, projectId: string, offerId: string): Promise { + const offers = await deps.configStore.getOffers(projectId).catch(() => null) + if (offers === null) { + logger.warn({ projectId, offerId }, 'offer config fetch failed; skipping offer id validation') + return true + } + return offers.some((o) => o.id === offerId) +} export function offersRoute(deps: AppDeps) { const app = new Hono() @@ -19,6 +32,9 @@ export function offersRoute(deps: AppDeps) { } const auth = c.get('auth') const scope: Scope = { projectId: auth.projectId, environment: auth.environment } + if (!(await isKnownOffer(deps, scope.projectId, offerId))) { + return c.json({ error: { code: 'not_found', message: 'Unknown offer id.' } }, 404) + } await deps.offerMetricsStore.recordClick(scope, offerId, parsed.data.userId ?? null, new Date()) return c.json({ recorded: true } satisfies OfferClickResponse) }) @@ -33,6 +49,9 @@ export function offersRoute(deps: AppDeps) { } const auth = c.get('auth') const scope: Scope = { projectId: auth.projectId, environment: auth.environment } + if (!(await isKnownOffer(deps, scope.projectId, offerId))) { + return c.json({ error: { code: 'not_found', message: 'Unknown offer id.' } }, 404) + } await deps.offerMetricsStore.recordImpression(scope, offerId, parsed.data.userId ?? null, new Date(), parsed.data.impressionId) return c.json({ recorded: true } satisfies OfferImpressionResponse) }) diff --git a/apps/api/test/offers.test.ts b/apps/api/test/offers.test.ts index 29e14d4..3960baf 100644 --- a/apps/api/test/offers.test.ts +++ b/apps/api/test/offers.test.ts @@ -62,6 +62,21 @@ describe('POST /v1/offers/:id/click', () => { expect(res.status).toBe(400) expect(fakes.metrics.clicks).toEqual([]) }) + it('rejects an unknown offer id with 404 and records nothing', async () => { + const { app, fakes } = setup() + const res = await app.request('/v1/offers/unknown-offer/click', { method: 'POST', headers, body: JSON.stringify({}) }) + expect(res.status).toBe(404) + expect((await res.json()).error).toEqual({ code: 'not_found', message: 'Unknown offer id.' }) + expect(fakes.metrics.clicks).toEqual([]) + }) + it('fails open and records when the config store errors', async () => { + const { app, fakes } = setup() + fakes.configStore.getOffers = async () => { throw new Error('config plane down') } + const res = await app.request('/v1/offers/o1/click', { method: 'POST', headers, body: JSON.stringify({ userId: 'u1' }) }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ recorded: true }) + expect(fakes.metrics.clicks).toEqual([{ offerId: 'o1', userId: 'u1' }]) + }) }) describe('POST /v1/offers/:id/impression', () => { @@ -106,4 +121,25 @@ describe('POST /v1/offers/:id/impression', () => { expect(res.status).toBe(400) expect(fakes.metrics.impressions).toEqual([]) }) + + it('rejects an unknown offer id with 404 and records nothing', async () => { + const { app, fakes } = setup() + const res = await app.request('/v1/offers/unknown-offer/impression', { + method: 'POST', headers, body: JSON.stringify({ impressionId }), + }) + expect(res.status).toBe(404) + expect((await res.json()).error).toEqual({ code: 'not_found', message: 'Unknown offer id.' }) + expect(fakes.metrics.impressions).toEqual([]) + }) + + it('fails open and records when the config store errors', async () => { + const { app, fakes } = setup() + fakes.configStore.getOffers = async () => { throw new Error('config plane down') } + const res = await app.request('/v1/offers/o1/impression', { + method: 'POST', headers, body: JSON.stringify({ impressionId, userId: 'u1' }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ recorded: true }) + expect(fakes.metrics.impressions).toEqual([{ offerId: 'o1', userId: 'u1' }]) + }) }) From 7a7f914a8c23e70d7ca613a428b7e8779c4f4789 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 15:36:31 -0700 Subject: [PATCH 13/17] =?UTF-8?q?chore:=20fast-follow=20polish=20=E2=80=94?= =?UTF-8?q?=20request-id=20logs,=20docs=20page,=20tarball=20hygiene,=20rel?= =?UTF-8?q?ease=20fixes=20(closes=20#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 4 ++ README.md | 34 +++++++++++++- apps/api/src/app.ts | 26 +++++++++++ apps/api/src/routes/events.ts | 6 ++- apps/api/src/routes/offers.ts | 8 ++-- apps/api/src/routes/placements.ts | 2 +- apps/api/src/routes/stats.ts | 4 +- apps/api/test/app.test.ts | 10 ++++ apps/api/test/offers.test.ts | 21 ++++++++- apps/api/test/openapi.test.ts | 11 +++++ apps/api/test/stats.test.ts | 13 +++++- apps/api/test/timed-events.test.ts | 13 +++++- docs/retros/README.md | 74 ++++++++++++++++++++++++++++++ packages/contracts/README.md | 23 ++++++++++ packages/contracts/package.json | 1 + packages/sdk/package.json | 1 + packages/widgets/package.json | 1 + 17 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 docs/retros/README.md create mode 100644 packages/contracts/README.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be9e565..a949a65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ on: jobs: release: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -14,6 +16,8 @@ jobs: registry-url: 'https://registry.npmjs.org' - run: pnpm install --frozen-lockfile - run: pnpm turbo run build --filter='./packages/*' + - run: pnpm turbo run test --filter='./packages/*' - run: npx changeset publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: git push --tags diff --git a/README.md b/README.md index c1ebef2..c7cc9a3 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,24 @@ allowlist when one is configured on the key: requests carrying an `Origin` header not on that list are rejected with `403 origin_not_allowed` (secret keys, and requests with no `Origin` header, are exempt from this check). +### Data retention + +`DELETE /v1/users/:userId` erases a user's events, progress, unlocks, and +offer_events rows in one transaction, but **MAU (monthly active user) +counter rows are retained** — they exist for usage-based billing history and +contain only the project/environment/month and the external user id, no +event content. + +**Log retention:** every request is logged (`apps/api/src/app.ts`'s request +middleware) with the request path, which for user-scoped routes (e.g. `GET +/v1/users/:userId/achievements`, `DELETE /v1/users/:userId`) includes the +caller-supplied external `userId` verbatim. Erasure does **not** reach back +into already-emitted logs — it only deletes database rows. If you ship +these logs to persistent storage (stdout capture, a log aggregator, etc.), +applying your own rotation/retention policy — and redacting or expiring user +identifiers out of it in line with your data-retention obligations — is the +operator's responsibility, not something this API does for you. + ### Registered event types Enforcement is opt-in per project: set a project's `registeredEventTypes` @@ -169,4 +187,18 @@ MIT packages (`@promocean/contracts`, `@promocean/sdk`, `@promocean/widgets`) pu 2. **Bump versions**: Before releasing, run `pnpm changeset version` to consume pending changesets, bump `package.json` versions, and update changelogs. Commit and merge this version bump. -3. **Publish to npm**: Trigger the **Release** workflow from GitHub Actions (Actions → Release → Run workflow). The workflow builds packages and runs `changeset publish`, publishing any versions not yet on npm. Requires the `NPM_TOKEN` repo secret. +3. **Publish to npm**: Trigger the **Release** workflow from GitHub Actions (Actions → Release → Run workflow). The workflow runs the package test suite, builds packages, and runs `changeset publish` (which also tags each published version — the workflow pushes those tags to origin afterwards), publishing any versions not yet on npm. Requires the `NPM_TOKEN` repo secret. + +### Changeset authoring + +When you run `pnpm changeset`, only select the packages your change actually +touched (or whose public behavior it affects transitively). `changeset` +defaults to listing every package it's asked about, so it's easy to +over-select — e.g. tick `@promocean/sdk` for a change that only touched +`@promocean/widgets`. An over-broad changeset produces a changelog entry +("version bump") on a package with nothing to say why, which is confusing +for consumers reading release notes. If a package's version is only bumping +because `updateInternalDependencies: "patch"` cascaded a workspace +dependency bump (see `.changeset/config.json`), that's expected and separate +from this — the authoring step is about which packages *you* list, not +about the automatic dependency-bump cascade. diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 499d40a..83a558d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -27,6 +27,29 @@ const { version: apiVersion } = JSON.parse(readFileSync(pkgPath, 'utf8')) as { v const openApiDocument = buildOpenApiDocument(apiVersion) const openApiBody = JSON.stringify(openApiDocument) +// Static Redoc viewer for the OpenAPI document. The browser loads the Redoc +// bundle from its CDN at view time; the API itself takes on no doc-rendering +// dependency (no redoc package in this app's own dependency tree). Pinned to +// a specific version (rather than the mutable /latest/ alias) with a +// Subresource Integrity hash so the CDN can't silently swap the served +// script; bump both together when upgrading Redoc. +const docsHtml = ` + + + Promocean API docs + + + + + + + +` + // Races an arbitrary promise against a timeout. AbortSignal.timeout() only // helps callers that accept a signal (e.g. fetch); our readiness checks are // plain promises, so we race them against a rejecting timer instead. @@ -98,6 +121,9 @@ export function createApp(deps: AppDeps, opts: CreateAppOptions = {}) { app.get('/v1/openapi.json', (c) => c.body(openApiBody, 200, { 'content-type': 'application/json', 'cache-control': 'public, max-age=3600' }), ) + // Also auth-free, alongside the openapi.json route above: a human-readable + // Redoc viewer for the same document. + app.get('/docs', (c) => c.html(docsHtml)) app.use('/v1/*', createRateLimiter(rateLimitPerMinute, { maxBuckets: rateLimitMaxBuckets })) app.use('/v1/*', authMiddleware(deps.apiKeyStore)) app.route('/v1/events', eventsRoute(deps)) diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 0c69292..fd87663 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -20,7 +20,9 @@ export function eventsRoute(deps: AppDeps) { // Config-plane failure must not block ingestion: fail open (same pattern as the // multiplier lookup below), just without enforcement for this request. const registered = await deps.configStore.getRegisteredEventTypes(scope.projectId).catch((err) => { - logger.warn({ err }, 'registered event types fetch failed; skipping unregistered-event-type enforcement') + logger.child({ requestId: c.get('requestId') }).warn( + { err }, 'registered event types fetch failed; skipping unregistered-event-type enforcement', + ) return [] as string[] }) if (registered.length > 0 && !registered.includes(type)) { @@ -39,7 +41,7 @@ export function eventsRoute(deps: AppDeps) { try { multiplier = activeMultiplier(await deps.configStore.getTimedEvents(scope.projectId), occurredAt) } catch (err) { - logger.warn({ err }, 'timed events fetch failed; defaulting multiplier to 1') + logger.child({ requestId: c.get('requestId') }).warn({ err }, 'timed events fetch failed; defaulting multiplier to 1') } const plan = evaluateEvent({ userId, type, occurredAt }, definitions, multiplier) diff --git a/apps/api/src/routes/offers.ts b/apps/api/src/routes/offers.ts index aae221b..7fff65c 100644 --- a/apps/api/src/routes/offers.ts +++ b/apps/api/src/routes/offers.ts @@ -10,10 +10,10 @@ import { logger } from '../logger.js' // These routes are reachable with a browser-exposed publishable key, so a config-plane // outage must not block writes: on getOffers() failure we fail open (treat the offer as // known and record it) rather than 404, trading strict id validation for availability. -async function isKnownOffer(deps: AppDeps, projectId: string, offerId: string): Promise { +async function isKnownOffer(deps: AppDeps, projectId: string, offerId: string, requestId: string): Promise { const offers = await deps.configStore.getOffers(projectId).catch(() => null) if (offers === null) { - logger.warn({ projectId, offerId }, 'offer config fetch failed; skipping offer id validation') + logger.child({ requestId }).warn({ projectId, offerId }, 'offer config fetch failed; skipping offer id validation') return true } return offers.some((o) => o.id === offerId) @@ -32,7 +32,7 @@ export function offersRoute(deps: AppDeps) { } const auth = c.get('auth') const scope: Scope = { projectId: auth.projectId, environment: auth.environment } - if (!(await isKnownOffer(deps, scope.projectId, offerId))) { + if (!(await isKnownOffer(deps, scope.projectId, offerId, c.get('requestId')))) { return c.json({ error: { code: 'not_found', message: 'Unknown offer id.' } }, 404) } await deps.offerMetricsStore.recordClick(scope, offerId, parsed.data.userId ?? null, new Date()) @@ -49,7 +49,7 @@ export function offersRoute(deps: AppDeps) { } const auth = c.get('auth') const scope: Scope = { projectId: auth.projectId, environment: auth.environment } - if (!(await isKnownOffer(deps, scope.projectId, offerId))) { + if (!(await isKnownOffer(deps, scope.projectId, offerId, c.get('requestId')))) { return c.json({ error: { code: 'not_found', message: 'Unknown offer id.' } }, 404) } await deps.offerMetricsStore.recordImpression(scope, offerId, parsed.data.userId ?? null, new Date(), parsed.data.impressionId) diff --git a/apps/api/src/routes/placements.ts b/apps/api/src/routes/placements.ts index 42ea01f..de342e3 100644 --- a/apps/api/src/routes/placements.ts +++ b/apps/api/src/routes/placements.ts @@ -24,7 +24,7 @@ export function placementsRoute(deps: AppDeps) { try { active = activeEventIds(await deps.configStore.getTimedEvents(scope.projectId), now) } catch (err) { - logger.warn({ err }, 'timed events fetch failed; event-attached offers hidden') + logger.child({ requestId: c.get('requestId') }).warn({ err }, 'timed events fetch failed; event-attached offers hidden') } const offer = resolveOffer(slug, offers, now, active) return c.json({ diff --git a/apps/api/src/routes/stats.ts b/apps/api/src/routes/stats.ts index b316cee..15d0276 100644 --- a/apps/api/src/routes/stats.ts +++ b/apps/api/src/routes/stats.ts @@ -28,7 +28,9 @@ export function statsRoute(deps: AppDeps) { try { timedEventDefs = await deps.configStore.getTimedEvents(scope.projectId) } catch (err) { - logger.warn({ err }, 'timed events fetch failed; stats serving with empty timed-event windows') + logger.child({ requestId: c.get('requestId') }).warn( + { err }, 'timed events fetch failed; stats serving with empty timed-event windows', + ) } const windows = timedEventDefs.map((e) => ({ eventId: e.id, startsAt: e.startsAt, endsAt: e.endsAt })) diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts index 9b4255c..5d7065d 100644 --- a/apps/api/test/app.test.ts +++ b/apps/api/test/app.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { WebhookDispatcher } from '../src/webhooks.js' import { createApp } from '../src/app.js' +import { logger } from '../src/logger.js' import { makeFakes } from './fakes.js' const defs = [ @@ -84,6 +85,15 @@ describe('POST /v1/events', () => { expect(res.status).toBe(200) expect((await res.json()).deduped).toBe(false) }) + it('logs the registered-event-types fetch failure via a child logger carrying the request id', async () => { + const fakes = makeFakes(defs, auth, [], [], ['lesson_completed']) + fakes.configStore.getRegisteredEventTypes = async () => { throw new Error('config plane down') } + const childSpy = vi.spyOn(logger, 'child') + const res = await createApp(fakes).request('/v1/events', { method: 'POST', headers, body: body('k1234567') }) + expect(res.status).toBe(200) + expect(childSpy).toHaveBeenCalledWith({ requestId: expect.any(String) }) + childSpy.mockRestore() + }) }) describe('POST /v1/events webhook dispatch', () => { diff --git a/apps/api/test/offers.test.ts b/apps/api/test/offers.test.ts index 3960baf..ed9ed02 100644 --- a/apps/api/test/offers.test.ts +++ b/apps/api/test/offers.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createApp } from '../src/app.js' +import { logger } from '../src/logger.js' import { makeFakes } from './fakes.js' const offer = { @@ -41,6 +42,15 @@ describe('GET /v1/placements/:slug/offer', () => { const res = await app.request(`/v1/placements/homepage-banner/offer?userId=${'x'.repeat(129)}`, { headers }) expect(res.status).toBe(400) }) + it('fails open (empty active-events set) and logs via a child logger carrying the request id when getTimedEvents throws', async () => { + const { app, fakes } = setup() + fakes.configStore.getTimedEvents = async () => { throw new Error('config plane down') } + const childSpy = vi.spyOn(logger, 'child') + const res = await app.request('/v1/placements/homepage-banner/offer?userId=u1', { headers }) + expect(res.status).toBe(200) + expect(childSpy).toHaveBeenCalledWith({ requestId: expect.any(String) }) + childSpy.mockRestore() + }) }) describe('POST /v1/offers/:id/click', () => { @@ -77,6 +87,15 @@ describe('POST /v1/offers/:id/click', () => { expect(await res.json()).toEqual({ recorded: true }) expect(fakes.metrics.clicks).toEqual([{ offerId: 'o1', userId: 'u1' }]) }) + it('logs the config fetch failure via a child logger carrying the request id', async () => { + const { app, fakes } = setup() + fakes.configStore.getOffers = async () => { throw new Error('config plane down') } + const childSpy = vi.spyOn(logger, 'child') + const res = await app.request('/v1/offers/o1/click', { method: 'POST', headers, body: JSON.stringify({ userId: 'u1' }) }) + expect(res.status).toBe(200) + expect(childSpy).toHaveBeenCalledWith({ requestId: expect.any(String) }) + childSpy.mockRestore() + }) }) describe('POST /v1/offers/:id/impression', () => { diff --git a/apps/api/test/openapi.test.ts b/apps/api/test/openapi.test.ts index 5fd34ff..c8795ed 100644 --- a/apps/api/test/openapi.test.ts +++ b/apps/api/test/openapi.test.ts @@ -41,3 +41,14 @@ describe('GET /v1/openapi.json', () => { expect(doc.info.version.length).toBeGreaterThan(0) }) }) + +describe('GET /docs', () => { + it('is reachable without an Authorization header and serves the Redoc viewer', async () => { + const res = await app().request('/docs') + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toMatch(/text\/html/) + const html = await res.text() + expect(html).toContain('') + expect(html).toContain('cdn.redoc.ly') + }) +}) diff --git a/apps/api/test/stats.test.ts b/apps/api/test/stats.test.ts index 15bd667..b66ae0a 100644 --- a/apps/api/test/stats.test.ts +++ b/apps/api/test/stats.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AuthContext } from '@promocean/core' import { createApp } from '../src/app.js' +import { logger } from '../src/logger.js' import { makeFakes } from './fakes.js' const timedEvents = [ @@ -93,4 +94,14 @@ describe('GET /v1/stats', () => { expect(json.timedEvents).toEqual([]) expect(fakes.statsCalls[0]!.timedEventWindows).toEqual([]) }) + + it('logs the timed-events fetch failure via a child logger carrying the request id', async () => { + const { app, fakes } = setup(skAuth()) + fakes.configStore.getTimedEvents = async () => { throw new Error('config plane down') } + const childSpy = vi.spyOn(logger, 'child') + const res = await app.request('/v1/stats', { headers }) + expect(res.status).toBe(200) + expect(childSpy).toHaveBeenCalledWith({ requestId: expect.any(String) }) + childSpy.mockRestore() + }) }) diff --git a/apps/api/test/timed-events.test.ts b/apps/api/test/timed-events.test.ts index 4be54d9..96f1c6f 100644 --- a/apps/api/test/timed-events.test.ts +++ b/apps/api/test/timed-events.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { OfferDefinition, TimedEventDefinition } from '@promocean/core' import { createApp } from '../src/app.js' +import { logger } from '../src/logger.js' import { makeFakes } from './fakes.js' const mk = (over: Partial = {}): TimedEventDefinition => ({ @@ -47,6 +48,16 @@ describe('POST /v1/events — timed-event multiplier wiring', () => { expect(json.progress).toContainEqual({ achievementId: 'a1', current: 1, target: 2 }) expect(json.unlocks).toEqual([]) }) + it('logs the timed-events fetch failure via a child logger carrying the request id', async () => { + const fakes = makeFakes(defs, auth, [], []) + fakes.configStore.getTimedEvents = async () => { throw new Error('config plane down') } + const childSpy = vi.spyOn(logger, 'child') + const app = createApp(fakes) + const res = await app.request('/v1/events', { method: 'POST', headers, body: body('key_0003b') }) + expect(res.status).toBe(200) + expect(childSpy).toHaveBeenCalledWith({ requestId: expect.any(String) }) + childSpy.mockRestore() + }) }) describe('GET /v1/events/live', () => { diff --git a/docs/retros/README.md b/docs/retros/README.md new file mode 100644 index 0000000..fdd7c92 --- /dev/null +++ b/docs/retros/README.md @@ -0,0 +1,74 @@ +# Sprint retros + +One-paragraph retro stubs for Sprints 0-5, sourced from the git history and +the implementation plans in `docs/superpowers/plans/`. Full task-by-task +detail lives in `.superpowers/sdd/progress.md`. + +## Sprint 0-1 — foundation + achievements + +(`docs/superpowers/plans/2026-07-06-sprint-0-1-foundation-achievements.md`, +merged via PR #1.) Shipped the whole MVP skeleton in one pass: the +pnpm/Turborepo monorepo, `@promocean/contracts` (zod schemas), `@promocean/core` +(pure achievement evaluation), `adapter-db` (Postgres via Drizzle), the Strapi +config-plane CMS with API-key auth, the `apps/api` Hono runtime, the +`@promocean/sdk` client, accessible `@promocean/widgets`, and a demo app +proving the full track-to-unlock loop with Playwright e2e. Task-by-task review +caught real issues early (timing-safe key comparison, zod v4 idiom fixes, +pool-error handling). The whole-branch final review hit a session length +limit on the first attempt and had to be re-dispatched fresh — a reminder to +leave headroom before a final review on a large branch. + +## Sprint 2 — first-party offers + +(`docs/superpowers/plans/2026-07-06-sprint-2-offers.md`, merged via PR #7.) +Added the first-party offers vertical slice end to end: offer/placement +content types in the CMS, a pure resolution function in `core`, a TTL-cached +config-plane client, the placement-offer and click API endpoints, and the +`` widget with dismissal persistence and click tracking. Most +tasks reviewed clean on the first pass. The one real finding was an XSS gap — +`imageUrl`/`ctaUrl` from CMS-authored offer creative were rendered +unsanitized — fixed with an `http(s)`-only scheme allowlist at the widget +layer, a good reminder to treat CMS content as untrusted input at the +rendering boundary, not just at the API boundary. + +## Sprint 3 — timed events + lifecycle webhooks + +(`docs/superpowers/plans/2026-07-07-sprint-3-timed-events.md`, merged via PR +#9.) Added timed-event progress multipliers, event-gated offers, a signed +webhook dispatcher, a lifecycle scheduler driving `timed_event.live` / +`ending_soon` / `ended` transitions with claim-then-mark delivery and a +dead-letter store, plus a live countdown widget. Most tasks were review-clean +first pass, though Task 4 noted the webhook-endpoint secret was exposed via +the CMS's default REST routes (mirroring an existing api-key pattern). The +final review fixed several issues in one wave — that secret exposure, a +webhook delivery timeout, an orphan-event guard, and a multiplier- +documentation gap — before re-approving; a new issue (#8) was filed for +follow-up work and two existing issues extended in scope. + +## Sprint 4 — security completion, observability, OpenAPI, publishing + +(`docs/superpowers/plans/2026-07-07-sprint-4-polish.md`, merged via PR #12.) +Closed out the MVP's remaining spec requirements: per-key rate limiting, an +origin allowlist, secret-key-gated GDPR user erasure (the first +`keyType`-enforced endpoint), pino structured logging with request IDs, an +OpenAPI document generated straight from the zod contracts, and a Changesets +publishing setup for the three MIT packages. Nearly every task reviewed clean +first pass. The one lasting cosmetic issue — the erasure changeset also +listed `@promocean/sdk`/`@promocean/widgets`, producing misleading changelog +entries for packages the change didn't touch — was carried forward rather +than fixed in-sprint, and is what Sprint 6's changeset-authoring note now +addresses. + +## Sprint 5 — stats endpoint + data integrity + +(`docs/superpowers/plans/2026-07-07-sprint-5-stats-integrity.md`, merged via +PR #16.) Fixed two real data-integrity bugs (a dedup/increment race in event +ingestion, and impressions being recorded on every placement fetch instead of +only on actual render) by moving to a single transactional ingestion store +and a dedicated idempotent impression-beacon endpoint, then added a +secret-key-only `/v1/stats` aggregation endpoint and a server-rendered stats +page in the demo app. DoD was verified live against real aggregated data, not +just fakes. The final review's fix wave was small (a fail-open warning +comment, a delta-rounding rationale note) but filed three follow-up issues +(offer-id validation, test hardening, stats polish) — a sign the sprint +correctly deferred polish rather than scope-creeping to absorb it. diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 0000000..fc03b09 --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,23 @@ +# @promocean/contracts + +Zod schemas and TypeScript types shared across the Promocean API, SDK, and +widgets: event tracking, achievements, offers, timed events, webhooks, +stats, users, and the error envelope. This package has no runtime behavior +of its own — it's the single source of truth for request/response shapes so +the API and its clients can't drift. + +## Install + + npm i @promocean/contracts + +## Usage + +```ts +import { trackEventRequestSchema, type TrackEventResponse } from '@promocean/contracts' + +const parsed = trackEventRequestSchema.safeParse(body) +``` + +Most consumers won't need this package directly — [`@promocean/sdk`](../sdk/README.md) +re-exports the types it uses and validates responses against these schemas +at runtime. diff --git a/packages/contracts/package.json b/packages/contracts/package.json index f16b8fe..4cbd10c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -5,6 +5,7 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "files": ["dist", "README.md", "LICENSE"], "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6533ad9..1f3f666 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -5,6 +5,7 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "files": ["dist", "README.md", "LICENSE"], "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 6be44bf..0b23a6c 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -5,6 +5,7 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "files": ["dist", "README.md", "LICENSE"], "sideEffects": false, "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { From cc39e9092dd030bf5ddd94e22c110bf19529d413 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 15:46:22 -0700 Subject: [PATCH 14/17] test: race loop, StrictMode beacon, schema coverage, suggester boundary; nameById fallback (closes #14) Co-Authored-By: Claude Fable 5 --- apps/api/src/routes/events.ts | 2 +- apps/api/test/app.test.ts | 21 ++++++++++++++++++++ packages/adapter-db/test/ingestion.test.ts | 19 ++++++++++++++++++ packages/contracts/test/contracts.test.ts | 23 ++++++++++++++++++++++ packages/core/test/suggest.test.ts | 12 +++++++++++ packages/widgets/test/widgets.test.tsx | 13 ++++++++++++ 6 files changed, 89 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index fd87663..6b24d75 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -60,7 +60,7 @@ export function eventsRoute(deps: AppDeps) { const nameById = new Map(plan.increments.map((i) => [i.achievementId, i.name])) const unlocks: TrackEventResponse['unlocks'] = outcome.newUnlocks.map((u) => ({ achievementId: u.achievementId, - name: nameById.get(u.achievementId)!, + name: nameById.get(u.achievementId) ?? u.achievementId, unlockedAt: u.unlockedAt.toISOString(), })) diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts index 5d7065d..753ae4c 100644 --- a/apps/api/test/app.test.ts +++ b/apps/api/test/app.test.ts @@ -94,6 +94,27 @@ describe('POST /v1/events', () => { expect(childSpy).toHaveBeenCalledWith({ requestId: expect.any(String) }) childSpy.mockRestore() }) + it('falls back to the achievement id as the name when a reported unlock is absent from the increments plan', async () => { + // Regression guard for a store/evaluation mismatch: the ingestion store is the source of + // truth for *which* achievements unlocked, but names come from the increments plan built + // from this request's evaluateEvent() call. If the store reports an unlock for an id that + // plan doesn't know about (e.g. config changed between evaluation and commit), nameById.get + // would previously return undefined and `!`-assert past it — a type-level lie with no + // runtime effect, except that c.json's JSON.stringify() drops undefined-valued keys, so the + // response's unlock silently loses its "name" key instead of crashing. + const fakes = makeFakes(defs, auth) + fakes.ingestionStore.ingestEvent = async () => ({ + deduped: false, + progress: [], + newUnlocks: [{ achievementId: 'ghost-achievement', unlockedAt: new Date('2026-07-08T00:00:00.000Z') }], + }) + const res = await createApp(fakes).request('/v1/events', { method: 'POST', headers, body: body('ghost0001') }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.unlocks).toEqual([ + { achievementId: 'ghost-achievement', name: 'ghost-achievement', unlockedAt: '2026-07-08T00:00:00.000Z' }, + ]) + }) }) describe('POST /v1/events webhook dispatch', () => { diff --git a/packages/adapter-db/test/ingestion.test.ts b/packages/adapter-db/test/ingestion.test.ts index a6d8b69..222b50a 100644 --- a/packages/adapter-db/test/ingestion.test.ts +++ b/packages/adapter-db/test/ingestion.test.ts @@ -59,6 +59,25 @@ describe('PgIngestionStore', () => { expect(await rawProgress('race-user', 'a-race')).toBe(2) }) + it('applies 8 concurrent increments without a lost update (n-way race)', async () => { + const store = new PgIngestionStore(db) + const at = new Date() + const userId = 'race-user-8' + const achievementId = 'a-race-8' + const target = 20 + await Promise.all( + Array.from({ length: 8 }, (_, i) => + store.ingestEvent( + scope, + { userId, type: 'lesson_completed', idempotencyKey: `race8-${i}`, occurredAt: at }, + [{ achievementId, delta: 1, target }], + '2026-07', + ), + ), + ) + expect(await rawProgress(userId, achievementId)).toBe(8) + }) + it('unlocks exactly once when a crossing call reaches the target, and only that call reports newUnlocks', async () => { const store = new PgIngestionStore(db) const at = new Date() diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index 9246620..2861a0c 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -7,6 +7,7 @@ import { eraseUserResponseSchema, offerImpressionRequestSchema, offerImpressionResponseSchema, + statsQuerySchema, statsResponseSchema, webhookMessageSchema, } from '../src/index.js' @@ -93,6 +94,28 @@ describe('offerImpressionRequestSchema', () => { }) expect(result.success).toBe(false) }) + it('accepts a request with userId omitted (anonymous impression)', () => { + const result = offerImpressionRequestSchema.safeParse({ + impressionId: '550e8400-e29b-41d4-a716-446655440000', + }) + expect(result.success).toBe(true) + if (result.success) expect(result.data).toEqual({ impressionId: '550e8400-e29b-41d4-a716-446655440000' }) + }) +}) + +describe('statsQuerySchema', () => { + it('accepts a valid Z-suffixed ISO datetime for from/to', () => { + const result = statsQuerySchema.safeParse({ from: '2026-01-01T00:00:00.000Z', to: '2026-12-31T23:59:59.999Z' }) + expect(result.success).toBe(true) + }) + it('rejects a junk datetime string', () => { + const result = statsQuerySchema.safeParse({ from: 'not-a-date' }) + expect(result.success).toBe(false) + }) + it('accepts an empty object (both bounds optional)', () => { + const result = statsQuerySchema.safeParse({}) + expect(result.success).toBe(true) + }) }) describe('offerImpressionResponseSchema', () => { diff --git a/packages/core/test/suggest.test.ts b/packages/core/test/suggest.test.ts index ac76d2c..6d6a1b5 100644 --- a/packages/core/test/suggest.test.ts +++ b/packages/core/test/suggest.test.ts @@ -20,6 +20,18 @@ describe('suggestEventType', () => { it('returns null for an empty registered list', () => { expect(suggestEventType('signup', [])).toBeNull() }) + it('matches at exactly distance 2 and returns null at exactly distance 3 (boundary)', () => { + const registeredList = ['level_complete'] + // 'level_complete' is 14 chars: l e v e l _ c o m p l e t e + // Deleting the trailing 2 chars ('t','e') gives the 12-char prefix 'level_comple'. + // A string and its own prefix differ by exactly the length difference: the lower bound + // on edit distance is |lengths differ| (each op changes length by at most 1), and that + // bound is achieved here via 2 plain deletions, so distance === 2 exactly (within <=2). + expect(suggestEventType('level_comple', registeredList)).toBe('level_complete') + // Deleting the trailing 3 chars ('e','t','e') gives the 11-char prefix 'level_compl'. + // Same prefix argument: distance === 3 exactly, which exceeds the <=2 threshold, so null. + expect(suggestEventType('level_compl', registeredList)).toBeNull() + }) it('breaks ties by registered-list order (lowest index wins)', () => { // 'lesson_completed' and 'profile_completed' are both distance 1 from a crafted input? Use // two entries with identical edit distance to a common typo to exercise tie-break order. diff --git a/packages/widgets/test/widgets.test.tsx b/packages/widgets/test/widgets.test.tsx index 6068c53..f82c406 100644 --- a/packages/widgets/test/widgets.test.tsx +++ b/packages/widgets/test/widgets.test.tsx @@ -1,3 +1,4 @@ +import { StrictMode } from 'react' import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { UnlockPayload } from '@promocean/contracts' @@ -117,6 +118,18 @@ describe('Placement', () => { expect(container.querySelector('[data-promocean-placement]')).toBeNull() expect(client.recordImpression).not.toHaveBeenCalled() }) + it('fires the impression beacon exactly once under StrictMode double-invoked effects', async () => { + const { client } = fakeClient() + client.getPlacementOffer = vi.fn().mockResolvedValue(offerCreative) + render( + + + , + ) + await waitFor(() => expect(screen.getByText('Welcome to Promocean')).toBeDefined()) + expect(client.recordImpression).toHaveBeenCalledTimes(1) + expect(client.recordImpression).toHaveBeenCalledWith('o1') + }) it('does not fire an impression beacon when unmounted before the fetch resolves', async () => { const { client } = fakeClient() let resolveOffer!: (o: unknown) => void From 2cd4ae1ca8f53d0ca524ae625ff18ac15d4ec03d Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 16:05:10 -0700 Subject: [PATCH 15/17] feat: production docker images and one-command compose stack Co-Authored-By: Claude Fable 5 --- .dockerignore | 43 ++++++++++++++++++ .env.example | 57 +++++++++++++++++++++--- apps/api/.env.example | 32 ++++++++++++++ apps/api/Dockerfile | 41 ++++++++++++++++++ apps/cms/Dockerfile | 47 ++++++++++++++++++++ apps/demo/Dockerfile | 46 ++++++++++++++++++++ apps/demo/next.config.ts | 10 ++++- docker-compose.yml | 94 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/api/.env.example create mode 100644 apps/api/Dockerfile create mode 100644 apps/cms/Dockerfile create mode 100644 apps/demo/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b2d2e8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,43 @@ +# Keep the Docker build context lean and reproducible. The pruner stage rebuilds +# node_modules from the committed lockfile, so nothing installed/generated locally +# should leak into the image or bust layer caching. +node_modules +**/node_modules + +# VCS / tooling +.git +.github +.changeset +.remember +.claude +.superpowers +docs + +# Build outputs (regenerated inside the image) +dist +**/dist +.next +**/.next +.turbo +**/.turbo +apps/cms/build +apps/cms/.strapi +apps/cms/.cache +apps/cms/.tmp + +# Test / local artifacts +**/test-results +**/playwright-report +**/*.tsbuildinfo +coverage + +# Local env files — images get config via compose, never baked secrets +.env +.env.* +**/.env +**/.env.* +!**/.env.example + +# Editor / OS cruft +.DS_Store +*.log diff --git a/.env.example b/.env.example index d0151b2..676dc67 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,55 @@ -DATABASE_URL=postgres://promocean:promocean@localhost:5433/promocean -CONFIG_PLANE_SECRET=dev-config-secret -STRAPI_URL=http://localhost:1337 -API_PORT=3001 +# ============================================================================= +# Promocean — one-command compose stack contract. +# +# cp .env.example .env +# docker compose --profile stack up -d --wait +# +# docker compose auto-loads this file (as .env) from the repo root. Every +# variable read by a service in docker-compose.yml is documented here. The +# in-network service URLs (postgres:5432, cms:1337, api:3001) are fixed inside +# docker-compose.yml and are NOT configured here. +# +# The values below are working DEMO defaults — fine for local evaluation. +# Rotate every secret before any non-local deployment. +# ============================================================================= + +# --- Strapi secrets (CMS) ---------------------------------------------------- +# APP_KEYS is a comma-separated list; the rest are single secrets. +APP_KEYS=mAKne60TY0QOJMNUNCDycg==,U/rEOPAmrAew5FiwZ+pSlQ==,Hlysyc4MZF99iRE70R05bg==,9ucJZc3CRVonKa4Pj9vgkQ== +API_TOKEN_SALT=Yg6N381jwuVHDjHZYtDqfw== +ADMIN_JWT_SECRET=YXWKrbvzeQddIrgedorS7g== +TRANSFER_TOKEN_SALT=8AA/neE0ev/Ds/YfhMUfYw== +JWT_SECRET=xBJyFXIuJKLhu2jkCiN6iA== +ENCRYPTION_KEY=10s+VhLba6/fA2fznlMCxg== + +# --- Strapi admin seed (created on first boot if no admin exists) ------------ +ADMIN_FIRST_NAME=Admin +ADMIN_LAST_NAME=User +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=changeMe123! + +# --- Demo data seeding (CMS) ------------------------------------------------- +# When true and the DB is empty, seeds the demo project + pk/sk test keys, +# achievements, placement/offer, and a live timed event. SEED_DEMO=true -LOG_PLAINTEXT_KEYS=true +# Reveal seeded plaintext API keys in the CMS logs (dev convenience only). +LOG_PLAINTEXT_KEYS=false + +# --- Shared config-plane secret (CMS <-> API) ------------------------------- +CONFIG_PLANE_SECRET=dev-config-secret + +# --- API tuning knobs (all have compose-level defaults) ---------------------- +LOG_LEVEL=info +RATE_LIMIT_PER_MINUTE=300 +RATE_LIMIT_MAX_BUCKETS=10000 +WEBHOOK_REDELIVERY_GRACE_MINUTES=5 +TIMED_EVENT_SCAN_GRACE_MINUTES=60 +WEBHOOK_DEAD_LETTER_TTL_DAYS=30 + +# --- Demo app: browser-facing public config (BAKED at image BUILD time) ------ +# These are inlined into the client bundle; only non-secret values belong here. NEXT_PUBLIC_PROMOCEAN_KEY=pk_test_demo_1234567890abcdef NEXT_PUBLIC_PROMOCEAN_API=http://localhost:3001 + +# --- Demo app: server-side secret key (RUNTIME env, never shipped to browser) +PROMOCEAN_SECRET_KEY=sk_test_demo_1234567890abcdef diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..e5cafb3 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,32 @@ +# ============================================================================= +# Promocean API — environment contract. +# +# For running the API directly (`pnpm --filter api dev|start`). In the compose +# stack these are supplied by docker-compose.yml (see the repo-root .env.example); +# the in-network values differ (DATABASE_URL host = postgres, STRAPI_URL = cms). +# ============================================================================= + +# Postgres connection. Host-mapped port is 5433 (see docker-compose.yml); +# in-network the API talks to postgres:5432. +DATABASE_URL=postgres://promocean:promocean@localhost:5433/promocean + +# Strapi config-plane base URL. In-network this is http://cms:1337. +STRAPI_URL=http://localhost:1337 + +# Shared secret the API sends to Strapi's config-plane endpoints. +CONFIG_PLANE_SECRET=dev-config-secret + +# Port the HTTP server binds. +API_PORT=3001 + +# Structured-log verbosity (pino level). +LOG_LEVEL=info + +# Fixed-window rate limiter: requests/minute and max distinct client buckets. +RATE_LIMIT_PER_MINUTE=300 +RATE_LIMIT_MAX_BUCKETS=10000 + +# Webhook + timed-event lifecycle scheduler grace windows. +WEBHOOK_REDELIVERY_GRACE_MINUTES=5 +TIMED_EVENT_SCAN_GRACE_MINUTES=60 +WEBHOOK_DEAD_LETTER_TTL_DAYS=30 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..6780cff --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 +# --------------------------------------------------------------------------- +# Promocean API (Hono) — multi-stage build for a pnpm/turbo monorepo. +# +# Base image: node:22-alpine. The API's only heavy native dependency is `pg`, +# which ships a pure-JS fallback and builds cleanly against musl, so alpine is +# safe here (no musl fallback needed, unlike the CMS's sharp). +# --------------------------------------------------------------------------- +FROM node:22-alpine AS base +RUN corepack enable +WORKDIR /app + +# --- Stage 1: prune the monorepo down to just the `api` package + its deps --- +FROM base AS pruner +COPY . . +# turbo@2 matches the repo's turbo major (package.json: "turbo": "^2.5.4"). +RUN pnpm dlx turbo@2 prune api --docker + +# --- Stage 2: install (with dev deps) and build --- +FROM base AS installer +# Install deps first from the pruned lockfile for maximal layer caching. +COPY --from=pruner /app/out/json/ . +RUN pnpm install --frozen-lockfile +# Then bring in pruned source and build api (+ its workspace deps via ^build). +COPY --from=pruner /app/out/full/ . +RUN pnpm turbo run build --filter=api +# Strip dev dependencies in place, leaving prod node_modules + the built +# workspace package dist/ outputs (workspace links are preserved). +RUN pnpm prune --prod + +# --- Stage 3: minimal non-root runtime --- +FROM base AS runner +ENV NODE_ENV=production +# node:alpine ships an unprivileged `node` user (uid 1000); run as it. +COPY --from=installer --chown=node:node /app . +USER node +EXPOSE 3001 +# Healthcheck via node's built-in http (no curl/wget assumption); hits /readyz. +HEALTHCHECK --interval=10s --timeout=5s --start-period=40s --retries=5 \ + CMD node -e "require('http').get('http://127.0.0.1:'+(process.env.API_PORT||3001)+'/readyz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" +CMD ["node", "apps/api/dist/index.js"] diff --git a/apps/cms/Dockerfile b/apps/cms/Dockerfile new file mode 100644 index 0000000..f4f847a --- /dev/null +++ b/apps/cms/Dockerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1 +# --------------------------------------------------------------------------- +# Promocean CMS (Strapi 5) — multi-stage build for a pnpm/turbo monorepo. +# +# Base image: node:22-alpine. Strapi pulls in `sharp` (native, libvips) which +# is the known musl risk. sharp 0.33+ ships musl prebuilt binaries and +# `libc6-compat` covers the remaining glibc symbol lookups, so alpine builds +# and runs. If a future sharp/libvips bump breaks under musl, switch every +# `node:22-alpine` below to `node:22-slim`, drop the `apk add libc6-compat` +# line, and this Dockerfile keeps working unchanged otherwise. +# --------------------------------------------------------------------------- +FROM node:22-alpine AS base +# libc6-compat: glibc shim so sharp's musl prebuilt resolves cleanly. +RUN apk add --no-cache libc6-compat && corepack enable +WORKDIR /app + +# --- Stage 1: prune the monorepo down to just the `cms` package + its deps --- +FROM base AS pruner +COPY . . +RUN pnpm dlx turbo@2 prune cms --docker + +# --- Stage 2: install (with dev deps) and build the Strapi admin --- +FROM base AS installer +COPY --from=pruner /app/out/json/ . +RUN pnpm install --frozen-lockfile +COPY --from=pruner /app/out/full/ . +# `strapi build` (run via turbo) compiles the admin panel; it needs devDeps. +RUN pnpm turbo run build --filter=cms +# Drop dev deps in place; the built admin lives in apps/cms/build + .strapi and +# survives the prune (only node_modules is pruned). +RUN pnpm prune --prod + +# --- Stage 3: minimal non-root runtime --- +FROM base AS runner +ENV NODE_ENV=production +COPY --from=installer --chown=node:node /app . +USER node +EXPOSE 1337 +# Strapi exposes /_health returning 204 with no auth — perfect for a probe. +HEALTHCHECK --interval=10s --timeout=5s --start-period=90s --retries=10 \ + CMD node -e "require('http').get('http://127.0.0.1:'+(process.env.PORT||1337)+'/_health',r=>process.exit(r.statusCode===204||r.statusCode===200?0:1)).on('error',()=>process.exit(1))" +# `strapi start` serves the prebuilt admin (NODE_ENV=production). Exec the +# workspace-local bin shim directly (not via `node`): its `#!/bin/sh` shebang +# sets the pnpm NODE_PATH Strapi needs, then `exec node bin/strapi.js`, so the +# real node process becomes PID 1's child and receives SIGTERM for clean stops. +WORKDIR /app/apps/cms +CMD ["node_modules/.bin/strapi", "start"] diff --git a/apps/demo/Dockerfile b/apps/demo/Dockerfile new file mode 100644 index 0000000..91136fb --- /dev/null +++ b/apps/demo/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1 +# --------------------------------------------------------------------------- +# Promocean demo (Next.js 16, App Router) — multi-stage build. +# +# Base image: node:22-alpine (no native deps of concern). +# +# NEXT_PUBLIC_* values are inlined into the client bundle at BUILD time, so +# they arrive as Docker build args and are exported to the env before +# `next build`. Only the publishable key + browser API URL are baked (both +# non-secret). The secret key (PROMOCEAN_SECRET_KEY) and server-side +# PROMOCEAN_API_URL are RUNTIME env, injected by compose, never baked. +# --------------------------------------------------------------------------- +FROM node:22-alpine AS base +RUN corepack enable +WORKDIR /app + +# --- Stage 1: prune the monorepo down to just the `demo` package + its deps -- +FROM base AS pruner +COPY . . +RUN pnpm dlx turbo@2 prune demo --docker + +# --- Stage 2: install (with dev deps) and build the standalone server --- +FROM base AS installer +COPY --from=pruner /app/out/json/ . +RUN pnpm install --frozen-lockfile +COPY --from=pruner /app/out/full/ . +# Build-time public config (safe to embed in the client bundle). +ARG NEXT_PUBLIC_PROMOCEAN_KEY=pk_test_demo_1234567890abcdef +ARG NEXT_PUBLIC_PROMOCEAN_API=http://localhost:3001 +ENV NEXT_PUBLIC_PROMOCEAN_KEY=$NEXT_PUBLIC_PROMOCEAN_KEY \ + NEXT_PUBLIC_PROMOCEAN_API=$NEXT_PUBLIC_PROMOCEAN_API \ + NEXT_TELEMETRY_DISABLED=1 +RUN pnpm turbo run build --filter=demo + +# --- Stage 3: minimal non-root runtime (standalone, no pnpm/full deps) --- +FROM base AS runner +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3002 HOSTNAME=0.0.0.0 +# Standalone output is self-contained: server.js + traced node_modules. +COPY --from=installer --chown=node:node /app/apps/demo/.next/standalone ./ +COPY --from=installer --chown=node:node /app/apps/demo/.next/static ./apps/demo/.next/static +COPY --from=installer --chown=node:node /app/apps/demo/public ./apps/demo/public +USER node +EXPOSE 3002 +HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \ + CMD node -e "require('http').get('http://127.0.0.1:'+(process.env.PORT||3002)+'/',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" +CMD ["node", "apps/demo/server.js"] diff --git a/apps/demo/next.config.ts b/apps/demo/next.config.ts index 66e1566..7d0c6cf 100644 --- a/apps/demo/next.config.ts +++ b/apps/demo/next.config.ts @@ -1,8 +1,16 @@ import type { NextConfig } from "next"; +import path from "node:path"; const nextConfig: NextConfig = { - /* config options here */ reactCompiler: true, + // Emit a self-contained server bundle (.next/standalone/apps/demo/server.js + // + a minimal node_modules) so the Docker runtime stage needs neither pnpm + // nor the full workspace install. + output: "standalone", + // In a pnpm/turbo monorepo, pin the file-tracing root at the repo root so + // Next traces the linked @promocean/* workspace packages into standalone and + // lays the output out predictably at apps/demo/server.js. + outputFileTracingRoot: path.join(import.meta.dirname, "../../"), }; export default nextConfig; diff --git a/docker-compose.yml b/docker-compose.yml index 7832e2c..0356e69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,9 @@ services: + # ------------------------------------------------------------------------- + # postgres — profile-less on purpose: `docker compose up -d postgres` (the + # dev flow) stays byte-for-byte unchanged. Host port 5433 -> in-network 5432. + # The healthcheck is the gate the stack services wait on. + # ------------------------------------------------------------------------- postgres: image: postgres:17 environment: @@ -9,5 +14,94 @@ services: - "5433:5432" volumes: - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U promocean"] + interval: 5s + timeout: 5s + retries: 10 + + # ------------------------------------------------------------------------- + # cms (Strapi) — seeds the demo project/keys on first boot when SEED_DEMO=true + # and the DB is empty. Waits for postgres to be healthy. + # ------------------------------------------------------------------------- + cms: + profiles: ["stack"] + build: + context: . + dockerfile: apps/cms/Dockerfile + depends_on: + postgres: + condition: service_healthy + ports: + - "1337:1337" + environment: + HOST: 0.0.0.0 + PORT: "1337" + DATABASE_URL: postgres://promocean:promocean@postgres:5432/promocean + APP_KEYS: ${APP_KEYS} + API_TOKEN_SALT: ${API_TOKEN_SALT} + ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET} + TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT} + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + CONFIG_PLANE_SECRET: ${CONFIG_PLANE_SECRET} + SEED_DEMO: ${SEED_DEMO:-true} + LOG_PLAINTEXT_KEYS: ${LOG_PLAINTEXT_KEYS:-false} + ADMIN_FIRST_NAME: ${ADMIN_FIRST_NAME:-Admin} + ADMIN_LAST_NAME: ${ADMIN_LAST_NAME:-User} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme} + + # ------------------------------------------------------------------------- + # api (Hono) — runs drizzle migrations at boot. Waits for postgres healthy + # AND cms healthy (it reads the config-plane from Strapi). /readyz probe. + # ------------------------------------------------------------------------- + api: + profiles: ["stack"] + build: + context: . + dockerfile: apps/api/Dockerfile + depends_on: + postgres: + condition: service_healthy + cms: + condition: service_healthy + ports: + - "3001:3001" + environment: + API_PORT: "3001" + DATABASE_URL: postgres://promocean:promocean@postgres:5432/promocean + STRAPI_URL: http://cms:1337 + CONFIG_PLANE_SECRET: ${CONFIG_PLANE_SECRET} + LOG_LEVEL: ${LOG_LEVEL:-info} + RATE_LIMIT_PER_MINUTE: ${RATE_LIMIT_PER_MINUTE:-300} + RATE_LIMIT_MAX_BUCKETS: ${RATE_LIMIT_MAX_BUCKETS:-10000} + WEBHOOK_REDELIVERY_GRACE_MINUTES: ${WEBHOOK_REDELIVERY_GRACE_MINUTES:-5} + TIMED_EVENT_SCAN_GRACE_MINUTES: ${TIMED_EVENT_SCAN_GRACE_MINUTES:-60} + WEBHOOK_DEAD_LETTER_TTL_DAYS: ${WEBHOOK_DEAD_LETTER_TTL_DAYS:-30} + + # ------------------------------------------------------------------------- + # demo (Next.js) — NEXT_PUBLIC_* are baked at build (browser bundle); the + # secret key + server-side API URL are runtime env. Waits for api healthy. + # ------------------------------------------------------------------------- + demo: + profiles: ["stack"] + build: + context: . + dockerfile: apps/demo/Dockerfile + args: + NEXT_PUBLIC_PROMOCEAN_KEY: ${NEXT_PUBLIC_PROMOCEAN_KEY:-pk_test_demo_1234567890abcdef} + NEXT_PUBLIC_PROMOCEAN_API: ${NEXT_PUBLIC_PROMOCEAN_API:-http://localhost:3001} + depends_on: + api: + condition: service_healthy + ports: + - "3002:3002" + environment: + PORT: "3002" + # Server-side (React Server Component) calls stay in-network. + PROMOCEAN_API_URL: http://api:3001 + PROMOCEAN_SECRET_KEY: ${PROMOCEAN_SECRET_KEY:-sk_test_demo_1234567890abcdef} + volumes: pgdata: From 5f14f37ada1639588cbd0977d6993b8f118c259f Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 16:21:43 -0700 Subject: [PATCH 16/17] ci: build images and run e2e against the compose stack; one-command quickstart docs Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 37 ++++++++++++++---------------------- README.md | 41 ++++++++++++++++++++++++++++++++++------ 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55ecc4f..6925f7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,21 +20,6 @@ jobs: e2e: runs-on: ubuntu-latest needs: test - env: - DATABASE_URL: postgres://promocean:promocean@localhost:5433/promocean - CONFIG_PLANE_SECRET: ci-config-secret - STRAPI_URL: http://localhost:1337 - SEED_DEMO: "true" - APP_KEYS: ci-key-1,ci-key-2 - API_TOKEN_SALT: ci-salt - ADMIN_JWT_SECRET: ci-admin-secret - TRANSFER_TOKEN_SALT: ci-transfer-salt - JWT_SECRET: ci-jwt-secret - ENCRYPTION_KEY: ci-encryption-key - LOG_PLAINTEXT_KEYS: "false" - NEXT_PUBLIC_PROMOCEAN_KEY: pk_test_demo_1234567890abcdef - NEXT_PUBLIC_PROMOCEAN_API: http://localhost:3001 - PROMOCEAN_SECRET_KEY: sk_test_demo_1234567890abcdef steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -42,14 +27,20 @@ jobs: with: node-version: 22 cache: pnpm - - run: docker compose up -d postgres - run: pnpm install --frozen-lockfile - - run: pnpm turbo run build - - run: | - (cd apps/cms && pnpm start &) - npx wait-on@8.0.3 -t 180000 http://localhost:1337/_health - - run: | - (cd apps/api && pnpm start &) - npx wait-on@8.0.3 -t 60000 http://localhost:3001/healthz + # .env.example carries working demo values (pk/sk test keys the CMS + # seeder hardcodes, matching NEXT_PUBLIC_PROMOCEAN_KEY/PROMOCEAN_SECRET_KEY + # baked/passed below) — the same contract a local `docker compose + # --profile stack up` follows. No CI-specific overrides needed. + - run: cp .env.example .env + # Plain uncached build: `docker buildx bake`-based GHA layer caching + # would need a bake file wired to compose's build config; not worth the + # complexity for 3 small alpine images. Revisit if build time grows. + - run: docker compose --profile stack build + - run: docker compose --profile stack up -d --wait - run: pnpm --filter demo exec playwright install --with-deps chromium - run: pnpm --filter demo e2e + - if: failure() + run: docker compose --profile stack logs + - if: always() + run: docker compose --profile stack down -v diff --git a/README.md b/README.md index c7cc9a3..9b6c2e3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,39 @@ multiplier wins — multipliers don't stack. Progress is always **clamped at the achievement target**, so a ×2 event takes 9/10 to 10/10, not 11. Event windows (`startsAt`/`endsAt`) are absolute UTC instants, not durations. -## Quickstart (dev) +## Quickstart + +The fastest way to see the whole thing working — clone, then one command: + + git clone https://github.com/hynding/promocean.git + cd promocean + cp .env.example .env + docker compose --profile stack up + +This builds and boots Postgres, the Strapi CMS, the API, and the demo app +(each gated behind healthchecks, so services come up in the right order), and +seeds a demo project with test API keys. Once it's up: + +- `http://localhost:3002/?user=manual-1` — the demo app; click **Complete a + lesson** to see an achievement unlock live. +- `http://localhost:3002/stats` — server-rendered aggregate stats for + everything you just did. +- `http://localhost:1337/admin` — the Strapi CMS admin (log in with the + `ADMIN_EMAIL`/`ADMIN_PASSWORD` from your `.env`). +- `http://localhost:3001/v1/openapi.json` — the API's OpenAPI document. + +Tear it down with `docker compose --profile stack down` (add `-v` to also +drop the Postgres volume). + +Note: `.env.example` sets `SEED_DEMO=true`, which seeds a publicly known demo +publishable key (`pk_test_demo_…`) — fine for local dev and CI, but this must +never be enabled in a staging or production environment. + +## Quickstart (dev, no Docker for the apps) + +Postgres still runs in a container (profile-less, so it starts on its own); +`cms`, `api`, and `demo` run on the host via Turborepo instead of as compose +services — useful for iterating on app code without rebuilding images: corepack enable && pnpm install pnpm build @@ -25,10 +57,6 @@ each need their own environment configured first — see below for a from-scratc setup that boots the full stack (cms + api + demo) and proves the achievement loop end to end. -Note: `.env.example` sets `SEED_DEMO=true`, which seeds a publicly known demo -publishable key (`pk_test_demo_…`) — fine for local dev and CI, but this must -never be enabled in a staging or production environment. - ### Running the full stack manually From the repo root, first run `pnpm install` then `pnpm build` (workspace packages @@ -77,7 +105,8 @@ above): pnpm --filter demo e2e This is also run in CI as the `e2e` job in `.github/workflows/ci.yml`, which -boots Postgres, cms, and api with throwaway secrets before running the spec. +builds the images and runs `docker compose --profile stack up -d --wait` +(the same one-command flow above) before running the spec against it. ## API surface From 16ab9c5b4ce3fea7020d4494f510891ff8806ab4 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Wed, 8 Jul 2026 20:09:07 -0700 Subject: [PATCH 17/17] fix: delivered_at backfill migration, exhausted-claim dead-lettering, env NaN guards, doc corrections Co-Authored-By: Claude Fable 5 --- README.md | 14 +- apps/api/.env.example | 10 +- apps/api/src/app.ts | 5 +- apps/api/src/env.ts | 18 + apps/api/src/index.ts | 9 +- apps/api/src/webhooks.ts | 24 + apps/api/test/env.test.ts | 44 ++ apps/api/test/webhooks.test.ts | 69 ++ docker-compose.yml | 2 +- .../migrations/0005_backfill_delivered_at.sql | 7 + .../migrations/meta/0005_snapshot.json | 725 ++++++++++++++++++ .../adapter-db/migrations/meta/_journal.json | 7 + packages/adapter-db/src/stores.ts | 13 + .../adapter-db/test/webhook-delivery.test.ts | 68 ++ packages/core/src/ports.ts | 4 + 15 files changed, 1005 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/env.ts create mode 100644 apps/api/test/env.test.ts create mode 100644 packages/adapter-db/migrations/0005_backfill_delivered_at.sql create mode 100644 packages/adapter-db/migrations/meta/0005_snapshot.json diff --git a/README.md b/README.md index 9b6c2e3..6c2ce6e 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ middleware so tooling can fetch the spec without a key. | GET | `/v1/events/live` | pk or sk | List scheduled/live/ending-soon timed events and their multipliers. | | GET | `/v1/stats` | sk only | Aggregate stats for the project: event/unlock/impression/click totals, per-achievement unlocks, per-offer CTR, per-timed-event participant counts. Optional `?from=&to=` ISO datetime range. Rejected with `403 forbidden` for publishable keys. | | GET | `/v1/openapi.json` | none | Serve the OpenAPI document, generated from the same zod contracts the routes validate against. | +| GET | `/docs` | none | Serve an HTML API reference (Redoc) rendered from the same OpenAPI document. | Every key is rate-limited independently at `RATE_LIMIT_PER_MINUTE` requests per minute (default `300`; single-instance in-memory bucket, keyed by a hash @@ -190,9 +191,14 @@ marking, the claim is left stale and a later tick's **redelivery sweep** re-drives it (incrementing an attempt counter, capped at 5 attempts) with a freshly built message and a new `messageId`, as above. A **retention sweep** on the same tick purges dead letters older than -`WEBHOOK_DEAD_LETTER_TTL_DAYS` (default 30). A disabled event that was -never observed live emits no `ended` message — disabling before an event -ever went live means no lifecycle transition ever fired for it. +`WEBHOOK_DEAD_LETTER_TTL_DAYS` (default 30). Once redelivery attempts hit +the cap of 5, an **exhaustion sweep** on the same tick dead-letters the +claim (``) and marks it delivered so it stops being retried. +Disabling an event stops its lifecycle transitions +from firing at whatever point the disable happens: an event disabled +before ever going live emits no messages at all, and one disabled after +going live emits no `ended` message either — its state simply snaps back +to draft. The dispatcher `POST`s directly to whatever URL a project configures as a webhook endpoint. There is currently no SSRF protection (e.g. blocking @@ -205,7 +211,7 @@ Scheduler tuning (all optional, read once at process start): | Env var | Default | Purpose | | --- | --- | --- | | `WEBHOOK_REDELIVERY_GRACE_MINUTES` | `5` | How long a claimed-but-undelivered transition sits before the redelivery sweep re-drives it. | -| `TIMED_EVENT_SCAN_GRACE_MINUTES` | `60` | How far back the config-plane scan window looks for timed events. Must exceed the redelivery grace (a shorter scan window would let events drop out of the feed before a stale claim could ever be redriven) — if misconfigured, the scheduler logs a warning at startup and clamps it to `WEBHOOK_REDELIVERY_GRACE_MINUTES + 5`. | +| `TIMED_EVENT_SCAN_GRACE_MINUTES` | `60` | How far back the config-plane scan window looks for timed events. Must exceed the redelivery grace (a shorter scan window would let events drop out of the feed before a stale claim could ever be redriven) — if misconfigured, the scheduler logs a warning at startup and clamps it to `WEBHOOK_REDELIVERY_GRACE_MINUTES + 5`. If the api is down longer than this grace, transitions that occurred during the outage are dropped permanently — no claim is ever made and no dead letter is recorded — so size it to your expected downtime. | | `WEBHOOK_DEAD_LETTER_TTL_DAYS` | `30` | Dead letters older than this are purged by the retention sweep. | ## Publishing diff --git a/apps/api/.env.example b/apps/api/.env.example index e5cafb3..393b555 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,9 +1,13 @@ # ============================================================================= # Promocean API — environment contract. # -# For running the API directly (`pnpm --filter api dev|start`). In the compose -# stack these are supplied by docker-compose.yml (see the repo-root .env.example); -# the in-network values differ (DATABASE_URL host = postgres, STRAPI_URL = cms). +# The api reads these directly off process.env at startup; nothing in this +# repo loads this file automatically — copy it to `.env` and source it +# yourself (e.g. `export $(cat .env | xargs)`, direnv, dotenv, etc.) if you +# want these values loaded from a file when running the API directly +# (`pnpm --filter api dev|start`). In the compose stack these are supplied by +# docker-compose.yml (see the repo-root .env.example); the in-network values +# differ (DATABASE_URL host = postgres, STRAPI_URL = cms). # ============================================================================= # Postgres connection. Host-mapped port is 5433 (see docker-compose.yml); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 83a558d..9f3f5f3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,6 +5,7 @@ import { Hono } from 'hono' import { cors } from 'hono/cors' import type { ApiKeyStore, ConfigStore, ErasureStore, IngestionStore, OfferMetricsStore, ProgressStore, StatsStore } from '@promocean/core' import { authMiddleware } from './auth.js' +import { envInt } from './env.js' import { logger } from './logger.js' import { buildOpenApiDocument } from './openapi.js' import { createRateLimiter } from './rate-limit.js' @@ -89,8 +90,8 @@ export interface CreateAppOptions { } export function createApp(deps: AppDeps, opts: CreateAppOptions = {}) { - const rateLimitPerMinute = opts.rateLimitPerMinute ?? Number(process.env.RATE_LIMIT_PER_MINUTE ?? 300) - const rateLimitMaxBuckets = opts.rateLimitMaxBuckets ?? Number(process.env.RATE_LIMIT_MAX_BUCKETS ?? 10_000) + const rateLimitPerMinute = opts.rateLimitPerMinute ?? envInt('RATE_LIMIT_PER_MINUTE', 300) + const rateLimitMaxBuckets = opts.rateLimitMaxBuckets ?? envInt('RATE_LIMIT_MAX_BUCKETS', 10_000) const app = new Hono() app.use('*', async (c, next) => { const requestId = randomUUID() diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 0000000..118dcca --- /dev/null +++ b/apps/api/src/env.ts @@ -0,0 +1,18 @@ +import { logger } from './logger.js' + +/** + * Reads an integer env var, guarding against `Number(junk)` silently producing NaN — a junk + * `RATE_LIMIT_MAX_BUCKETS` would otherwise disable its cap entirely, and a junk grace-window + * value would make sweeps throw every tick. Missing (unset) falls back silently; a value that + * is set but not a finite number falls back with a warning so misconfiguration is visible. + */ +export function envInt(name: string, fallback: number): number { + const raw = process.env[name] + if (raw === undefined) return fallback + const parsed = Number(raw) + if (!Number.isFinite(parsed)) { + logger.warn({ name, raw, fallback }, 'env: invalid integer value, using fallback') + return fallback + } + return parsed +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index fc75da5..a203d40 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,19 +2,20 @@ import { serve } from '@hono/node-server' import { createDb, runMigrations, PgErasureStore, PgIngestionStore, PgOfferMetricsStore, PgProgressStore, PgStatsStore, PgWebhookDeliveryStore } from '@promocean/adapter-db' import { StrapiConfigPlane } from '@promocean/adapter-strapi' import { createApp } from './app.js' +import { envInt } from './env.js' import { logger } from './logger.js' import { installShutdownHandlers } from './shutdown.js' import { WebhookDispatcher, resolveScanGraceMinutes, startLifecycleScheduler } from './webhooks.js' const db = createDb(process.env.DATABASE_URL!) await runMigrations(db) -const redeliveryGraceMinutes = Number(process.env.WEBHOOK_REDELIVERY_GRACE_MINUTES ?? 5) +const redeliveryGraceMinutes = envInt('WEBHOOK_REDELIVERY_GRACE_MINUTES', 5) // Single-sourced (Sprint 6 Task 4 review fix): compute the effective scan-grace window once // and hand it to BOTH the config-plane feed and the lifecycle scheduler, so they always agree // on how far back "ended" events are still considered in scope. The scheduler's own clamp is // kept as a backstop but is a no-op given an already-resolved value. const scanGraceMinutes = resolveScanGraceMinutes( - Number(process.env.TIMED_EVENT_SCAN_GRACE_MINUTES ?? 60), + envInt('TIMED_EVENT_SCAN_GRACE_MINUTES', 60), redeliveryGraceMinutes, logger, ) @@ -31,7 +32,7 @@ const stopScheduler = startLifecycleScheduler({ dispatcher: webhooks, redeliveryGraceMinutes, scanGraceMinutes, - deadLetterTtlDays: Number(process.env.WEBHOOK_DEAD_LETTER_TTL_DAYS ?? 30), + deadLetterTtlDays: envInt('WEBHOOK_DEAD_LETTER_TTL_DAYS', 30), }) const app = createApp({ configStore: plane, @@ -50,7 +51,7 @@ const app = createApp({ checkConfigPlane: async () => { await plane.getAllTimedEvents() }, }, }) -const port = Number(process.env.API_PORT ?? 3001) +const port = envInt('API_PORT', 3001) const server = serve({ fetch: app.fetch, port }) logger.info({ port }, 'promocean api listening') diff --git a/apps/api/src/webhooks.ts b/apps/api/src/webhooks.ts index 1eb0169..a0cc54b 100644 --- a/apps/api/src/webhooks.ts +++ b/apps/api/src/webhooks.ts @@ -235,6 +235,30 @@ export function startLifecycleScheduler(opts: { logger.error({ err }, 'lifecycle scheduler: redelivery sweep failed') } + // Phase 2b: exhaustion sweep — claims that hit MAX_REDELIVERY_ATTEMPTS are excluded by + // findStaleClaims forever; dead-letter and mark them delivered here so they stop being + // silently orphaned (per plan: cap retries, then dead-letter + stop the loop). + try { + const exhaustedClaims = await deliveryStore.findExhaustedClaims(MAX_REDELIVERY_ATTEMPTS) + for (const claim of exhaustedClaims) { + try { + await deliveryStore.recordDeadLetter( + claim.projectId, + '', + JSON.stringify(claim), + 'redelivery attempts exhausted', + now, + ) + await deliveryStore.markDelivered(claim.projectId, claim.eventId, claim.transition) + logger.warn({ claim }, 'lifecycle scheduler: redelivery attempts exhausted, dead-lettering claim') + } catch (err) { + logger.error({ err, claim }, 'lifecycle scheduler: failed to dead-letter exhausted claim') + } + } + } catch (err) { + logger.error({ err }, 'lifecycle scheduler: exhaustion sweep failed') + } + // Phase 3: retention sweep — purge old dead letters. try { const cutoff = new Date(now.getTime() - deadLetterTtlDays * 24 * 60 * 60 * 1000) diff --git a/apps/api/test/env.test.ts b/apps/api/test/env.test.ts new file mode 100644 index 0000000..10e7de1 --- /dev/null +++ b/apps/api/test/env.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { envInt } from '../src/env.js' +import { logger } from '../src/logger.js' + +describe('envInt', () => { + const ENV_KEY = 'ENV_INT_TEST_VAR' + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + }) + afterEach(() => { + delete process.env[ENV_KEY] + warnSpy.mockRestore() + }) + + it('parses a valid integer value', () => { + process.env[ENV_KEY] = '42' + expect(envInt(ENV_KEY, 7)).toBe(42) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('falls back and warns on a junk (non-numeric) value', () => { + process.env[ENV_KEY] = 'not-a-number' + expect(envInt(ENV_KEY, 7)).toBe(7) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + { name: ENV_KEY, raw: 'not-a-number', fallback: 7 }, + expect.any(String), + ) + }) + + it('falls back and warns on an infinite value', () => { + process.env[ENV_KEY] = 'Infinity' + expect(envInt(ENV_KEY, 7)).toBe(7) + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + + it('falls back silently (no warning) when the var is missing', () => { + delete process.env[ENV_KEY] + expect(envInt(ENV_KEY, 7)).toBe(7) + expect(warnSpy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/api/test/webhooks.test.ts b/apps/api/test/webhooks.test.ts index e4bdeab..6fcbd6b 100644 --- a/apps/api/test/webhooks.test.ts +++ b/apps/api/test/webhooks.test.ts @@ -25,6 +25,7 @@ function makeDeliveryStore() { markDelivered: async () => {}, findStaleClaims: async () => [], incrementAttempts: async () => {}, + findExhaustedClaims: async () => [], deleteDeadLettersBefore: async () => 0, } return { deliveryStore, deadLetters } @@ -378,6 +379,74 @@ describe('startLifecycleScheduler — group C2 (redelivery sweep)', () => { }) }) +describe('startLifecycleScheduler — group C2b (exhaustion sweep)', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('dead-letters and marks delivered an exhausted claim, without re-driving it', async () => { + vi.setSystemTime(new Date('2026-07-15T00:10:00Z')) + // No events in the feed: the transition scan (phase 1) and redelivery sweep (phase 2) + // have nothing to claim/re-drive, isolating this assertion to the exhaustion sweep. + const configStore = makeConfigStore({ allTimedEvents: [] }) + const { deliveryStore, deadLetters } = makeDeliveryStore() + const marked: unknown[] = [] + deliveryStore.markDelivered = async (projectId, eventId, transition) => { marked.push([projectId, eventId, transition]) } + deliveryStore.findExhaustedClaims = vi.fn() + .mockResolvedValueOnce([{ projectId: 'p1', eventId: 'e1', transition: 'live', attempts: 5 }]) + .mockResolvedValue([]) + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(dispatcher.deliverTransition).not.toHaveBeenCalled() + expect(deadLetters).toHaveLength(1) + expect(deadLetters[0]).toMatchObject({ projectId: 'p1', url: '', error: 'redelivery attempts exhausted' }) + expect(JSON.parse(deadLetters[0].payload)).toEqual({ projectId: 'p1', eventId: 'e1', transition: 'live', attempts: 5 }) + expect(marked).toEqual([['p1', 'e1', 'live']]) + }) + + it('calls findExhaustedClaims with MAX_REDELIVERY_ATTEMPTS (5)', async () => { + vi.setSystemTime(new Date('2026-07-15T00:10:00Z')) + const configStore = makeConfigStore() + const { deliveryStore } = makeDeliveryStore() + const findExhaustedClaims = vi.fn().mockResolvedValue([]) + deliveryStore.findExhaustedClaims = findExhaustedClaims + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(findExhaustedClaims).toHaveBeenCalledWith(5) + }) + + it('a per-claim failure while dead-lettering an exhausted claim does not stop the sweep from continuing', async () => { + vi.setSystemTime(new Date('2026-07-15T00:10:00Z')) + const configStore = makeConfigStore() + const { deliveryStore } = makeDeliveryStore() + const marked: unknown[] = [] + deliveryStore.markDelivered = async (projectId, eventId, transition) => { marked.push([projectId, eventId, transition]) } + let call = 0 + deliveryStore.recordDeadLetter = async () => { call++; if (call === 1) throw new Error('db down') } + deliveryStore.findExhaustedClaims = vi.fn().mockResolvedValueOnce([ + { projectId: 'p1', eventId: 'e-fail', transition: 'live', attempts: 5 }, + { projectId: 'p1', eventId: 'e-ok', transition: 'live', attempts: 5 }, + ]).mockResolvedValue([]) + const dispatcher = fakeDispatcher() + const testLogger = { warn: vi.fn(), error: vi.fn(), info: vi.fn() } as unknown as Logger + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000, logger: testLogger }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + // the failing claim is not marked delivered, but the second claim still is + expect(marked).toEqual([['p1', 'e-ok', 'live']]) + expect(testLogger.error).toHaveBeenCalled() + }) +}) + describe('startLifecycleScheduler — group C3 (retention sweep)', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) diff --git a/docker-compose.yml b/docker-compose.yml index 0356e69..19a911c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,7 @@ services: ADMIN_FIRST_NAME: ${ADMIN_FIRST_NAME:-Admin} ADMIN_LAST_NAME: ${ADMIN_LAST_NAME:-User} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeMe123!} # ------------------------------------------------------------------------- # api (Hono) — runs drizzle migrations at boot. Waits for postgres healthy diff --git a/packages/adapter-db/migrations/0005_backfill_delivered_at.sql b/packages/adapter-db/migrations/0005_backfill_delivered_at.sql new file mode 100644 index 0000000..e2b0fcc --- /dev/null +++ b/packages/adapter-db/migrations/0005_backfill_delivered_at.sql @@ -0,0 +1,7 @@ +-- Custom SQL migration file, put your code below! -- +-- Backfill delivered_at for pre-existing claims created before migration 0004 +-- introduced delivered_at/attempts. Without this, every historical claim row +-- has delivered_at = NULL, causing the scheduler to re-drive (duplicate +-- webhooks) or dead-letter ('') all pre-existing rows on the +-- first tick after upgrade. +UPDATE "runtime"."timed_event_notifications" SET "delivered_at" = "fired_at" WHERE "delivered_at" IS NULL; \ No newline at end of file diff --git a/packages/adapter-db/migrations/meta/0005_snapshot.json b/packages/adapter-db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..1df3038 --- /dev/null +++ b/packages/adapter-db/migrations/meta/0005_snapshot.json @@ -0,0 +1,725 @@ +{ + "id": "2df5e3e3-67f2-4018-b1a2-13d9e104a9d8", + "prevId": "d29da447-031e-40d4-96c8-450db274fa9d", + "version": "7", + "dialect": "postgresql", + "tables": { + "runtime.achievement_progress": { + "name": "achievement_progress", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_id": { + "name": "achievement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current": { + "name": "current", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "progress_uq": { + "name": "progress_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.events": { + "name": "events", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_idem_uq": { + "name": "events_idem_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "events_stats_ix": { + "name": "events_stats_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.monthly_active_users": { + "name": "monthly_active_users", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mau_uq": { + "name": "mau_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.offer_events": { + "name": "offer_events", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offer_id": { + "name": "offer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "offer_events_idem_uq": { + "name": "offer_events_idem_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"runtime\".\"offer_events\".\"kind\" = 'impression' and \"runtime\".\"offer_events\".\"idempotency_key\" is not null", + "concurrently": false + }, + "offer_events_stats_ix": { + "name": "offer_events_stats_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "offer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.timed_event_notifications": { + "name": "timed_event_notifications", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transition": { + "name": "transition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "event_notif_uq": { + "name": "event_notif_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transition", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.unlocks": { + "name": "unlocks", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_id": { + "name": "achievement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "unlocks_uq": { + "name": "unlocks_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "unlocks_stats_ix": { + "name": "unlocks_stats_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.usage_counters": { + "name": "usage_counters", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events_count": { + "name": "events_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "usage_uq": { + "name": "usage_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.webhook_dead_letters": { + "name": "webhook_dead_letters", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "runtime": "runtime" + }, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/adapter-db/migrations/meta/_journal.json b/packages/adapter-db/migrations/meta/_journal.json index d3f3aa9..ab82f25 100644 --- a/packages/adapter-db/migrations/meta/_journal.json +++ b/packages/adapter-db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1783545736622, "tag": "0004_fancy_electro", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1783566180718, + "tag": "0005_backfill_delivered_at", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/adapter-db/src/stores.ts b/packages/adapter-db/src/stores.ts index e1354c2..5c55cd0 100644 --- a/packages/adapter-db/src/stores.ts +++ b/packages/adapter-db/src/stores.ts @@ -267,6 +267,19 @@ export class PgWebhookDeliveryStore implements WebhookDeliveryStore { eq(timedEventNotifications.transition, transition), )) } + async findExhaustedClaims(minAttempts: number) { + const rows = await this.db.select({ + projectId: timedEventNotifications.projectId, + eventId: timedEventNotifications.eventId, + transition: timedEventNotifications.transition, + attempts: timedEventNotifications.attempts, + }).from(timedEventNotifications) + .where(and( + isNull(timedEventNotifications.deliveredAt), + gte(timedEventNotifications.attempts, minAttempts), + )) + return rows.map((r) => ({ ...r, transition: r.transition as TimedEventTransition })) + } async deleteDeadLettersBefore(cutoff: Date) { const deleted = await this.db.delete(webhookDeadLetters) .where(lt(webhookDeadLetters.createdAt, cutoff)) diff --git a/packages/adapter-db/test/webhook-delivery.test.ts b/packages/adapter-db/test/webhook-delivery.test.ts index 130f8e6..e185166 100644 --- a/packages/adapter-db/test/webhook-delivery.test.ts +++ b/packages/adapter-db/test/webhook-delivery.test.ts @@ -90,6 +90,36 @@ describe('PgWebhookDeliveryStore', () => { const staleForScope = staleClaims.filter((c) => c.projectId === 'p-sc') expect(staleForScope).toEqual([{ projectId: 'p-sc', eventId: 'stale', transition: 'live', attempts: 1 }]) }) + + it('findExhaustedClaims returns only undelivered rows at or above minAttempts', async () => { + const store = new PgWebhookDeliveryStore(db) + const old = new Date(Date.now() - 60 * 60 * 1000) + + // below cap: undelivered, attempts < minAttempts -> excluded + await db.$client.query( + `insert into runtime.timed_event_notifications (project_id, event_id, transition, fired_at, delivered_at, attempts) values ('p-ec','below-cap','live',$1,null,4)`, + [old], + ) + // delivered: attempts >= minAttempts but delivered -> excluded + await db.$client.query( + `insert into runtime.timed_event_notifications (project_id, event_id, transition, fired_at, delivered_at, attempts) values ('p-ec','delivered','live',$1,now(),5)`, + [old], + ) + // exhausted: undelivered, attempts >= minAttempts -> included + await db.$client.query( + `insert into runtime.timed_event_notifications (project_id, event_id, transition, fired_at, delivered_at, attempts) values ('p-ec','exhausted','live',$1,null,5)`, + [old], + ) + // over cap: undelivered, attempts > minAttempts -> included + await db.$client.query( + `insert into runtime.timed_event_notifications (project_id, event_id, transition, fired_at, delivered_at, attempts) values ('p-ec','over-cap','live',$1,null,7)`, + [old], + ) + + const exhaustedClaims = await store.findExhaustedClaims(5) + const exhaustedForScope = exhaustedClaims.filter((c) => c.projectId === 'p-ec') + expect(exhaustedForScope.map((c) => c.eventId).sort()).toEqual(['exhausted', 'over-cap']) + }) }) describe('PgWebhookDeliveryStore dead-letter retention', () => { @@ -110,3 +140,41 @@ describe('PgWebhookDeliveryStore dead-letter retention', () => { expect(rows).toEqual([{ url: 'https://x.test/recent' }]) }) }) + +describe('migration 0005 — delivered_at backfill', () => { + it('backfills delivered_at to fired_at for claims left null by pre-0004 data, and leaves delivered rows untouched', async () => { + const store = new PgWebhookDeliveryStore(db) + const firedAt = new Date(Date.now() - 60 * 60 * 1000) + + // Simulates a claim made before migration 0004 introduced delivered_at: null it out + // via raw SQL exactly as it would appear on an existing database pre-upgrade. + await store.claimTransition('p-bf', 'e-null', 'live') + await db.$client.query( + `update runtime.timed_event_notifications set fired_at = $1, delivered_at = null where project_id='p-bf' and event_id='e-null' and transition='live'`, + [firedAt], + ) + + // An already-delivered row must be untouched by the backfill. + await store.claimTransition('p-bf', 'e-delivered', 'live') + await store.markDelivered('p-bf', 'e-delivered', 'live') + const { rows: beforeRows } = await db.$client.query( + `select delivered_at from runtime.timed_event_notifications where project_id='p-bf' and event_id='e-delivered' and transition='live'`, + ) + const deliveredAtBefore = beforeRows[0].delivered_at + + // Execute the 0005 migration's UPDATE statement raw (mirrors migrations/0005_backfill_delivered_at.sql). + await db.$client.query( + `UPDATE "runtime"."timed_event_notifications" SET "delivered_at" = "fired_at" WHERE "delivered_at" IS NULL`, + ) + + const { rows: nullRows } = await db.$client.query( + `select fired_at, delivered_at from runtime.timed_event_notifications where project_id='p-bf' and event_id='e-null' and transition='live'`, + ) + expect(nullRows[0].delivered_at.getTime()).toBe(nullRows[0].fired_at.getTime()) + + const { rows: deliveredRows } = await db.$client.query( + `select delivered_at from runtime.timed_event_notifications where project_id='p-bf' and event_id='e-delivered' and transition='live'`, + ) + expect(deliveredRows[0].delivered_at.getTime()).toBe(deliveredAtBefore.getTime()) + }) +}) diff --git a/packages/core/src/ports.ts b/packages/core/src/ports.ts index 6fcf949..00979bd 100644 --- a/packages/core/src/ports.ts +++ b/packages/core/src/ports.ts @@ -84,6 +84,10 @@ export interface WebhookDeliveryStore { /** Rows where delivered_at IS NULL AND fired_at < olderThan AND attempts < maxAttempts. */ findStaleClaims(olderThan: Date, maxAttempts: number): Promise> incrementAttempts(projectId: string, eventId: string, transition: TimedEventTransition): Promise + /** Rows where delivered_at IS NULL AND attempts >= minAttempts — claims findStaleClaims + * excludes forever once they've exhausted their redelivery attempts. Callers dead-letter + * and mark these delivered so the loop stops rather than leaving them orphaned. */ + findExhaustedClaims(minAttempts: number): Promise> /** Deletes dead letters created before cutoff. Returns the number deleted. */ deleteDeadLettersBefore(cutoff: Date): Promise }