Skip to content

Sprint 6: Dockerized stack + pre-deploy hardening — one-command boot, webhook at-least-once, memory bounds - #17

Merged
hynding merged 17 commits into
mainfrom
sprint-6-docker-hardening
Jul 9, 2026
Merged

Sprint 6: Dockerized stack + pre-deploy hardening — one-command boot, webhook at-least-once, memory bounds#17
hynding merged 17 commits into
mainfrom
sprint-6-docker-hardening

Conversation

@hynding

@hynding hynding commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the Sprint 6 plan (docs/superpowers/plans/2026-07-08-sprint-6-docker-hardening.md): the whole platform now boots as production-style containers with one command, CI exercises those containers on every merge, and the pre-deploy hardening backlog is closed.

  • Docker (one-command boot): multi-stage turbo prune --docker images for api/cms/demo (alpine, non-root, sharp builds clean — no slim fallback needed); compose stack profile with healthcheck-gated ordering (postgres→cms→api(readyz)→demo); dev flow (docker compose up -d postgres) untouched; documented .env contract incl. the previously-missing apps/api/.env.example
  • CI against containers: the e2e job builds all three images and runs the Playwright suite against the compose stack (hand-rolled service boots removed); failure dumps compose logs; if: always() teardown
  • Webhook hardening (closes Webhook delivery hardening: redelivery, retention, shutdown, replay protection, SSRF posture #8): required messageId on all webhook payloads; delivered_at/attempts columns (with an 0005 backfill migration so existing DBs don't burst-redeliver on upgrade); redelivery sweep restores at-least-once (5-attempt cap → dead-letter + mark, never silent orphans); dead-letter TTL retention; SIGTERM graceful shutdown (reentrancy-safe, force-exit fallback, live-proven: api stops in 0.18s, cms ~240ms); ended-event scan window with a single-sourced clamp against the redelivery grace
  • Memory bounds (closes Rate-limiter memory hardening: bucket eviction + negative auth-cache growth #10): rate-limiter expired-bucket sweep (true-rollover-gated — new-key floods can't trigger O(n) sweeps) + 10k bucket cap with shared overflow bucket; negative auth-cache FIFO eviction cap; NaN-guarded env parsing so misconfiguration can't silently disable the cap
  • Offer-id validation (closes Validate offer id on impression/click routes (stats pollution hardening) #13): impression/click reject unknown offer ids (404 not_found), fail-open on config outage — stats can no longer be polluted by injected ids
  • Polish (closes MVP fast-follow polish (from Sprint 4 final review) #11): request-id child loggers on route warns; SRI-pinned Redoc page at GET /docs; npm files allowlists; release.yml tests-before-publish + tag push; log-retention note; docs/retros/
  • Test hardening (closes Sprint 5 test-hardening bundle #14): 8-way ingestion race loop, StrictMode beacon test, stats/impression schema coverage, hand-verified Levenshtein boundary pair, nameById fallback

Behavior notes for existing consumers

  • Webhook payloads now carry a required messageId — additive for receivers (HMAC covers the raw body as sent), but strict schema validators must allow the field. Redeliveries are new messages with fresh ids: dedup by messageId, replay-check the signed createdAt.
  • POST /v1/offers/:id/impression|click return 404 not_found for unknown offer ids where they previously returned 200 and recorded. First-party widgets unaffected.
  • .env.example flips LOG_PLAINTEXT_KEYS to false — seeded keys no longer print in cms logs on fresh setups.
  • Local + CI e2e now require Docker (compose stack replaces hand-booted services).

Test plan

  • Full workspace 27/27 turbo tasks green post fix-wave (api 113, adapter-db 25 incl. Testcontainers redelivery/backfill/race suites)
  • Live verification: docker compose --profile stack up -d --wait from wiped volumes → readyz 200, seeded demo, e2e 3/3 against containers; graceful docker stop timings captured; README quickstart executed verbatim from a clean clone
  • CI run 28982672086 green (test + compose-based e2e); the fix-wave commit re-triggers checks on this PR — watch them

Review notes

Ten tasks through the full gate cycle (four needed fix→re-review loops: zod idiom, markDelivered value-stability, shutdown reentrancy/clamp single-sourcing, sweep gating) plus a final whole-branch review — verdict ready to merge with fixes, fix wave 16ab9c5 applied and re-approved (backfill migration, exhausted-claim dead-lettering, env NaN guards, doc corrections). Known accepted limits, documented in-repo: scheduler downtime longer than the scan grace drops those transitions; overlapping ticks can duplicate a redelivery (at-least-once semantics); overflow-bucket keeps new keys rate-limited-but-shared at cap.

🤖 Generated with Claude Code

hynding and others added 17 commits July 8, 2026 13:59
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 <noreply@anthropic.com>
…rdening

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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ead-letter retention

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ting test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oses #8 scope pt 1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…utdown (closes #8 scope pt 2)

Bounds the timed-event scan feed and closes the process down cleanly:

- cms: timedEventsAll accepts ?endedWithinMinutes=<positive int>, 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 <noreply@anthropic.com>
…th tested force-exit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
… creation

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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ne, release fixes (closes #11)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry; nameById fallback (closes #14)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uickstart docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… env NaN guards, doc corrections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hynding hynding changed the title ci: build images and run e2e against the compose stack; one-command quickstart docs Sprint 6: Dockerized stack + pre-deploy hardening — one-command boot, webhook at-least-once, memory bounds Jul 9, 2026
@hynding
hynding merged commit 4572092 into main Jul 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment