{
  "spec": "kb-module",
  "audited_at": "2026-06-12",
  "findings": [
    {
      "id": "km-001",
      "severity": "P0",
      "status": "fixed",
      "summary": "POST /api/kb/articles now verifies space_id belongs to caller's tenant via assertTenantOwnsKbSpace before create",
      "evidence": "apps/zync-api/src/routes/kb.ts:245-247 — `if (!(await assertTenantOwnsKbSpace(db, session.tid, data.space_id))) { return c.json(invalidTenantReferenceBody('space_id'), 400) }` (guard in packages/db/src/queries/tenant-guards.ts:96-107 checks kb_spaces.tenant_id)"
    },
    {
      "id": "km-002",
      "severity": "P0",
      "status": "fixed",
      "summary": "PATCH /api/kb/spaces/:id now verifies customer_id belongs to tenant before update",
      "evidence": "apps/zync-api/src/routes/kb.ts:162-167 — `if (data.customer_id !== undefined && data.customer_id !== null) { const valid = await q.verifyCustomerBelongsToTenant(data.customer_id); if (!valid) { return c.json({ error: 'Customer not found for this tenant' }, 422) } }`"
    },
    {
      "id": "km-003",
      "severity": "P0",
      "status": "fixed",
      "summary": "Portal KB router applies portalAuthMiddleware (zync_portal_session cookie) on all routes",
      "evidence": "apps/zync-api/src/routes/portal/kb.ts:19 — `portalKbRoutes.use('*', portalAuthMiddleware)`; mounted at apps/zync-api/src/routes/portal/index.ts:93 `portalRouter.route('/kb', portalKbRoutes)` → /api/portal/kb/*"
    },
    {
      "id": "km-004",
      "severity": "P0",
      "status": "fixed",
      "summary": "Active portal KB UI fetches /api/portal/kb/* (proxied via /api/*), not the stale /portal/:tenantSlug/api/kb/* path",
      "evidence": "apps/zync-app/src/portal/api.ts:14,268-269 — `const BASE = '/api/portal'`; `portalFetch('/kb/spaces')`. Wired in apps/zync-app/src/portal/portalRoutes.tsx:61-63. apps/zync-app/src/worker.ts:25-28 proxies `/api/*` to API worker. Stale dead code remains at apps/zync-app/src/features/portal/kb/PortalKbPage.tsx:19 (`/portal/${tenantSlug}/api/kb/spaces`) but is not routed."
    },
    {
      "id": "km-005",
      "severity": "P0",
      "status": "fixed",
      "summary": "AttachmentsFooter portalMode uses /api/portal/kb prefix, matching the API mount at /api/portal/kb",
      "evidence": "apps/zync-app/src/features/kb/components/AttachmentsFooter.tsx:111 — `const apiBase = portalMode ? '/api/portal/kb' : '/api/kb'`. API mount: apps/zync-api/src/routes/index.ts:372 `routes.route('/portal', portalRouter)` + apps/zync-api/src/routes/portal/index.ts:93 `portalRouter.route('/kb', portalKbRoutes)`."
    },
    {
      "id": "km-006",
      "severity": "P0",
      "status": "open",
      "summary": "Inline editor image upload still returns stable public CDN URLs (or /api/kb/images/ fallback), not 60-min signed R2 URLs as spec requires",
      "evidence": "apps/zync-api/src/routes/kb.ts:544-549 — comment: 'Return a stable public CDN URL (not signed — embedded images must be accessible to anonymous public-KB readers indefinitely)'; `const url = imageDomain ? \\`${imageDomain}/${r2Key}\\` : \\`/api/kb/images/${encodeURIComponent(r2Key)}\\`` — no signKbUrl() call (contrast attachments at :644 which uses signKbUrl). Spec Security Notes: 'Images embedded in Tiptap content stored in R2 and served via signed URL — not inline base64.'"
    }
  ]
}
