# 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 onnotifications.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 integration → App 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 integration → Domain → 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
- Open http://localhost:8787/docs
- Authorize with
Bearer <token>(try M2M for publish, user token for list) POST /v1/notificationsthenGET /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).
-
Create a project at https://supabase.com/dashboard (any region close to your users; note the project ref from the URL, e.g.
abcdefghijklmnop). -
Apply the schema. Either paste
supabase/migrations/0001_notifications.sqlinto the SQL editor, or use the CLI:npx supabase link --project-ref <PROJECT_REF> npx supabase db push -
Collect two values from Project Settings → API:
- Project URL —
https://<ref>.supabase.co→ goes inwrangler.tomlasSUPABASE_URL service_rolekey → becomes theSUPABASE_SERVICE_ROLE_KEYWorker secret. Never put this inwrangler.toml, client code, or the anon position — it bypasses RLS by design.
- Project URL —
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 scopenotifications/publish
# 3.3 One app client per producer (Client ID + Client secret)
For each application that will publish (e.g. totum-assess):
- App integration → App clients → Create app client
- Choose Confidential client (not public/SPA)
- Enable Generate a client secret
- Enable authentication flow Client credentials
- Allow OAuth scope
notifications/publish - Create → copy immediately:
- Client ID →
$CLIENT_ID(also becomes notificationsource) - Client secret →
$CLIENT_SECRET(shown once; store safely; regenerate if lost)
- Client ID →
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
- A Cloudflare account with Workers enabled; note the Account ID (dashboard sidebar).
- For production’s custom domain: the
totumcloud.comzone must be on this account.wrangler.tomldeclaresnotifications.totumcloud.comas a custom domain — Cloudflare creates the DNS record and certificate automatically on first deploy. - 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)
- Every PR that changes the client gets a changeset:
pnpm changeset(pick bump level, describe the change). - Merge the PR → CI runs (lint, typecheck, ≥90% coverage, OpenAPI drift check, build).
- The bot opens/updates Version Packages — merge it when you want to ship.
- 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, constructNotificationsClientwith your token getter, usesubscribe()/useNotifications. Remember: call the unsubscribe function on logout. - Publish from a service → M2M token (3.3/3.4) +
client.publish(...)(or plainPOST /v1/notifications). Always setdedupeKey. - 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.