# v7.0 Phase 5.6 — WorkOS SSO sign-in flow Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development.

**Goal:** Ship Phase 5.6 (WorkOS SSO sign-in) — domain claim + sign-in flow + sso_required enforcement. Folds 6 codex CRITICALs and 10 codex WARNINGs from spec passes 1+2.

**Tech Stack:** Next.js 16 + Supabase + `@workos-inc/node` SDK + `tldts` (new dep). NEW env (codex plan-pass NOTE #1): `SSO_STATE_SIGNING_SECRET` (≥32 random bytes; module-load validation refuses to start if shorter), `WORKOS_CLIENT_ID` (required by `workos.sso.getAuthorizationUrl`).

**Skip Vercel preview deploy verification** — agent env doesn't have Vercel access; CI does the real verification.

## File structure

### New
- `data/deltas/20260509120000_phase5_6_workos_signin.sql`
- `apps/web/lib/dns/normalize-domain.ts` — `normalizeDomain` + `normalizeEmailDomain` (uses `tldts`)
- `apps/web/lib/dns/verify-txt.ts` — Promise.race timeout-bound DNS TXT lookup
- `apps/web/lib/auth/enforce-sso-required.ts` — single chokepoint helper
- `apps/web/lib/workos/sign-in.ts` — authorize URL builder + state cookie sign/verify (HMAC)
- `apps/web/app/api/dashboard/orgs/[orgId]/sso/domains/route.ts` — POST add
- `apps/web/app/api/dashboard/orgs/[orgId]/sso/domains/[domainId]/route.ts` — DELETE revoke
- `apps/web/app/api/dashboard/orgs/[orgId]/sso/domains/[domainId]/verify/route.ts` — POST verify
- `apps/web/app/api/dashboard/orgs/[orgId]/sso/required/route.ts` — PATCH toggle
- `apps/web/app/api/auth/sso/start/route.ts`
- `apps/web/app/api/auth/sso/callback/route.ts`
- `apps/web/app/login/sso/page.tsx` — email input
- `apps/web/components/admin/SsoDomainsCard.tsx`
- `apps/web/components/admin/SsoRequiredToggle.tsx`
- 9 test files

### Modified
- `apps/web/components/admin/SsoSetupCard.tsx` — embed `<SsoDomainsCard>` + `<SsoRequiredToggle>` (toggle renders even when SSO inactive per WARNING #7)
- `apps/web/app/api/auth/callback/route.ts` (Phase 2.1) — call `enforceSsoRequired()` after `exchangeCodeForSession`
- `apps/web/__tests__/_helpers/supabase-stub.ts` — handlers for 6 new RPCs + `auth.users` lookup-by-email + `auth.admin.{getUserById,createUser,generateLink,signOut}` mocks + `auth.verifyOtp` mock + `organization_domain_claims` + `sso_authentication_states` + `workos_user_identities` table behavior
- `apps/web/lib/dashboard/membership-guard.ts` — MAP gains `domain_already_claimed: 422`, `domain_already_pending: 409`, `verification_failed: 422`, `no_active_sso: 422`, `state_workos_org_mismatch: 401`, `state_workos_connection_mismatch: 401`, `state_already_consumed: 401`, `state_expired: 401`, `state_not_found: 401`, `state_nonce_mismatch: 401`, `email_domain_not_claimed_for_org: 403`, `user_email_collision: 409`, `session_user_mismatch: 500` (note: 500 is "we set status to 500 but body has the error string"), `sso_unavailable: 404`, `invalid_state: 401`
- `apps/web/package.json` — `tldts` dep + bump to 6.3.0-pre.11
- `package.json` (root) — bump to 6.3.0-pre.11
- `apps/web/.env.example` — `SSO_STATE_SIGNING_SECRET`
- `CHANGELOG.md`

## Tasks

### Task 0: Pull spec
```
git fetch origin spec/v7.0-phase5.6-workos-signin
git checkout origin/spec/v7.0-phase5.6-workos-signin -- docs/specs/v7.0-phase5.6-workos-signin.md
git add docs/specs/v7.0-phase5.6-workos-signin.md
git commit -m "docs: pull Phase 5.6 spec from PR #133 for impl reference"
```

### Task 1: Migration

`data/deltas/20260509120000_phase5_6_workos_signin.sql`:

1. ALTER `organization_settings` ADD COLUMN `sso_required BOOLEAN NOT NULL DEFAULT FALSE`.
2. CREATE TABLE `organization_domain_claims` per spec (with `ever_verified` flag + 3 CHECK constraints + 2 indexes).
3. CREATE TABLE `sso_authentication_states` per spec (with 2 indexes).
4. CREATE TABLE `workos_user_identities` per spec (with 2 indexes — unique on `(workos_user_id, workos_organization_id)` + lookup by `user_id`).
5. RLS enable on all 3 new tables; `GRANT ALL TO service_role`.
6. 6 RPCs (`SECURITY DEFINER + SET search_path = public, audit, auth, pg_temp + REVOKE FROM PUBLIC,anon,authenticated + GRANT TO service_role`):
   - `claim_domain(p_caller_user_id uuid, p_org_id uuid, p_normalized_domain text, p_challenge_token text)`
   - `mark_domain_verified(p_caller_user_id uuid, p_org_id uuid, p_domain_id uuid)`
   - `revoke_domain_claim(p_caller_user_id uuid, p_org_id uuid, p_domain_id uuid)`
   - `set_sso_required(p_caller_user_id uuid, p_org_id uuid, p_required boolean)` — asymmetric guard (false always allowed; true requires active SSO)
   - `consume_sso_authentication_state(p_state_id uuid, p_nonce_hash text, p_workos_organization_id text, p_workos_connection_id text)`
   - `record_workos_sign_in(p_organization_id uuid, p_email text, p_normalized_email_domain text, p_workos_user_id text, p_workos_organization_id text, p_workos_connection_id text)` — verified-domain check + identity link path

Codex plan-pass WARNING #5 — `consume_sso_authentication_state` MUST be atomic. Use `UPDATE ... WHERE id = p_state_id AND consumed_at IS NULL ... RETURNING ...` pattern, NOT SELECT-then-UPDATE. If RETURNING returns 0 rows, distinguish:

```sql
-- Try atomic consume first.
UPDATE public.sso_authentication_states
   SET consumed_at = NOW()
 WHERE id = p_state_id
   AND consumed_at IS NULL
   AND expires_at > NOW()
RETURNING nonce, organization_id, workos_organization_id, workos_connection_id, initiated_email
INTO v_nonce, v_org_id, v_wos_org, v_wos_conn, v_init_email;

IF v_nonce IS NULL THEN
  -- Either not found, expired, or already consumed. Look up to figure out which.
  SELECT consumed_at, expires_at INTO v_existing_consumed, v_existing_expires
    FROM public.sso_authentication_states WHERE id = p_state_id;
  IF v_existing_consumed IS NULL AND v_existing_expires IS NULL THEN
    RAISE EXCEPTION 'state_not_found' USING ERRCODE = 'P0001';
  ELSIF v_existing_consumed IS NOT NULL THEN
    RAISE EXCEPTION 'state_already_consumed' USING ERRCODE = 'P0001';
  ELSE
    RAISE EXCEPTION 'state_expired' USING ERRCODE = 'P0001';
  END IF;
END IF;

-- Now verify nonce + workos identifiers.
IF v_nonce <> p_nonce_hash THEN RAISE EXCEPTION 'state_nonce_mismatch' ...;
IF v_wos_org <> p_workos_organization_id THEN RAISE EXCEPTION 'state_workos_org_mismatch' ...;
IF v_wos_conn <> p_workos_connection_id THEN RAISE EXCEPTION 'state_workos_connection_mismatch' ...;

RETURN jsonb_build_object('stateId', p_state_id, 'organizationId', v_org_id, 'initiatedEmail', v_init_email);
```

Tests for atomic consume (4 tests): first consume succeeds, second consume gets `state_already_consumed`, expired-only state gets `state_expired`, missing state gets `state_not_found`.

Codex plan-pass WARNING #8 — domain lookup predicate everywhere uses `status = 'verified'`, NEVER just `ever_verified`. The `ever_verified` flag exists ONLY for the unique-index ownership rule. `enforceSsoRequired` and `start.route` queries use `.eq('status', 'verified')` explicitly. Add a regression test: after revoking a verified domain, `/api/auth/sso/start` returns `sso_unavailable` (the previously-verified domain no longer routes).

Privilege grep test (`sso-signin-privilege.test.ts`) asserts:
- REVOKE FROM PUBLIC,anon,authenticated for all 6 RPCs
- GRANT TO service_role
- 6 SECURITY DEFINER occurrences
- 6 search_path locks
- Schema-qualified `public.organization_domain_claims`, `public.organization_settings`, `audit.append`
- Unique index on `lower(domain) WHERE ever_verified = TRUE`
- `record_workos_sign_in` raises `email_domain_not_claimed_for_org`
- `consume_sso_authentication_state` returns each of the 5 negative states

- [ ] **Step 1.1: Write migration SQL.** Bodies use Phase 5.4 patterns.
- [ ] **Step 1.2: Write privilege grep test.**
- [ ] **Step 1.3: Run grep test.** Expected: PASS.
- [ ] **Step 1.4: Commit.**

### Task 2: Helpers

#### 2a: Domain normalization

`apps/web/lib/dns/normalize-domain.ts` — exact code from spec. New dep `tldts`:
```bash
cd apps/web && npm install tldts
```

Tests `__tests__/lib/dns/normalize-domain.test.ts` (15+ cases):
- Plain domain `example.com` → ok
- IDN `münchen.de` → `xn--mnchen-3ya.de`
- Trailing dot `example.com.` → `example.com`
- URL with scheme `https://example.com` → `must_be_bare_domain`
- Port `example.com:8080` → rejected
- TLD only `com` → `public_suffix_only`
- ETLD only `co.uk` → `public_suffix_only`
- `localhost` → `must_have_tld`
- Empty / null / non-string → `invalid_input`
- Email `user@example.com` → `must_be_bare_domain` (use normalizeEmailDomain instead)
- normalizeEmailDomain happy path → `example.com`
- normalizeEmailDomain empty local-part `@example.com` → `invalid_email`
- normalizeEmailDomain no @ → `invalid_email`
- normalizeEmailDomain trailing @ → `invalid_email`
- normalizeEmailDomain mixed-case `User@EXAMPLE.com` → `example.com`

- [ ] **Step 2a.1: Add tldts dep.**
- [ ] **Step 2a.2: Write helper.**
- [ ] **Step 2a.3: Write 15+ tests.**
- [ ] **Step 2a.4: Run vitest.** Expected: 15+ pass.
- [ ] **Step 2a.5: Commit.**

#### 2b: DNS verify

`apps/web/lib/dns/verify-txt.ts` — exact code from spec.

Tests `__tests__/lib/dns/verify-txt.test.ts` (mocking `node:dns/promises`):
- Matching TXT chunk → ok
- Long chunked TXT joined before compare → ok
- No matching record → `no_matching_txt_record`
- ENOTFOUND → `no_txt_records`
- Resolver hangs → `timeout` within bound (test 9b from spec)

- [ ] **Step 2b.1: Write helper.**
- [ ] **Step 2b.2: Write tests.**
- [ ] **Step 2b.3: Run vitest. Verify timeout test takes ~timeoutMs not ~lookup-hang.**
- [ ] **Step 2b.4: Commit.**

#### 2c: enforceSsoRequired

`apps/web/lib/auth/enforce-sso-required.ts` — exact code from spec.

Tests `__tests__/lib/auth/enforce-sso-required.test.ts`:
- No email → allow
- Malformed email → allow (defer to auth layer)
- Email domain not claimed → allow
- Domain claimed but `sso_required=false` → allow
- Domain claimed, `sso_required=true`, `sso_connection_status='active'` → redirect_to_sso
- Domain claimed, `sso_required=true`, `sso_connection_status='disabled'` → allow (codex pass-2 NOTE #2 — UX shows banner but enforcement requires active)

- [ ] **Step 2c.1-4: Write helper, tests, run, commit.**

#### 2d: SSO state cookie helpers + signing-secret validator

Codex plan-pass WARNING #4 — secret length must be enforced at module load, fail-closed. Add to `apps/web/lib/workos/sign-in.ts`:

```typescript
let cachedSecret: Buffer | null = null;
const MIN_SECRET_BYTES = 32;
export function getSsoStateSigningSecret(): Buffer {
  if (cachedSecret) return cachedSecret;
  const raw = process.env.SSO_STATE_SIGNING_SECRET;
  if (!raw) throw new Error('SSO_STATE_SIGNING_SECRET is not configured');
  // Accept hex or base64; fall back to utf8 byte length.
  const buf = /^[0-9a-fA-F]+$/.test(raw)
    ? Buffer.from(raw, 'hex')
    : Buffer.from(raw, 'utf8');
  if (buf.length < MIN_SECRET_BYTES) {
    throw new Error(`SSO_STATE_SIGNING_SECRET must be at least ${MIN_SECRET_BYTES} bytes (got ${buf.length})`);
  }
  cachedSecret = buf;
  return cachedSecret;
}
export function __resetSsoStateSigningSecretForTests(): void { cachedSecret = null; }
```

Routes call `getSsoStateSigningSecret()` instead of reading `process.env` directly.



`apps/web/lib/workos/sign-in.ts`:

```typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

export function signStateCookie(payload: { stateId: string; nonce: string }, secret: string): string {
  const json = JSON.stringify(payload);
  const b64 = Buffer.from(json).toString('base64url');
  const sig = createHmac('sha256', secret).update(b64).digest('base64url');
  return `${b64}.${sig}`;
}

export type ParseResult =
  | { ok: true; payload: { stateId: string; nonce: string } }
  | { ok: false; reason: string };

export function parseStateCookie(value: string | undefined, secret: string): ParseResult {
  if (!value) return { ok: false, reason: 'missing' };
  const dot = value.lastIndexOf('.');
  if (dot < 1) return { ok: false, reason: 'malformed' };
  const b64 = value.slice(0, dot);
  const givenSig = value.slice(dot + 1);
  const expectSig = createHmac('sha256', secret).update(b64).digest('base64url');
  // Length-equal compare.
  if (givenSig.length !== expectSig.length) return { ok: false, reason: 'bad_signature' };
  if (!timingSafeEqual(Buffer.from(givenSig), Buffer.from(expectSig))) {
    return { ok: false, reason: 'bad_signature' };
  }
  try {
    const json = Buffer.from(b64, 'base64url').toString('utf8');
    const payload = JSON.parse(json) as { stateId?: unknown; nonce?: unknown };
    if (typeof payload.stateId !== 'string' || typeof payload.nonce !== 'string') {
      return { ok: false, reason: 'malformed_payload' };
    }
    return { ok: true, payload: { stateId: payload.stateId, nonce: payload.nonce } };
  } catch {
    return { ok: false, reason: 'malformed_payload' };
  }
}

export function buildAuthorizeUrl(args: {
  workosOrganizationId: string;
  workosConnectionId: string;
  clientId: string;  // codex plan-pass CRITICAL #3 — required by SDK
  stateId: string;
  redirectUri: string;
}): string {
  // Calls workos.sso.getAuthorizationUrl({ connection, clientId, state, redirectUri }).
  // Returns the full URL string.
}
```

Tests `__tests__/lib/workos/sign-in.test.ts`:
- sign + parse round trip → ok
- Tampered signature → `bad_signature`
- Tampered payload → `bad_signature`
- Missing dot → `malformed`
- Empty cookie → `missing`
- Wrong secret → `bad_signature`
- Non-JSON payload → `malformed_payload`
- Missing fields → `malformed_payload`
- `getSsoStateSigningSecret()` throws when env unset
- `getSsoStateSigningSecret()` throws when secret is < 32 bytes
- `getSsoStateSigningSecret()` accepts a 32-byte hex string

- [ ] **Step 2d.1-4: Write helper, tests, run, commit.**

### Task 3: Domain claim routes

3 routes in `apps/web/app/api/dashboard/orgs/[orgId]/sso/domains/`. All admin-gated route check (Phase 5.4 pattern) BEFORE RPC. RPC re-validates as defense in depth.

- POST `/route.ts` — body `{ domain: string }`. Route normalizes via `normalizeDomain`; rejects with 422 if invalid. Generates 32-byte hex challenge. Calls `claim_domain(callerUserId, orgId, normalized, challenge)`. Returns `{ id, domain, status: 'pending', challengeRecordName: '_workos-verify.<domain>', challengeRecordValue: <token> }`.
- DELETE `/[domainId]/route.ts` — calls `revoke_domain_claim`. Returns updated row.
- POST `/[domainId]/verify/route.ts` — Loads claim from DB; resolves DNS via `verifyTxtChallenge('_workos-verify.<domain>', claim.challenge_token)`; if ok, calls `mark_domain_verified`. Returns 422 `verification_failed` with `reason` field on DNS miss.

Tests `__tests__/api/dashboard/orgs/sso/domains.test.ts` (covering tests 1-9 + 10 + 30 from spec).

- [ ] **Step 3.1-5 per route:** Write, write tests, run, commit, repeat.

### Task 4: sso_required PATCH route

`apps/web/app/api/dashboard/orgs/[orgId]/sso/required/route.ts`:

Body `{ ssoRequired: boolean }`. Route admin check, then call `set_sso_required(callerUserId, orgId, ssoRequired)`. Asymmetric guard lives in the RPC: turning OFF always succeeds; turning ON requires active SSO (RPC raises `no_active_sso` → 422 via MAP).

Tests `__tests__/api/dashboard/orgs/sso/required.test.ts`: covers tests 22-24 from spec + asymmetric guard turning OFF when SSO inactive.

- [ ] **Step 4.1-5: Write, tests, run, commit.**

### Task 5: SSO sign-in start route

`apps/web/app/api/auth/sso/start/route.ts`:

Body `{ email: string }`. Public (no auth required). Steps:
1. Normalize email-domain via `normalizeEmailDomain`. If invalid, return 404 `sso_unavailable` (generic — codex pass-2 WARNING #8 anti-enumeration).
2. Look up verified domain claim by domain. If none, return 404 `sso_unavailable`.
3. Look up `organization_settings` for that org. If `sso_connection_status !== 'active'`, return 404 `sso_unavailable` (logged server-side as `sso_not_active`).
4. Generate `stateId = randomUUID()`, `nonce = randomBytes(32).toString('hex')`, `nonceHash = sha256(nonce)`.
5. Insert `sso_authentication_states` row via service-role client (no RPC needed — straight INSERT with all the fields). Set `expires_at = now() + 10min`.
6. Sign cookie with `signStateCookie({ stateId, nonce }, SSO_STATE_SIGNING_SECRET)`. Set `Set-Cookie: sso_state=<signed>; HttpOnly; Secure; SameSite=Lax; Max-Age=600; Path=/`.
7. Build authorize URL: `workos.sso.getAuthorizationUrl({ connection: workosConnectionId, state: stateId, redirectUri: ${origin}/api/auth/sso/callback })`.
8. Return `{ authorizationUrl }`. (Frontend navigates.)

Tests `__tests__/api/auth/sso/start.test.ts`: covers tests 11-14 from spec + state cookie format + persisted DB row + new (codex pass-2 WARNING #8) generic-error tests.

- [ ] **Step 5.1-5: Write, tests, run, commit.**

### Task 6: SSO sign-in callback route

`apps/web/app/api/auth/sso/callback/route.ts`. Most complex route. Steps:

1. Read `state` + `code` query params.
2. Parse cookie: `parseStateCookie(req.cookies.get('sso_state')?.value, getSsoStateSigningSecret())` — codex plan-pass CRITICAL #1, must use `?.value` because Next.js `cookies.get()` returns `{ name, value } | undefined`. Reject 401 `invalid_state` if not ok.
3. Verify `query.state === cookie.payload.stateId`. Reject 401 `invalid_state` on mismatch.
4. Exchange code: `const { profile } = await workos.sso.getProfileAndToken({ code, clientId: process.env.WORKOS_CLIENT_ID! })`.
5. Compute `nonceHash = createHash('sha256').update(cookie.payload.nonce).digest('hex')`.
6. Call `consume_sso_authentication_state(stateId, nonceHash, profile.organizationId, profile.connectionId)`. RPC returns one of the 5 negative states (each maps to 401 via MAP) or `{ stateId, organizationId, initiatedEmail }`.
7. Normalize profile email-domain via `normalizeEmailDomain`. (Should always succeed since profile.email comes from IdP.)
8. Call `record_workos_sign_in(organizationId, profile.email, normalizedDomain, profile.id, profile.organizationId, profile.connectionId)`. Three return paths:
   - `{ result: 'linked', userId }` → continue.
   - `{ result: 'user_not_provisioned', email, organizationId }` → call `auth.admin.createUser({ email, email_confirm: true, user_metadata: { workos_user_id: profile.id, source: 'workos_sso' } })`. On success, re-call `record_workos_sign_in` (will return `linked`). On error code 422 (email exists) → 409 `user_email_collision`.
   - Error → mapped via `mapPostgresError`.
9. Look up linked Supabase user's CURRENT email: `const { data: u, error: getErr } = await supabase.auth.admin.getUserById(userId)`. Reject 500 if no user found. Use `u.user.email` for the magic link mint (per WARNING #3).
10. `const { data: linkData, error: linkErr } = await supabase.auth.admin.generateLink({ type: 'magiclink', email: linkedEmail })` — extract `linkData.properties.hashed_token`.
11. `const { data, error: otpErr } = await anonClient.auth.verifyOtp({ token_hash: hashedToken, type: 'magiclink' })`. **Codex plan-pass CRITICAL #2 — verifyOtp returns `{ data: { user, session }, error }`. Destructure carefully.** Reject 500 if `otpErr || !data?.user || !data?.session`.
12. Verify `data.user.id === userId AND data.user.email?.toLowerCase() === linkedEmail.toLowerCase()`. On mismatch: revoke via `await supabase.auth.admin.signOut(data.session.access_token, 'global')`, audit `org.sso.session.mismatch` (NULL actor), return 500 `session_user_mismatch` (cookie cleared per WARNING #6).
13. Set Supabase cookies via `@supabase/ssr` `setAll()`.
14. **Clear `sso_state` cookie on response** (codex plan-pass WARNING #6 — clear on every terminal path including success, invalid_state, exchange failure, mismatch). Helper `clearStateCookie(res)` sets `Set-Cookie: sso_state=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`.
15. 302 redirect to `/dashboard`.

Tests `__tests__/api/auth/sso/callback.test.ts`: covers tests 15-21 + 27-31 from spec, PLUS:
- Test (codex plan-pass CRITICAL #1): Next.js cookie object shape `{ name, value }` is correctly unwrapped — pass real `NextRequest` cookie shape, not a raw string.
- Test (codex plan-pass CRITICAL #2): `verifyOtp` mock returns `{ data: { user, session }, error: null }`, asserts route reads `data.session.access_token` for sign-out path.
- Test (codex plan-pass NOTE #2): on `session_user_mismatch`, assert `auth.admin.signOut` was called with the session.access_token AND `audit.append` was called with action `org.sso.session.mismatch` AND the `sso_state` cookie was cleared (look for `Set-Cookie: sso_state=;` in response headers).
- Test (codex plan-pass NOTE #2): on success, assert `sso_state` cookie cleared.

- [ ] **Step 6.1-5: Write, tests, run, commit.**

### Task 7: Admin UI components + login page + sso_required enforcement at /api/auth/callback

#### 7a: SsoDomainsCard

`apps/web/components/admin/SsoDomainsCard.tsx` — list, add form, verify button per pending claim showing `_workos-verify.<domain>` TXT record name + value, revoke for verified.

#### 7b: SsoRequiredToggle

`apps/web/components/admin/SsoRequiredToggle.tsx` — switch + asymmetric-guard amber banner per codex pass-2 NOTE #2. Renders even when SSO inactive.

#### 7c: SsoSetupCard mod

Embed both new components.

#### 7d: /login/sso page

`apps/web/app/login/sso/page.tsx` — email input, submits to `/api/auth/sso/start`, on success navigates to returned `authorizationUrl`. Shows `?reason=sso_required` banner if redirected there from `/api/auth/callback`.

#### 7e: /api/auth/callback enforcement

Modify `apps/web/app/api/auth/callback/route.ts` to call `enforceSsoRequired(session.user.email)` after `exchangeCodeForSession`. On `redirect_to_sso`, signOut + redirect.

Tests `__tests__/api/auth/callback-sso-enforcement.test.ts`: covers tests 21-23 from spec.

#### 7f: Sign-in surface registry test

`__tests__/auth/sign-in-surface-registry.test.ts` — greps source for `signInWithPassword` / `signInWithOAuth` and fails if any new occurrence appears outside the documented set.

- [ ] **Steps 7a-7f.** Run vitest. Commit.

### Task 8: Final wiring + version bump + push + PR

- Update `apps/web/__tests__/_helpers/supabase-stub.ts` with all new RPCs + `auth.admin.*` mocks + `auth.verifyOtp` mock + 3 new tables behavior.
- `package.json` + `apps/web/package.json` bump to `6.3.0-pre.11`.
- `CHANGELOG.md` Phase 5.6 entry.
- `apps/web/.env.example` add `SSO_STATE_SIGNING_SECRET=` + `WORKOS_CLIENT_ID=`.
- Run full vitest + tsc.
- Push + PR.

### Skip directives (repeat from spec)
- Skip Vercel preview deploy — agent env has no Vercel access; CI does the real verification.
- Skip running migration locally — CI's Delegance migrate flow regression validates.
- Skip live WorkOS authorize round-trip — mock SDK profile responses.
- Skip live DNS lookups in tests — mock `node:dns/promises.resolveTxt`.
- Skip end-to-end browser test — jsdom is the ceiling.

## Deferred to follow-up (codex plan-pass WARNING #7)

- Rate limiting on `/api/auth/sso/start` (per-IP + per-domain). Acknowledged risk: write amplification against `sso_authentication_states`. Migration ships with the `expires_at`-keyed index for efficient cleanup; a Phase 5.7 cron will hard-delete consumed/expired rows. For now, the 10-min TTL + a future cleanup job + Vercel platform-level rate limits are the mitigation. Track in CHANGELOG entry.
