# Session Security — Implementation Plan

**Spec:** docs/specs/2026-05-31-session-security.md  ·  **Slug:** session-security  ·  **Wave:** 13
**Depends on:** auth-2fa, foundation-auth-rbac, operational-audit-trail

## Goal
Add full session-lifecycle management on top of the JWT auth issued by `foundation-auth-rbac`: a persisted `user_sessions` table, an active-sessions panel with remote revocation, per-tenant security policy (idle timeout, session cap, 2FA-for-roles, suspicious-login blocking), idle-timeout re-auth flow, suspicious-login detection with email alerts, admin team-session management (Business+), and cleanup cron jobs. Revocation is enforced at the edge via a KV blocklist so the stateless access JWT can be killed mid-flight.

## Architecture
- **DB:** Two new tables — `user_sessions` and `tenant_security_settings` — plus one column added to upstream `user_preferences` (`notify_new_login`). All FKs are UUID→UUID against `tenants(id)` and `users(id)`. Defined in `@zync/db`.
- **Session creation:** Login / refresh / signup flows (owned by `foundation-auth-rbac`) call a new `createSession()` helper that inserts a `user_sessions` row storing `token_hash = hashToken(accessJwt)` (reuses upstream `hashToken`), UA-derived `device_name`, `ip_address` (INET), and `country_code` from the `CF-IPCountry` header.
- **Enforcement:** `authMiddleware` (upstream, in `@zync/auth`) is extended via a new `sessionGuard()` middleware run immediately after JWT verification. It (1) checks the KV blocklist `session:revoked:{tokenHash}` on binding `RATELIMIT_KV`, (2) loads the session row, (3) enforces idle timeout against `tenant_security_settings.idle_timeout_minutes`, (4) updates `last_active_at`. Revoked / idle-expired sessions return 401.
- **Revocation:** sets `revoked_at`/`revoked_reason` on the row AND writes the token hash to the KV blocklist with TTL = remaining lifetime, so the edge rejects it within one request.
- **Audit:** suspicious logins and admin bulk revocations are recorded through the upstream `logAuditEvent` helper into `tenant_audit_log` (event types `auth.suspicious_login`, `auth.sessions_revoked`).
- **Policy reads:** `tenant_security_settings` is a dedicated indexed table (PK = `tenant_id`) read on the auth hot path; `getTenantSecuritySettings()` caches it in `cache.default` per-PoP for 60s, mirroring the `user_version` pattern in `foundation-auth-rbac`.
- **UI:** `/settings/security` page in the `app` (Vite+React) gains Active Sessions, Security Policy, and (admin/Business+) Team Sessions sections, plus a global idle-timeout `<IdleTimeoutProvider>` driving a non-dismissible re-auth modal.
- **Cron:** Cloudflare Cron Triggers in `zync-api` — `session-expired-sweep` (every 15 min) and `session-idle-cleanup` (nightly).

## Tech Stack
- **Packages:** `@zync/db` (Drizzle schema + queries), `@zync/auth` (middleware, session helpers), `@zync/types` (shared types), `@zync/ui` (modal/section primitives already exported: `Dialog`, `Button`, `Card`, `Switch`, `Input`, `Table`, `Alert`, `EmptyState`).
- **Apps:** `zync-api` (Hono routes + cron), `app` (Vite+React settings UI).
- **Libraries:** Drizzle ORM, `ua-parser-js` (device-name parsing), `jose` (already used upstream for JWT), zod (route validation — `require-zod-validation-in-routes`).
- **Cloudflare bindings:** `RATELIMIT_KV` (revocation blocklist), `DB`/Hyperdrive (Neon Postgres), Cron Triggers. Email via upstream `sendEmail` / `EmailNotificationAdapter`.
- **Cross-cutting:** timing-safe compare for re-auth credentials (`timingSafeEqual`, no string equality for tokens), CSP-safe inline-free UI, aria roles on modal (`role="alertdialog"`, focus trap), RTL/Hebrew strings via `translations`/`useDirection`, `prefers-reduced-motion` honored on countdown animation.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 13.1 | 1, 2 | `@zync/db` schema + migration, `@zync/types` | Task 2 after Task 1 types land |
| 13.2 | 3, 4, 5 | `@zync/db` queries, `@zync/auth` helpers + middleware | 4,5 depend on 3 |
| 13.3 | 6, 7, 8, 9 | `zync-api` routes (user, admin, reauth, settings) | parallel after 13.2 |
| 13.4 | 10 | `foundation-auth-rbac` login hooks (session create + suspicious + cap + 2FA-role) | after 13.2 |
| 13.5 | 11, 12 | `zync-api` cron | parallel after 13.2 |
| 13.6 | 13, 14, 15, 16 | `app` settings UI + idle provider | parallel after 13.3 |

