# Foundation Delta: Two-Factor Authentication (Phone OTP via Firebase) — Implementation Plan

**Spec:** docs/specs/2026-05-31-auth-2fa.md  ·  **Slug:** auth-2fa  ·  **Wave:** 2
**Depends on:** foundation-auth-rbac

## Goal
Extend the foundation auth system with phone-number OTP as an optional second factor, delivered via Firebase Authentication SMS and verified server-side via the Firebase Auth REST API (no Node SDK — Cloudflare Workers incompatible). Zync's own JWT remains the authoritative identity layer; Firebase is only an SMS/OTP transport. 2FA is optional per user, enforceable tenant-wide by OWNER/ADMIN, supports single-use backup codes, and supports a 30-day "remember device" trust token. This covers tenant users only — system-admin TOTP is owned by `foundation-auth-rbac`.

## Architecture
This delta sits entirely inside the existing `packages/auth` and `packages/db` packages plus the `zync-api` Worker and `zync-app` React app. It consumes these upstream artifacts from `foundation-auth-rbac`:

- **Tables consumed:** `users` (adds `two_factor_enabled`, `two_factor_phone`, `two_factor_phone_suffix`), `tenants` (adds `enforce_2fa`, `disable_2fa_remember_device`), `magic_link_tokens` (reused via `purpose` discriminator with new values `pending_2fa`, `pending_2fa_setup`), `refresh_tokens` (full JWT issuance on second-factor success).
- **Exports consumed:** `SessionPayload` interface (extended with `enforce_2fa`, `two_factor_verified`), `requirePermission(permission)` middleware, the auth middleware that populates `c.get('session')`, the JWT issuance helper that mints `zync_session` + `zync_refresh` cookies, the `user_version:{userId}` KV revocation mechanism (reused so changing `enforce_2fa` forces token refresh), and the `RATE_LIMITER_AUTH` CF RateLimiter binding.

Data flow: (1) First factor `POST /api/auth/login` succeeds → if `two_factor_enabled` or `enforce_2fa`, issue a `magic_link_tokens` temp session (`purpose='pending_2fa'` / `'pending_2fa_setup'`) instead of the full JWT. (2) Client runs Firebase phone-OTP client-side, posts the resulting Firebase `idToken` to `POST /api/auth/2fa/verify`. (3) Worker verifies the Firebase ID token (RS256 via `crypto.subtle`, against Google securetoken x509 keys), confirms the verified phone hash matches `users.two_factor_phone`, consumes the temp token, and mints the full JWT with `two_factor_verified: true`. Backup codes and device-trust cookies are alternate second-factor paths. The `require2FAIfEnforced()` middleware (chained after auth, before `requirePermission`) blocks unenrolled users in enforcing tenants except on bypass endpoints.

## Tech Stack
- **packages/db** (Drizzle schema + migrations + queries): new table `user_2fa_backup_codes`, new table `user_trusted_devices`, column deltas on `users` and `tenants`, query modules for backup codes and trusted devices.
- **packages/auth** (TypeScript, runtime-agnostic): `firebase-verify.ts` (RS256 ID-token verification), `backup-codes.ts` (generation/hash/verify), `middleware.ts` extension (`require2FAIfEnforced`), `SessionPayload` type extension, `two-factor.ts` service helpers.
- **apps/zync-api** (Hono on Cloudflare Workers): `/api/auth/2fa/*` routes, `/api/auth/trusted-devices*` routes, modified `/api/auth/login`, modified `/api/auth/2fa` settings/security toggle route. Bindings: `RATE_LIMITER_AUTH` (reused), KV (reused for backup-code attempt counting + `user_version`). Secrets: `FIREBASE_API_KEY`, `FIREBASE_PROJECT_ID` (env-suffixed `_DEV`/`_PROD`).
- **apps/zync-app** (Vite + React): Firebase client SDK (`firebase/auth`, code-split, auth pages only), `libphonenumber-js` for IL phone validation, enrollment modal, login 2FA challenge screen, backup-code screen, forced-setup page (`/auth/2fa/setup-required`), `/profile` Security tab, `/settings/security` enforcement toggle + trusted-devices list.
- Hashing: SHA-256 via `crypto.subtle.digest` for phone, backup codes, and device-trust tokens. All token/code comparisons are constant-time (timing-safe equality over the hex digests).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 2a — schema | 1 | `packages/db/src/schema/*`, migration SQL | No (blocks all) |
| 2b — core libs | 2, 3, 4 | `packages/auth/src/*`, `packages/db/src/queries/*` | Yes (independent of each other after 2a) |
| 2c — API | 5, 6, 7, 8 | `apps/zync-api/src/routes/auth/*` | 5 blocks 6/7/8; 6,7,8 parallel after 5 |
| 2d — secrets/config | 9 | `wrangler.toml`, env | Yes (parallel with 2c) |
| 2e — UI | 10, 11, 12, 13, 14 | `apps/zync-app/src/*` | 10 (Firebase client) blocks 11/12/13; 14 parallel |
| 2f — tests | 15 | `apps/zync-api/test/*`, `packages/auth/test/*` | After 5–8 |

