{
  "slug": "customer-portal-access-control",
  "spec_file": "docs/specs/2026-05-31-customer-portal-access-control.md",
  "findings": [
    {
      "id": "customer-portal-access-control-001",
      "severity": "P0",
      "type": "security",
      "summary": "portal_sessions table absent — portal JWTs are stateless with no revocation lookup",
      "spec_ref": "Plan Architecture — portal_sessions (tenant-portals); Task 4 — portalAuthMiddleware verifies portal_sessions row",
      "code_ref": "packages/db/migrations/*.sql",
      "evidence": "grep portal_session across packages/db/migrations returns zero SQL matches; no Drizzle schema file; verifyPortalCookie (apps/zync-api/src/routes/portal/index.ts:54) calls verifyJwt only with no DB session lookup.",
      "repro": "Staff freeze portal user → existing portal_session cookie remains valid until JWT exp (8h).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-002",
      "severity": "P0",
      "type": "security",
      "summary": "Portal login authenticates any customer_contacts email — skips customer_portal_users status=active",
      "spec_ref": "Plan Consumes — customer_portal_users status active|frozen; Portal User Permissions",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:103",
      "evidence": "POST /auth/request calls findCustomerContactByEmail only (packages/db/src/queries/customer-portal.ts:81); grep customerPortalUsers in apps/zync-api/src/routes/portal returns zero matches.",
      "repro": "Contact exists with no active customer_portal_users row (or status=frozen) → POST /api/portal/auth/request + verify → portal_session issued.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-003",
      "severity": "P0",
      "type": "broken",
      "summary": "Magic-link request never sends email — token only inserted to DB",
      "spec_ref": "Overview — magic link auth; Plan upstream tenant-portals auth layer",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:106",
      "evidence": "Handler calls generatePortalToken (packages/db/src/queries/portal-auth.ts:59) which inserts magic_link_tokens only; grep sendEmail|queueEmail in apps/zync-api/src/routes/portal returns zero matches.",
      "repro": "POST /api/portal/auth/request { email, tenantId } → 200 { ok:true } but no email queued; UI expects manual token paste (login.tsx:54).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-004",
      "severity": "P0",
      "type": "broken",
      "summary": "Portal login API requires tenantId UUID but SPA routes pass tenant slug",
      "spec_ref": "Plan Route boundary — /api/portal/...; tenant-portals /portal/{tenantSlug}/login",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:79",
      "evidence": "requestSchema requires tenantId: z.string().uuid(); login.tsx:24 sends useParams tenantId; main.tsx:77 path portal/:tenantId/login; Playwright navigates /portal/demo/login (apps/zync-app/tests/portal-login.spec.ts:12).",
      "repro": "POST /api/portal/auth/request { email, tenantId: 'demo' } → 400 Invalid request.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-005",
      "severity": "P0",
      "type": "security",
      "summary": "No cross-tenant slug/tid validation — JWT tid never checked against URL tenant context",
      "spec_ref": "Architecture — customerId from JWT only; tenant isolation on every portal route",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:157",
      "evidence": "Data handlers use ps.tid from cookie JWT only; grep tenantSlug in apps/zync-api/src/routes/portal returns zero; SPA routes use /portal/:tenantId/* without passing slug to API for validation.",
      "repro": "Obtain portal JWT for tenant A; browse tenant B portal SPA with same cookie → API returns tenant A data under tenant B URL.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-006",
      "severity": "P0",
      "type": "security",
      "summary": "Portal invoice list exposes non-portal invoice statuses (SENT, VOID, etc.) — not restricted to TAX_ISSUED/PAID/PARTIALLY_PAID",
      "spec_ref": "Access Rules — Invoices status NOT IN DRAFT; Plan Task 3 — PORTAL_VISIBLE_INVOICE_STATUSES",
      "code_ref": "packages/db/src/queries/customer-portal.ts:137",
      "evidence": "getPortalInvoices filters ne(invoices.status, 'DRAFT') only; grep PORTAL_VISIBLE_INVOICE_STATUSES across repo returns zero matches in packages/db.",
      "repro": "Customer with SENT or VOID invoice → GET /api/portal/invoices returns those rows in items.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-007",
      "severity": "P1",
      "type": "missing",
      "summary": "portalQuery(db, tenantId, customerId) scoping factory absent",
      "spec_ref": "Query Filter — portalQuery factory; Plan Task 3",
      "code_ref": "packages/db/src/queries/",
      "evidence": "grep portalQuery across packages/db and apps/zync-api returns zero matches; no packages/db/src/queries/portalQuery.ts file.",
      "repro": "N/A — factory not implemented.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-008",
      "severity": "P1",
      "type": "missing",
      "summary": "portalAuthMiddleware and requirePortalAccess absent — ad-hoc verifyPortalCookie per route",
      "spec_ref": "API — portalAuthMiddleware required; Plan Task 4",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:54",
      "evidence": "grep portalAuthMiddleware|requirePortalAccess across apps/zync-api returns zero; each handler duplicates verifyPortalCookie locally (index.ts:54, files.ts:37).",
      "repro": "N/A — shared middleware not registered.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-009",
      "severity": "P1",
      "type": "missing",
      "summary": "PortalSessionClaims, PortalVisibility types and PORTAL_VISIBILITY_KEYS absent in @zync/types",
      "spec_ref": "Portal JWT Claims; Plan Task 1",
      "code_ref": "packages/types/src/",
      "evidence": "grep PortalSessionClaims|PortalVisibility|portal.ts in packages/types returns zero matches; no packages/types/src/portal.ts file.",
      "repro": "import { PortalSessionClaims } from '@zync/types' — module not found.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-010",
      "severity": "P1",
      "type": "missing",
      "summary": "DEFAULT_PORTAL_VISIBILITY and resolvePortalVisibility absent in @zync/config",
      "spec_ref": "Staff-Side Portal Visibility Config defaults; Plan Task 2",
      "code_ref": "packages/config/src/",
      "evidence": "grep DEFAULT_PORTAL_VISIBILITY|resolvePortalVisibility across packages/config returns zero matches; no packages/config/src/portal-visibility.ts.",
      "repro": "N/A — default merge helper not implemented.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-011",
      "severity": "P1",
      "type": "missing",
      "summary": "getPortalVisibility wave-10 domain helper absent",
      "spec_ref": "Plan Task 3 — getPortalVisibility reads tenant_settings.portal_visibility scoped by tenant_id",
      "code_ref": "packages/db/src/queries/settings-portal.ts:58",
      "evidence": "grep getPortalVisibility across packages/db returns zero; wave-11 getPortalSettings exists at settings-portal.ts:58 instead.",
      "repro": "N/A — wave-10 read path not implemented.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-012",
      "severity": "P1",
      "type": "missing",
      "summary": "requirePortalVisibility middleware absent — invoices/projects/tickets not gated by portal_visibility",
      "spec_ref": "Staff-Side Portal Visibility Config; Plan Task 4 requirePortalVisibility",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:157",
      "evidence": "grep requirePortalVisibility|show_invoices in apps/zync-api/src/routes/portal/index.ts returns zero; only files.ts:86 gates show_files via getPortalSettings.",
      "repro": "Set tenant_settings.portal_visibility.show_invoices=false → GET /api/portal/invoices still returns 200 with items.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-013",
      "severity": "P1",
      "type": "missing",
      "summary": "Portal access route group absent — no invoice detail/PDF, project detail, ticket detail/reply, contracts, proposals, time-summary",
      "spec_ref": "API portal-scoped routes; Plan Tasks 5–8 apps/zync-api/src/server/portal/access/",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts",
      "evidence": "grep /invoices/:id|/projects/:id|/tickets/:id|/contracts|/proposals|time-summary in apps/zync-api/src/routes/portal returns zero; grep server/portal across apps/zync-api returns zero; mounted routes: auth, list invoices/projects/tickets, files only.",
      "repro": "GET /api/portal/invoices/:id → 404; GET /api/portal/invoices/:id/pdf → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-014",
      "severity": "P1",
      "type": "missing",
      "summary": "Isolation enforcement tests absent",
      "spec_ref": "Plan Task 9 — apps/zync-api/test/portal-access-control.test.ts",
      "code_ref": "apps/zync-api/test/",
      "evidence": "glob portal-access-control.test.ts under apps/zync-api returns zero files; grep portal-access-control in apps/zync-api/test returns zero.",
      "repro": "N/A — test file not created.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-015",
      "severity": "P1",
      "type": "missing",
      "summary": "Staff authMiddleware does not reject portal JWT presented as Bearer on staff /api/* routes",
      "spec_ref": "Plan Task 4 — portal_customer JWT on staff /api/* → 403",
      "code_ref": "apps/zync-api/src/middleware/auth.ts:59",
      "evidence": "authMiddleware calls verifySession on zync_session or Authorization Bearer with no type/role check; grep portal_customer in apps/zync-api/src/middleware returns zero.",
      "repro": "PUT portal_session JWT value in Authorization: Bearer on a staff route using only authMiddleware (no requirePermission) → verifySession succeeds if signature valid.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-016",
      "severity": "P2",
      "type": "divergence",
      "summary": "Portal JWT claims use type:'portal' and omit role:'portal_customer' and portalRole",
      "spec_ref": "Portal JWT Claims — sub, customerId, tenantId, portalRole; Plan Task 1 PortalSessionClaims",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:128",
      "evidence": "verify issues payload { type:'portal', tid, customerId, sub } — no portalRole, no role:'portal_customer', tenantId key is tid not tenantId.",
      "repro": "Decode portal_session JWT → missing portalRole and role claims required by spec/plan.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-017",
      "severity": "P2",
      "type": "divergence",
      "summary": "tenant_settings.portal_visibility column DEFAULT '{}' not spec DEFAULT_PORTAL_VISIBILITY object",
      "spec_ref": "Staff-Side Portal Visibility Config; Plan Task 3 ALTER DEFAULT",
      "code_ref": "packages/db/migrations/0011_lethal_the_spike.sql:11",
      "evidence": "Migration: ADD COLUMN portal_visibility jsonb DEFAULT '{}'::jsonb; schema tenants.ts:126-128 mirrors default({}). Plan requires NOT NULL DEFAULT with all 7 keys true/false per DEFAULT_PORTAL_VISIBILITY.",
      "repro": "New tenant row → portal_visibility={}; show_invoices undefined until staff saves settings.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-018",
      "severity": "P2",
      "type": "divergence",
      "summary": "Files visibility gate uses getPortalSettings without resolvePortalVisibility merge",
      "spec_ref": "Plan Task 4 — requirePortalVisibility loads getPortalVisibility + resolvePortalVisibility",
      "code_ref": "apps/zync-api/src/routes/portal/files.ts:85",
      "evidence": "showFiles = settings?.portal_visibility?.show_files truthy check; no resolvePortalVisibility call; empty {} defaults show_files to falsy (404) rather than spec default true for files false only.",
      "repro": "Fresh tenant with portal_visibility={} → GET /api/portal/files returns 404 even though spec default show_files is false (expected) but also cannot distinguish unset vs disabled without merge helper.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-019",
      "severity": "P2",
      "type": "divergence",
      "summary": "customer_portal_users.modules_enabled not enforced on portal data routes",
      "spec_ref": "Plan Consumes — modules_enabled on customer_portal_users; portal-access staff config",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:157",
      "evidence": "portal-access.ts stores modulesEnabled on customer_portal_users; grep modulesEnabled|modules_enabled in apps/zync-api/src/routes/portal returns zero.",
      "repro": "Enable portal with modules=['invoices'] only → GET /api/portal/projects still returns projects.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-020",
      "severity": "P2",
      "type": "divergence",
      "summary": "portal_can_submit_tickets tenant setting not checked before POST /api/portal/tickets",
      "spec_ref": "tenant_settings portal_can_submit_tickets; Portal User Permissions — tickets create",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:190",
      "evidence": "POST /tickets handler calls createPortalTicket without reading portal_can_submit_tickets; grep portal_can_submit in apps/zync-api/src/routes/portal returns zero.",
      "repro": "portal_can_submit_tickets=false (default) → POST /api/portal/tickets still creates ticket (201).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-021",
      "severity": "P2",
      "type": "divergence",
      "summary": "Portal project list exposes billingType and currency beyond spec project detail fields",
      "spec_ref": "GET /portal/api/projects/:id — name, status, description, milestones only",
      "code_ref": "packages/db/src/queries/customer-portal.ts:166",
      "evidence": "getPortalProjects selects billingType, currency, startDate, endDate in addition to name/status/description; no project detail route exists to further trim fields.",
      "repro": "GET /api/portal/projects → items include billingType and currency fields.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-022",
      "severity": "P2",
      "type": "divergence",
      "summary": "Portal invoice list lacks cursor pagination and status/from/to query filters",
      "spec_ref": "GET /portal/api/invoices query status?, from?, to?, cursor; Plan Task 5",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:157",
      "evidence": "GET /invoices handler calls getPortalInvoices with no query parsing; getPortalInvoices has fixed limit(100) and no cursor/status/date filters (customer-portal.ts:111).",
      "repro": "GET /api/portal/invoices?status=PAID&cursor=... → ignores filters; returns first 100 rows only.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-023",
      "severity": "P2",
      "type": "divergence",
      "summary": "Per-customer scoping uses legacy getPortalInvoices helpers not portalQuery factory",
      "spec_ref": "Query Filter — every portal route via portalQuery; Architecture — typed factory prevents forgotten filter",
      "code_ref": "packages/db/src/queries/customer-portal.ts:111",
      "evidence": "Routes import getPortalInvoices/getPortalProjects/getPortalTickets from customer-portal.ts (tenant-portals wave-9) not portalQuery; ad-hoc helpers accept tenantId+customerId as caller args from route JWT extraction.",
      "repro": "N/A — partial scoping exists but not via spec-mandated factory pattern.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-024",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Portal access routes live under routes/portal not server/portal/access per plan",
      "spec_ref": "Plan Tech Stack — apps/zync-api/src/server/portal/access/",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:1",
      "evidence": "glob apps/zync-api/src/server/portal/** returns zero files; implementation at apps/zync-api/src/routes/portal/.",
      "repro": "N/A — directory layout differs from plan only.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "customer-portal-access-control-025",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Spec API prefix /portal/api/* diverges from implemented /api/portal/* (plan-reconciled)",
      "spec_ref": "API — GET /portal/api/invoices; Plan Notes — route prefix aligned to /api/portal/...",
      "code_ref": "apps/zync-api/src/routes/index.ts:361",
      "evidence": "routes.route('/portal', portalRouter) under /api mount → /api/portal/invoices; spec text uses /portal/api/invoices.",
      "repro": "N/A — intentional plan reconciliation; spec text stale.",
      "fix_direction": "spec_review"
    },
    {
      "id": "customer-portal-access-control-026",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Spec POST /portal/auth/session diverges from implemented POST /api/portal/auth/verify (plan-reconciled)",
      "spec_ref": "Portal JWT Claims — issued by POST /portal/auth/session",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:115",
      "evidence": "grep auth/session in apps/zync-api/src/routes/portal returns zero; verify endpoint at :115 issues JWT cookie.",
      "repro": "N/A — plan uses /auth/verify not /auth/session.",
      "fix_direction": "spec_review"
    }
  ],
  "summary": {
    "P0": 6,
    "P1": 9,
    "P2": 8,
    "P3": 3,
    "total": 26
  }
}