## Tasks

### Task 1: `user_sessions` & `tenant_security_settings` schema + migration
**Blocks:** 2,3,4,5,10  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/sessions.ts` (new file)
- Modify: `packages/db/src/schema/index.ts` (export new tables)
- Modify: `packages/db/src/schema/auth.ts` (add `notify_new_login` to `user_preferences`)
- Create: `packages/db/migrations/<timestamp>_session_security.sql`
**Steps:**
- [ ] Add the two Drizzle table definitions and the `user_preferences` column.
- [ ] Write the raw Postgres migration with all CREATE TABLE / CREATE INDEX / ALTER statements below.
- [ ] Register both tables in the schema barrel and Drizzle `schema` object.
**Schema / Interfaces:**
```sql
CREATE TABLE user_sessions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash TEXT NOT NULL UNIQUE,           -- SHA-256 of access JWT; never store raw token
  device_name TEXT,                          -- "Chrome on MacBook" (UA parse)
  ip_address INET,
  country_code TEXT,                         -- from CF-IPCountry header
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_active_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at TIMESTAMPTZ NOT NULL,
  revoked_at TIMESTAMPTZ,
  revoked_reason TEXT CHECK (revoked_reason IN ('user', 'admin', 'idle_timeout', 'suspicious')),
  CONSTRAINT valid_expiry CHECK (expires_at > created_at)
);

-- Partial index: only live sessions in the user-session-list hot path
CREATE INDEX idx_sessions_user ON user_sessions(user_id, revoked_at) WHERE revoked_at IS NULL;
CREATE INDEX idx_sessions_tenant ON user_sessions(tenant_id, last_active_at DESC);
-- Primary lookup: token_hash → user (every authenticated request)
CREATE UNIQUE INDEX idx_sessions_token ON user_sessions(token_hash);
-- Cleanup sweep (cron): only expired rows scanned
CREATE INDEX idx_sessions_expires_at ON user_sessions(expires_at) WHERE expires_at < now();
-- User session list panel
CREATE INDEX idx_sessions_user_id ON user_sessions(user_id, created_at DESC);
-- Tenant-scoped invalidation (admin)
CREATE INDEX idx_sessions_tenant_id ON user_sessions(tenant_id);