## Tasks

### Task 1: Schema deltas + new 2FA tables (Drizzle + migration)
**Blocks:** 2, 3, 4, 5, 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/users.ts`
- Modify: `packages/db/src/schema/tenants.ts`
- Create: `packages/db/src/schema/user-2fa-backup-codes.ts`
- Create: `packages/db/src/schema/user-trusted-devices.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables)
- Create: `packages/db/migrations/<timestamp>_auth_2fa.sql`
**Steps:**
- [ ] Add the three `users` columns and two `tenants` columns as Drizzle column definitions matching the DDL below.
- [ ] Add Drizzle table definitions for `user_2fa_backup_codes` and `user_trusted_devices`, including the partial index on unused backup codes and the `(user_id, tenant_id)` index on trusted devices.
- [ ] Hand-write the raw Postgres migration (Drizzle `generate` output reviewed) so the partial index `WHERE used_at IS NULL` and the `UNIQUE` on `token_hash` are emitted verbatim.
- [ ] Do NOT add a new `purpose` column to `magic_link_tokens` — it already exists (added by another spec, not foundation-auth-rbac). The new values `'pending_2fa'` and `'pending_2fa_setup'` are application-level discriminators only; widen the existing `purpose` CHECK constraint to ALSO allow them. IMPORTANT: the value list and constraint name below are illustrative — before applying, read the live `magic_link_tokens` definition on the target Neon branch (`\d magic_link_tokens`), reuse its actual constraint name, and append the two new values to its actual existing value set rather than replacing with the list shown here (other specs may have added purpose values this plan does not know about).
**Schema / Interfaces:**
```sql
-- Delta on existing users table
ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN two_factor_phone TEXT;          -- SHA-256(phone_e164) hex; display-masking only, never raw phone
ALTER TABLE users ADD COLUMN two_factor_phone_suffix TEXT;   -- last 2 digits of phone, for masked display

-- Delta on existing tenants table
ALTER TABLE tenants ADD COLUMN enforce_2fa BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE tenants ADD COLUMN disable_2fa_remember_device BOOLEAN NOT NULL DEFAULT false;

-- Widen magic_link_tokens.purpose CHECK to ALSO allow the two new discriminators.
-- (purpose column + its CHECK pre-exist; this only adds allowed values.)
-- ILLUSTRATIVE ONLY: read the live constraint name and existing value set first
-- (\d magic_link_tokens), then append 'pending_2fa' and 'pending_2fa_setup' to the
-- ACTUAL existing values — do not blindly replace with the list below.
ALTER TABLE magic_link_tokens DROP CONSTRAINT IF EXISTS <actual_existing_constraint_name>;
ALTER TABLE magic_link_tokens ADD CONSTRAINT <actual_existing_constraint_name>
  CHECK (purpose IN (<all existing values>, 'pending_2fa', 'pending_2fa_setup'));

-- New table: backup codes (8 generated on enrollment)
CREATE TABLE user_2fa_backup_codes (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  code_hash   TEXT NOT NULL,                 -- SHA-256 of plaintext backup code (hex)
  used_at     TIMESTAMPTZ,                   -- NULL = unused
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_2fa_backup_codes_user ON user_2fa_backup_codes(user_id) WHERE used_at IS NULL;

-- New table: remembered (trusted) devices, 30-day TTL
CREATE TABLE user_trusted_devices (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  token_hash  TEXT NOT NULL UNIQUE,          -- SHA-256 of the device trust token (hex)
  user_agent  TEXT,                          -- browser/OS for display in security settings
  ip_address  INET,                          -- IP at trust time (informational)
  expires_at  TIMESTAMPTZ NOT NULL,          -- now() + 30 days
  revoked_at  TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_trusted_devices_user ON user_trusted_devices(user_id, tenant_id);
```
**Acceptance:**
- [ ] `drizzle-kit` migration applies cleanly to a Neon Postgres branch.
- [ ] Partial index `idx_2fa_backup_codes_user` exists with predicate `used_at IS NULL`.
- [ ] `user_trusted_devices.token_hash` has a UNIQUE constraint.
- [ ] `magic_link_tokens.purpose` accepts `'pending_2fa'` and `'pending_2fa_setup'` and rejects unknown values.

