# Foundation: Auth, RBAC & Tier Entitlements

Audience: AI coding agents first.

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-monorepo`  
**Referenced by:** All specs (every protected route, every tier-gated feature)

---

## Overview

Defines authentication (signup, login, session), multi-tenant authorization (roles, permissions, RBAC), and tier-based feature entitlements. All three concerns are tightly coupled: a session carries both role and tier, and every API route check combines them.

---

## Core Concepts

| Concept | Meaning |
|---------|---------|
| **Tenant** | An organization account. Each tenant has one subscription tier. |
| **User** | A person with an account. A user belongs to one or more tenants with a role per tenant. |
| **Role** | A named set of permissions within a tenant. Defined per tenant (tenant-customizable). |
| **Permission** | Granular capability string, e.g. `tasks:write`, `invoices:read`. |
| **Tier** | Subscription level that gates feature access system-wide for a tenant. |
| **System Admin** | A Zync staff member with access to the admin control plane. Separate from tenant users. |

---

## Auth Flow

### Session Model

JWT-based. Issued by `zync-api`, stored as `httpOnly` cookie (`domain=.zync.is`).

```ts
// JWT payload
interface SessionPayload {
  sub: UserId               // user ID
  tid: TenantId | null      // active tenant (null = system admin or no tenant yet)
  role: string              // role name within tenant
  permissions: string[]     // expanded permission set at token time
  tier: TenantTier          // tenant's subscription tier
  type: 'user' | 'admin'   // distinguishes system admin session
  exp: number
  iat: number
}
```

Permissions are expanded at token creation (flat array, no DB lookup on each request). Token TTL: 1 hour. Refresh via silent background call to `/api/auth/refresh` (uses refresh token stored in `httpOnly` cookie — separate cookie).

### Token Pair

| Token | TTL | Storage | Purpose |
|-------|-----|---------|---------|
| Access JWT | 1h | `httpOnly` cookie `zync_session` | API auth |
| Refresh token | 30d | `httpOnly` cookie `zync_refresh` | Silent renew |

Refresh tokens stored in DB (`refresh_tokens` table) with `user_id`, `tenant_id`, `expires_at`, `revoked_at`. Login from a new device creates a new refresh token. Logout revokes the current refresh token.

### Access token revocation (freeze + role change)

Access JWTs are stateless — they can't be revoked mid-flight. To satisfy the "freeze = immediate revocation" and "permissions reissued on role change" requirements, auth middleware performs one KV read per request:

```
KV key: `user_version:{userId}`
Value: integer (incremented on freeze or role/permission change)
```

JWT payload carries `v: number` (the version at issue time). Auth middleware:

```
1. Build cache key: new Request(`https://zync-internal/user-version/${userId}`)
2. Check cache.default.match(cacheKey)
   → HIT:  compare token.v < cached_version → 401 or continue  [zero KV reads]
   → MISS: KV.get('user_version:{userId}')
           → store in cache.default with TTL 60s
           → compare token.v < kv_version → 401 or continue
3. On freeze/role-change:
   a. KV.put('user_version:{userId}', version++)
   b. Enqueue cache-purge task (best-effort, ~60s propagation on miss anyway)