CREATE TABLE tenant_security_settings (
  tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
  idle_timeout_minutes INTEGER,                  -- NULL = disabled
  max_sessions_per_user INTEGER NOT NULL DEFAULT 10,
  require_2fa_for_roles TEXT[] NOT NULL DEFAULT '{}',
  block_suspicious_logins BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE user_preferences ADD COLUMN notify_new_login BOOLEAN NOT NULL DEFAULT true;
```
**Acceptance:**
- [ ] `pnpm --filter @zync/db migrate` applies cleanly against Neon.
- [ ] `EXPLAIN (ANALYZE, BUFFERS)` on a `token_hash` lookup reports `Index Scan` using `idx_sessions_token`, not `Seq Scan`.

### Task 2: Shared types
**Blocks:** 6,7,8,9,13,14,15,16  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/types/src/session.ts` (new file)
- Modify: `packages/types/src/index.ts`
**Steps:**
- [ ] Add the session/security DTO and policy types below; export from barrel.
**Schema / Interfaces:**
```ts
export type RevokedReason = 'user' | 'admin' | 'idle_timeout' | 'suspicious';

export interface UserSession {
  id: string;
  device_name: string | null;
  ip_address: string | null;
  country_code: string | null;
  created_at: string;
  last_active_at: string;
  is_current: boolean;
}

export interface TenantSecuritySettings {
  idle_timeout_minutes: number | null;
  max_sessions_per_user: number;
  require_2fa_for_roles: string[];
  block_suspicious_logins: boolean;
}

export interface AdminUserSessions {
  user_id: string;
  user_name: string;
  active_sessions: number;
  last_active_at: string;
}
```
**Acceptance:**
- [ ] `pnpm --filter @zync/types build` typechecks; names exported from package root.

### Task 3: Session DB queries
**Blocks:** 4,5,6,7,8,9,10,11,12  ·  **Blocked by:** 1,2
**Files:**
- Create: `packages/db/src/queries/sessions.ts`
- Modify: `packages/db/src/queries/index.ts`
**Steps:**
- [ ] Implement the query helpers below using `tenantQuery`/`systemQuery` patterns; all writes go through Drizzle, never raw SQL from routes (`no-raw-drizzle-from-routes`).
- [ ] `revokeSession` and bulk variants set `revoked_at = now()` + `revoked_reason` and return the affected rows' `token_hash` + `expires_at` so callers can populate the KV blocklist.
**Schema / Interfaces:**
```ts
export function createSession(db: Db, args: {
  tenantId: string; userId: string; tokenHash: string;
  deviceName: string | null; ipAddress: string | null;
  countryCode: string | null; expiresAt: Date;
}): Promise<{ id: string }>;

export function getSessionByTokenHash(db: Db, tokenHash: string):
  Promise<{ id: string; tenant_id: string; user_id: string; last_active_at: Date;
            expires_at: Date; revoked_at: Date | null } | null>;

export function touchSession(db: Db, sessionId: string): Promise<void>; // last_active_at = now()

export function listUserSessions(db: Db, userId: string):
  Promise<Array<{ id: string; device_name: string | null; ip_address: string | null;
                  country_code: string | null; created_at: Date; last_active_at: Date;
                  token_hash: string }>>;

export function countActiveSessions(db: Db, userId: string): Promise<number>;

export function revokeSession(db: Db, sessionId: string, userId: string,
  reason: RevokedReason): Promise<{ token_hash: string; expires_at: Date } | null>;

export function revokeOtherUserSessions(db: Db, userId: string, keepSessionId: string,
  reason: RevokedReason): Promise<Array<{ token_hash: string; expires_at: Date }>>;

export function revokeAllSessionsForUser(db: Db, tenantId: string, userId: string,
  reason: RevokedReason): Promise<Array<{ token_hash: string; expires_at: Date }>>;

export function revokeAllTenantSessions(db: Db, tenantId: string,
  reason: RevokedReason): Promise<Array<{ token_hash: string; expires_at: Date }>>;

export function listTenantUserSessionSummaries(db: Db, tenantId: string, userId?: string):
  Promise<AdminUserSessions[]>;

export function getRecentSessionsForUser(db: Db, userId: string, limit: number):
  Promise<Array<{ country_code: string | null; ip_address: string | null }>>;

export function sweepExpiredSessions(db: Db): Promise<number>; // DELETE expires_at < now()-'1 minute'
export function cleanupIdleSessions(db: Db): Promise<Array<{ token_hash: string; expires_at: Date }>>;

export function getTenantSecuritySettings(db: Db, tenantId: string):
  Promise<TenantSecuritySettings>; // returns defaults if no row
export function upsertTenantSecuritySettings(db: Db, tenantId: string,
  patch: Partial<TenantSecuritySettings>): Promise<TenantSecuritySettings>;
```
**Acceptance:**
- [ ] Revoke helpers return token hashes + expiries for blocklist population.
- [ ] `getTenantSecuritySettings` returns `{ idle_timeout_minutes: null, max_sessions_per_user: 10, require_2fa_for_roles: [], block_suspicious_logins: false }` when no row exists.

### Task 4: KV revocation blocklist + session helpers in `@zync/auth`
**Blocks:** 5,6,7,8,10,11,12  ·  **Blocked by:** 3
**Files:**
- Create: `packages/auth/src/sessions.ts`
- Modify: `packages/auth/src/index.ts`
**Steps:**
- [ ] Implement `blocklistToken` / `isTokenBlocklisted` against binding `RATELIMIT_KV`, key `session:revoked:{tokenHash}`, value `'1'`, `expirationTtl` = seconds until `expires_at` (floored to ≥60s).
- [ ] Implement `parseDeviceName(userAgent)` via `ua-parser-js` returning e.g. `"Chrome on macOS"`.
- [ ] Implement `recordSessionOnLogin` that hashes the access JWT via upstream `hashToken`, derives device/ip/country, and calls `createSession`.
- [ ] Implement `revokeAndBlocklist(db, kv, rows, ...)` that revokes in DB then writes every returned token hash to the blocklist.
**Schema / Interfaces:**
```ts
export function sessionRevokedKey(tokenHash: string): string; // `session:revoked:${tokenHash}`
export function blocklistToken(kv: KVNamespace, tokenHash: string, expiresAt: Date): Promise<void>;
export function isTokenBlocklisted(kv: KVNamespace, tokenHash: string): Promise<boolean>;
export function parseDeviceName(userAgent: string | null): string | null;
export function recordSessionOnLogin(db: Db, args: {
  tenantId: string; userId: string; accessToken: string; userAgent: string | null;
  ip: string | null; countryCode: string | null; expiresAt: Date;
}): Promise<{ id: string }>;
```
**Acceptance:**
- [ ] Blocklisting a token then calling `isTokenBlocklisted` returns true; entry auto-expires at session `expires_at` (KV TTL).

### Task 5: `sessionGuard` middleware + cached policy read
**Blocks:** 6,7,8,9  ·  **Blocked by:** 4
**Files:**
- Create: `packages/auth/src/session-guard.ts`
- Modify: `packages/auth/src/index.ts`
- Modify: `packages/auth/src/middleware.ts` (wire `sessionGuard` into the `authMiddleware` chain after JWT verify)
**Steps:**
- [ ] After `authMiddleware` verifies the JWT, compute `tokenHash = hashToken(rawToken)` and reject with 401 if `isTokenBlocklisted`.
- [ ] Load the session via `getSessionByTokenHash`; if missing or `revoked_at` set → 401.
- [ ] Read `getTenantSecuritySettings` (cached per-PoP 60s in `cache.default`, key `https://zync-internal/sec-settings/{tenantId}`). If `idle_timeout_minutes` is set and `last_active_at < now() - interval`, revoke the session (`idle_timeout`) + blocklist, return 401.
- [ ] Otherwise `touchSession` (update `last_active_at`); store `sessionId` on the request context for downstream "is_current" checks.
- [ ] Honor the upstream `BYPASS_2FA_GUARD` / public-route allowlist — `sessionGuard` only runs on routes that passed `authMiddleware`.
**Schema / Interfaces:**
```ts
export function sessionGuard(): MiddlewareHandler; // Hono middleware; sets c.set('sessionId', id)
export function getCachedSecuritySettings(db: Db, cache: Cache, tenantId: string):
  Promise<TenantSecuritySettings>;
```
**Acceptance:**
- [ ] A request bearing a blocklisted token returns 401 with `{ error: 'session_revoked' }`.
- [ ] With `idle_timeout_minutes = 30`, a session idle >30 min returns 401 and its row shows `revoked_reason = 'idle_timeout'`.
- [ ] Idle settings read at most once per 60s per PoP (cache hit otherwise).

### Task 6: User session routes
**Blocks:** 13  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/routes/user-sessions.ts`
- Modify: `apps/zync-api/src/app.ts` (mount routes)
**Steps:**
- [ ] `GET /api/user/sessions` → `listUserSessions` for `session.sub`; mark `is_current` where `token_hash === hashToken(currentToken)`; never return `token_hash`.
- [ ] `DELETE /api/user/sessions/:sessionId` → `revokeSession(..., 'user')` scoped to current user; reject revoking the current session with 400; blocklist returned hash.
- [ ] `DELETE /api/user/sessions` → `revokeOtherUserSessions(userId, currentSessionId, 'user')`; blocklist all.
- [ ] `POST /api/user/sessions/keepalive` → `touchSession(currentSessionId)`; 204.
- [ ] All routes behind `authMiddleware` + `sessionGuard`; zod-validate path params.
**Schema / Interfaces:**
```
GET    /api/user/sessions            → 200 UserSession[]
DELETE /api/user/sessions/:sessionId → 200 { revoked: true } | 400 cannot_revoke_current
DELETE /api/user/sessions            → 200 { revoked: number }
POST   /api/user/sessions/keepalive  → 204
```
**Acceptance:**
- [ ] Listing returns the caller's live sessions with exactly one `is_current: true`.
- [ ] Revoking a session blocklists its token; a subsequent request with that token gets 401.

### Task 7: Admin session routes (Business+)
**Blocks:** 15  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/routes/admin-sessions.ts`
- Modify: `apps/zync-api/src/app.ts`
**Steps:**
- [ ] `GET /api/admin/sessions` → `listTenantUserSessionSummaries(tenantId, query.user_id?)`; guard `requirePermission('settings:security:read')` + `requireTier('business')`.
- [ ] `DELETE /api/admin/sessions/user/:userId` → `revokeAllSessionsForUser(tenantId, userId, 'admin')`; blocklist all; `logAuditEvent` `auth.sessions_revoked` with `metadata: { scope: 'user', user_id }`.
- [ ] `DELETE /api/admin/sessions` → require body `{ confirm: 'revoke all' }` (zod literal); `revokeAllTenantSessions(tenantId, 'admin')`; blocklist all; `logAuditEvent` `auth.sessions_revoked` `{ scope: 'tenant' }`.
- [ ] All admin routes `requireTier('business')`.
**Schema / Interfaces:**
```
GET    /api/admin/sessions?user_id=        → 200 AdminUserSessions[]
DELETE /api/admin/sessions/user/:userId    → 200 { revoked: number }
DELETE /api/admin/sessions  body {confirm}  → 200 { revoked: number } | 400 confirm_required
```
**Acceptance:**
- [ ] Freelancer-tier tenant gets 402/403 from all admin session routes.
- [ ] Tenant-wide revoke requires the exact `confirm: 'revoke all'` string or returns 400.
- [ ] Each bulk revoke writes one `auth.sessions_revoked` row to `tenant_audit_log`.

### Task 8: Re-auth route
**Blocks:** 16  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/routes/reauth.ts`
- Modify: `apps/zync-api/src/app.ts`
**Steps:**
- [ ] `POST /api/auth/reauth` accepts `{ password }` XOR `{ totp_code }` (zod refine). Requires a still-valid (not expired) session token in `Authorization`.
- [ ] If `password`: `verifyPassword(input, user.password_hash)` (upstream; constant-time). If `totp_code`: verify via the upstream 2FA path used by `POST /api/auth/2fa/verify`. Compare with `timingSafeEqual` where applicable; no raw string equality on secrets.
- [ ] On success: issue a fresh access+refresh pair via upstream `signSession`, `recordSessionOnLogin` for the new token, `touchSession`/replace, reset idle timer; return `{ access_token, refresh_token }` and set the `httpOnly` cookies.
- [ ] On failure: 401 `{ error: 'invalid_credentials' }` (password) or `{ error: 'invalid_totp' }`.
- [ ] Rate-limit via binding `RATE_LIMITER_AUTH`.
**Schema / Interfaces:**
```
POST /api/auth/reauth
  body: { password: string } | { totp_code: string }
  → 200 { access_token: string, refresh_token: string }
  → 401 { error: 'invalid_credentials' | 'invalid_totp' }
```
**Acceptance:**
- [ ] Correct password returns 200 with a new token pair and resets `last_active_at`.
- [ ] Wrong password/TOTP returns the correct 401 error code; credential compare is constant-time.

### Task 9: Tenant security settings routes
**Blocks:** 14  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/routes/security-settings.ts`
- Modify: `apps/zync-api/src/app.ts`
**Steps:**
- [ ] `GET /api/settings/security` → `getTenantSecuritySettings(tenantId)`; `requirePermission('settings:security:read')`.
- [ ] `PUT /api/settings/security` → zod-validate `{ idle_timeout_minutes?: number|null, max_sessions_per_user?: number, require_2fa_for_roles?: string[], block_suspicious_logins?: boolean }`; `upsertTenantSecuritySettings`; advanced fields (`block_suspicious_logins`, `require_2fa_for_roles`) require `requireTier('business')`; `requirePermission('settings:security:write')`.
- [ ] Invalidate the per-PoP cache key on write (best-effort cache purge; 60s max staleness otherwise).
- [ ] `logAuditEvent` `settings.updated` with field-level metadata.
**Schema / Interfaces:**
```
GET /api/settings/security → 200 TenantSecuritySettings
PUT /api/settings/security
  body: Partial<TenantSecuritySettings>
  → 200 TenantSecuritySettings | 402/403 if advanced field on non-Business tier
```
**Acceptance:**
- [ ] Setting `idle_timeout_minutes` takes effect on the next request within ≤60s (cache TTL).
- [ ] Setting `block_suspicious_logins` on Freelancer tier is rejected.

### Task 10: Login-flow integration (session create, cap, suspicious, 2FA-role)
**Blocks:** —  ·  **Blocked by:** 4,5
**Files:**
- Modify: `packages/auth/src/login.ts` (or the login service consumed by `POST /api/auth/login`, `/signup`, `/refresh`, `/switch-tenant`)
- Create: `packages/auth/src/suspicious-login.ts`
**Steps:**
- [ ] After successful credential check, before issuing tokens: `countActiveSessions(userId)`; if `>= max_sessions_per_user` → reject login `429 { error: 'session_cap_reached' }` with message "Maximum active sessions reached. Sign out of another device to continue."
- [ ] On token issue, call `recordSessionOnLogin` (Task 4) to persist the `user_sessions` row.
- [ ] Suspicious detection: `getRecentSessionsForUser(userId, 3)`; flag if `CF-IPCountry` differs from all recent country codes, OR the IP `/24` prefix differs from all recent IPs. If flagged:
  - If `block_suspicious_logins` true → reject login `403 { error: 'login_blocked_suspicious' }`.
  - Else if `user_preferences.notify_new_login` true → `sendEmail` "New sign-in from [City], [Country] at [time]. If this wasn't you, revoke all sessions" (link to `/settings/security`).
  - Always `logAuditEvent` `auth.suspicious_login` with `metadata: { country_code, ip_address }`.
- [ ] 2FA-for-roles: if the user's role ∈ `require_2fa_for_roles` and the user has not enrolled 2FA (no usable `user_2fa_backup_codes` / `two_factor_enabled = false`), allow login but mark the session so the app redirects to `/settings/security/2fa-setup` and blocks other routes until enrolled (reuse the upstream forced-enrollment session pattern from `auth-2fa`).
**Schema / Interfaces:**
```ts
export function isSuspiciousLogin(args: {
  countryCode: string | null; ip: string | null;
  recent: Array<{ country_code: string | null; ip_address: string | null }>;
}): boolean; // country mismatch OR /24 prefix mismatch
```
**Acceptance:**
- [ ] 11th concurrent login (cap 10) returns 429 with the cap message.
- [ ] Login from a new country writes `auth.suspicious_login` and emails the user (when `notify_new_login`).
- [ ] With `block_suspicious_logins = true`, the flagged login is rejected 403.

### Task 11: Expired-session sweep cron (every 15 min)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/cron/session-expired-sweep.ts`
- Modify: `apps/zync-api/wrangler.toml` (cron trigger `*/15 * * * *`)
- Modify: `apps/zync-api/src/scheduled.ts` (dispatch on schedule)
**Steps:**
- [ ] Run `DELETE FROM user_sessions WHERE expires_at < now() - INTERVAL '1 minute'` via `sweepExpiredSessions`; KV blocklist entries expire on their own TTL (no explicit purge).
**Acceptance:**
- [ ] Cron deletes only rows past `expires_at - 1 minute`; uses `idx_sessions_expires_at` (no seq scan).

### Task 12: Idle-cleanup cron (nightly)
**Blocks:** —  ·  **Blocked by:** 3,4
**Files:**
- Create: `apps/zync-api/src/cron/session-idle-cleanup.ts`
- Modify: `apps/zync-api/wrangler.toml` (cron trigger `0 3 * * *`)
- Modify: `apps/zync-api/src/scheduled.ts`
**Steps:**
- [ ] For each tenant with `idle_timeout_minutes` set, revoke sessions where `last_active_at < now() - idle_timeout_minutes` (reason `idle_timeout`).
- [ ] Revoke any session where `expires_at < now()` and `revoked_at IS NULL`.
- [ ] Blocklist all revoked token hashes returned by `cleanupIdleSessions`.
**Acceptance:**
- [ ] After cron, offline idle-past-threshold sessions are revoked and blocklisted.

### Task 13: Active Sessions UI section
**Blocks:** —  ·  **Blocked by:** 2,6
**Files:**
- Create: `apps/zync-app/src/features/security/ActiveSessions.tsx`
- Create: `apps/zync-app/src/features/security/useSessions.ts`
- Modify: `apps/zync-app/src/routes/settings/security.tsx`
**Steps:**
- [ ] `useSessions` react-query hook → `GET /api/user/sessions`; mutations for `DELETE /api/user/sessions/:id` and `DELETE /api/user/sessions`.
- [ ] Render list: current session marked with `●` (and an aria-label "current session"); each row shows device, country/city, started + last-active (localized via `translations`/`useDirection`, RTL-safe). `[Sign out ✕]` per non-current row; `[Sign out all other sessions]` button.
- [ ] Use `@zync/ui` `Card`/`Table`/`Button`; empty/error via `EmptyState`/`ErrorState`. Honor `prefers-reduced-motion` on row transitions.
**Acceptance:**
- [ ] Current session cannot be signed out from the UI; signing out another row removes it from the list.

### Task 14: Security Policy UI section
**Blocks:** —  ·  **Blocked by:** 2,9
**Files:**
- Create: `apps/zync-app/src/features/security/SecurityPolicy.tsx`
- Modify: `apps/zync-app/src/routes/settings/security.tsx`
**Steps:**
- [ ] Form bound to `GET/PUT /api/settings/security`: idle-timeout (numeric minutes, blank = disabled), max sessions per user, require-2FA-for-roles (multiselect of tenant roles), block-suspicious-logins toggle (`Switch`).
- [ ] Disable/lock advanced fields (block-suspicious, require-2FA) behind a Business+ gate (`useTierGate`) with an upgrade affordance.
- [ ] Labelled inputs with aria associations; localized strings; RTL-safe layout.
**Acceptance:**
- [ ] Non-Business tenant sees advanced fields disabled with upgrade prompt; saving basic fields succeeds.

### Task 15: Team Sessions UI (admin, Business+)
**Blocks:** —  ·  **Blocked by:** 2,7
**Files:**
- Create: `apps/zync-app/src/features/security/TeamSessions.tsx`
- Modify: `apps/zync-app/src/routes/settings/security.tsx`
**Steps:**
- [ ] Tab visible only to admins on Business+ (`requirePermission`/`useTierGate`); fetch `GET /api/admin/sessions`.
- [ ] Table of users with active-session counts + last active + `[Revoke all]` per user (`DELETE /api/admin/sessions/user/:userId`).
- [ ] `[Revoke all sessions — entire team]` opens a confirmation `Dialog` requiring the user to type `revoke all`; on confirm `DELETE /api/admin/sessions` with `{ confirm: 'revoke all' }`.
**Acceptance:**
- [ ] Team revoke button is hidden for non-admin / non-Business users.
- [ ] Entire-team revoke requires typed `revoke all` confirmation before the request fires.

### Task 16: Idle-timeout provider + re-auth modal
**Blocks:** —  ·  **Blocked by:** 2,8
**Files:**
- Create: `apps/zync-app/src/features/security/IdleTimeoutProvider.tsx`
- Create: `apps/zync-app/src/features/security/ReauthModal.tsx`
- Modify: `apps/zync-app/src/App.tsx` (wrap app shell), `apps/zync-app/src/routes/login.tsx` (idle banner)
**Steps:**
- [ ] Provider reads effective `idle_timeout_minutes` (from `/api/settings/security` or session bootstrap). Debounced listeners on `mousemove`, `keydown`, `click`, `scroll`, `touchstart` track last interaction; send `POST /api/user/sessions/keepalive` on activity (throttled).
- [ ] At `idle_timeout_minutes - 2` min inactivity, open `ReauthModal` with a 2-min countdown updating every second. Modal is non-dismissible (no Esc, no outside click), `role="alertdialog"`, focus-trapped, aria-live countdown; respect `prefers-reduced-motion`.
- [ ] `[Stay logged in]` → `POST /api/auth/reauth` with `{ password }` or `{ totp_code }` (TOTP field when 2FA enabled). On 200: replace tokens, reset timer, close. On 401: inline error, countdown continues. On reaching 0: `POST /api/auth/logout`, redirect `/login?reason=idle_timeout`.
- [ ] Login page: when `?reason=idle_timeout`, show info banner "You were logged out due to inactivity." cleared after 10s.
**Acceptance:**
- [ ] Modal appears exactly 2 minutes before logout regardless of total timeout; cannot be dismissed by Esc/outside click.
- [ ] Successful re-auth resets the idle timer and closes the modal; countdown hitting 0 redirects to `/login?reason=idle_timeout` with the banner shown.