### Task 2: Firebase ID-token verification library
**Blocks:** 5, 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/auth/src/firebase-verify.ts`
**Steps:**
- [ ] Implement `verifyFirebaseIdToken(idToken, env)` using only `crypto.subtle` (no third-party JWT lib).
- [ ] Fetch Google securetoken x509 public keys from `https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com`; cache via `caches.default` honoring the response `Cache-Control` max-age.
- [ ] Base64url-decode the JWT header, select the cert by `kid`, import the RSA public key (`crypto.subtle.importKey` SPKI from the x509 cert), verify the RS256 signature over `header.payload`.
- [ ] Validate claims: `iss === "https://securetoken.google.com/" + env.FIREBASE_PROJECT_ID`, `aud === env.FIREBASE_PROJECT_ID`, `exp > now`, `iat <= now`, `auth_time` present, `sub` non-empty.
- [ ] Return `{ uid, phoneNumber }`; throw `FirebaseTokenError` (mapped to 401 `invalid_token` by callers) on any failure.
- [ ] All claim string comparisons use timing-safe equality.
**Schema / Interfaces:**
```ts
// packages/auth/src/firebase-verify.ts
export interface FirebaseUser { uid: string; phoneNumber: string }
export class FirebaseTokenError extends Error {}
export async function verifyFirebaseIdToken(
  idToken: string,
  env: { FIREBASE_PROJECT_ID: string }
): Promise<FirebaseUser>
```
**Acceptance:**
- [ ] A valid Firebase ID token for the configured project verifies and returns `{ uid, phoneNumber }`.
- [ ] Tampered signature, wrong `aud`, wrong `iss`, or expired `exp` each throw `FirebaseTokenError`.
- [ ] No `node:` imports; runs under `workerd`.

### Task 3: Backup-code generation, hashing, and phone-hash helpers
**Blocks:** 5, 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/auth/src/backup-codes.ts`
- Create: `packages/auth/src/phone-hash.ts`
**Steps:**
- [ ] `generateBackupCodes()` returns 8 plaintext codes, each `XXXX-XXXX` (two groups of 4), uppercase alphanumeric from the unambiguous alphabet `ABCDEFGHJKMNPQRSTUVWXYZ23456789` (excludes 0/O, 1/I/L). Source randomness from `crypto.getRandomValues`.
- [ ] `hashBackupCode(plaintext)` → `SHA-256` hex of the normalized (uppercased, hyphen-preserved) code.
- [ ] `verifyBackupCode(plaintext, codeHash)` → timing-safe hex comparison.
- [ ] `hashPhoneE164(phoneE164)` → `SHA-256` hex; `phoneSuffix(phoneE164)` → last 2 digits.
- [ ] All hashing via `crypto.subtle.digest` with the `SHA-256` algorithm over the UTF-8 encoded input.
**Schema / Interfaces:**
```ts
// packages/auth/src/backup-codes.ts
export function generateBackupCodes(count?: number): string[]   // default 8
export function hashBackupCode(plaintext: string): Promise<string>
export function verifyBackupCode(plaintext: string, codeHash: string): Promise<boolean>
// packages/auth/src/phone-hash.ts
export function hashPhoneE164(phoneE164: string): Promise<string>
export function phoneSuffix(phoneE164: string): string
```
**Acceptance:**
- [ ] Generated codes contain no ambiguous characters and match `^[A-Z2-9]{4}-[A-Z2-9]{4}$`.
- [ ] `verifyBackupCode` returns true only for the exact plaintext that produced the hash.
- [ ] `hashPhoneE164` is deterministic and 64 hex chars.

