{
  "slug": "kb-module",
  "spec_file": "docs/specs/2026-05-30-kb-module.md",
  "findings": [
    {
      "id": "kb-module-001",
      "severity": "P0",
      "type": "security",
      "summary": "POST /api/kb/articles inserts into foreign-tenant space_id without verifying space ownership",
      "spec_ref": "Plan Task 3/6 — all reads/writes tenant-scoped; Architecture tenant_id row isolation",
      "code_ref": "apps/zync-api/src/routes/kb.ts:213",
      "evidence": "Handler calls q.createArticle({ spaceId: data.space_id, ... }) after fractional position lookup; no q.getSpaceById(data.space_id) or equivalent tenant+space check. kb_articles.space_id FK references kb_spaces(id) only — cross-tenant space UUID is accepted.",
      "repro": "Session tenant B + space_id UUID from tenant A → POST /api/kb/articles { space_id, title, slug, content } → 201 with tenant_id=B linked to tenant A space.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-002",
      "severity": "P0",
      "type": "security",
      "summary": "PATCH /api/kb/spaces/:id accepts customer_id without tenant customer ownership check",
      "spec_ref": "Plan Task 5 — vault create rejects customer_id not owned by tenant",
      "code_ref": "apps/zync-api/src/routes/kb.ts:134",
      "evidence": "POST /spaces validates via q.verifyCustomerBelongsToTenant at :97-101; PATCH passes customer_id straight to updateSpace at :137 with no verifyCustomerBelongsToTenant call.",
      "repro": "PATCH /api/kb/spaces/:id { customer_id: <foreign-tenant-customer-uuid> } → 200; vault space can be bound to another tenant's customer row.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-003",
      "severity": "P0",
      "type": "security",
      "summary": "portal-kb router never validates portal_session cookie — c.get('session') is always unset",
      "spec_ref": "Plan Task 8 — portal session → customer_id; Portal attachment URL enforces customer_id checks",
      "code_ref": "apps/zync-api/src/routes/portal-kb.ts:25",
      "evidence": "getPortalSession reads c.get('session').customerId; portalKbRouter has no auth/portal middleware (contrast portal/index.ts:54 verifyPortalCookie on portal_session). grep verifyPortalCookie in portal-kb.ts → 0 matches.",
      "repro": "GET /api/portal-kb/spaces with valid portal_session cookie → 401 Unauthorized (session never populated). Unauthenticated → 401. Portal KB API unreachable for portal users.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-004",
      "severity": "P0",
      "type": "broken",
      "summary": "Portal KB UI fetches /portal/:tenantSlug/api/kb/* which is not proxied to the API worker",
      "spec_ref": "Portal equivalent /portal/:tenantSlug/kb/:spaceSlug/:articleSlug; Plan Task 15 portal API endpoints",
      "code_ref": "apps/zync-app/src/features/portal/kb/PortalKbPage.tsx:19",
      "evidence": "PortalKbPage fetches `/portal/${tenantSlug}/api/kb/spaces`. apps/zync-app/src/worker.ts:18 proxies only paths starting with /api/, /oauth/, /f/, /status — not /portal/*/api/*. API mounts portal KB at /api/portal-kb (apps/zync-api/src/routes/index.ts:244).",
      "repro": "Log in as portal user → open /portal/:tenantSlug/kb → network shows GET /portal/:slug/api/kb/spaces → SPA 404/HTML, not JSON.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-005",
      "severity": "P0",
      "type": "broken",
      "summary": "AttachmentsFooter portalMode calls wrong API prefix /api/portal/kb",
      "spec_ref": "Plan Task 15 — portal attachment downloads use portal signed-URL endpoint",
      "code_ref": "apps/zync-app/src/features/kb/components/AttachmentsFooter.tsx:111",
      "evidence": "apiBase = portalMode ? '/api/portal/kb' : '/api/kb'. grep 'portal/kb' in apps/zync-api → 0 route mounts; actual router is /api/portal-kb (routes/index.ts:244).",
      "repro": "Portal article page with attachment → click download → GET /api/portal/kb/attachments/:id/url → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-006",
      "severity": "P0",
      "type": "security",
      "summary": "Inline editor images stored/served as public CDN URLs, not 60-min signed R2 URLs",
      "spec_ref": "Security Notes — images embedded via signed URL not inline; R2 keys never exposed; vault files confidential",
      "code_ref": "apps/zync-api/src/routes/kb.ts:494",
      "evidence": "POST /articles/:id/images comment: 'Return a stable public CDN URL (not signed — embedded images must be accessible to anonymous public-KB readers indefinitely)'. Returns KB_IMAGE_DOMAIN URL or /api/kb/images/... — no signKbUrl call.",
      "repro": "Upload image in vault article → content embeds permanent public URL → unauthenticated fetch succeeds without portal/customer check.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-007",
      "severity": "P1",
      "type": "missing",
      "summary": "kb:publish permission seeded but never enforced on publish paths",
      "spec_ref": "Permissions — Publish articles requires kb:publish; Plan Task 6 acceptance",
      "code_ref": "apps/zync-api/src/routes/kb.ts:347",
      "evidence": "grep requirePermission('kb:publish') in apps/ → 0 matches. POST /articles/:id/publish uses requirePermission('kb:write') at :347; PATCH /articles/:id allows status=PUBLISHED under kb:write at :248-296.",
      "repro": "Grant user kb:write without kb:publish → POST /api/kb/articles/:id/publish → 200 PUBLISHED.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-008",
      "severity": "P1",
      "type": "missing",
      "summary": "Fallback /api/kb/images/* route referenced by image upload but not implemented",
      "spec_ref": "Article Editor — paste/drag image uploads to R2 → embedded as signed URL in content",
      "code_ref": "apps/zync-api/src/routes/kb.ts:499",
      "evidence": "Image upload returns `/api/kb/images/${encodeURIComponent(r2Key)}` when KB_IMAGE_DOMAIN unset. grep 'kb/images' route handler in apps/zync-api/src → only this string literal in kb.ts; no GET handler registered.",
      "repro": "POST /api/kb/articles/:id/images without KB_IMAGE_DOMAIN env → editor embeds /api/kb/images/... URL → GET returns 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-009",
      "severity": "P2",
      "type": "divergent",
      "summary": "Editor auto-save debounce is 30s, spec requires 2s",
      "spec_ref": "Article Editor — Auto-save: debounced 2s → PATCH /api/kb/articles/:id",
      "code_ref": "apps/zync-app/src/features/kb/components/ArticleEditor.tsx:67",
      "evidence": "useDebounce(pendingContent, 30_000) and useDebounce(titleDirty, 30_000); file header comment also says '30 s debounce'.",
      "repro": "Edit article body → 'Unsaved changes' persists ~30s before PATCH fires.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-010",
      "severity": "P2",
      "type": "divergent",
      "summary": "UI permission stubs always return true — edit button and edit route unguarded client-side",
      "spec_ref": "Article Read View — Edit button shown to kb:write; Article editor route guarded with kb:write",
      "code_ref": "apps/zync-app/src/features/kb/ArticleReadPage.tsx:28",
      "evidence": "useHasPermission(_perm) { return true } with comment 'wave-3: permission context wired post-integration' in ArticleReadPage:28-30 and ArticleEditPage:31-33.",
      "repro": "User without kb:write opens /kb/:space/:slug → Edit link visible; /edit route does not redirect (guard checks hasKbWrite which is always true).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-011",
      "severity": "P2",
      "type": "divergent",
      "summary": "Portal published-article queries omit deletedAt filter — soft-deleted articles may appear",
      "spec_ref": "Portal customers read PUBLISHED articles in accessible vaults only",
      "code_ref": "packages/db/src/queries/kb.ts:691",
      "evidence": "kbPortalQuery.listPublishedArticleTree filters status=PUBLISHED but no isNull(kbArticles.deletedAt). Staff kbTenantQuery.listArticleTree filters deletedAt at :141.",
      "repro": "Soft-delete a previously PUBLISHED vault article → portal tree/detail may still return it.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-012",
      "severity": "P2",
      "type": "divergent",
      "summary": "DELETE /api/kb/articles/:id soft-deletes; spec/plan expect hard delete with cascade",
      "spec_ref": "API DELETE /api/kb/articles/:id → delete; Plan Task 6 DELETE hard-delete + vector removal",
      "code_ref": "apps/zync-api/src/routes/kb.ts:321",
      "evidence": "Route calls softDeleteArticle (sets deleted_at subtree) at :321; comment 'soft delete'. Spec DDL has no deleted_at on kb_articles.",
      "repro": "DELETE /api/kb/articles/:id → row remains in DB with deleted_at set; staff tree hides via isNull(deletedAt) filter.",
      "fix_direction": "spec_review"
    },
    {
      "id": "kb-module-013",
      "severity": "P2",
      "type": "divergent",
      "summary": "kb_articles.status CHECK allows PENDING_REVIEW; kb-module spec enum is DRAFT|PUBLISHED only",
      "spec_ref": "Data Model kb_articles.status DRAFT|PUBLISHED; Permissions publish flow",
      "code_ref": "packages/db/src/schema/kb.ts:109",
      "evidence": "statusCheck allows ('DRAFT','PENDING_REVIEW','PUBLISHED'). packages/types/src/kb.ts:106 types status as DRAFT|PUBLISHED only. Review workflow added via kb-review routes (out of kb-module spec scope).",
      "repro": "Article can enter PENDING_REVIEW via submit-review; not described in kb-module spec.",
      "fix_direction": "spec_review"
    },
    {
      "id": "kb-module-014",
      "severity": "P2",
      "type": "divergent",
      "summary": "PortalArticlePage omits RelatedArticles sibling links present in staff read view",
      "spec_ref": "Article Read View — Related: sibling articles max 5 ordered by position; Plan Task 15 reuse RelatedArticles",
      "code_ref": "apps/zync-app/src/features/portal/kb/PortalArticlePage.tsx:118",
      "evidence": "PortalArticlePage renders ArticleRenderer + AttachmentsFooter + ArticleToc only. ArticleReadPage imports RelatedArticles at :24,:161. grep RelatedArticles in PortalArticlePage → 0.",
      "repro": "Open portal article with siblings → no 'Related articles' section.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-015",
      "severity": "P2",
      "type": "divergent",
      "summary": "Vault spaces sidebar groups by customer_id but shows generic label, not customer name",
      "spec_ref": "Spaces List — Vault spaces grouped by customer below",
      "code_ref": "apps/zync-app/src/features/kb/components/SpacesSidebar.tsx:175",
      "evidence": "vaultsByCustomer Map keyed by customerId; each group header renders t('kb.vaultGroup') ('Client vaults') not customer name. NewSpaceModal loads customer names (:15-26) but sidebar does not use them.",
      "repro": "Create vaults for Acme Corp and Beta Ltd → sidebar shows two identical 'Client vaults' headers.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-016",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "KbModule index/space placeholder routes use hardcoded English strings",
      "spec_ref": "Plan Task 16 — no hardcoded English in KB JSX; all via t()",
      "code_ref": "apps/zync-app/src/modules/kb/index.tsx:55",
      "evidence": "Index route element: 'Select a space to browse articles.'; :spaceSlug route: 'Select an article from the sidebar.' — literal English, no t() call.",
      "repro": "Open /kb with Hebrew locale → placeholder text stays English.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "kb-module-017",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Plan Task 16 cites packages/i18n/locales/{en,he}/kb.json; strings live in packages/ui i18n",
      "spec_ref": "Plan Task 16 — packages/i18n/locales/en/kb.json + he/kb.json",
      "code_ref": "packages/ui/src/i18n/en.json:339",
      "evidence": "glob packages/i18n/locales/**/kb.json → 0 files. grep '\"kb.' packages/ui/src/i18n/en.json → 80 keys; he.json → 80 keys (parity present).",
      "repro": "Plan file paths do not exist; i18n delivered via @zync/ui bundle instead.",
      "fix_direction": "spec_review"
    }
  ],
  "summary": {
    "P0": 6,
    "P1": 2,
    "P2": 7,
    "P3": 2,
    "total": 17
  }
}
