Architecture — what Cloudflare/Supabase do
Architecture — what Cloudflare does, what Supabase does
Three lanes: the browser (and future relying parties), the Cloudflare edge, and the Supabase data plane. Solid arrows are wired today; dashed arrows are the product vision that has not yet shipped.
What each platform actually does
Edge, static hosting, abuse gate, future identity surfaces
Cloudflare Pages (×2) — hosts sporthead.id (Next.js static export, out/ uploaded by wrangler pages deploy) and sporthead.com (raw HTML). Both use deploy-time env-baked NEXT_PUBLIC_* vars; the Supabase anon key ships in the bundle.
Workers — sporthead-reserve handles email reservations with Turnstile + IP_SALT-hashed dedup, writes to D1. federation-router is a 501 stub today; intended to resolve Host → federation → theme via KV cache. Planned: OIDC issuer (auth endpoints), VC signer (key custody), traits CDN (proxies Supabase Storage at traits.sporthead.id).
D1 — single SQLite-on-edge DB for reservations only. Not the identity store.
KV (Workers KV) — federation lookup cache (planned, namespace not provisioned). Will also cache published JWKS once issuer ships.
Turnstile — abuse gate on the reservation form. Secret is conditional in the worker — if unset, gate is silently off (workers/sporthead-reserve/src/index.js:39).
DNS for sporthead.id is still on GoDaddy. Migration to Cloudflare is in the active backlog.
System of record · Postgres + Auth + Storage + RPCs
Postgres (managed) — 14 migrations applied to project shot-id-production (EU-Ireland). Holds every identity row, cohort flag, trait definition, federation tenant, VC issuance log, country metadata, handle vocab.
Supabase Auth — auth.users is the credential source of truth. PIN is passed as the password field. PIN-only users get a synthetic <uuid>@anon.sporthead.id email. mailer_autoconfirm=true sidesteps verification. Session JWTs persist in browser localStorage via supabase-js.
RLS — per-table policies enforce ownership on profiles and verifiable_credentials. The public_profiles view is anon-readable but excludes email/pin_hash/geo raw. pending_profiles is intentionally open at v0 (the P0 wound).
RPCs / triggers (SECURITY DEFINER) — generate_sporthead_handle samples vocab, handle_new_user drains pending_profiles into profiles on auth.users INSERT, enforce_identity_lock trigger blocks handle/locale/country/geo mutation post-lock, lookup_signin_email_by_handle bridges handle → auth email for sign-in.
Supabase Storage — single public traits/ bucket with 99 trait PNGs. trait_definitions.storage_path resolves to a CDN URL. Will be fronted by the planned traits CDN worker for branded URLs.
All Supabase calls from sporthead-id happen client-side with the anon key. There is no BFF.
Source · CI · Packages (planned)
GitHub Actions — one workflow deploy.yml, fires on push to main. Deploys both Pages projects in parallel via wrangler. No PR gate (lint/typecheck/test/build) anywhere. No release workflow.
GitHub Packages (planned per ADR-0003) — intended registry for @sporthead/identity, @sporthead/credentials, @sporthead/design-system. Driven by changesets. .changeset/ directory never bootstrapped; nothing has shipped to a registry.
Secrets — CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID in Actions secrets. Worker secrets (IP_SALT, TURNSTILE_SECRET_KEY, worker SUPABASE_ANON_KEY) via wrangler secret put — declared in wrangler.toml comments but not necessarily set (no audit).
The "credential-issuer" boundary that doesn't exist yet
The product vision needs three operationally-distinct boundaries: issuer (Cloudflare Workers — OIDC + VC signer), holder (the Sport Head, via sporthead.id), verifier (Clubhouse, federations, partner apps). Today, only the holder surface exists.
The OIDC issuer + VC signer worker pair is the missing seam that makes sporthead.id an actual identity platform vs. a registration site. Until they ship, every "Sign in with SHOT" partnership is theoretical.
The federation-router worker is the second missing seam — it's the only point where federation-scoped traffic is shaped before reaching the data plane. Today it returns 501 so federation portals can't render at all.
The identity write path, step by step
- Step 1 (browser) —
sporthead.idstatic bundle loads. Generates a UUIDpending_idin localStorage under keysh-mock-pending-id. - Steps 1–4 (each journey step) — Country, geo, avatar, handle each write to localStorage AND upsert a row in
pending_profilesvia the public anon key. This is the surface attackers see. - Step 5 (PIN) — Browser calls
supabase.auth.signUp({ email, password: pin })withraw_user_meta_data.pending_idset. PIN-only users get a synthetic email; explicit email users provide their own. - Supabase Auth — bcrypt-hashes the PIN, creates
auth.usersrow, fires INSERT trigger. - Trigger
handle_new_user(SECURITY DEFINER) — reads thepending_id, copies all journey fields intoprofiles, strips synthetic emails, deletes the pending row. - Browser — calls
profiles.update({ identity_locked_at })immediately to ceremonially lock the identity. Theenforce_identity_locktrigger now blocks future handle/locale/country/geo mutations unlessidentity_lock_versionbumps. - Welcome — Pages app renders the avatar from Supabase Storage public URLs and exposes share / download buttons. No VC is issued (issuance code doesn't exist).
Sign-in for returning users follows a similar shape: browser calls lookup_signin_email_by_handle(handle) (SECURITY DEFINER, anon-granted, leaks recovery email), then supabase.auth.signInWithPassword({ email, password: pin }), then hydrateLocalStorageFromProfile() to repopulate sh-mock-* keys.