SHOTid auditfeat/v2 · 38549fa · 2026-05-16
Architecture · §03

Architecture — what Cloudflare/Supabase do

Branchfeat/v2 Commit38549fa AudienceJonny, Liam, eng
03

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.

CLIENT CLOUDFLARE SUPABASE Browser · sporthead.id Next.js bundle + anon key localStorage: sh-mock-* Browser · sporthead.com static HTML reserve form SHOTclubhouse future OIDC client "Sign in with SHOT" Federation portals sportsl.com, etc. vanity-domain themed VC verifier 3rd-party app verifies presented VC DNS + edge + Turnstile DNS still on GoDaddy (!) CF Pages · sporthead-id Next.js static export (out/) deployed via wrangler CF Pages · sporthead-com raw HTML, no build 5 locales Worker · sporthead-reserve Turnstile + IP_SALT hash D1 id is <TODO> Worker · federation-router returns HTTP 501 KV id is <TODO> Worker · OIDC issuer planned: /authorize /token, /userinfo, /jwks Worker · VC signer planned: signing keys joint Jonny + Liam control Worker · traits CDN planned: traits.sporthead.id proxies Supabase Storage D1 reservations (not provisioned) Workers KV FED_CACHE (federations lookup) + planned JWKS cache Supabase Auth auth.users · PIN as password synthetic @anon.sporthead.id Postgres · profiles + cohort handle, locale, country, geo founder/og, avatar_state, rarity pending_profiles RLS: USING (true) P0 leak surface trait_definitions DB registry · display_tier 99 PNGs in Storage verifiable_credentials schema only · 0 rows signing key absent RPCs + triggers (SECURITY DEFINER) generate_sporthead_handle · handle_new_user lookup_signin_email_by_handle · enforce_identity_lock Supabase Storage bucket: traits/ (public) 99 trait PNGs federations · countries tenant + journey metadata handle vocab tables public_profiles (view, anon-readable) handle · locale · country · sport · cohort excludes email · pin_hash · geo raw anon RLS = USING (true) handle_new_user trigger live today planned / unbuilt critical leak path

What each platform actually does

Cloudflare layer

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.

Workerssporthead-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.

Supabase layer

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 Authauth.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.

GitHub layer

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.

SecretsCLOUDFLARE_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).

Where the lanes split

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

  1. Step 1 (browser)sporthead.id static bundle loads. Generates a UUID pending_id in localStorage under key sh-mock-pending-id.
  2. Steps 1–4 (each journey step) — Country, geo, avatar, handle each write to localStorage AND upsert a row in pending_profiles via the public anon key. This is the surface attackers see.
  3. Step 5 (PIN) — Browser calls supabase.auth.signUp({ email, password: pin }) with raw_user_meta_data.pending_id set. PIN-only users get a synthetic email; explicit email users provide their own.
  4. Supabase Auth — bcrypt-hashes the PIN, creates auth.users row, fires INSERT trigger.
  5. Trigger handle_new_user (SECURITY DEFINER) — reads the pending_id, copies all journey fields into profiles, strips synthetic emails, deletes the pending row.
  6. Browser — calls profiles.update({ identity_locked_at }) immediately to ceremonially lock the identity. The enforce_identity_lock trigger now blocks future handle/locale/country/geo mutations unless identity_lock_version bumps.
  7. 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.