{
  "audited_against_commit": "3a08d594cac219a673dafde36d3a511b483fca50",
  "summary": {
    "p0": 6,
    "p1": 14,
    "p2": 5,
    "already_fixed": 0,
    "stale_wrong": 0
  },
  "findings": [
    {
      "id": "tp-001",
      "severity": "P0",
      "status": "absent",
      "title": "portal_sessions table and stateful JWT revocation missing",
      "spec_ref": "§ Portal JWT expiry and revocation (portal_sessions schema, lines 184–200)",
      "code_ref": "ABSENT — packages/db has contractor_portal_sessions only; no portal_sessions migration or Drizzle schema",
      "detail": "Spec requires SHA-256 token_hash rows in portal_sessions verified on every request with revoked_at support. Implementation mints a stateless JWT via signJwt with no DB session row and no revocation path.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-002",
      "severity": "P0",
      "status": "absent",
      "title": "portalAuthMiddleware missing; ad-hoc cookie verify only",
      "spec_ref": "§ Permissions / Customer Portal RBAC (lines 129–136); API auth endpoints (lines 147–150)",
      "code_ref": "ABSENT — apps/zync-api/src/middleware/portalAuth.ts; inline verifyPortalCookie in apps/zync-api/src/routes/portal/index.ts:54-73",
      "detail": "No dedicated middleware gates /api/portal/*. Each route duplicates portal_session cookie parsing and verifyJwt. No portal_sessions lookup, no role=portal_customer assertion, no shared requirePortalScope helper.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-003",
      "severity": "P0",
      "status": "divergent",
      "title": "Portal login authenticates any customer_contacts email, not active customer_portal_users",
      "spec_ref": "§ Authentication (lines 22–27); POST /api/portal/auth/login",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:103-107; packages/db/src/queries/customer-portal.ts:81-103",
      "detail": "auth/request and validatePortalToken resolve customer via customer_contacts.email only. customer_portal_users.status='active' is never checked. Any contact email in the tenant can obtain a portal JWT even without a portal user row or when frozen.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-004",
      "severity": "P0",
      "status": "divergent",
      "title": "Frozen/revoked portal users not blocked on portal API requests",
      "spec_ref": "§ Force re-auth (lines 268–268); Permissions (lines 129–136)",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:54-73,157-203; apps/zync-api/src/routes/customers/portal.ts:124-156",
      "detail": "Staff freeze sets customer_portal_users.status='frozen' and bumps user_version on linked users row, but portal routes only verify the stateless portal_session JWT (type=portal). No per-request status re-check; frozen contacts retain API access until JWT expires (8h).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-005",
      "severity": "P0",
      "status": "divergent",
      "title": "Portal JWT uses type=portal not role=portal_customer",
      "spec_ref": "§ Authentication (line 26); Architecture Decisions (line 274)",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:47-52,128-133; packages/auth/src — no portal token helpers",
      "detail": "JWT payload is {type:'portal', tid, customerId, sub} with cookie name portal_session. Spec requires role='portal_customer', zync_portal_session cookie, and explicit staff/portal auth boundary via middleware role checks.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-006",
      "severity": "P0",
      "status": "divergent",
      "title": "Portal invoice list exposes non-portal invoice statuses",
      "spec_ref": "§ My Invoices (lines 58–66); GET /api/portal/invoices",
      "code_ref": "packages/db/src/queries/customer-portal.ts:111-143",
      "detail": "getPortalInvoices filters only ne(status,'DRAFT'). Spec allows TAX_ISSUED, PAID, PARTIALLY_PAID only. Current query also returns SENT, APPROVED, REJECTED, VOID, WRITTEN_OFF, BAD_DEBT to portal customers.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-007",
      "severity": "P1",
      "status": "divergent",
      "title": "Magic-link request never sends email",
      "spec_ref": "§ Authentication magic link (lines 25, 149); POST /api/portal/auth/magic",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:88-111",
      "detail": "auth/request generates a magic_link_tokens row but never calls sendEmail. SPA instructs user to paste token manually (apps/zync-app/src/routes/portal/login.tsx). No email is dispatched.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-008",
      "severity": "P1",
      "status": "absent",
      "title": "Email+password portal login missing",
      "spec_ref": "§ Authentication (lines 23–24); POST /api/portal/auth/login",
      "code_ref": "ABSENT — only POST /api/portal/auth/request and /auth/verify in apps/zync-api/src/routes/portal/index.ts",
      "detail": "No login endpoint verifies users.password_hash for customer_portal_users. Only magic-token flow exists.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-009",
      "severity": "P1",
      "status": "absent",
      "title": "Portal password reset flow missing",
      "spec_ref": "§ Portal Password Reset (lines 202–233)",
      "code_ref": "ABSENT — no /api/portal/auth/forgot-password or /reset-password routes; no reset-password SPA page",
      "detail": "Forgot-password (204-always, rate-limited), reset-password, and portal_sessions invalidation on password change are not implemented.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-010",
      "severity": "P1",
      "status": "absent",
      "title": "Portal session refresh, logout, and expiry guard missing",
      "spec_ref": "§ Portal Session Refresh and Re-authentication (lines 235–261)",
      "code_ref": "ABSENT — no POST /api/portal/auth/refresh or /logout; no usePortalSession in apps/zync-app",
      "detail": "No refresh rotation, no logout revocation, no client-side exp check with session_expired banner or portal_return_to redirect. JWT TTL hardcoded to 8h (PORTAL_JWT_TTL) vs spec 4h.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-011",
      "severity": "P1",
      "status": "divergent",
      "title": "Portal routing uses tenantId UUID not tenantSlug",
      "spec_ref": "§ Overview / Customer Portal URL (lines 12, 18, 23)",
      "code_ref": "apps/zync-app/src/main.tsx:81-85; apps/zync-api/src/routes/portal/index.ts:77-79",
      "detail": "SPA routes are /portal/:tenantId/* and API auth/request requires tenantId UUID. Spec requires /portal/{tenantSlug}/ with slug→tenant resolution and cross-tenant slug validation against JWT tenantId. KB routes alone use :tenantSlug, creating inconsistency.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-012",
      "severity": "P1",
      "status": "divergent",
      "title": "Portal KB SPA calls non-existent proxy paths; API uses wrong auth",
      "spec_ref": "§ Shared Knowledge Base (lines 78–82); GET /api/portal/kb/spaces",
      "code_ref": "apps/zync-app/src/features/portal/kb/PortalKbPage.tsx:19; apps/zync-api/src/routes/portal-kb.ts:25-38,247",
      "detail": "SPA fetches /portal/{tenantSlug}/api/kb/* (not proxied by zync-app worker — only /api/* is). API mounts at /api/portal-kb and reads c.get('session') (staff session) with no portal_session middleware, so it always 401s for portal customers. Path /api/portal/kb does not exist.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-013",
      "severity": "P1",
      "status": "absent",
      "title": "GET /api/portal/dashboard endpoint missing",
      "spec_ref": "§ My Dashboard (lines 33–47); GET /api/portal/dashboard",
      "code_ref": "ABSENT — dashboard SPA aggregates client-side from invoices/projects/tickets (apps/zync-app/src/routes/portal/dashboard.tsx)",
      "detail": "No server-side dashboard widget aggregation (outstanding balance, active projects, open tickets, unread KB). Client computes partial stats from raw list endpoints.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-014",
      "severity": "P1",
      "status": "absent",
      "title": "Portal invoice HTML view and Pay Now endpoints missing",
      "spec_ref": "§ My Invoices actions (lines 62–66); GET /api/portal/invoices/:id/html; POST .../pay",
      "code_ref": "ABSENT — apps/zync-api/src/routes/portal/index.ts has only GET /invoices list",
      "detail": "No invoice HTML rendering, no payment initiation via getPaymentAdapter, no status filter dropdown (All/Tax Issued/Paid/Partially Paid) in API or SPA.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-015",
      "severity": "P1",
      "status": "absent",
      "title": "Portal ticket detail, reply, and thread missing",
      "spec_ref": "§ My Support Tickets (lines 68–76); GET/POST /api/portal/tickets/:id",
      "code_ref": "ABSENT — only GET/POST list/create in apps/zync-api/src/routes/portal/index.ts; no TicketDetail SPA page",
      "detail": "No ticket detail route, no reply endpoint, no ticket_messages thread with author_type=customer, no category picker or attachment upload per spec.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-016",
      "severity": "P1",
      "status": "absent",
      "title": "Portal proposals and profile pages/routes missing",
      "spec_ref": "§ Proposals (lines 84–86); § Profile (lines 88–90); API lines 164–166",
      "code_ref": "ABSENT — no /api/portal/proposals, /profile, /profile/password; no SPA routes",
      "detail": "Proposals list and authenticated profile (name, locale, password change, notification prefs) are not implemented.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-017",
      "severity": "P1",
      "status": "absent",
      "title": "Dedicated /portal/{slug}/projects page missing",
      "spec_ref": "§ My Projects (lines 49–56)",
      "code_ref": "ABSENT — no apps/zync-app/src/routes/portal/projects.tsx; API GET /projects exists but no page route in main.tsx",
      "detail": "Projects API list endpoint exists but no portal projects page with read-only project cards, progress %, or customer-visible description per spec.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-018",
      "severity": "P1",
      "status": "divergent",
      "title": "Portal files download SPA path does not match API",
      "spec_ref": "portal-file-sharing § Download; GET /api/portal-files/:id/download (shared)",
      "code_ref": "apps/zync-app/src/routes/portal/files.tsx:59; apps/zync-api/src/routes/portal-files.ts:240",
      "detail": "SPA calls GET /api/portal/files/:id/download (no such route). Actual shared download is GET /api/portal-files/:id/download with dual staff/portal auth.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-019",
      "severity": "P1",
      "status": "absent",
      "title": "Schema deltas portal_sessions, customer_portal_users.locale, portal_max_session_hours absent",
      "spec_ref": "§ Locale & RTL schema delta (lines 182, 263–266); portal_sessions (lines 188–197)",
      "code_ref": "ABSENT — packages/db/src/schema/customers.ts:99-132 has no locale column; tenant_settings has portal_visibility but no portal_max_session_hours",
      "detail": "Required ALTER TABLE deltas from tenant-portals plan Task 1 are not present in schema or migrations.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-020",
      "severity": "P1",
      "status": "absent",
      "title": "PortalShell, white-label CSS injection, and portal locale resolution missing",
      "spec_ref": "§ White-Label Customization (lines 110–121); § Locale & RTL (lines 171–180)",
      "code_ref": "ABSENT — no PortalShell.tsx; portal pages use staff useLocale from browser localStorage",
      "detail": "No server-side logo/brand-color CSS variables, no portal-specific LocaleProvider (customer_portal_users.locale → tenant default → he), no CSP on portal shell.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-021",
      "severity": "P1",
      "status": "absent",
      "title": "Staff revoke-all-portal-sessions endpoint missing",
      "spec_ref": "§ Force re-auth (lines 268–268); POST revoke-sessions",
      "code_ref": "ABSENT — apps/zync-api/src/routes/customers/portal.ts has freeze/unfreeze only, no revokeAllPortalSessions",
      "detail": "Freeze bumps user_version but without portal_sessions there is nothing to revoke. No POST /api/customers/:id/portal-users/:uid/revoke-sessions endpoint per plan Task 10.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-022",
      "severity": "P2",
      "status": "absent",
      "title": "portalQuery scoping factory absent",
      "spec_ref": "customer-portal-access-control § portalQuery factory",
      "code_ref": "ABSENT — ad-hoc tenantId+customerId WHERE in packages/db/src/queries/customer-portal.ts; kbPortalQuery exists separately",
      "detail": "No unified portalQuery(db, tenantId, customerId) factory enforcing dual scope on all portal domains as spec 82 requires.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-023",
      "severity": "P2",
      "status": "divergent",
      "title": "Magic-link auth uses POST verify with manual token paste, not email link flow",
      "spec_ref": "§ Authentication (line 25); GET /api/portal/auth/magic/verify redirect",
      "code_ref": "apps/zync-app/src/routes/portal/login.tsx:31-37; apps/zync-api/src/routes/portal/index.ts:115-152",
      "detail": "Spec: email link to /portal/{tenantSlug}/magic?token= → verify → redirect dashboard. Implementation: POST /auth/verify with pasted token; no /magic SPA callback route; auth/request returns 200 not 204.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-024",
      "severity": "P2",
      "status": "absent",
      "title": "Staff Portal RBAC sidebar specialization not implemented",
      "spec_ref": "§ Staff Portal (lines 94–106)",
      "code_ref": "ABSENT — no STAFF/READ_ONLY reduced sidebar logic tied to portal terminology in apps/zync-app/src/shell/Sidebar.tsx",
      "detail": "Staff portal is described as main app with permission-scoped sidebar. No portal-specific RBAC sidebar rendering for STAFF/READ_ONLY roles found.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tp-025",
      "severity": "P2",
      "status": "divergent",
      "title": "Existing portal data routes scope customer_id from JWT but skip visibility gates",
      "spec_ref": "customer-portal-access-control § portal_visibility; spec API endpoints",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:157-203 — no requirePortalVisibility; portal/files.ts gates show_files only",
      "detail": "Invoices, projects, tickets list routes do not check tenant_settings.portal_visibility flags (show_invoices, show_projects, show_tickets). Files route gates correctly.",
      "fix_direction": "code_to_spec"
    }
  ]
}