```

`cache.default` is per-PoP. Cache miss = 1 KV read then cached for 60s per PoP per user. At 10 RPS across 100 PoPs: ~100 KV reads/minute vs 600 uncached. Revocation propagates within 60s across all PoPs (acceptable; frozen users can't refresh tokens which are invalidated immediately in DB).

**KV budget math (Workers Paid: 10M reads/day):**
- 1,000 active users × 60s miss cycle × 24h = 1,440 reads/user/day → 1.44M reads/day at 1k users. Well within budget.
- Freeze propagation window: ≤60s. Acceptable for all but highest-security use cases (document in audit-compliance spec).

---

## Signup & Onboarding Flow

### Self-registration (Freelancer / new tenant)

1. User submits email + password on `zync.is/signup`
2. API creates `User` record (status: `PENDING_EMAIL`)
3. Verification email sent via communications adapter
4. User clicks link → status → `ACTIVE`; first `Tenant` created; user assigned `OWNER` role
5. Access JWT + refresh token issued; redirect to `app.zync.is/onboarding`

### Invitation flow (tenant adds member)

1. Tenant admin sends invitation from `app.zync.is/users`
2. API checks current active member count vs `getMaxTeamMembers(tenant.tier)` — returns 402 if at cap
3. API creates `Invitation` record (token, email, role, tenant_id, expires 7d)
3. Invitation email sent
4. Invitee clicks link → lands on `zync.is/invite?token=...`
5. If email already has account: accept → creates `TenantMembership` record
6. If new email: mini-signup (name + password) → creates User + TenantMembership
7. JWT issued; redirect to `app.zync.is`

### Admin-approval tenants (optional per tenant setting)

Some tenants require admin approval of new members. If `tenant.require_approval = true`:
- New invitation accept → status: `PENDING_APPROVAL` (not active)
- Tenant admin sees pending queue at `/users`
- Approve → status: `ACTIVE`; user notified
- Freeze a user: status → `FROZEN`; all sessions revoked; freeze reason stored

---

## Password Auth

- Passwords hashed with **PBKDF2** via Web Crypto API (`crypto.subtle.deriveKey`) — natively available in Cloudflare Workers and offloaded to `PasswordHashDO`; the remaining successful-login flow runs in `AuthWriteDO` so membership, 2FA, JWT, refresh-token, theme, and audit work do not exceed the stateless Worker's 10 ms CPU budget
  - Parameters: 600,000 iterations, SHA-256, 32-byte output (NIST-recommended 2023 baseline)
  - Note: Argon2id would be preferable but requires WASM with memory parameters that risk exceeding Workers CPU limits; revisit if Workers CPU quotas increase
- Password reset: email link with 1h signed token (`/api/auth/reset-password?token=...`)
- Minimum: 8 chars. No hardcoded max (no truncation with PBKDF2 applied to UTF-8 encoded bytes)

---

## Multi-tenant Membership

A user can be a member of multiple tenants. Session JWT carries the *active tenant* (`tid`). Switching tenants: client calls `/api/auth/switch-tenant` → new JWT issued with new `tid`.

```
User ──< TenantMembership >── Tenant
               │
               ▼
             Role ──< RolePermission >── Permission
```

### DB Schema (abbreviated)

Column-name sketch (relationships + key constraints). Plan #1 transcribes to full Postgres DDL: every `id` is `UUID PRIMARY KEY DEFAULT gen_random_uuid()`, every `*_id` FK is `UUID REFERENCES <table>(id)`, every `*_at`/`created_at` is `TIMESTAMPTZ`. Load-bearing constraints are annotated inline.

```sql
users (id, email, password_hash, email_verified_at, status, created_at)
  -- users are GLOBAL (one user, many tenant memberships): UNIQUE(email)
  -- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended'))
  --   per-tenant freeze lives on tenant_memberships.status, NOT here
tenants (id, slug, name, tier, require_approval, created_at)
  -- UNIQUE(slug) — slug is the tenant subdomain/routing key
  -- tier TEXT NOT NULL CHECK (tier IN ('freelancer', 'business', 'enterprise', 'white_label'))
  -- require_approval BOOLEAN NOT NULL DEFAULT false
tenant_memberships (user_id, tenant_id, role_id, status, freeze_reason, created_at)
  -- PRIMARY KEY (user_id, tenant_id); role_id REFERENCES roles(id)
  -- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'frozen'))
roles (id, tenant_id, name, is_system_role, created_at)
  -- is_system_role: true = cannot delete (OWNER, ADMIN, MEMBER, VIEWER, CONTRACTOR)
  -- UNIQUE(tenant_id, name)
permissions (id, key, description)
  -- seed data: all permission keys; UNIQUE(key)
role_permissions (role_id, permission_id)
  -- PRIMARY KEY (role_id, permission_id)
invitations (id, tenant_id, email, role_id, token_hash, expires_at, accepted_at)
  -- accepted_at NULL = pending; UNIQUE(token_hash)
refresh_tokens (id, user_id, tenant_id, token_hash, expires_at, revoked_at)
  -- revoked_at NULL = active; UNIQUE(token_hash)
```

---

## RBAC

### Built-in system roles (seeded per tenant on creation, `is_system_role = true`)

| Role | Default permissions |
|------|-------------------|
| `OWNER` | All permissions |
| `ADMIN` | All except billing management, user deletion |
| `MEMBER` | Tasks read/write, projects read, time tracking, inventory read/write, KB read |
| `VIEWER` | Read-only across all modules |
| `CONTRACTOR` | Time tracking, tasks assigned to them only |

### Permission keys (seed data)

Grouped by module:

```
# Tasks
tasks:read  tasks:write  tasks:delete  tasks:assign