### Task 4: SessionPayload extension + `require2FAIfEnforced` middleware
**Blocks:** 5, 8  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/auth/src/types.ts` (or wherever `SessionPayload` lives)
- Modify: `packages/auth/src/middleware.ts`
- Modify: `packages/auth/src/index.ts` (export)
**Steps:**
- [ ] Extend `SessionPayload` with `enforce_2fa: boolean` (from `tenants.enforce_2fa` at issue time) and `two_factor_verified: boolean` (true if 2FA completed this session).
- [ ] Update the JWT issuance helper to populate `enforce_2fa` (JOIN `tenants`) and `two_factor_verified` (passed by caller; default false; true on second-factor success / trusted-device bypass).
- [ ] Implement `require2FAIfEnforced()` exactly per spec: pass-through for non-user sessions or sessions without `tid`; for user sessions where `enforce_2fa && !two_factor_verified`, return 403 with `{ error: '2fa_required', message, setup_url: '/auth/2fa/setup-required' }`.
- [ ] Define `BYPASS_2FA_GUARD` set and wire the API router so chain order is `authMiddleware → require2FAIfEnforced() → requirePermission() → handler`, with bypass endpoints skipping the guard.
- [ ] Document that changing `enforce_2fa` increments `user_version:{userId}` for all tenant members (reusing the spec-5 KV mechanism), so refreshed tokens carry the new flag within ≤60s.
**Schema / Interfaces:**
```ts
interface SessionPayload {
  // existing fields unchanged: sub, tid, role, permissions, tier, type, v, exp, iat
  enforce_2fa: boolean          // from tenants.enforce_2fa at token issue time
  two_factor_verified: boolean  // true if 2FA was completed in this session
}
export function require2FAIfEnforced(): (c: Context, next: Next) => Promise<Response | void>
export const BYPASS_2FA_GUARD: Set<string> // 'POST /api/auth/2fa/enroll/start', 'POST /api/auth/2fa/enroll/verify', 'GET /api/auth/me', 'POST /api/auth/logout'
```
**Acceptance:**
- [ ] User session with `enforce_2fa=true, two_factor_verified=false` gets 403 `2fa_required` on a guarded route.
- [ ] Same session reaches handler after second factor sets `two_factor_verified=true`.
- [ ] Admin sessions (`type==='admin'`) and tenantless user sessions bypass the guard.

### Task 5: Modify `POST /api/auth/login` for 2FA branch + device-trust bypass
**Blocks:** 6, 7  ·  **Blocked by:** 2, 3, 4
**Files:**
- Modify: `apps/zync-api/src/routes/auth/login.ts`
- Create: `packages/db/src/queries/trusted-devices.ts`
**Steps:**
- [ ] After successful first-factor (email+password) verification, load `users.two_factor_enabled` and `tenants.enforce_2fa` for the active membership.
- [ ] **Device-trust bypass first:** if a `zync_device_trust` cookie is present, hash it (`SHA-256`) and look up `user_trusted_devices` by `token_hash` for this `user_id`+`tenant_id` where `revoked_at IS NULL AND expires_at > now()`. If valid → issue full JWT directly with `two_factor_verified: true` (skip challenge). If invalid/expired → clear the stale cookie (`Max-Age=0`) and continue.
- [ ] If `two_factor_enabled`: create a `magic_link_tokens` row with `purpose='pending_2fa'`, TTL 10 min, store SHA-256 hash; return `200 { requires_2fa: true, session_token: "temp_<uuid>" }` (plaintext token; client keeps in memory).
- [ ] Else if `enforce_2fa && !two_factor_enabled`: create `magic_link_tokens` with `purpose='pending_2fa_setup'`, TTL 10 min; return `200 { requires_2fa_setup: true, session_token: "temp_<uuid>" }`.
- [ ] Else: existing flow — issue full JWT + set `zync_session` + `zync_refresh` cookies.
- [ ] Preserve existing login rate limit (10/15min/IP) and `Origin` header check.
**Schema / Interfaces:**
```ts
// packages/db/src/queries/trusted-devices.ts
export function findValidTrustedDevice(tokenHash: string, userId: string, tenantId: string): Promise<TrustedDevice | null>
export function createTrustedDevice(args: { userId: string; tenantId: string; tokenHash: string; userAgent?: string; ipAddress?: string; expiresAt: Date }): Promise<void>
export function listTrustedDevices(userId: string, tenantId: string): Promise<TrustedDevice[]>
export function revokeTrustedDevice(id: string, userId: string, tenantId: string): Promise<void>
export function revokeAllTrustedDevices(userId: string, tenantId: string): Promise<void>
export function revokeAllTrustedDevicesForTenant(tenantId: string): Promise<void>
// login response shapes
type LoginResp =
  | { requires_2fa: true; session_token: string }
  | { requires_2fa_setup: true; session_token: string }
  | { expiresAt: string } // full JWT path (cookies set)
