Supabase
Status: Reference
Status: Active Last reviewed: 2026-06-27 Part of: External APIs index
Role
Primary backend for TwoMore. Provides:
- PostgreSQL — all relational data (clubs, sessions, members, dues, matches)
- Row-Level Security (RLS) — authorization enforced at the DB layer
- Auth — JWT issuance; Kakao tokens are exchanged here for session JWTs
- Storage — avatar images and any future file uploads
- Freshness substrate — TanStack Query + focus refetch + push + focused polling; persistent Postgres-Changes channels are currently retired
- Edge Functions — push dispatch, scenario seeding, weather, static maps, account export, PortOne verification/webhooks, and other trusted server work
Credentials
| Env var | Value shape | Scope |
|---|---|---|
EXPO_PUBLIC_SUPABASE_URL | https://<project-ref>.supabase.co | Public — safe in bundle |
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY | sb_publishable_* or equivalent publishable key | Public — preferred client key |
EXPO_PUBLIC_SUPABASE_ANON_KEY | JWT with role=anon | Public — RLS enforces access |
TWOMORE_SECRET_KEY | Supabase secret key | Edge Functions only — never ship in client |
Security model: publishable/anon client keys are intentionally public. All row-level access control lives in PostgreSQL RLS policies, not in key secrecy. The service key must never appear in
EXPO_PUBLIC_*or in the app bundle.
Where to find them
Supabase dashboard → Settings → API.
Setup (local dev)
# Start local Supabase stack (Docker required)
npx supabase start
# Apply all migrations to local DB
npx supabase db reset
# Regenerate TypeScript types after schema changes
npx supabase gen types typescript --local \
| npx prettier --config .prettierrc.json --parser typescript \
> packages/app/src/adapters/supabase/generated.types.tsAlways regenerate types before writing a new adapter. Stale types are a silent source of bugs.
Free tier quotas
| Resource | Free limit | TwoMore usage pattern |
|---|---|---|
| Database size | 500 MB | ~1 KB/row → ~500k rows before upgrade |
| Storage | 1 GB | Avatar images only in Phase 1 |
| Realtime connections | See Supabase plan | Currently zero persistent app sockets |
| Edge Function invocations | See Supabase plan | Actively used |
| Auth MAUs | 50,000 | Far exceeds Phase 1 target |
Upgrade trigger: move to Pro ($25/month) when DB approaches 400 MB or MAUs approach 40k. Auth remains free up to 50k on Pro as well.
Hex-arch wiring
ports/repositories/*.repository.port.ts
↕ (implements)
adapters/supabase/*.supabase.ts ← only layer that imports @supabase/supabase-js
↕ (uses)
adapters/supabase/generated.types.ts ← never leaks past this boundary
adapters/supabase/mappers/*.mapper.ts ← snake_case DB rows ↔ camelCase domain entitiesRules:
@supabase/supabase-jsimports are forbidden outsidepackages/app/src/adapters/supabase/generated.types.tstypes must not be re-exported or used in ports/domain- Every adapter uses an explicit mapper; no
camelcaseKeys()or generic transforms
Error handling
Supabase client methods return { data, error } — never throw.
// Adapter pattern
const { data, error } = await supabase.from('clubs').select('*').eq('id', id).single();
if (error) throw new RepositoryError(`Club not found: ${id}`, { cause: error });
return clubMapper.toDomain(data);Map Postgres/PostgREST error codes to domain errors inside the adapter. The domain and UI never see raw Supabase errors.
| Code | Meaning | Domain action |
|---|---|---|
23505 | Unique constraint violation | Throw DuplicateError |
23503 | Foreign key violation | Throw ReferenceError |
42501 | RLS permission denied | Throw PermissionError |
PGRST116 | Row not found (.single()) | Return null or throw NotFoundError |
PGRST301 | JWT expired | Surface to auth layer → re-login |
RLS policy conventions
Each table has policies for: SELECT, INSERT, UPDATE, DELETE. Policy naming: {table}_{operation}_{who} — e.g. sessions_select_member.
Auth helper available in policies:
auth.uid() -- current user's UUID
auth.role() -- 'anon' | 'authenticated'Custom claims (club roles) are stored in club_members.role and joined in policies, not in JWT custom claims — this avoids token bloat and keeps roles in sync with DB state.
Freshness / realtime posture
Persistent Supabase Realtime WebSocket channels are currently retired. Live session surfaces use focus-gated polling through useLiveSessionData; DM uses native query refetchInterval; signals use push notification handlers plus cache-bust; ordinary surfaces use staleTime and mutation invalidation.
If a future realtime hook is reintroduced, it must live under packages/app/src/presentation/hooks/realtime/, use useFocusedEffect, and target specific cache keys. @twomore/no-realtime-subscribe-outside-realtime-hooks and @twomore/require-is-focused-in-realtime-hook are kept as regression guards.
Storage conventions
| Bucket | Content | Access |
|---|---|---|
avatars | Profile photos | Public read, authenticated write |
File path format: avatars/{userId}/{timestamp}.{ext}
Never store full URLs in the DB — store only the relative path and reconstruct with getPublicUrl() in the storage adapter.
Edge Functions
Edge Functions live under supabase/functions/{name}/index.ts. Trusted functions use TWOMORE_SECRET_KEY (set via npx supabase secrets set) for service-authority work. Direct Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') reads are forbidden by @twomore/no-legacy-service-role-key-in-edge-functions.