# Setup guide — deploy to Cloudflare
Stand up a staging / production notification service: Supabase database, Cognito auth, Cloudflare Worker (dashboard-first), and optional GitHub Actions releases.
Trying things on your laptop first? Use Local developer testing (API keys → inbox), then come back here.
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
- Publisher credentials: named API keys and/or Cognito M2M app clients
- A GitHub Actions pipeline that tests, publishes the npm client, and deploys
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.
# CORS (browser access)
Browser apps on other domains need CORS. This Worker already enables it in code (allow any origin). You do not configure CORS in the Cloudflare dashboard for this API.
After you deploy the Worker, cross-origin fetch / Swagger Try it out from another host should work. Allowed request headers include Authorization, Content-Type, X-Api-Key, and If-None-Match.
Server-to-server callers (M2M, API keys, curl) ignore CORS — no change for them.
To tighten origins later, change the cors({ origin: … }) middleware in packages/api/src/app.ts and redeploy (do not use Transform Rules for this — they don’t handle API OPTIONS preflight cleanly).
# 1. 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→ Worker VariableSUPABASE_URL(dashboard) service_rolekey → Worker SecretSUPABASE_SERVICE_ROLE_KEY. Never put this in git, client code, or the anon key slot — 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.
# 2. Cognito (authentication)
Uses your existing user pool(s) — nothing about user sign-in changes.
For named publisher API keys (no M2M), you can skip resource server / app client creation and set PUBLISHER_API_KEYS on the Worker instead — see api-keys.md. You still need AUTH_ISSUER so end users can read inboxes.
New to Cognito M2M? Cognito explained.
Production change controls: Cognito M2M reference.
Click-by-click: cognito-setup.md. Short version below.
# 2.1 Find the issuer
Pool overview → User pool ID + region:
https://cognito-idp.<region>.amazonaws.com/<userPoolId>
This is AUTH_ISSUER. (Multiple pools: comma-separate them.)
# 2.2 Resource server + scope (for M2M publishers)
User pool → App integration → Resource servers → Create:
- Identifier:
notifications - Custom scope name:
publish→ full scopenotifications/publish
# 2.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).
Local M2M dry run before deploy: local-development.md §3.
# 2.4 Getting a token (producer side)
PowerShell (Windows) — prefer curl.exe:
$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: cognito-setup.md §3.4.
Cache the token until expires_in; don’t fetch one per request.
# 2.5 Human publishers (optional)
You usually skip this. Backends use scopes (§2.2–2.4) or API keys, not groups.
Only if an admin should publish with their user login: create a Cognito group, add users, and set PUBLISHER_GROUPS to that exact group name. See cognito-setup.md — Human/admin publishers.
Regular users need nothing — any valid token from the pool can read its own inbox.
# 3. Cloudflare (the Worker)
Preferred: configure and operate Workers in the Cloudflare dashboard (create Workers, variables, secrets, custom domain, cron, Git Builds).
Backup: Wrangler CLI (§3.4) if you prefer the terminal or need a one-shot code upload.
wrangler.toml is deploy plumbing only (entrypoint, Worker names, custom domain, retention cron) plus keep_vars = true so dashboard Variables are not wiped on deploy. Runtime config (AUTH_ISSUER, SUPABASE_URL, secrets, …) lives in the dashboard — never as [vars] in that file.
Workers Builds form (when connecting Git) — copy these:
| Worker | Root directory | Build command | Deploy command |
|---|---|---|---|
Staging (inovus-notifications-staging) |
/ (repo root) |
(empty) | pnpm --filter @inovus-medical/notifications-api deploy:staging |
Production (inovus-notifications) |
/ (repo root) |
(empty) | pnpm --filter @inovus-medical/notifications-api deploy:production |
Details and why: §3.2 → Get the API code → Option A. This repo uses two Workers (one per environment):
| Environment | Worker name | Typical URL |
|---|---|---|
| Staging | inovus-notifications-staging |
https://inovus-notifications-staging.<account>.workers.dev |
| Production | inovus-notifications |
https://notifications.totumcloud.com (custom domain) |
# 3.1 One-time account setup
- A Cloudflare account with Workers enabled. Note the Account ID (Workers overview / account home) — needed later for GitHub Actions.
- For production’s custom domain, the
totumcloud.comzone must sit on this Cloudflare account (Custom Domains). - Optional until you wire CI: create an API token (My Profile → API Tokens → Create → Edit Cloudflare Workers template). Not required for a dashboard-only first standup.
# 3.2 Preferred — Cloudflare dashboard
Do this once per environment (staging, then production). Official docs: environment variables, secrets, custom domains, cron triggers.
# Create the Worker
- Open Workers & Pages → Create → Create Worker.
- Name it exactly as in the table above (
inovus-notifications-stagingorinovus-notifications). - Deploy the starter so the Worker exists (you will replace the code in the next subsection).
# Variables and secrets
Open the Worker → Settings → Variables and Secrets → Add. These are the only place deployed runtime config lives (not wrangler.toml).
Use type Variable (plaintext) for non-secrets and type Secret (encrypted, hidden after save) for credentials. Click Deploy after adding.
| Name | Type | Value | Notes |
|---|---|---|---|
AUTH_ISSUER |
Variable | pool issuer from 2.1 | Comma-list for multiple pools |
SUPABASE_URL |
Variable | project URL from §1 | Per environment |
DB_DRIVER |
Variable | supabase |
Required in deployed envs |
PUBLISHER_SCOPES |
Variable | notifications/publish |
If using M2M (2.2); omit if API-keys-only |
PUBLISHER_GROUPS |
Variable | e.g. notification-publishers |
Optional (2.5) |
RETENTION_DAYS |
Variable | 90 |
0 disables purge logic (cron can still fire) |
V1_PAYLOAD_TEMPLATES |
Variable | JSON map | Only if legacy CreatePayload callers exist — v1-compatibility.md |
AUTH_AUDIENCE, AUTH_SUB_CLAIM, AUTH_GROUPS_CLAIM, AUTH_JWKS_URL |
Variable | — | Optional; defaults fit Cognito |
SUPABASE_SERVICE_ROLE_KEY |
Secret | service role from §1 | Never a plaintext Variable |
PUBLISHER_API_KEYS |
Secret | name:secret,… |
Optional — api-keys.md |
# Custom domain (production)
On the production Worker → Settings → Domains & Routes → Add → Custom Domain → notifications.totumcloud.com → Add Custom Domain.
Cloudflare creates the DNS record and certificate on the zone. Staging can stay on *.workers.dev.
# Retention cron
On each Worker → Settings → Triggers → Cron Triggers → add:
0 3 * * *
(03:00 UTC daily — matches wrangler.toml.) Propagation can take a few minutes.
# Get the API code onto the Worker
The dashboard “Hello World” starter is not this service. Prefer connecting the Git repo so Cloudflare builds and deploys for you.
# Option A — Connect Git (Workers Builds) — preferred
On each Worker: Settings → Builds (or create via Import a repository). Official reference: Workers Builds configuration.
Use the repo root as the working directory (this is a pnpm monorepo; the API package depends on packages/core). Fill the form exactly as below — do not leave Deploy as the default npx wrangler deploy (that misses the Wrangler --env and the package path).
Staging Worker (inovus-notifications-staging):
| Field | Value |
|---|---|
| Git repository | this repo |
| Git branch | main (or your release branch) |
| Root directory | / (leave empty / repo root — not packages/api) |
| Build command | (leave empty) — Wrangler bundles TypeScript at deploy time; there is no separate compile step |
| Deploy command | pnpm --filter @inovus-medical/notifications-api deploy:staging |
| Non-production branch deploy (optional) | leave default, or disable non-production builds |
Production Worker (inovus-notifications):
| Field | Value |
|---|---|
| Git repository | same repo |
| Git branch | main (or your release branch) |
| Root directory | / (repo root — not packages/api) |
| Build command | (leave empty) |
| Deploy command | pnpm --filter @inovus-medical/notifications-api deploy:production |
Why those deploy commands:
pnpm --filter @inovus-medical/notifications-apiruns the script inpackages/apifrom the monorepo root (installs/links workspace deps).deploy:staging/deploy:productionarewrangler deploy --env staging|production— required so Cloudflare targets the right Worker name / routes / cron inwrangler.toml. That file intentionally has no runtime Variables (so deploys do not clobber the dashboard).
If the UI forces a non-empty Build command, use exit 0 (no-op). Do not put the deploy command in the Build field.
Set runtime Variables & Secrets (§3.2 above) on the Worker itself — Build variables are only for the CI container, not for AUTH_ISSUER / Supabase at request time. Set them before or right after the first code deploy; later deploys leave dashboard Variables alone.
# Option B — One Wrangler upload from your laptop
cd packages/api
npx wrangler login
pnpm deploy:staging # → Worker inovus-notifications-staging
pnpm deploy:production # → Worker inovus-notifications
# Option C — GitHub Actions later
§4 deploys on Version Packages merges (same deploy:staging / deploy:production scripts). You can use Actions instead of Workers Builds, or keep Builds for config-only Workers and Actions for releases — pick one deploy path per environment to avoid fighting over which commit is live.
After the first successful code deploy, day-to-day config changes (rotate API keys, tweak AUTH_ISSUER, add a publisher) stay in the dashboard.
# 3.3 Verify
curl https://notifications.totumcloud.com/health # {"status":"ok"} → auth+DB wiring good
open https://notifications.totumcloud.com/docs # interactive API docs (Try it out uses this host)
# end-to-end with a publisher credential (API key or M2M from 2.4):
curl -X POST https://notifications.totumcloud.com/v2/notifications \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"userGuid":"<your own sub>","title":"Hello","body":"First notification","dedupeKey":"setup-test"}'
Or use X-Api-Key instead of Bearer if you configured PUBLISHER_API_KEYS.
Then sign in as that user (or curl GET /v2/notifications with a user token) and confirm the notification appears. Re-run the publish — you should get 200 with "duplicate": true.
Live request logs: Worker → Logs in the dashboard, or npx wrangler tail --env production.
# 3.4 Backup — Wrangler CLI only
Use this if you prefer not to click through the dashboard for code upload (or for scripting). Runtime Variables & Secrets still belong in the dashboard (§3.2) — wrangler.toml does not define them.
cd packages/api
npx wrangler login # or export CLOUDFLARE_API_TOKEN=...
# Secrets (optional CLI instead of dashboard):
npx wrangler secret put SUPABASE_SERVICE_ROLE_KEY --env staging
# npx wrangler secret put PUBLISHER_API_KEYS --env staging
pnpm deploy:staging
npx wrangler secret put SUPABASE_SERVICE_ROLE_KEY --env production
# npx wrangler secret put PUBLISHER_API_KEYS --env production
pnpm deploy:production
Plaintext Variables (AUTH_ISSUER, SUPABASE_URL, DB_DRIVER, …) must already be set on the Worker in the dashboard — Wrangler will not invent them. Production custom domain and cron are declared in wrangler.toml and apply on deploy. Then verify as in §3.3.
# 4. GitHub Actions (automated releases)
Full plain-language guide: releasing.md (what a changeset is, when you need one, Version Packages, first-time secrets).
The pipeline in .github/workflows/release.yml runs on every push to master (this repo’s default branch):
pending changesets → “Version Packages” PR → merging it publishes the npm client and deploys staging → production.
# 4.1 Repository secrets
Settings → Secrets and variables → Actions:
| Secret | From |
|---|---|
CLOUDFLARE_API_TOKEN |
My Profile → API Tokens → Edit Cloudflare Workers (needed for CI even if you configure Workers in the dashboard) |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare dashboard |
SUPABASE_ACCESS_TOKEN |
https://supabase.com/dashboard/account/tokens |
SUPABASE_PROJECT_REF_STAGING / _PRODUCTION |
§1 |
SUPABASE_DB_PASSWORD_STAGING / _PRODUCTION |
database password (Project Settings → Database) |
NPM_TOKEN |
npm automation/granular token for @inovus-medical. Must be set as both NPM_TOKEN and NODE_AUTH_TOKEN in the Release workflow (setup-node’s .npmrc reads NODE_AUTH_TOKEN). |
# 4.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.
# 4.2b Allow Actions to open the Version Packages PR
Changesets pushes branch changeset-release/master, then opens a PR. That fails with:
GitHub Actions is not permitted to create or approve pull requests
Fix (once per repo, or at org level):
- Repo → Settings → Actions → General
- Under Workflow permissions, choose Read and write permissions
- Check Allow GitHub Actions to create and approve pull requests
- Save
If that checkbox is greyed out, an organization (or enterprise) policy owns it. An org owner must enable the same option at:
https://github.com/organizations/<ORG>/settings/actions
(then you can enable it on the repo if the org still requires a per-repo confirm). For Enterprise, it may need enabling at enterprise Actions settings first.
Until then, open the PR yourself: compare changeset-release/master → master (title e.g. “Version Packages”), then merge when you want to publish.
# 4.3 Release flow (day-to-day)
See releasing.md (includes what’s in .changeset/ — config.json vs pending .md files). Short version:
- Client PR includes
pnpm changeset(adds a pending.md; onlyconfig.jsonbetween releases is normal). - Merge to
master→ Version Packages PR. - Merge that → npm publish.
Worker/Supabase deploy jobs are paused; deploy separately until secrets are ready (details in releasing.md).
# 5. 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 → named API key (api-keys.md) or M2M token (2.3/2.4) +
client.publish(...)(or plainPOST /v2/notifications). Always setdedupeKey. - Legacy V1 callers → point them at this service unchanged; see v1-compatibility.md.
# 6. Troubleshooting (deployed)
| Symptom | Likely cause |
|---|---|
health 500 “Storage unreachable” |
Wrong/missing Worker SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY, or migration not applied. Confirm with Worker Logs (health check failed). Dashboard Values must match a curl that returns 200 against PostgREST. |
| 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 | API key used on an inbox route; or M2M token lacks scope (PUBLISHER_SCOPES); or user token lacks PUBLISHER_GROUPS |
Publish 401 with X-Api-Key |
Secret not in PUBLISHER_API_KEYS Worker secret |
| 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 |
| Browser app gets “Failed to fetch” / CORS | Redeploy a Worker build that includes the CORS middleware (see setup guide). Not a dashboard setting. Confirm you’re hitting the API Worker host. |
429 Too Many Requests |
Per-identity rate limit — see rate-limiting.md. Honour Retry-After. Client retries by default when allowed. |
Logs: Worker → Logs in the Cloudflare dashboard, or npx wrangler tail --env production for a live stream (includes console.error from unhandled errors and failed health checks).
Local laptop issues: local-development.md.