{
  "slug": "session-security",
  "spec_file": "docs/specs/2026-05-31-session-security.md",
  "findings": [
    {
      "id": "session-security-001",
      "severity": "P0",
      "type": "broken",
      "summary": "Team session admin routes use system-admin auth, not tenant admin",
      "spec_ref": "Admin Session Management — GET /api/admin/sessions",
      "code_ref": "apps/zync-api/src/routes/admin-sessions.ts:26",
      "evidence": "adminSessionsRoute.use('*', adminAuthMiddleware) requires zync_admin cookie (admin.zync.is); handlers then require session.type === 'user' with tid, which verifyAdminSession admin JWTs never satisfy.",
      "repro": "As tenant OWNER on app.zync.is with zync_session cookie, GET /api/admin/sessions returns 401 (no zync_admin cookie)."
    },
    {
      "id": "session-security-002",
      "severity": "P1",
      "type": "missing",
      "summary": "settings:security:read/write permissions not seeded; security APIs always 403",
      "spec_ref": "API — GET/PUT /api/settings/security Requires admin",
      "code_ref": "packages/db/src/seed/permission-keys.ts:36",
      "evidence": "PERMISSION_KEYS lists settings:read/write only; security-settings.ts:23 and admin-sessions.ts:31 require settings:security:read/write which no seeded role receives.",
      "repro": ""
    },
    {
      "id": "session-security-003",
      "severity": "P1",
      "type": "missing",
      "summary": "sessionGuard middleware never wired into request pipeline",
      "spec_ref": "Idle Timeout — auth middleware enforcement",
      "code_ref": "apps/zync-api/src/middleware/session-guard.ts:35",
      "evidence": "grep sessionGuard across repo (excluding worktrees) only finds definition in session-guard.ts; auth.ts:30-71 chain has no import or call.",
      "repro": ""
    },
    {
      "id": "session-security-004",
      "severity": "P1",
      "type": "missing",
      "summary": "recordSessionOnLogin never called; user_sessions rows not created on login",
      "spec_ref": "Session creation — login / refresh / signup flows",
      "code_ref": "apps/zync-api/src/lib/issue-session.ts:42",
      "evidence": "recordSessionOnLogin in packages/auth/src/sessions.ts:94 has zero call sites in apps/zync-api; issueForMembership only insertRefreshToken + signSession cookies.",
      "repro": ""
    },
    {
      "id": "session-security-005",
      "severity": "P1",
      "type": "missing",
      "summary": "POST /api/user/sessions/keepalive endpoint absent",
      "spec_ref": "API — POST /api/user/sessions/keepalive",
      "code_ref": "NONE",
      "evidence": "grep keepalive in apps/zync-api returns no routes; sessions.ts documents only GET and DELETE at /api/sessions.",
      "repro": ""
    },
    {
      "id": "session-security-006",
      "severity": "P1",
      "type": "missing",
      "summary": "IdleTimeoutProvider not mounted in app shell",
      "spec_ref": "Inactivity detection — re-auth modal",
      "code_ref": "apps/zync-app/src/main.tsx:61",
      "evidence": "grep IdleTimeoutProvider in apps/zync-app only finds IdleTimeoutProvider.tsx definition; main.tsx renders Shell without IdleTimeoutProvider wrapper.",
      "repro": ""
    },
    {
      "id": "session-security-007",
      "severity": "P1",
      "type": "missing",
      "summary": "/settings/security page not registered in settings module routes",
      "spec_ref": "Active Sessions View — /settings/security",
      "code_ref": "apps/zync-app/src/modules/settings.tsx:95",
      "evidence": "routes/settings/security.tsx exists but grep security in modules/settings.tsx returns no Route; settings hub index also has no security link.",
      "repro": ""
    },
    {
      "id": "session-security-008",
      "severity": "P1",
      "type": "missing",
      "summary": "Suspicious login detection, email alert, and audit not implemented",
      "spec_ref": "Suspicious Login Detection",
      "code_ref": "NONE",
      "evidence": "grep isSuspiciousLogin|auth.suspicious_login|getRecentSessionsForUser in apps/zync-api and packages/auth returns no implementation; login.ts:50 has no country/IP comparison.",
      "repro": ""
    },
    {
      "id": "session-security-009",
      "severity": "P1",
      "type": "missing",
      "summary": "Login session cap (429 at max_sessions_per_user) not enforced at login",
      "spec_ref": "Session Cap — max_sessions_per_user",
      "code_ref": "apps/zync-api/src/routes/auth/login.ts:182",
      "evidence": "countActiveSessions in packages/db/src/queries/sessions.ts:195 is never called from login.ts; only refresh.ts:56 enforces a refresh-token cap.",
      "repro": ""
    },
    {
      "id": "session-security-010",
      "severity": "P1",
      "type": "missing",
      "summary": "require_2fa_for_roles login enforcement not implemented",
      "spec_ref": "2FA Policy — require_2fa_for_roles",
      "code_ref": "apps/zync-api/src/routes/auth/login.ts:167",
      "evidence": "login.ts checks tenants.enforce_2fa only; grep require2faForRoles in apps/zync-api/src/routes/auth returns no matches; no redirect to /settings/security/2fa-setup for role-based policy.",
      "repro": ""
    },
    {
      "id": "session-security-011",
      "severity": "P1",
      "type": "divergent",
      "summary": "Idle-timeout client lacks 2-min warning, countdown, keepalive, and logout redirect",
      "spec_ref": "Idle Timeout — re-auth modal 2 minutes before logout",
      "code_ref": "apps/zync-app/src/features/security/IdleTimeoutProvider.tsx:71",
      "evidence": "Provider opens modal when idle > full idleMs (line 73), not idle_timeout-2min; no per-second countdown, no POST keepalive, no totp_code field, no logout+redirect to /login?reason=idle_timeout on zero.",
      "repro": ""
    },
    {
      "id": "session-security-012",
      "severity": "P1",
      "type": "missing",
      "summary": "Cloudflare cron triggers disabled; session cleanup crons never auto-run",
      "spec_ref": "Session Cleanup Cron — session-expired-sweep every 15 min",
      "code_ref": "apps/zync-api/wrangler.toml:244",
      "evidence": "[triggers] crons block is commented with PENDING note; runSessionExpiredSweep/runSessionIdleCleanup only invoked from runCronGroup when scheduled() fires.",
      "repro": ""
    },
    {
      "id": "session-security-013",
      "severity": "P2",
      "type": "divergent",
      "summary": "User session routes mounted at /api/sessions not /api/user/sessions",
      "spec_ref": "API — GET/DELETE /api/user/sessions",
      "code_ref": "apps/zync-api/src/routes/index.ts:531",
      "evidence": "routes.route('/sessions', sessionsRoute); spec paths are /api/user/sessions, /api/user/sessions/:sessionId, /api/user/sessions/keepalive.",
      "repro": ""
    },
    {
      "id": "session-security-014",
      "severity": "P2",
      "type": "divergent",
      "summary": "Reauth at /api/reauth returns {ok:true}, not token pair at /api/auth/reauth",
      "spec_ref": "API — POST /api/auth/reauth",
      "code_ref": "apps/zync-api/src/routes/reauth.ts:49",
      "evidence": "Mounted at /api/reauth (index.ts:534); accepts password only, returns {ok:true}; spec requires /api/auth/reauth with password|totp_code and new access_token/refresh_token cookies.",
      "repro": ""
    },
    {
      "id": "session-security-015",
      "severity": "P2",
      "type": "divergent",
      "summary": "Session list/revoke requires settings:read/write not authenticated-only",
      "spec_ref": "API — GET /api/user/sessions Requires authenticated",
      "code_ref": "apps/zync-api/src/routes/sessions.ts:28",
      "evidence": "GET / uses requirePermission('settings:read'); DELETE uses settings:write; spec requires only authenticated user for own sessions.",
      "repro": ""
    },
    {
      "id": "session-security-016",
      "severity": "P2",
      "type": "divergent",
      "summary": "Dual KV blocklist keys: logout uses KV blocklist:, revocation uses RATELIMIT_KV session:revoked:",
      "spec_ref": "Session Revocation — session:revoked:{tokenHash}",
      "code_ref": "apps/zync-api/src/middleware/auth.ts:51",
      "evidence": "auth middleware checks c.env.KV blocklist:{hash} (logout.ts:34); session revocation routes call blocklistRevokedTokens on RATELIMIT_KV with session:revoked: prefix (packages/auth/src/sessions.ts:15); sessionGuard unwired so session:revoked never checked on requests.",
      "repro": ""
    },
    {
      "id": "session-security-017",
      "severity": "P2",
      "type": "missing",
      "summary": "Login page idle_timeout banner not implemented",
      "spec_ref": "Idle Timeout — login page ?reason=idle_timeout banner",
      "code_ref": "apps/zync-app/src/features/auth/LoginPage.tsx:3",
      "evidence": "grep reason=idle_timeout|idle_timeout in apps/zync-app/src/features/auth returns no matches; LoginPage renders LoginForm only.",
      "repro": ""
    },
    {
      "id": "session-security-018",
      "severity": "P2",
      "type": "divergent",
      "summary": "DELETE current session not rejected with 400",
      "spec_ref": "Session Revocation — User can revoke any session except current",
      "code_ref": "apps/zync-api/src/routes/sessions.ts:60",
      "evidence": "DELETE /:id calls revokeSession without checking isCurrent; spec requires 400 cannot_revoke_current when revoking active session.",
      "repro": ""
    },
    {
      "id": "session-security-019",
      "severity": "P2",
      "type": "divergent",
      "summary": "Revocation blocklist TTL uses fixed 24h not per-session expires_at",
      "spec_ref": "Session Revocation — KV blocklist TTL = session expires_at",
      "code_ref": "apps/zync-api/src/routes/sessions.ts:76",
      "evidence": "blocklistRevokedTokens called with expiresAt = new Date(Date.now() + 24*60*60*1000) instead of each row's expires_at from user_sessions.",
      "repro": ""
    },
    {
      "id": "session-security-020",
      "severity": "P2",
      "type": "divergent",
      "summary": "Logout does not revoke user_sessions row or use session:revoked KV key",
      "spec_ref": "Session Revocation — revoked_at + session:revoked KV",
      "code_ref": "apps/zync-api/src/routes/auth/logout.ts:18",
      "evidence": "logout revokes refresh_tokens and puts blocklist:{hash} on KV; no revokeSession call and no user_sessions.revoked_at update.",
      "repro": ""
    },
    {
      "id": "session-security-021",
      "severity": "P2",
      "type": "divergent",
      "summary": "session-idle-cleanup cron revokes DB rows but does not KV blocklist tokens",
      "spec_ref": "Session Cleanup Cron — blocklist revoked token hashes",
      "code_ref": "apps/zync-api/src/cron/session-idle-cleanup.ts:12",
      "evidence": "runSessionIdleCleanup only calls cleanupIdleSessions which returns count of ids, not token hashes; no blocklistRevokedTokens call.",
      "repro": ""
    },
    {
      "id": "session-security-022",
      "severity": "P2",
      "type": "divergent",
      "summary": "sessionGuard idle revoke does not blocklist token on RATELIMIT_KV",
      "spec_ref": "Idle Timeout — revoke + blocklist on middleware enforcement",
      "code_ref": "apps/zync-api/src/middleware/session-guard.ts:73",
      "evidence": "idle branch calls revokeSession but never blocklistToken/blocklistRevokedTokens; even if wired, JWT could remain valid until separate blocklist check.",
      "repro": ""
    },
    {
      "id": "session-security-023",
      "severity": "P2",
      "type": "divergent",
      "summary": "sessionGuard fail-open when no user_sessions row exists",
      "spec_ref": "Session lifecycle — persisted session per access JWT",
      "code_ref": "apps/zync-api/src/middleware/session-guard.ts:56",
      "evidence": "if (!sessionRow) return next() passes through pre-migration tokens without enforcement.",
      "repro": ""
    },
    {
      "id": "session-security-024",
      "severity": "P2",
      "type": "missing",
      "summary": "Shared session DTO types not added to @zync/types",
      "spec_ref": "Plan Task 2 — packages/types/src/session.ts",
      "code_ref": "NONE",
      "evidence": "glob packages/types/src/session* returns 0 files; grep UserSession|RevokedReason in packages/types returns no matches.",
      "repro": ""
    },
    {
      "id": "session-security-025",
      "severity": "P2",
      "type": "divergent",
      "summary": "Admin session summaries omit user_name and user_id query filter",
      "spec_ref": "API — GET /api/admin/sessions query user_id",
      "code_ref": "packages/db/src/queries/sessions.ts:177",
      "evidence": "listTenantUserSessionSummaries returns userId/activeSessions/lastActiveAt only; admin-sessions.ts:39 calls without user_id filter; spec AdminUserSessions includes user_name.",
      "repro": ""
    },
    {
      "id": "session-security-026",
      "severity": "P2",
      "type": "missing",
      "summary": "getRecentSessionsForUser query helper not implemented",
      "spec_ref": "Plan Task 3 — getRecentSessionsForUser",
      "code_ref": "packages/db/src/queries/sessions.ts:1",
      "evidence": "grep getRecentSessionsForUser in packages/db returns only plan doc references; sessions.ts exports no such function.",
      "repro": ""
    },
    {
      "id": "session-security-027",
      "severity": "P3",
      "type": "divergent",
      "summary": "TeamSessions UI shows truncated userId not user display name",
      "spec_ref": "Admin Session Management — User column with names",
      "code_ref": "apps/zync-app/src/features/security/TeamSessions.tsx:149",
      "evidence": "renders User {s.userId.slice(0,8)}… instead of user_name from spec wireframe.",
      "repro": ""
    },
    {
      "id": "session-security-028",
      "severity": "P3",
      "type": "divergent",
      "summary": "Session API responses use camelCase wrapper not spec snake_case array",
      "spec_ref": "API — GET /api/user/sessions Returns [{ id, device_name, ... }]",
      "code_ref": "apps/zync-api/src/routes/sessions.ts:41",
      "evidence": "returns { sessions: [{ deviceName, ipAddress, countryCode, isCurrent, ... }] } not bare snake_case array.",
      "repro": ""
    },
    {
      "id": "session-security-029",
      "severity": "P3",
      "type": "divergent",
      "summary": "sessionGuard does not set sessionId on request context",
      "spec_ref": "Plan Task 5 — c.set('sessionId', id)",
      "code_ref": "apps/zync-api/src/middleware/session-guard.ts:79",
      "evidence": "middleware touches session and returns next() without c.set('sessionId', sessionRow.id).",
      "repro": ""
    },
    {
      "id": "session-security-030",
      "severity": "P3",
      "type": "divergent",
      "summary": "idx_sessions_expires_at partial predicate omitted (non-partial index)",
      "spec_ref": "Required Indexes — idx_sessions_expires_at WHERE expires_at < NOW()",
      "code_ref": "packages/db/migrations/0027_session_security.sql:53",
      "evidence": "migration comment notes volatile functions disallowed; creates idx_sessions_expires_sweep on expires_at without partial WHERE clause from spec.",
      "repro": ""
    }
  ],
  "summary": { "P0": 1, "P1": 11, "P2": 14, "P3": 4, "total": 30 }
}