# Projects
projects:read  projects:write  projects:delete

# Customers
customers:read  customers:write  customers:delete

# Invoices
invoices:read  invoices:write  invoices:delete  invoices:send

# Expenses
expenses:read  expenses:write

# Inventory
inventory:read  inventory:write  inventory:manage

# Billing
billing:read  billing:manage

# Time tracking
time:read  time:track  time:manage

# Support / CRM
tickets:read  tickets:write  tickets:assign  tickets:resolve

# Knowledge Base
kb:read  kb:write  kb:delete  kb:share

# Marketing
marketing:read  marketing:write

# Reports & Analytics
reports:read  reports:export

# Settings
settings:read  settings:write

# Users
users:read  users:invite  users:manage  users:freeze

# Payouts
payouts:read  payouts:manage

# Calendar
calendar:read  calendar:write

# Webhooks / API
webhooks:read  webhooks:manage
```

### Custom roles

Tenant admins can create custom roles at `/settings/roles` (or `/admin/tenant/:slug/roles` for system admin). Custom roles are built from the permission matrix. Custom roles can be deleted; system roles cannot.

### API permission check

Every protected route goes through `requirePermission(permission: string)` middleware:

```ts
// packages/auth/src/middleware.ts
export function requirePermission(permission: string) {
  return (c: Context) => {
    const session = c.get('session')  // populated by auth middleware
    if (!session.permissions.includes(permission)) {
      return c.json({ error: 'Forbidden' }, 403)
    }
  }
}
```

Admin routes use `requireAdminSession()` instead — checks `session.type === 'admin'`.

---

## Tier Entitlements

Subscription tiers gate feature availability at the tenant level. Entitlement checks happen in two places:
1. **API routes** — `requireTier(tier: TenantTier)` middleware blocks the request
2. **Frontend** — `useTierGate(tier)` hook drives UI visibility (hides/disables, not just 403)

### Tier hierarchy

```ts
enum TenantTier {
  FREELANCER = 'freelancer',    // Free
  BUSINESS = 'business',        // 89 ILS
  ENTERPRISE = 'enterprise',    // 159 ILS
  WHITE_LABEL = 'white_label',  // 250 ILS
}
```

`FREELANCER < BUSINESS < ENTERPRISE < WHITE_LABEL`. `requireTier('business')` passes for business, enterprise, and white_label.

### Entitlement matrix

| Feature | Freelancer | Business | Enterprise | White Label |
|---------|-----------|---------|-----------|------------|
| Team members | 1 | 8 | 15 | 50 |
| OCR (monthly) | 50 web uploads | Unlimited AI OCR | Unlimited AI OCR | Unlimited AI OCR |
| Schedules | 1 | Unlimited | Unlimited | Unlimited |
| Calendars | 1 | Unlimited | Unlimited | Unlimited |
| AI Invoice categorization | ✗ | ✓ | ✓ | ✓ |
| AI Assistant (chat) | ✗ | ✓ | ✓ | ✓ |
| Automated reports | ✗ | ✓ | ✓ | ✓ |
| Telegram AI Assistant | ✗ | ✓ | ✓ | ✓ |
| WhatsApp AI Assistant | ✗ | ✗ | ✓ | ✓ |
| Priority support | ✗ | ✗ | ✓ | ✓ |
| Custom domain | ✗ | ✗ | ✗ | ✓ |
| Headless API access | ✗ | ✗ | ✗ | ✓ |
| Full webhook set | ✗ | ✗ | ✗ | ✓ |
| Invoice automation | ✗ | ✓ | ✓ | ✓ |

### Entitlement helpers (`packages/auth/src/entitlements.ts`)

```ts
export function meetsMinimumTier(tenantTier: TenantTier, required: TenantTier): boolean {
  const order = [TenantTier.FREELANCER, TenantTier.BUSINESS, TenantTier.ENTERPRISE, TenantTier.WHITE_LABEL]
  return order.indexOf(tenantTier) >= order.indexOf(required)
}

