{
  "audited_at": "2026-06-17",
  "spec": "kb-module",
  "findings": [
    {
      "id": "km-006",
      "severity": "P0",
      "title": "Inline editor image upload returns dead public/proxy URLs instead of signKbUrl presigned GET",
      "location": "apps/zync-api/src/routes/kb.ts:544-549",
      "evidence": "POST /articles/:id/images uploads via putKbObject then builds `const imageDomain = c.env.KB_IMAGE_DOMAIN ?? ''`; `const url = imageDomain ? \\`${imageDomain}/${r2Key}\\` : \\`/api/kb/images/${encodeURIComponent(r2Key)}\\``. No signKbUrl() call (attachments use signKbUrl at :644). Comment still says 'stable public CDN URL (not signed)'. Bucket zync-storage is private (R2_PUBLIC_URL/KB_IMAGE_DOMAIN unset) so public CDN path serves nothing. signKbUrl in kb-storage.ts:65-111 now works with R2_ACCESS_KEY_ID set — fix is to return presigned GET per kb-module Security Notes: 'Images embedded in Tiptap content stored in R2 and served via signed URL'.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-007",
      "severity": "P0",
      "title": "GET /api/kb/images/:key serve route missing — embedded images 404 end-to-end",
      "location": "apps/zync-api/src/routes/kb.ts:549",
      "evidence": "Image upload fallback returns `/api/kb/images/${encodeURIComponent(r2Key)}` when KB_IMAGE_DOMAIN unset. grep `kb/images` in apps/zync-api/src/routes → only this string literal in kb.ts; no GET handler registered (contrast profile avatar proxy at profile.ts:300 `profileRoute.get('/avatar/:key{.+}', ...)` streaming `c.env.STORAGE.get(key)` with tenant prefix guard). ArticleEditor inserts returned url into Tiptap img src (ArticleEditor.tsx:134-135); ArticleRenderer renders without refresh — staff/portal readers get broken images.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-008",
      "severity": "P1",
      "title": "encodeURIComponent on slash-containing R2 keys breaks wildcard path matching",
      "location": "apps/zync-api/src/routes/kb.ts:549",
      "evidence": "r2Key format is `kb/${session.tid}/images/${articleId}/${imageId}/${safeFilename}` (:540) containing `/`. Upload returns `/api/kb/images/${encodeURIComponent(r2Key)}` which encodes slashes as `%2F`. workerd does not auto-decode `%2F` in path segments; correct pattern is unencoded key with wildcard route `:key{.+}` as shipped for avatars (profile.ts:300). Even after adding a serve route, current URL format will not match unless encodeURIComponent is removed.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-009",
      "severity": "P0",
      "title": "Portal KB missing GET /api/portal/kb/attachments/:id/url — portal attachment downloads fail",
      "location": "apps/zync-api/src/routes/portal/kb.ts:1-54",
      "evidence": "AttachmentsFooter portalMode uses `const apiBase = portalMode ? '/api/portal/kb' : '/api/kb'` (AttachmentsFooter.tsx:111) and fetches `${apiBase}/attachments/${attachment.id}/url` (:53). portal/kb.ts registers spaces, articles, attachments list only — no `/attachments/:id/url` route and no signKbUrl call. Staff route exists at kb.ts:632-647. Portal KbArticleView uses `<AttachmentsFooter attachments={attachments} portalMode />` (portal/pages/Kb.tsx:221).",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-010",
      "severity": "P1",
      "title": "kb:publish permission never enforced on publish paths",
      "location": "apps/zync-api/src/routes/kb.ts:397,291",
      "evidence": "kb-module Permissions table: 'Publish articles | kb:publish'. grep `requirePermission('kb:publish')` in apps/ → 0 matches. POST /articles/:id/publish uses requirePermission('kb:write') (:397). PATCH /articles/:id allows data.status === 'PUBLISHED' under kb:write (:341-346). User with kb:write but not kb:publish can publish.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-011",
      "severity": "P1",
      "title": "Editor PublishMenu has no submit-for-review flow when kb_require_review is enabled",
      "location": "apps/zync-app/src/features/kb/components/PublishMenu.tsx:49-105",
      "evidence": "settings-kb spec: MEMBER authors submit DRAFT→PENDING_REVIEW when kb_require_review=true; API has POST /articles/:id/submit-review (kb-review.ts:53) and publish gate returns 409 REVIEW_REQUIRED for non-admin (kb.ts:414-422). useSubmitForReview hook exists (useKbReview.ts:81-93) but grep useSubmitForReview across apps/zync-app → only its definition; PublishMenu only calls publish/unpublish/duplicate with no kb_require_review awareness.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-012",
      "severity": "P1",
      "title": "kb_versioning_enabled tenant setting stored but not honored in API or editor",
      "location": "apps/zync-api/src/routes/kb/versions.ts:55,apps/zync-app/src/features/kb/ArticleEditPage.tsx:158-169",
      "evidence": "settings-kb spec: versioning On/Off toggle bound to kb_versioning_enabled; plan requires hide Save & version when off and skip snapshots on approve when off. Version routes gated only by requireTier(BUSINESS) (versions.ts:55) with no getKbSettings check. approveArticle (kb-review.ts:150-178) publishes without version snapshot. ArticleEditPage always renders Save version button (:158-169) with no kb_versioning_enabled query.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-013",
      "severity": "P2",
      "title": "Portal published-article queries omit deletedAt filter",
      "location": "packages/db/src/queries/kb.ts:715-767",
      "evidence": "kbPortalQuery.listPublishedArticleTree filters status=PUBLISHED only (:724-728), no isNull(kbArticles.deletedAt). getPublishedArticleById/getPublishedArticleBySlug same (:744-765). Staff kbTenantQuery.listArticleTree filters isNull(deletedAt) at :154. Soft-deleted PUBLISHED vault articles may still appear in portal tree/detail.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-014",
      "severity": "P2",
      "title": "KB UI permission stubs always return true — edit routes/buttons unguarded client-side",
      "location": "apps/zync-app/src/features/kb/ArticleReadPage.tsx:28-30",
      "evidence": "ArticleReadPage: `function useHasPermission(_perm: string): boolean { return true }` with comment 'permission context wired post-integration'. Same stub in ArticleEditPage.tsx:31-33 and NewArticlePage.tsx:17-19. Edit link shown to all users; /edit route guard uses hasKbWrite which is always true.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-015",
      "severity": "P2",
      "title": "Portal article view omits RelatedArticles sibling section",
      "location": "apps/zync-app/src/portal/pages/Kb.tsx:218-224",
      "evidence": "kb-module Article Read View spec: 'Related: sibling articles (same parent); max 5'. Staff ArticleReadPage imports RelatedArticles (:24,:161). Active portal KbArticleView renders ArticleRenderer + AttachmentsFooter + ArticleToc only (Kb.tsx:220-223); no RelatedArticles. Dead-code PortalArticlePage.tsx has same omission but is not routed.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-016",
      "severity": "P2",
      "title": "Vault spaces sidebar shows generic group label instead of customer name",
      "location": "apps/zync-app/src/features/kb/components/SpacesSidebar.tsx:174-176",
      "evidence": "kb-module spec: 'Vault spaces grouped by customer below'. SpacesSidebar groups vaultsByCustomer Map but header renders `t('kb.vaultGroup')` for every group — not customer name. NewSpaceModal loads customer names but sidebar does not use them.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-017",
      "severity": "P2",
      "title": "Max tree depth enforced client-side only — API accepts parent_id nesting beyond 3 levels",
      "location": "apps/zync-api/src/routes/kb.ts:307-336",
      "evidence": "kb-article-editor spec: 'Max depth: 3 (validated on drop)'. ArticleTree.tsx enforces MAX_TREE_DEPTH client-side (:6,:19). PATCH /articles/:id passes parent_id to updateArticleMeta with no depth validation (:307-336). createArticle POST similarly accepts any parent_id (:248-267).",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-018",
      "severity": "P2",
      "title": "kb_default_space_id setting not applied when creating new articles",
      "location": "apps/zync-app/src/features/kb/NewArticlePage.tsx:39-53",
      "evidence": "settings-kb spec: 'Default space for new articles' via kb_default_space_id. SettingsKbPage persists setting (SettingsKbPage.tsx:214). NewArticlePage only uses route param spaceSlug (`spaces?.find((s) => s.slug === spaceSlug)`) — no fetch of /api/settings/kb to preselect default space when user navigates to /kb without a space.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-019",
      "severity": "P2",
      "title": "POST reject pending review ignores optional feedback body from spec",
      "location": "apps/zync-api/src/routes/kb-review.ts:96-105",
      "evidence": "settings-kb API spec: `POST /api/kb/articles/:id/reject` body `{ feedback?: string }`. kb-review route calls `rejectArticle(db, session.tid, c.req.param('id'), session.sub)` with no request body parse. rejectArticle (kb-review.ts:183-205) only sets status DRAFT — no feedback storage.",
      "fix_direction": "code_to_spec",
      "still_open": true
    },
    {
      "id": "km-020",
      "severity": "P2",
      "title": "GET /api/kb/spaces omits position and article_count required by settings-kb spec",
      "location": "apps/zync-api/src/routes/kb.ts:92-105",
      "evidence": "settings-kb spec GET /api/kb/spaces returns `[{ id, name, slug, position, customer_id, article_count }]`. Handler calls kbTenantQuery.listSpaces() (:99) which orders by type/name (kb.ts queries:83-89) without counts. listSpacesWithCounts exists for /api/kb/spaces/settings only (kb-spaces-settings.ts:44-52).",
      "fix_direction": "code_to_spec",
      "still_open": true
    }
  ]
}
