Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a7fed5d
docs: sprint 6 design spec — dockerized stack + pre-deploy hardening
hynding Jul 8, 2026
961d485
docs: sprint 6 implementation plan — dockerized stack + pre-deploy ha…
hynding Jul 8, 2026
f030338
feat(contracts): webhook message id and not_found error code
hynding Jul 8, 2026
58ac9c6
fix(contracts): use zod v4 top-level z.uuid() for webhook messageId
hynding Jul 8, 2026
f2986a0
feat(core,adapter-db): webhook delivery status, stale-claim lookup, d…
hynding Jul 8, 2026
419760f
fix(adapter-db): markDelivered is value-stable idempotent; discrimina…
hynding Jul 8, 2026
ada5d67
feat(api): webhook redelivery, dead-letter retention, message ids (cl…
hynding Jul 8, 2026
9a29cb5
feat(cms,adapter-strapi,api): ended-event scan window and graceful sh…
hynding Jul 8, 2026
7cdba54
fix(api): single-source scan-grace clamp; reentrancy-safe shutdown wi…
hynding Jul 8, 2026
8a0db3a
fix(api,adapter-strapi): bound rate-limiter buckets and negative auth…
hynding Jul 8, 2026
f2bfdc0
fix(api): sweep rate-limit buckets only on true rollover, not new-key…
hynding Jul 8, 2026
c01c2d5
fix(api): validate offer id on impression and click routes (closes #13)
hynding Jul 8, 2026
7a7f914
chore: fast-follow polish — request-id logs, docs page, tarball hygie…
hynding Jul 8, 2026
cc39e90
test: race loop, StrictMode beacon, schema coverage, suggester bounda…
hynding Jul 8, 2026
2cd4ae1
feat: production docker images and one-command compose stack
hynding Jul 8, 2026
5f14f37
ci: build images and run e2e against the compose stack; one-command q…
hynding Jul 8, 2026
16ab9c5
fix: delivered_at backfill migration, exhausted-claim dead-lettering,…
hynding Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
57 changes: 52 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
37 changes: 14 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,27 @@ 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
- uses: actions/setup-node@v4
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
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
Expand All @@ -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
126 changes: 118 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -97,15 +126,37 @@ 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
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).

### 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`
Expand All @@ -118,6 +169,51 @@ 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). Once redelivery attempts hit
the cap of 5, an **exhaustion sweep** on the same tick dead-letters the
claim (`<exhausted>`) 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
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`. 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

MIT packages (`@promocean/contracts`, `@promocean/sdk`, `@promocean/widgets`) publish via a two-step manual flow:
Expand All @@ -126,4 +222,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.
36 changes: 36 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# =============================================================================
# Promocean API — environment contract.
#
# 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);
# 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
Loading
Loading