export function getMaxTeamMembers(tier: TenantTier): number {
  return { freelancer: 1, business: 8, enterprise: 15, white_label: 50 }[tier]
}
```

### API tier middleware

```ts
export function requireTier(minimum: TenantTier) {
  return (c: Context) => {
    const session = c.get('session')
    if (!meetsMinimumTier(session.tier, minimum)) {
      return c.json({ error: 'Upgrade required', requiredTier: minimum }, 402)
    }
  }
}
```

### Frontend tier gate

```ts
// apps/zync-app/src/hooks/use-tier-gate.ts
export function useTierGate(minimum: TenantTier): { allowed: boolean; upgrade: () => void }
```

Used in UI to conditionally render upgrade prompts vs feature content.

---

## Metered Entitlements

Some entitlements are not boolean (pass/fail) but have per-period quotas: OCR uploads/month (50 for Freelancer), team-seat caps, mailing credits (marketing). These cannot be enforced with `requireTier` alone.

### Usage counters

```sql
usage_counters (
  tenant_id UUID,
  counter_key TEXT,   -- e.g. 'ocr_uploads', 'team_seats'
  period TEXT,        -- 'YYYY-MM' for monthly, 'all_time' for seats
  count INTEGER DEFAULT 0,
  PRIMARY KEY (tenant_id, counter_key, period)
)
```

`packages/db/src/queries/usage.ts` exports:
- `incrementCounter(tenantId, key, period)` — atomic increment, returns new count
- `checkCounterLimit(tenantId, key, period, limit)` — throws `QuotaExceededError` if count >= limit

API routes that consume metered features call `checkCounterLimit` before processing, then `incrementCounter` on success. The `audit-compliance` spec's services log derives from this table.

Limits are resolved from `getQuotaLimit(tier, key)` which reads from a static config map (not DB) — no schema migration needed when tier limits change.

## Social / SSO (future)

Not in v1. Stub: `oauth_accounts (user_id, provider, provider_user_id, created_at)` table exists but no flows implemented. Prevents migration-breaking schema change later.

---

## Security Notes

- All auth endpoints are rate-limited (see `system-communications-notifications` spec for rate limit middleware)
- Failed login attempts: exponential backoff after 5 failures, lockout after 10
- Refresh token rotation on use (old token immediately invalidated)
- Concurrent session limit: 10 active refresh tokens per user per tenant
- Invitation token: SHA-256 hash stored in DB, plaintext sent in email only
- Password reset token: same pattern — hash stored, plaintext in email
- `AuthWriteDO` transport failures fail closed; login is not retried inline after a configured DO call because session issuance may already have committed a refresh token.
- On configured production deployments, bearer-token verification, blocklist/version checks, and the session-row/idle-timeout gate run in `AuthWriteDO`; the edge Worker keeps only request parsing and fail-closed transport handling. An inline path remains available for local environments without the binding.
- Auth notification producers enqueue typed `auth.email` jobs fail-closed. The registered consumer sends through the platform Resend adapter with a stable message idempotency key, acknowledges only after provider success, and retries provider failures without acknowledging the message. The dedicated type avoids claiming the existing customer-communications `email` messages.

Rationale (2026-07-12): production login traces showed the otherwise-correct post-hash flow exceeding Cloudflare's 10 ms stateless CPU limit, so the existing secret-gated auth durable object now owns the complete credential/session flow.
Rationale (2026-07-12): production protected-route traces also exceeded the 10 ms stateless CPU limit during JWT/session-row verification, so the same secret-gated durable object now owns that gate.
Rationale (2026-07-13): auth email jobs were previously treated as an unregistered queue type and producer enqueue failures were swallowed; the typed handler and fail-closed producer preserve delivery semantics across verification, reset, invitation, and email-change notifications. The queue discriminator is namespaced because customer communications already use `type: 'email'`.

### CSRF Protection

`SameSite=Lax` on cookies prevents cross-site form POST (navigation context), but does not cover cross-origin `fetch` with credentials from a same-site iframe or compromised subdomain. Upgrade the cookie to `SameSite=Strict` for the access JWT:

```
Set-Cookie: zync_session=...; HttpOnly; Secure; SameSite=Strict; domain=.zync.is
```

`SameSite=Strict` means the cookie is never sent on any cross-site request — not even top-level navigation from another site. This is acceptable for `app.zync.is` (users always navigate directly, never via external site click that needs immediate auth).

For the portal (`/portal/{tenantSlug}/...`) which is accessed via email links (cross-site navigation): portal sessions are separate short-lived JWTs in URL params, not `httpOnly` cookies — CSRF is not applicable there.

Additional protection: every state-mutating API endpoint validates the `Origin` header against a fixed allowlist (reject if absent or mismatched), implemented in auth middleware before permission checks. The single zync-api worker serves both production and the dev/staging deploy, so the allowlist covers both environments — production (`https://app.zync.is`, `https://admin.zync.is`, `https://zync.is`), the dev deploy (`https://dev.zync.is`, `https://app.dev.zync.is`), and the `workers.dev` preview origins. The list is defined once in `apps/zync-api/src/lib/origins.ts` (`APP_ORIGINS`) and imported by cors.ts and every auth route — never re-declared per route, so the environments can't silently diverge. Post-auth redirect targets (e.g. verify-email → `/onboarding`) are derived from the request host via `appOriginForRequest` so a dev verification never bounces the user to the prod app.

