{
  "slug": "foundation-auth-rbac",
  "spec_file": "docs/specs/2026-05-30-foundation-auth-rbac.md",
  "findings": [
    {
      "id": "foundation-auth-rbac-001",
      "severity": "P0",
      "type": "broken",
      "summary": "Re-clicking verify-email link redirects without issuing session cookies",
      "spec_ref": "API Endpoints — GET /api/auth/verify-email",
      "code_ref": "apps/zync-api/src/routes/auth/verify-email.ts:52",
      "evidence": "Idempotent branch when emailVerifiedAt is set returns c.redirect to onboarding but never calls setSessionCookie/setRefreshCookie or issues tokens.",
      "repro": "Complete signup, verify email once, click the same verification link again; land on /onboarding with no zync_session cookie and API calls return 401."
    },
    {
      "id": "foundation-auth-rbac-002",
      "severity": "P0",
      "type": "broken",
      "summary": "Post-verify redirect targets /onboarding but SPA route is not registered",
      "spec_ref": "Signup & Onboarding Flow — redirect to app.zync.is/onboarding",
      "code_ref": "apps/zync-app/src/main.tsx:88",
      "evidence": "verify-email redirects to https://app.zync.is/onboarding (verify-email.ts:93); main.tsx Shell routes map moduleRoutes only and routes/index.tsx moduleRoutes (lines 97-158) has no onboarding entry despite routes/onboarding/index.tsx existing.",
      "repro": "Complete email verification; browser lands on /onboarding with session cookies but SPA has no matching route."
    },
    {
      "id": "foundation-auth-rbac-003",
      "severity": "P0",
      "type": "missing",
      "summary": "GET /api/auth/invite/:token metadata endpoint absent; invite page always fails",
      "spec_ref": "Invitation flow — invitee clicks link",
      "code_ref": "apps/zync-www/src/components/auth/InviteAcceptForm.tsx:248",
      "evidence": "InviteAcceptForm fetches GET /api/auth/invite/${token} on mount; grep apps/zync-api/src/routes/auth for invite/:token returns no handler — invite.ts only defines POST /invite and POST /invite/accept.",
      "repro": "Open zync.is/invite/accept?token=<valid>; page shows expired/error because metadata GET 404s."
    },
    {
      "id": "foundation-auth-rbac-004",
      "severity": "P0",
      "type": "broken",
      "summary": "Invite mini-signup sends fullName but API expects name",
      "spec_ref": "Invitation flow — new email mini-signup (name + password)",
      "code_ref": "apps/zync-www/src/components/auth/InviteAcceptForm.tsx:56",
      "evidence": "InviteAcceptForm POST body uses fullName; acceptInviteSchema in apps/zync-api/src/schemas/auth.ts:43 expects name; invite.ts:121-122 returns 422 requiresSignup when name missing.",
      "repro": "Accept invitation as a new email user; submit form; API returns 422 Account setup required."
    },
    {
      "id": "foundation-auth-rbac-005",
      "severity": "P1",
      "type": "missing",
      "summary": "Tenant-admin approve pending membership endpoint missing",
      "spec_ref": "Admin-approval tenants — Approve → status ACTIVE",
      "code_ref": "apps/zync-api/src/routes/users/team.ts:59",
      "evidence": "team.ts lists members (including pending_approval via listTeamMembers) but has no approve route; approvePendingMember exists only in packages/db/src/queries/admin-tenants.ts and is wired at apps/zync-api/src/routes/admin/tenant-users.ts:70 not under /api/users.",
      "repro": "Tenant with require_approval=true; invitee accepts; admin opens /settings/users; no API to flip pending_approval → active from tenant plane."
    },
    {
      "id": "foundation-auth-rbac-006",
      "severity": "P1",
      "type": "missing",
      "summary": "safeRedirect and ?redirect= post-auth navigation not implemented on auth routes",
      "spec_ref": "Open Redirect Prevention — login/signup/refresh ?redirect=",
      "code_ref": "packages/auth/src/redirect.ts:18",
      "evidence": "safeRedirect exported from packages/auth/src/index.ts; grep apps/zync-api/src/routes/auth shows zero imports/usages; login.ts signup.ts refresh.ts do not read or sanitize redirect query params.",
      "repro": ""
    },
    {
      "id": "foundation-auth-rbac-007",
      "severity": "P2",
      "type": "divergent",
      "summary": "Session cookies omit domain=.zync.is required by spec",
      "spec_ref": "Security Notes — CSRF / Set-Cookie domain=.zync.is",
      "code_ref": "apps/zync-api/src/lib/cookies.ts:19",
      "evidence": "BASE cookie options explicitly omit domain with comment 'No domain'; packages/auth/src/session.ts:29 defines SESSION_COOKIE_OPTS.domain='.zync.is' but cookies.ts does not use it.",
      "repro": ""
    },
    {
      "id": "foundation-auth-rbac-008",
      "severity": "P2",
      "type": "divergent",
      "summary": "GET /api/auth/me hardcodes twoFactorEnabled to false",
      "spec_ref": "API Endpoints — GET /api/auth/me response shape",
      "code_ref": "apps/zync-api/src/routes/auth/me.ts:44",
      "evidence": "Handler returns twoFactorEnabled: false with comment; users.two_factor_enabled column exists at packages/db/src/schema/users.ts:24 and getMeView does not select it (packages/db/src/queries/auth-reads.ts:88-111).",
      "repro": ""
    },
    {
      "id": "foundation-auth-rbac-009",
      "severity": "P2",
      "type": "divergent",
      "summary": "Password hashing uses 10k SHA-512 not spec 600k SHA-256 PBKDF2",
      "spec_ref": "Password Auth — PBKDF2 parameters",
      "code_ref": "packages/auth/src/password.ts:17",
      "evidence": "ITERATIONS = 10_000 and ALGO = 'SHA-512'; stored format pbkdf2sha512$; spec mandates 600,000 iterations SHA-256 32-byte output pbkdf2$600000$.",
      "repro": ""
    },
    {
      "id": "foundation-auth-rbac-010",
      "severity": "P2",
      "type": "divergent",
      "summary": "POST /api/auth/logout requires valid session; spec allows cookie-only idempotent 204",
      "spec_ref": "API Endpoints — POST /api/auth/logout",
      "code_ref": "apps/zync-api/src/routes/auth/index.ts:52",
      "evidence": "logoutRoute mounted behind protectedRoutes which applies authMiddleware; expired or missing access token yields 401 before handler can clear cookies and return 204.",
      "repro": ""
    },
    {
      "id": "foundation-auth-rbac-011",
      "severity": "P2",
      "type": "bug",
      "summary": "Refresh token cap allows 11th active token (off-by-one)",
      "spec_ref": "Security Notes — Concurrent session limit: 10 active refresh tokens",
      "code_ref": "apps/zync-api/src/routes/auth/refresh.ts:57",
      "evidence": "Guard is if (active > MAX_ACTIVE_TOKENS) with MAX_ACTIVE_TOKENS=10; when active==10 the check fails open and a new token is minted.",
      "repro": "Create 10 active refresh tokens for same user/tenant; POST /api/auth/refresh succeeds and inserts an 11th."
    },
    {
      "id": "foundation-auth-rbac-012",
      "severity": "P2",
      "type": "divergent",
      "summary": "Login exponential backoff helper defined but never enforced",
      "spec_ref": "Security Notes — exponential backoff after 5 failures",
      "code_ref": "apps/zync-api/src/lib/login-throttle.ts:48",
      "evidence": "backoffSeconds() exported; grep apps/zync-api shows only definition — login.ts calls isLockedOut/recordFailure but never backoffSeconds or Retry-After delay.",
      "repro": ""
    },
    {
      "id": "foundation-auth-rbac-013",
      "severity": "P2",
      "type": "divergent",
      "summary": "Password reset signed token is reusable until expiry",
      "spec_ref": "API Endpoints — POST /api/auth/reset-password 400 if already used",
      "code_ref": "apps/zync-api/src/routes/auth/password.ts:76",
      "evidence": "reset-password verifies JWT via verifySignedToken only; no KV/DB consumed flag; same token can reset password repeatedly within 1h TTL.",
      "repro": ""
    },
    {
      "id": "foundation-auth-rbac-014",
      "severity": "P2",
      "type": "divergent",
      "summary": "POST /api/auth/signup has no Origin header validation",
      "spec_ref": "Security Notes — Origin validation on state-mutating endpoints",
      "code_ref": "apps/zync-api/src/routes/auth/signup.ts:20",
      "evidence": "signupRoute.post handler has no Origin check; contrast authMiddleware apps/zync-api/src/middleware/auth.ts:31-36 and login.ts:51-54 which reject foreign origins.",
      "repro": ""
    },
    {
      "id": "foundation-auth-rbac-015",
      "severity": "P3",
      "type": "divergent",
      "summary": "Sixth ACCOUNTANT system role seeded beyond spec's five roles",
      "spec_ref": "RBAC — Built-in system roles",
      "code_ref": "packages/db/src/seed/permission-keys.ts:53",
      "evidence": "SYSTEM_ROLE_NAMES includes OWNER,ADMIN,MEMBER,VIEWER,CONTRACTOR,ACCOUNTANT; spec lists five roles only; seedSystemRoles comment says 5 but inserts all SYSTEM_ROLE_NAMES.",
      "repro": ""
    },
    {
      "id": "foundation-auth-rbac-016",
      "severity": "P3",
      "type": "divergent",
      "summary": "Invite landing path is /invite/accept not spec /invite?token",
      "spec_ref": "Invitation flow — lands on zync.is/invite?token",
      "code_ref": "apps/zync-www/src/pages/invite/accept.astro:3",
      "evidence": "www page at /invite/accept reads token from query; spec documents /invite?token=.",
      "repro": ""
    }
  ],
  "summary": { "P0": 4, "P1": 2, "P2": 8, "P3": 2, "total": 16 }
}