```
**Acceptance:**
- [ ] User with 2FA enabled and no trusted-device cookie receives `{ requires_2fa: true, session_token }` and no `zync_session` cookie.
- [ ] User in enforcing tenant without 2FA receives `{ requires_2fa_setup: true, session_token }`.
- [ ] Valid `zync_device_trust` cookie yields a full JWT with no challenge.
- [ ] Stale/expired device-trust cookie is cleared and challenge proceeds.

### Task 6: 2FA verification + backup-code login endpoints
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/routes/auth/2fa-verify.ts`
- Create: `packages/db/src/queries/backup-codes.ts`
- Modify: `apps/zync-api/src/routes/auth/index.ts` (mount routes)
**Steps:**
- [ ] `POST /api/auth/2fa/verify` `{ session_token, firebase_id_token }`: validate `session_token` against `magic_link_tokens` (`purpose='pending_2fa'`, not expired, not used); call `verifyFirebaseIdToken`; confirm `hashPhoneE164(firebaseUser.phoneNumber) === users.two_factor_phone` (timing-safe); mark temp token used; issue full JWT + refresh (`two_factor_verified: true`); set cookies. Errors: 401 `invalid_token`, 410 `session_expired`.
- [ ] `POST /api/auth/2fa/backup-code` `{ session_token, backup_code }`: validate `session_token`; look up `user_2fa_backup_codes` by `hashBackupCode(backup_code)` where `used_at IS NULL`; on match set `used_at=now()` (atomic, single-use); issue full JWT + refresh. Errors: 401 `invalid_code`, 410 `session_expired`.
- [ ] **Backup-code attempt limit:** track attempts per `session_token` in KV (TTL = session TTL); on the 6th attempt return 429 and invalidate the session token.
- [ ] **Remember-device opt-in:** both endpoints accept optional `{ trust_device: true }`. If true AND `tenants.disable_2fa_remember_device === false`: generate a random device-trust token, store `SHA-256` hash in `user_trusted_devices` with `expires_at = now()+30d`, capture `user_agent`/`ip_address`, and set `Set-Cookie: zync_device_trust=<plaintext>; HttpOnly; Secure; SameSite=Strict; domain=.zync.is; Max-Age=2592000`.
- [ ] Apply `RATE_LIMITER_AUTH`: `/2fa/verify` 5/10min/IP, `/2fa/backup-code` 5/10min/IP.
**Schema / Interfaces:**
```ts
// packages/db/src/queries/backup-codes.ts
export function insertBackupCodes(userId: string, codeHashes: string[]): Promise<void>
export function consumeBackupCode(userId: string, codeHash: string): Promise<boolean> // sets used_at; returns true if an unused match was consumed
export function countUnusedBackupCodes(userId: string): Promise<number>
export function deleteUnusedBackupCodes(userId: string): Promise<void>
// routes
// POST /api/auth/2fa/verify       { session_token, firebase_id_token, trust_device? } -> 200 cookies | 401 invalid_token | 410 session_expired | 429
// POST /api/auth/2fa/backup-code  { session_token, backup_code, trust_device? }       -> 200 cookies | 401 invalid_code | 410 session_expired | 429
```
**Acceptance:**
- [ ] Valid temp token + matching Firebase phone hash issues full JWT and sets `zync_session`/`zync_refresh`.
- [ ] Each backup code works exactly once; reuse returns 401.
- [ ] 6th backup-code attempt on one `session_token` returns 429 and the session token is invalidated.
- [ ] `trust_device:true` sets `zync_device_trust` cookie only when `disable_2fa_remember_device=false`.