### Open Redirect Prevention

`/api/auth/login`, `/api/auth/signup`, and `/api/auth/refresh` accept a `?redirect=` query param for post-auth navigation. This must not redirect to external domains:

```ts
function safeRedirect(redirectParam: string | null, defaultPath = '/dashboard'): string {
  if (!redirectParam) return defaultPath
  // Allow only relative paths or same-origin absolute URLs
  const ALLOWED_ORIGINS = new Set(['https://app.zync.is', 'https://admin.zync.is'])
  try {
    const url = new URL(redirectParam, 'https://app.zync.is')
    if (!ALLOWED_ORIGINS.has(url.origin)) return defaultPath
    return url.pathname + url.search
  } catch {
    return defaultPath
  }
}
```

Any `redirect` value pointing to an external domain falls back to `/dashboard`. Log the attempt for security monitoring.

---

## System Admin Auth

System admins use a separate login at `admin.zync.is/login`. They authenticate with email + password + TOTP (required, no bypass). Session type = `'admin'`, no `tid`. Admin sessions do not carry tenant permissions.

TOTP: `otplib` package. Seed stored encrypted in `admin_users.totp_secret` (encrypted with `ADMIN_ENCRYPTION_KEY` env secret).

---

## API Endpoints

Core auth endpoints. Module-specific endpoints live in their own specs.

```
POST /api/auth/signup
     body: { email, password, name }
     Response: { userId } — verification email sent; user status = PENDING_EMAIL

GET  /api/auth/verify-email
     query: { token }
     Action: status → ACTIVE; first tenant created; OWNER role assigned
     Response: redirect to app.zync.is/onboarding

POST /api/auth/login
     body: { email, password }
     Response (no 2FA):          { expiresAt } + set zync_session + zync_refresh httpOnly cookies
     Response (2FA enabled):     200 { requires_2fa: true, session_token: "temp_<uuid>" }
     Response (2FA setup req.):  200 { requires_2fa_setup: true, session_token: "temp_<uuid>" }
     Rate limit: 10 attempts/15min/IP
     Runtime: Origin, rate-limit, and Zod validation stay in the edge Worker; credential/session work is delegated to secret-gated AuthWriteDO and its Response (including Set-Cookie) is forwarded unchanged

POST /api/auth/refresh
     Auth: zync_refresh cookie
     Action: rotate refresh token (old token revoked, new issued)
     Response: new zync_session + zync_refresh cookies

POST /api/auth/logout
     Auth: Bearer token (Authorization header) OR zync_session cookie
     Action:
       1. Find refresh_tokens row by token_hash; set revoked_at = NOW()
       2. Add access token to KV blocklist: key = blocklist:{tokenHash}, value = 1, TTL = token remaining lifetime
       3. Clear zync_session and zync_refresh cookies (Max-Age=0)
     Response: 204 No Content
     Note: Idempotent — already-revoked token returns 204 (not 401)

GET /api/auth/me
    Auth: Bearer token or zync_session cookie
    Response: {
      id, email, name, avatarUrl,
      tenantId, tenantSlug, role, tier,
      permissions: string[],
      emailVerified: boolean,
      twoFactorEnabled: boolean
    }
    Source: JOIN users + tenant_memberships + tenants on JWT sub/tenantId
    Cache: 30s CDN cache with Vary: Authorization
    Note: 401 if token expired or blocklisted

POST /api/auth/switch-tenant
     Auth: zync_session cookie
     body: { tenantId }
     Response: new JWT for target tenant (requires existing membership in target tenant)

POST /api/auth/forgot-password
     body: { email }
     Response: 204 always (no email enumeration)
     Action: if email found, sends password reset link (1h signed token)
     Rate limit: 3/hour/email

POST /api/auth/reset-password
     body: { token, password }
     Response: 204 on success; 400 if token expired or already used
```

