# Setup guide — from zero to production

Complete walkthrough for standing up the notification service. Follow it top to bottom for a new environment; each section is independent enough to revisit later.

What you’ll end up with:

  • A Cloudflare Worker serving the API (staging on workers.dev, production on notifications.totumcloud.com)
  • A Supabase Postgres database holding the notifications
  • Cognito app clients that let your applications publish
  • A GitHub Actions pipeline that tests, publishes the npm client, and deploys — with no manual steps

Prerequisites: Node ≥ 20, pnpm ≥ 9, a Cloudflare account, a Supabase organization, admin access to your Cognito user pool, and (for releases) admin on the GitHub repo and the @inovus-medical npm org.


# 1. Local development (5 minutes)

git clone <this repo> && cd org.inovus.notifications
pnpm install
pnpm test                                             # everything should be green
cd packages/api
cp .dev.vars.example .dev.vars
pnpm dev                                              # wrangler dev on http://localhost:8787

Open http://localhost:8787/docs — the interactive Swagger UI. Confirm the Worker is up:

curl.exe http://localhost:8787/health
# → {"status":"ok"}

Default local config uses the in-memory store (wiped on restart). You do not need Supabase yet. Authenticated /v1/* calls need a real Cognito issuer + tokens — next subsection.

Useful loops:

pnpm test                # run everything
pnpm --filter @inovus-medical/notifications-api exec vitest run test/routes.test.ts   # one file
pnpm test:coverage       # with the ≥90% gates CI enforces
pnpm lint && pnpm typecheck
pnpm openapi:generate    # regenerate packages/api/openapi.json after changing routes/schemas

# 1b. Send and receive a notification locally

Prove the happy path before wiring Supabase or deploying. Keep DB_DRIVER=memory; only Cognito is required for auth.

# 1. Point the Worker at your Cognito pool

Edit packages/api/.dev.vars (never commit this file):

AUTH_ISSUER=https://cognito-idp.<region>.amazonaws.com/<userPoolId>
PUBLISHER_SCOPES=notifications/publish
DB_DRIVER=memory
# Optional — only if you created a Cognito group for human publishers (usually you have not):
# PUBLISHER_GROUPS=notification-publishers

What those publisher lines mean:

Var Meaning Create a Cognito group?
PUBLISHER_SCOPES=notifications/publish Tokens whose scope includes this string may publish to any user (M2M / backends). You do create a Cognito resource server scope with this name — see cognito-setup.md. No
PUBLISHER_GROUPS=… Tokens whose cognito:groups includes this name may publish (interactive admin users). Only if you use this — otherwise omit the var

For the local dry run below you only need scopes + an M2M app client. Restart pnpm dev so Wrangler reloads the vars.

If you do not have a publisher app client yet, create the resource server + M2M client first (§3.2–3.3 / cognito-setup.md), then come back here. You still do not need Supabase for this dry run.

# 2. Get two tokens

Publisher (M2M) — you need three Cognito values first (create them in §3 if missing; full console clicks: cognito-setup.md):

Variable Exact place in AWS Cognito console
$CLIENT_ID User pool → App integrationApp clients → your confidential client → Client ID
$CLIENT_SECRET Same page → Client secret (copy when the client is created; if lost, regenerate under client Actions)
Cognito domain User pool → App integrationDomain → e.g. https://myprefix.auth.eu-west-2.amazoncognito.com

Must be a Confidential app client with Generate a client secret enabled and the Client credentials flow + scope notifications/publish. SPA/public clients have no secret and will not work here.

Examples below are PowerShell on Windows (use curl.exe, not curl). macOS/Linux: see cognito-setup.md §3.4.

$ClientId = "paste-from-app-client-detail"
$ClientSecret = "paste-from-app-client-secret"
$CognitoDomain = "https://<prefix>.auth.<region>.amazoncognito.com"

curl.exe -X POST "$CognitoDomain/oauth2/token" `
  -H "Content-Type: application/x-www-form-urlencoded" `
  -u "${ClientId}:${ClientSecret}" `
  -d "grant_type=client_credentials&scope=notifications/publish"

-u "id:secret" is HTTP Basic auth (curl encodes it). Copy access_token from the JSON into $M2MToken:

$M2MToken = "eyJraWQiOiJ...."   # paste access_token from the response

User — any normal signed-in user access token from the same pool (your app’s login, or Cognito Hosted UI). Decode it at jwt.io and copy the sub claim:

$UserToken = "eyJraWQiOiJ...."  # paste user access token
$UserSub = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"  # JWT "sub" claim

# 3. Publish (send)

curl.exe -s -X POST http://localhost:8787/v1/notifications `
  -H "Authorization: Bearer $M2MToken" `
  -H "Content-Type: application/json" `
  -d "{`"userGuid`":`"$UserSub`",`"title`":`"Hello`",`"body`":`"Local setup test`",`"severity`":`"success`",`"dedupeKey`":`"local-setup-1`"}"

Expect 201 and a body like { "notification": { … }, "duplicate": false }.
Run the same command again → 200 with "duplicate": true (same row; dedupeKey worked).

Common failures:

Status Meaning
401 Wrong/expired token, or AUTH_ISSUER ≠ token iss
403 M2M client missing notifications/publish, or PUBLISHER_SCOPES mismatch

# 4. List the inbox (receive)

curl.exe -s http://localhost:8787/v1/notifications `
  -H "Authorization: Bearer $UserToken"

Expect your notification in items and unreadCount ≥ 1. Optional:

# badge
curl.exe -s http://localhost:8787/v1/notifications/unread-count `
  -H "Authorization: Bearer $UserToken"

# mark read (use the id from the list response)
curl.exe -s -X POST "http://localhost:8787/v1/notifications/$NotificationId/read" `
  -H "Authorization: Bearer $UserToken"

# 5. Same flow in Swagger

  1. Open http://localhost:8787/docs
  2. Authorize with Bearer <token> (try M2M for publish, user token for list)
  3. POST /v1/notifications then GET /v1/notifications

When that works, the service logic is fine. Continue below to add durable storage (Supabase), then deploy.


# 2. Supabase (database)

One project per environment (staging, production).

  1. Create a project at https://supabase.com/dashboard (any region close to your users; note the project ref from the URL, e.g. abcdefghijklmnop).

  2. Apply the schema. Either paste supabase/migrations/0001_notifications.sql into the SQL editor, or use the CLI:

    npx supabase link --project-ref <PROJECT_REF>
    npx supabase db push
  3. Collect two values from Project Settings → API:

    • Project URLhttps://<ref>.supabase.co → goes in wrangler.toml as SUPABASE_URL
    • service_role key → becomes the SUPABASE_SERVICE_ROLE_KEY Worker secret. Never put this in wrangler.toml, client code, or the anon position — it bypasses RLS by design.

Do not enable Supabase Auth, add RLS policies, or expose PostgREST to browsers. The Worker is the only client of this database (that’s invariant #7); the table’s deny-all RLS is a backstop, not the authorization mechanism.


# 3. Cognito (authentication)

Uses your existing user pool(s) — nothing about user sign-in changes. Step-by-step console path (including where Client ID and Client secret appear): cognito-setup.md. Short version below.

# 3.1 Find the issuer

Pool overview → User pool ID + region:

https://cognito-idp.<region>.amazonaws.com/<userPoolId>

This is AUTH_ISSUER. (Multiple pools against one deployment: comma-separate them.)

# 3.2 Resource server + scope (for app publishers — the normal case)

User pool → App integration → Resource servers → Create:

  • Identifier: notifications
  • Custom scope name: publish → full scope notifications/publish

# 3.3 One app client per producer (Client ID + Client secret)

For each application that will publish (e.g. totum-assess):

  1. App integration → App clients → Create app client
  2. Choose Confidential client (not public/SPA)
  3. Enable Generate a client secret
  4. Enable authentication flow Client credentials
  5. Allow OAuth scope notifications/publish
  6. Create → copy immediately:
    • Client ID$CLIENT_ID (also becomes notification source)
    • Client secret$CLIENT_SECRET (shown once; store safely; regenerate if lost)

Also note App integration → Domain — that hostname is used in the token URL below (not the issuer URL).

# 3.4 Getting a token (producer side)

PowerShell (Windows) — prefer curl.exe (PowerShell’s curl is not real curl):

$ClientId = "…"       # App client → Client ID
$ClientSecret = "…"   # App client → Client secret
$CognitoDomain = "https://<prefix>.auth.<region>.amazoncognito.com"  # App integration → Domain

curl.exe -X POST "$CognitoDomain/oauth2/token" `
  -H "Content-Type: application/x-www-form-urlencoded" `
  -u "${ClientId}:${ClientSecret}" `
  -d "grant_type=client_credentials&scope=notifications/publish"

Bash/macOS/Linux variant: cognito-setup.md §3.4.

Cache the token until expires_in; don’t fetch one per request.

# 3.5 Human publishers (optional)

You usually skip this. Backends use scopes (§3.2–3.4), not groups.

Only if an admin should publish with their user login: create a Cognito group (User pool → Groups), add users, and set PUBLISHER_GROUPS to that exact group name (e.g. notification-publishers). See cognito-setup.md — Human/admin publishers.

Regular users need nothing — any valid token from the pool can read its own inbox.


# 4. Cloudflare (the Worker)

# 4.1 One-time account setup

  1. A Cloudflare account with Workers enabled; note the Account ID (dashboard sidebar).
  2. For production’s custom domain: the totumcloud.com zone must be on this account. wrangler.toml declares notifications.totumcloud.com as a custom domain — Cloudflare creates the DNS record and certificate automatically on first deploy.
  3. Create an API token (My Profile → API Tokens) with the Edit Cloudflare Workers template — used by CI and for manual deploys.

# 4.2 Configure the environments

Edit packages/api/wrangler.toml — replace the placeholders in both [env.staging.vars] and [env.production.vars]:

Var Value Notes
AUTH_ISSUER your pool issuer (3.1) comma-list for multiple pools
SUPABASE_URL project URL (2.3) per environment
PUBLISHER_SCOPES notifications/publish matches 3.2
PUBLISHER_GROUPS notification-publishers optional (3.5)
DB_DRIVER supabase already set
RETENTION_DAYS 90 default 0 disables the purge cron
V1_PAYLOAD_TEMPLATES JSON map only if legacy CreatePayload callers exist — see v1-compatibility.md
AUTH_AUDIENCE, AUTH_SUB_CLAIM, AUTH_GROUPS_CLAIM, AUTH_JWKS_URL optional overrides; defaults fit Cognito

# 4.3 Secrets + first deploy

cd packages/api
npx wrangler login                    # or export CLOUDFLARE_API_TOKEN=...

npx wrangler secret put SUPABASE_SERVICE_ROLE_KEY --env staging
pnpm deploy:staging

npx wrangler secret put SUPABASE_SERVICE_ROLE_KEY --env production
pnpm deploy:production

# 4.4 Verify

curl https://notifications.totumcloud.com/health          # {"status":"ok"} → auth+DB wiring good
open https://notifications.totumcloud.com/docs             # interactive API docs

# end-to-end with a real M2M token (3.4):
curl -X POST https://notifications.totumcloud.com/v1/notifications \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"userGuid":"<your own sub>","title":"Hello","body":"First notification","dedupeKey":"setup-test"}'

Then sign in as that user in any app using the client (or curl GET /v1/notifications with a user token) and confirm the notification appears. Re-run the publish — you should get 200 with "duplicate": true.


# 5. GitHub Actions (automated releases)

The pipeline in .github/workflows/release.yml runs on every push to main: pending changesets → “Version Packages” PR → merging it publishes the npm client and deploys staging → production.

# 5.1 Repository secrets

Settings → Secrets and variables → Actions:

Secret From
CLOUDFLARE_API_TOKEN 4.1 step 3
CLOUDFLARE_ACCOUNT_ID Cloudflare dashboard
SUPABASE_ACCESS_TOKEN https://supabase.com/dashboard/account/tokens
SUPABASE_PROJECT_REF_STAGING / _PRODUCTION 2.1
SUPABASE_DB_PASSWORD_STAGING / _PRODUCTION database password (Project Settings → Database)
NPM_TOKEN npm automation token for @inovus-medical — or skip by configuring npm Trusted Publishing for this repo/workflow

# 5.2 Environments

Settings → Environments → create staging and production. Add required reviewers on production if you want a human approval gate between the staging smoke test and the production deploy.

# 5.3 Release flow (day-to-day)

  1. Every PR that changes the client gets a changeset: pnpm changeset (pick bump level, describe the change).
  2. Merge the PR → CI runs (lint, typecheck, ≥90% coverage, OpenAPI drift check, build).
  3. The bot opens/updates Version Packages — merge it when you want to ship.
  4. Watch the Actions run: npm publish → staging deploy + smoke test → production deploy + smoke test.

Worker-only changes (no client changes) don’t need a changeset; note that deploys are triggered by the publish step, so a worker-only change ships with the next client release — or run pnpm --filter @inovus-medical/notifications-api deploy:production manually.


# 6. Wiring up applications

  • Consume in a browser app → install @inovus-medical/notifications, construct NotificationsClient with your token getter, use subscribe()/useNotifications. Remember: call the unsubscribe function on logout.
  • Publish from a service → M2M token (3.3/3.4) + client.publish(...) (or plain POST /v1/notifications). Always set dedupeKey.
  • Legacy V1 callers → point them at this service unchanged; see v1-compatibility.md.

# 7. Troubleshooting

Symptom Likely cause
health 500 “Storage unreachable” Wrong SUPABASE_URL, missing/incorrect SUPABASE_SERVICE_ROLE_KEY secret, or migration not applied
Everything 401 “issuer is not accepted” AUTH_ISSUER doesn’t exactly match the token’s iss claim — compare with the payload at jwt.io
401 “signature is invalid or the token has expired” Expired token, or token from a different pool/region than AUTH_ISSUER
Publish 403 Token lacks the scope/group: check the token’s scope claim against PUBLISHER_SCOPES (M2M) or cognito:groups against PUBLISHER_GROUPS (user token)
Publish works but user sees nothing userGuid in the publish doesn’t equal the user’s token sub — they must match exactly
First request fails with “Invalid notification service configuration — …” Env var validation failed; the message names the exact variable
CI fails on “openapi.json is stale” Run pnpm openapi:generate and commit the diff
Custom domain 404/522 after first deploy DNS/cert still provisioning (give it a few minutes) or the zone isn’t on the same Cloudflare account

Logs: npx wrangler tail --env production streams live request logs, including the console.error output from unhandled errors and failed health checks.