### Task 7: Enrollment, disable, and backup-code regeneration endpoints
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/routes/auth/2fa-enroll.ts`
- Modify: `apps/zync-api/src/routes/auth/index.ts`
**Steps:**
- [ ] `POST /api/auth/2fa/enroll/start` (session, no 2FA required, on `BYPASS_2FA_GUARD`): no-op endpoint reserved for server-side rate limiting (Firebase SMS is sent client-side). Returns 204.
- [ ] `POST /api/auth/2fa/enroll/verify` (session, no 2FA required, on bypass) `{ firebase_id_token }`: verify Firebase ID token; set `users.two_factor_enabled=true`, `two_factor_phone=hashPhoneE164(phone)`, `two_factor_phone_suffix=phoneSuffix(phone)`; generate 8 backup codes, store hashes via `insertBackupCodes`; return `200 { backup_codes: string[] }` (one-time plaintext). Rate limit 3/10min/user.
- [ ] `POST /api/auth/2fa/backup-codes/regenerate` (session, 2FA verified): require body `{ current_otp }` re-verified via Firebase (or a valid current backup code); `deleteUnusedBackupCodes`; generate + store 8 new; return plaintext. Rate limit 3/hour/user.
- [ ] `DELETE /api/auth/2fa` (session + current OTP) `{ current_otp }`: verify OTP via Firebase ID token (or backup code); set `users.two_factor_enabled=false`, null out `two_factor_phone`/`two_factor_phone_suffix`; `deleteUnusedBackupCodes`. Returns 204.
- [ ] On enrollment completing while in a `pending_2fa_setup` forced flow, mark that temp token used and issue the full JWT with `two_factor_verified:true`.
**Schema / Interfaces:**
```ts
// POST /api/auth/2fa/enroll/start             -> 204
// POST /api/auth/2fa/enroll/verify  { firebase_id_token } -> 200 { backup_codes: string[] }
// POST /api/auth/2fa/backup-codes/regenerate { current_otp } -> 200 { backup_codes: string[] }
// DELETE /api/auth/2fa { current_otp }         -> 204 | 401 invalid_token
```
**Acceptance:**
- [ ] `enroll/verify` flips `two_factor_enabled` true, stores phone hash + suffix, and returns exactly 8 plaintext codes shown once.
- [ ] `regenerate` invalidates all prior unused codes and issues 8 new, gated on current OTP.
- [ ] `DELETE /api/auth/2fa` requires a valid current OTP/backup code and deletes unused codes.
- [ ] Enroll endpoints are reachable by an unenrolled user in an enforcing tenant (bypass guard).

### Task 8: Tenant enforcement toggle + trusted-devices management endpoints
**Blocks:** —  ·  **Blocked by:** 4, 5
**Files:**
- Create: `apps/zync-api/src/routes/auth/security-settings.ts`
- Create: `apps/zync-api/src/routes/auth/trusted-devices.ts`
- Modify: `apps/zync-api/src/routes/auth/index.ts`
**Steps:**
- [ ] `PATCH /api/settings/security` (perm `settings:write`, OWNER/ADMIN) `{ enforce_2fa, disable_2fa_remember_device }`: update `tenants`; on enabling `enforce_2fa`, increment `user_version:{userId}` for every tenant member (reuse spec-5 KV mechanism) so refreshed JWTs carry the flag within ≤60s; on setting `disable_2fa_remember_device=true`, call `revokeAllTrustedDevicesForTenant(tenant_id)`.
- [ ] `GET /api/settings/security/members` (perm `users:read`): return count of members with `two_factor_enabled` vs total (for "X of Y members have 2FA enabled").
- [ ] `GET /api/auth/trusted-devices` (session): list this user's non-expired, non-revoked devices for current tenant (`user_agent`, `created_at`, `expires_at`, masked id).
- [ ] `DELETE /api/auth/trusted-devices/:id` (session): set `revoked_at=now()` for that device (scoped to current user+tenant).
- [ ] `DELETE /api/auth/trusted-devices` (session): revoke all for current user+tenant.
**Schema / Interfaces:**
```ts
// PATCH  /api/settings/security  { enforce_2fa: boolean, disable_2fa_remember_device: boolean } -> 200
// GET    /api/settings/security/members -> { enabled: number, total: number }
// GET    /api/auth/trusted-devices       -> { devices: { id, userAgent, createdAt, expiresAt }[] }
// DELETE /api/auth/trusted-devices/:id    -> 204
// DELETE /api/auth/trusted-devices        -> 204
```
**Acceptance:**
- [ ] Non-OWNER/ADMIN (lacking `settings:write`) receives 403 on the enforcement toggle.
- [ ] Enabling `enforce_2fa` bumps `user_version` for all members.
- [ ] Setting `disable_2fa_remember_device=true` revokes all tenant trusted devices.
- [ ] A user can revoke a single device and revoke-all; revoked devices no longer bypass 2FA.

### Task 9: Secrets + Firebase config wiring
**Blocks:** 6, 10  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/wrangler.toml`
- Create: `apps/zync-app/src/lib/firebase-config.ts`
- Modify: `apps/zync-api/src/env.d.ts` (Env type)
**Steps:**
- [ ] Declare `FIREBASE_API_KEY` and `FIREBASE_PROJECT_ID` secrets, env-suffixed `_DEV`/`_PROD` in `wrangler.toml`; add both to the Worker `Env` type.
- [ ] Add the DEV Firebase web config (safe to commit, dev-only project) to `firebase-config.ts`: `apiKey AIzaSyBJ1aZPj2Tf7kbh3ePCrk17BDAl9XiBCGE`, `authDomain zync-dev-dd678.firebaseapp.com`, `projectId zync-dev-dd678`, `storageBucket zync-dev-dd678.firebasestorage.app`, `messagingSenderId 452080181712`, `appId 1:452080181712:web:12e2f93c5974c2209199d5`. PROD values come from env at build time.
- [ ] Ensure no new CF bindings are added — `RATE_LIMITER_AUTH` is reused.
**Acceptance:**
- [ ] Worker `Env` exposes `FIREBASE_API_KEY` and `FIREBASE_PROJECT_ID`.
- [ ] DEV Firebase config loads in `zync-app` without leaking PROD credentials.

