# 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 on notifications.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).

  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 → Worker Variable SUPABASE_URL (dashboard)
    • service_role key → Worker Secret SUPABASE_SERVICE_ROLE_KEY. Never put this in git, client code, or the anon key slot — 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.


# 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 scope notifications/publish

# 2.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).

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

  1. A Cloudflare account with Workers enabled. Note the Account ID (Workers overview / account home) — needed later for GitHub Actions.
  2. For production’s custom domain, the totumcloud.com zone must sit on this Cloudflare account (Custom Domains).
  3. Optional until you wire CI: create an API token (My Profile → API Tokens → CreateEdit 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

  1. Open Workers & PagesCreateCreate Worker.
  2. Name it exactly as in the table above (inovus-notifications-staging or inovus-notifications).
  3. Deploy the starter so the Worker exists (you will replace the code in the next subsection).

# Variables and secrets

Open the Worker → SettingsVariables and SecretsAdd. 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 → SettingsDomains & RoutesAddCustom Domainnotifications.totumcloud.comAdd Custom Domain.

Cloudflare creates the DNS record and certificate on the zone. Staging can stay on *.workers.dev.

# Retention cron

On each Worker → SettingsTriggersCron 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-api runs the script in packages/api from the monorepo root (installs/links workspace deps).
  • deploy:staging / deploy:production are wrangler deploy --env staging|production — required so Cloudflare targets the right Worker name / routes / cron in wrangler.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):

  1. Repo → Settings → Actions → General
  2. Under Workflow permissions, choose Read and write permissions
  3. Check Allow GitHub Actions to create and approve pull requests
  4. 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/mastermaster (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:

  1. Client PR includes pnpm changeset (adds a pending .md; only config.json between releases is normal).
  2. Merge to masterVersion Packages PR.
  3. 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, construct NotificationsClient with your token getter, use subscribe()/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 plain POST /v2/notifications). Always set dedupeKey.
  • 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.