---

## user_preferences Table

User-level preferences persisted across sessions. Separate from tenant settings (which are org-wide).

```sql
CREATE TABLE user_preferences (
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  -- Notification channels: which event types trigger email/telegram delivery
  notification_channels JSONB NOT NULL DEFAULT '{"email":[],"telegram":[]}',
  -- UI preferences
  sidebar_collapsed   BOOLEAN NOT NULL DEFAULT false,
  default_currency    TEXT DEFAULT NULL,      -- override tenant default for this user; NULL = use tenant default
  timezone            TEXT NOT NULL DEFAULT 'Asia/Jerusalem',  -- IANA; concrete display zone (no tenant inheritance — see timezone-handling)
  locale              TEXT DEFAULT NULL,      -- 'he' | 'en'; NULL = use tenant default
  -- Time tracking
  default_timer_project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
  -- Dashboard
  dashboard_widgets   JSONB NOT NULL DEFAULT '[]',  -- ordered list of visible widget IDs
  updated_at          TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (user_id, tenant_id)
);
```

`notification_channels` is authoritative for per-user delivery preferences (spec 11 references this column). Read by `NotificationAdapter.canDeliver()`.

---

## Admin TOTP First-Time Enrollment

System admins (`admin_users`) must complete TOTP enrollment before accessing the admin plane. This is enforced at first login.

### First-login detection

`admin_users` table carries `totp_secret TEXT` (encrypted). On `POST /api/admin/auth/login`:
- If `totp_secret IS NULL`: first login → respond `{ requires_totp_setup: true, setup_token: "..." }`
- If `totp_secret IS NOT NULL`: require TOTP code in login body

### Enrollment endpoint

```
POST /api/admin/auth/totp/setup/start
     Auth: setup_token (short-lived, from first-login response)
     Response: { qr_code_url: string, secret: string }
     Action: generates TOTP secret, stores encrypted in KV (not DB yet), returns QR code URL for authenticator app
     QR URL format: otpauth://totp/Zync%20Admin:{adminEmail}?secret={secret}&issuer=Zync

POST /api/admin/auth/totp/setup/verify
     Auth: setup_token
     body: { code: string }  -- 6-digit TOTP code
     Action:
       1. Verify code against KV-stored secret using otplib
       2. On success: encrypt secret with ADMIN_ENCRYPTION_KEY, store in admin_users.totp_secret
       3. Delete KV entry; invalidate setup_token
       4. Issue full admin session JWT
     Response: 200 { message: "TOTP enrolled" } + set admin session cookie

POST /api/admin/auth/totp/verify
     Auth: temp_admin_token (from password-only first factor)
     body: { code: string }
     Action: verify TOTP; on success issue full admin JWT
     Rate limit: 5 attempts/10min/IP; lockout on 10 failures
```

**Middleware enforcement:** all `admin.zync.is` routes pass through `requireAdminSession()` which checks `session.type === 'admin'` AND `session.totp_verified === true`. Any admin without a session satisfying both conditions is redirected to `/admin/login`.

**Schema delta:**

```sql
CREATE TABLE admin_users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email       TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  totp_secret TEXT,               -- AES-256-GCM encrypted; NULL = not yet enrolled
  status      TEXT NOT NULL DEFAULT 'active',  -- 'active' | 'suspended'
  created_at  TIMESTAMPTZ DEFAULT NOW()
);
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Session storage | JWT in httpOnly cookie | Edge-compatible (no session store needed); cookie survives tab close |
| Password hashing | Argon2id WASM | Strongest modern KDF; WASM build runs in CF Workers |
| Token pair | Access + refresh | Short-lived access limits exposure; long-lived refresh enables seamless UX |
| Permissions in JWT | Yes (expanded flat array) | No DB lookup per request at edge; reissued on role change |
| RBAC model | Flat permission keys + roles | Simple enough for multi-tenant SaaS; avoids ABAC complexity for v1 |
| Tier enforcement | Both API (402) and client (hook) | Defense-in-depth; client gate enables graceful upgrade prompts |
| Admin MFA | TOTP required | Admin plane has full cross-tenant access — mandatory 2FA |
| Social login | Not v1 | Complexity vs value for B2B SaaS; schema stubs present for future |