### Task 10: Firebase client SDK integration (code-split, auth pages only)
**Blocks:** 11, 12, 13  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/lib/firebase-phone-auth.ts`
- Modify: `apps/zync-app/vite.config.ts` (manual chunk for `firebase/auth`)
**Steps:**
- [ ] Lazy-import `firebase/app` + `firebase/auth` only inside auth flows (dynamic `import()`), keeping the ~100KB SDK out of app-page bundles.
- [ ] Expose `sendPhoneOtp(phoneE164, recaptchaContainerId)` → initializes invisible `RecaptchaVerifier` (`size: 'invisible'`), calls `PhoneAuthProvider.verifyPhoneNumber()`, returns `verificationId`.
- [ ] Expose `confirmPhoneOtp(verificationId, code)` → builds `PhoneAuthCredential`, signs in, returns Firebase `idToken`.
- [ ] Validate IL phone numbers with `libphonenumber-js` before calling Firebase.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/lib/firebase-phone-auth.ts
export async function sendPhoneOtp(phoneE164: string, recaptchaContainerId: string): Promise<string> // verificationId
export async function confirmPhoneOtp(verificationId: string, code: string): Promise<string>          // firebase idToken
export function isValidIlPhone(input: string): boolean
```
**Acceptance:**
- [ ] `firebase/auth` is in its own chunk and not present in the main app-page bundle.
- [ ] DEV test numbers (e.g. `+972 444444444` / OTP `444444`) complete sign-in without real SMS.

### Task 11: `/profile` Security tab + enrollment modal
**Blocks:** —  ·  **Blocked by:** 10
**Files:**
- Create: `apps/zync-app/src/features/security/SecurityTab.tsx`
- Create: `apps/zync-app/src/features/security/Enroll2FAModal.tsx`
- Create: `apps/zync-app/src/features/security/BackupCodesDisplay.tsx`
- Create: `apps/zync-app/src/features/security/TrustedDevicesList.tsx`
**Steps:**
- [ ] Security tab shows current 2FA status, masked phone (`+972 ••••••44` from `two_factor_phone_suffix`), Enable/Disable buttons, unused backup-code count, and the trusted-devices list.
- [ ] Enrollment modal: step 1 phone entry + country selector (default 🇮🇱 +972, validated by `libphonenumber-js`); step 2 6-digit OTP (auto-submit on 6th digit) via `sendPhoneOtp`/`confirmPhoneOtp`; on Firebase `idToken`, POST `/api/auth/2fa/enroll/verify`; step 3 backup-codes grid of 8 with **Download as .txt** + **Copy all** and a "I've saved my backup codes" checkbox gating the Close button.
- [ ] Disable button → confirm modal requiring a current OTP; calls `DELETE /api/auth/2fa`.
- [ ] Regenerate button → requires current OTP; calls `/api/auth/2fa/backup-codes/regenerate`; shows new codes.
- [ ] Trusted-devices list: each row shows browser/OS, added date, expiry, **Revoke**; plus **Revoke all**.
- [ ] A11y: modal has `role="dialog"`, `aria-modal="true"`, focus trap, labelled OTP inputs; respect `prefers-reduced-motion` for any transitions. RTL/Hebrew: layout mirrors under `dir="rtl"`; all copy via i18n keys.
**Acceptance:**
- [ ] Full enrollment happy path completes and reveals 8 codes once; Close is disabled until the save checkbox is ticked.
- [ ] Masked phone renders from the 2-digit suffix without exposing the full number.
- [ ] Modal is keyboard-navigable and focus-trapped; works under RTL.

