Appearance
Auth expansion β multi-provider sign-in, Pending membership, policy-driven login (Phase 1) β
Date: 2026-07-14 Β· Status: approved (design review in chat) Β· Linear: pending (workspace at free issue limit) Branch: feature/auth-expansion-phase1
1. Context and goal β
Dispatch is pivoting from a single-cooperative instance to a multi-organization SaaS. Today sign-in is Google-only and hard-gated to the gravitasacumen.org Workspace in four places: the handle_new_user DB trigger (rejects other domains), the hd: param the web client sends, the "Internal" Google consent screen, and the login-page copy. This phase opens authentication:
- Google β any account (gate removed).
- GitHub β new provider.
- Apple β wired in code/config but feature-flagged off until an Apple Developer Program membership ($99/yr) and credentials exist.
- Email + password β full flow: signup, email verification, password reset. Email (not username) is the identifier.
Organizations themselves (create/join/invites, subdomain and custom-domain resolution, per-org login settings, SAML) are Phase 2 β but Phase 1 builds the seams they plug into.
2. Decisions log β
| Decision | Choice |
|---|---|
| Org model | Multi-org B2B: sign up β create or join an org (Phase 2). Phase 1 leaves new users in Pending. |
| Password identifier | Email + password (Supabase-native). No usernames. |
| Apple timing | Build behind a flag; enable when credentials exist. |
| Per-org "login IDs and keys" | Per-org policy (method toggles) + per-org enterprise SAML SSO (org brings IdP keys; Supabase Pro). Social providers stay platform-owned OAuth apps. Phase 2. |
| Account linking | Supabase default: auto-link identities sharing a verified email β one human, one account. Unverified emails never auto-link. |
| Email verification | Required for password signups (enable_confirmations = true locally to mirror hosted). |
| Approach | B β full auth revamp (dedicated routes, dev-bypass removal, MFA scaffold), not the minimal expansion. |
3. Target architecture (recorded for Phase 2's anchor) β
An org is resolved from the hostname β acme.dispatch.app (wildcard subdomain) or dispatch.acme.com (verified custom domain). The login page renders from a LoginPolicy:
ts
interface LoginPolicy {
methods: ('google' | 'apple' | 'github' | 'password' | 'sso')[];
samlDomain?: string; // present when the org has an enterprise SSO connection
}Phase 2 feeds this from the resolved org's config and adds an org-admin Login settings page (method toggles + SAML connection management, enforced server-side, signInWithSSO({ domain }) client-side). Phase 1 hard-codes the platform default policy (all methods except sso; apple filtered out while environment.auth.appleEnabled is false).
4. Phase 1 design β
4.1 Supabase config (supabase/config.toml) β
[auth.external.github]:enabled = true,client_id = "env(GITHUB_CLIENT_ID)",secret = "env(GITHUB_SECRET)".[auth.external.apple]: block present,enabled = false, commentedenv(APPLE_*)placeholders β flip on when credentials exist.[auth.external.google]: unchanged block; the client stops sendinghd:.[auth.email]:enable_confirmations = trueso local behaves like hosted prod (emails land in the bundled mail catcher).
4.2 Migrations (two files β enum constraint) β
Postgres refuses to use an enum value in the same transaction that ADD VALUEs it, and Supabase wraps each migration in a transaction β so this is two migration files, not one:
- File A:
alter type public.membership_role add value 'Pending';(nothing else). - File B: everything below.
Then, in file B:
- Make
'Pending'themembership_rolecolumn default onpublic.users. - Rewrite
handle_new_user: no domain rejection. Insert the profile for every new auth user withmembership_role = 'Pending'; keep the admin-allowlist promotion (allow-listed emails still becomeAdmin). Display name from provider metadata in precedence order:raw_user_meta_data->>'full_name',->>'name',->>'user_name'(GitHub), else the email local-part. (Apple supplies the name only on first consent; same keys.) - RLS hardening:
public.is_member()β astableSQL function reading the JWT claim the existing access-token hook already injects:coalesce(auth.jwt()->>'membership_role', 'Pending') <> 'Pending'(no table lookup). Sweep every existing policy that grants member-data accessto authenticatedand addis_member();Pendingusers keep exactly two rights: select/update on their ownpublic.usersrow. The access-token hook itself is unchanged (it already emits whatever role the profile has).
membership_role claim note: Go's RequireRole lists don't include Pending, so the API rejects Pending users by construction; libs/auth gains a RolePending constant and tests proving it's excluded.
4.3 Angular auth surface β
Routes (all standalone, external templates per repo convention):
| Route | Page |
|---|---|
/login | Policy-driven: OAuth buttons rendered from LoginPolicy.methods (Google, GitHub; Apple appears when flagged on) + email/password panel + links to /signup, /reset-password. |
/signup | Name, email, password β signUp() β "check your email" notice with resend. |
/verify | Landing for the confirmation link; success β session β redirect by role; error β resend option. |
/reset-password | Email form β requestPasswordReset() β sent notice. |
/reset-password/update | New-password form (session arrives via the recovery link / PASSWORD_RECOVERY event). |
/pending | Authenticated Pending users land here: "you're in β awaiting an organization", sign-out. This page is the Phase 2 seam (it becomes create-org / join-by-invite). |
/auth/callback | Unchanged. |
AuthService changes:
signInWithGoogle()βsignInWithProvider(p: 'google' | 'github' | 'apple'); dropshd:, keepsprompt: 'select_account'for Google only.- New:
signInWithPassword(email, pw),signUp(name, email, pw),requestPasswordReset(email),updatePassword(pw),resendVerification(email),signInWithSSO(domain)stub (throws "not available" β Phase 2). Roleunion gains'Pending';MANAGER_ROLESunchanged.- Guards: member routes redirect
Pendingβ/pending;/pendingrequires a session; auth pages redirect signed-in non-Pending users to/(the existing role-based landing). environment.workspaceDomainis deleted if nothing references it after thehd:removal (the admin feature has its ownDOMAINconstant and is untouched).
Dev bypass removed entirely: the login button, environment.devAuthBypass/devBypass, canDevBypass, isLoopbackHost/isLocalSupabase, and signInAsDevManager are deleted. The seeded dev principal (script kept, scripts/dev-principal-bypass.sh renamed to dev-principal-seed.sh) signs in through the real password form. E2E and unit tests that used the bypass switch to password sign-in.
Copy: auth surfaces say organization, never cooperative/Workspace; the login page no longer names a domain.
4.4 MFA scaffolding (TOTP) β cuttable if Phase 1 must shrink β
Supabase-native auth.mfa:
- Enroll: a "Security" section (new
/settings/securitypage): QR + manual secret, verify a code to activate, list + remove the factor. - Challenge: after password sign-in, if the account has a verified factor (
getAuthenticatorAssuranceLevel()reportsnextLevel = 'aal2'), show a code prompt before proceeding. - Out of scope: enforcing MFA on OAuth sign-ins, recovery codes, admin MFA policies.
4.5 Go API β
libs/auth:RolePendingconstant; tests thatRequireRole(ManagerRoles...)and member-level checks all reject it. JWT verification itself is provider-agnostic β no other change.apps/api/internal/admins: unchanged β the back-office allowlist remains@gravitasacumen.org(platform operators, not org members).
4.6 Email delivery and ops prerequisites (mirror of the GAC-48 pattern) β
| Item | Where |
|---|---|
| Google consent screen Internal β External | Google Cloud Console (existing client) |
GitHub OAuth app (prod): callback https://<project-ref>.supabase.co/auth/v1/callback | GitHub β Settings β Developer settings β OAuth Apps |
GitHub OAuth app (local): callback http://127.0.0.1:54321/auth/v1/callback; GITHUB_CLIENT_ID / GITHUB_SECRET into .env + .env.example | same |
| SMTP provider for hosted Supabase (verification + reset emails); suggestion: Resend | Supabase Dashboard β Auth β SMTP |
| Enable Google/GitHub providers + redirect URLs on the hosted project | Supabase Dashboard β Auth β Providers |
| Apple: Services ID, key, team ID β when the developer account exists | Apple Developer portal β flip appleEnabled + config |
4.7 Error handling β
- All
AuthServicemethods return a typed{ ok: true } | { ok: false; message: string }result; pages render inline DaisyUI alerts (no toasts for auth errors). Supabase error codes mapped to human copy: wrong credentials, unconfirmed email (offer resend), email already registered (offer sign-in/reset), weak password, expired/used recovery link (offer re-request), rate limits. - OAuth callback errors (user cancelled, provider error) surface on
/loginvia query-param detection in the callback page. - A
Pendinguser hitting any API route gets 403 (existing behavior for unknown roles) β the web app never calls member APIs while routing them to/pending.
4.8 Testing β
- Unit (web): AuthService methods with a mocked Supabase client; guard matrix (signed out / Pending / member / manager Γ route classes); each new page component (form validation, error rendering, policy-driven button set).
- Unit (Go):
RolePendingexclusion inlibs/auth. - E2E (Playwright, local stack): password signup β fetch confirmation link from the mail-catcher HTTP API β verify β land on
/pending; password sign-in as seeded member; full reset flow; Pending-user redirect. OAuth flows: manual checklist in the PR (not headlessly automatable). - Coverage gate (
nx test web --configuration=coverage) stays green.
5. Phase 2 outline (separate spec, not started) β
Org entity + membership + invites; hostnameβorg resolution (wildcard DNS on an apex domain, custom-domain verification, Cloudflare Pages multi-domain, per-domain Supabase redirect allowlist, dynamic API CORS); org Login settings page (method toggles + SAML connections); per-org LoginPolicy storage with server-side enforcement; signInWithSSO for real; /pending becomes create-org / join-by-invite. Ops flags: SAML needs Supabase Pro; domains need the apex + DNS setup. Product-wide cooperativeβorganization rename rides along.
6. Non-goals (Phase 1) β
Org CRUD/invites, domain resolution, SAML, per-org policy storage, username login, MFA on OAuth, recovery codes, admin-allowlist changes, terminology rename outside auth surfaces.