### Task 12: Login 2FA challenge + backup-code screens
**Blocks:** —  ·  **Blocked by:** 10
**Files:**
- Create: `apps/zync-app/src/features/auth/TwoFactorChallenge.tsx`
- Create: `apps/zync-app/src/features/auth/BackupCodeInput.tsx`
- Modify: `apps/zync-app/src/features/auth/LoginForm.tsx`
**Steps:**
- [ ] On login response `{ requires_2fa: true, session_token }`, store `session_token` in memory (never localStorage) and render the challenge screen.
- [ ] Challenge screen shows masked phone, 6-digit OTP input (auto-submit), a "Use a backup code instead" link, and a resend link (client-side rate-limited once/60s). Run `sendPhoneOtp`/`confirmPhoneOtp`, then POST `/api/auth/2fa/verify` with `{ session_token, firebase_id_token, trust_device }`.
- [ ] After a successful OTP entry, show the "Trust this device for 30 days" checkbox — hidden when the tenant has `disable_2fa_remember_device` (surfaced via login response or `/me`).
- [ ] Backup-code screen: single `____-____` input, "Use SMS instead" link; POST `/api/auth/2fa/backup-code`.
- [ ] On 410 `session_expired`, return to the login form; on 429, show a lockout message.
- [ ] A11y/i18n: inputs labelled, errors announced via `aria-live`; RTL mirrored; reduced-motion respected.
**Acceptance:**
- [ ] Successful OTP challenge lands the user in the app with cookies set.
- [ ] Backup-code path works and toggles back to SMS.
- [ ] Trust-device checkbox is hidden when the tenant disables remember-device.

### Task 13: Forced-setup page (`/auth/2fa/setup-required`)
**Blocks:** —  ·  **Blocked by:** 10
**Files:**
- Create: `apps/zync-app/src/features/auth/ForcedSetupPage.tsx`
- Modify: `apps/zync-app/src/router.tsx` (route)
**Steps:**
- [ ] On login response `{ requires_2fa_setup: true, session_token }`, redirect to `/auth/2fa/setup-required`.
- [ ] Full-page inline enrollment (same steps as the modal) using the `pending_2fa_setup` `session_token`, which authorizes only `/api/auth/2fa/enroll/*`.
- [ ] Banner: "Your workspace requires two-factor authentication." Block app access until enrollment completes; on success the worker issues the full JWT and the client proceeds into the app.
- [ ] Any guarded API call returning 403 `2fa_required` with `setup_url` redirects here.
- [ ] A11y/i18n/RTL as in Task 11.
**Acceptance:**
- [ ] A user in an enforcing tenant without 2FA cannot reach the app and is routed to setup-required.
- [ ] Completing setup issues the full JWT and unblocks the app.

### Task 14: `/settings/security` enforcement toggle UI (OWNER/ADMIN)
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/settings/SecuritySettingsPage.tsx`
- Modify: `apps/zync-app/src/router.tsx`
**Steps:**
- [ ] Render "Require 2FA for all workspace members" toggle bound to `tenants.enforce_2fa` via `PATCH /api/settings/security`; gate visibility with `useTierGate`/permission check for `settings:write`.
- [ ] When enforcement is on, show the secondary "Disable remember-device" toggle (`disable_2fa_remember_device`).
- [ ] Show "X of Y members have 2FA enabled" from `GET /api/settings/security/members`.
- [ ] Info text: "Members without 2FA will be prompted to enroll on next login."
- [ ] A11y: toggles are real `role="switch"` with labels; i18n/RTL as elsewhere.
**Acceptance:**
- [ ] Toggling enforcement persists and reflects member-coverage count.
- [ ] Page is hidden/disabled for users lacking `settings:write`.

### Task 15: Tests (as code, per spec coverage)
**Blocks:** —  ·  **Blocked by:** 5, 6, 7, 8
**Files:**
- Create: `packages/auth/test/firebase-verify.test.ts`
- Create: `packages/auth/test/backup-codes.test.ts`
- Create: `apps/zync-api/test/2fa-login.test.ts`
- Create: `apps/zync-api/test/2fa-enroll.test.ts`
- Create: `apps/zync-api/test/trusted-devices.test.ts`
**Steps:**
- [ ] Firebase verify: valid token passes; tampered signature / wrong `aud` / wrong `iss` / expired `exp` throw.
- [ ] Backup codes: format/alphabet, single-use consumption, timing-safe verify.
- [ ] Login: 2FA-enabled → `requires_2fa`; enforcing tenant without 2FA → `requires_2fa_setup`; valid device-trust cookie → direct JWT; stale cookie cleared.
- [ ] Verify/backup endpoints: success issues cookies; backup-code reuse 401; 6th attempt → 429 + session invalidated; `trust_device` sets cookie only when remember-device allowed.
- [ ] Enroll/disable/regenerate: enable flips flag + returns 8 codes; disable requires OTP and clears codes; regenerate invalidates old.
- [ ] Trusted devices: list/revoke/revoke-all; `disable_2fa_remember_device` revokes tenant devices.
- [ ] Enforcement middleware: 403 `2fa_required` when enforced+unverified; bypass endpoints reachable.
**Acceptance:**
- [ ] All tests pass under the Workers test runner against a Neon test branch.
- [ ] Backup-code single-use and 429 lockout are covered.
