# KB Article Editor — Implementation Plan

**Spec:** docs/specs/2026-05-31-kb-article-editor.md  ·  **Slug:** kb-article-editor  ·  **Wave:** 12
**Depends on:** email-template-editor, foundation-auth-rbac, kb-module

## Goal
Deliver the full article authoring experience layered on top of `kb-module`: a full-page Tiptap v2 editor with a dedicated title field, a drag-reorderable/nestable article tree sidebar, an explicit publish/unpublish/duplicate flow, a slide-in article settings panel (slug, parent, SEO meta, stats, delete), inline image upload to R2, 30-second debounced auto-save, RTL-aware editing, and server-side Tiptap-JSON validation plus a DOMPurify-hardened render pipeline that is the canonical reference implementation for every Tiptap surface in the codebase. No new tables — it extends `kb_articles` with SEO/metadata, soft-delete, view-count, and a generated search-text column.

## Architecture
This spec extends artifacts that `kb-module` already builds; it does not recreate them. It plugs in as follows:

- **`@zync/db`** — adds an `ALTER TABLE kb_articles` migration (SEO columns, `deleted_at`, generated `search_text`; `view_count` is owned by kb-module) to the existing `kb_articles` table (cols `id`, `tenant_id`, `space_id`, `parent_id`, `title`, `slug`, `content JSONB`, `status`, `position NUMERIC`, `published_at`, `created_by`, `updated_by`, `created_at`, `updated_at`). Extends the existing `tenantQuery(db, tenantId).kb` namespace (from kb-module) with `publishArticle`, `unpublishArticle`, `duplicateArticle`, `softDeleteArticle`, `moveArticle`, `treeDepth`, and `updateArticleMeta`. Existing helpers consumed: `getArticleById`, `getArticleBySlug`, `listArticleTree`, `siblingArticles`, `incrementViewCount`, `createAttachment`, `getAttachment`.
- **API (`apps/zync-api`)** — extends the existing `/api/kb` Hono router (`apps/zync-api/src/routes/kb.ts`) and its `authMiddleware` + `requireModuleEnabled('kb')` guards with: dedicated `POST /api/kb/articles/:id/publish` and `/unpublish`, `POST /api/kb/articles/:id/duplicate`, `POST /api/kb/articles/:id/images` (R2 multipart), and the SEO/metadata + reorder/nesting fields on the existing `PATCH /api/kb/articles/:id`. `DELETE /api/kb/articles/:id` becomes a **soft delete** (`deleted_at = now()`). All staff reads/writes filter `deleted_at IS NULL`. Tiptap content is validated server-side before any write via a new shared validator in `@zync/types`.
- **App (`apps/zync-app`)** — enriches the editor route `/kb/:spaceSlug/:articleSlug/edit` and adds the create route `/kb/:spaceSlug/new`. Builds on kb-module's `ArticleEditor.tsx`, `EditorToolbar.tsx`, `ArticleRenderer.tsx`, `ArticleTree.tsx`, `ArticleEditPage.tsx`, and hooks `useArticleTree`, `useKbArticle`, `useCreateArticle`, `useUpdateArticle`. Adds a `TitleInput`, `ArticleSettingsPanel` (Sheet), `PublishMenu` (DropdownMenu), drag-to-reorder/nest behaviour on the tree, image-paste/drop handling, and a centralized `editor-extensions.ts` shared by editor + renderer.
- **Upstream consumed (exact names):** tables `kb_articles`, `kb_attachments`, `tenants`, `users`, `customers`; `@zync/auth` `authMiddleware`, `requirePermission`, `requireModuleEnabled`; `@zync/db` `createDb`, `tenantQuery`, `buildPaginated`; `@zync/types` `ApiError`, kb-module's `KbArticle`/`KbArticleNode`/`KbAttachment`/`updateArticleSchema`/`createArticleSchema`/`serializeKbArticle`; kb-module storage helpers `kbR2Key`, `putKbObject`, `signKbUrl`, `assertAllowedFileType`; `@zync/ui` `Sheet`, `Dialog`, `DropdownMenu`, `Button`, `Input`, `Textarea`, `Form`, `FormField`, `FormLabel`, `Select`, `Toast`/`toast`, `Breadcrumb`, `Skeleton`, `Spinner`; i18n `useDirection`/`SUPPORTED_LOCALES`/`Locale`.

**Permission reconciliation (authoritative):** foundation-auth-rbac defines `kb:read`, `kb:write`, `kb:delete`, `kb:share` — there is **no** `knowledge:*` namespace and **no** `kb:publish`. The editor spec's `knowledge:read/write/delete` are transcribed to `kb:read/kb:write/kb:delete`. Publish/unpublish are gated by `kb:write` (kb-module's plan referenced `kb:publish`, which does not exist in foundation; `kb:write` is the canonical authority for publish on the editor surface — apply `kb:write` consistently). Soft-delete uses `kb:delete`.

**Shared-editor note:** the spec text says the Tiptap component is "the same component as spec 43 email-template-editor"; this is inaccurate — `email-template-editor` (the registry dependency) uses a raw HTML `<textarea>`, not Tiptap. The real shared Tiptap component is kb-module's `ArticleEditor`. This plan owns the canonical extension list (`editor-extensions.ts`) and the `renderArticleContent` sanitizer that spec 11/12/48/130 reuse.

## Tech Stack
- **Packages:** `@zync/db` (Drizzle `ALTER` migration + extended kb query namespace), `@zync/types` (Tiptap validator, extension list metadata, SEO Zod schema), `@zync/ui` (consumed only).
- **Apps:** `apps/zync-api` (Hono routes on existing `/api/kb` router, R2 image upload), `apps/zync-app` (Vite+React editor enrichments, TanStack Query hooks, dnd tree).
- **Libraries:** Tiptap v2 (`@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-table`, `@tiptap/extension-table-row`, `@tiptap/extension-table-cell`, `@tiptap/extension-table-header`, `@tiptap/extension-image`, `@tiptap/extension-link`, `@tiptap/extension-underline`, `@tiptap/extension-text-direction`), `@tiptap/html` (`generateHTML`), `dompurify`, `zod`, `@dnd-kit/core` + `@dnd-kit/sortable` (tree drag), `react-i18next`.
- **Cloudflare bindings:** `STORAGE` (R2 image objects + signed URLs), Hyperdrive→Neon Postgres via `createDb(env)`. (No new bindings.)

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema delta | 1 | `packages/db/migrations`, `packages/db/src/schema/kb.ts` | — |
| B — shared types & validators | 2, 3 | `packages/types/src/kb-editor.ts`, `packages/types/src/index.ts` | T2 & T3 parallel after none |
| C — db query extensions | 4 | `packages/db/src/queries/kb.ts` | after T1, T2 |
| D — API routes | 5, 6, 7 | `apps/zync-api/src/routes/kb.ts`, `apps/zync-api/src/lib` | T5→T6→T7 sequential (same file) after T3, T4 |
| E — app data hooks | 8 | `apps/zync-app/src/features/kb/api` | after T5–T7 |
| F — editor extensions & renderer | 9 | `apps/zync-app/src/features/kb/editor` | after T2 |
| G — app UI | 10, 11, 12, 13, 14 | `apps/zync-app/src/features/kb` | T10 first; T11–T14 partly parallel after T9, T10 |

## Tasks

### Task 1: kb_articles schema delta (SEO, soft-delete, view-count, search-text)
**Blocks:** 4  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/00XX_kb_article_editor.sql`
- Modify: `packages/db/src/schema/kb.ts` (add columns to existing `kbArticles` Drizzle table)
**Steps:**
- [ ] Write the `ALTER TABLE` migration with the exact DDL below; it runs after the kb-module migration that created `kb_articles`.
- [ ] Add the three new columns plus the `search_text` generated column to the Drizzle `kbArticles` definition (`metaTitle: text('meta_title')`, `metaDescription: text('meta_description')`, `deletedAt: timestamp('deleted_at', { withTimezone: true })`, and `searchText` as a generated column — model with `.generatedAlwaysAs(sql\`...\`)`). `viewCount` is already on the `kbArticles` definition (owned by kb-module); do NOT redeclare it.
- [ ] Add a partial index for live-article lookups filtered on `deleted_at IS NULL`.
- [ ] Add a GIN/btree index supporting `search_text` lookups used by search-completeness.
**Schema / Interfaces:**
```sql
-- 00XX_kb_article_editor.sql  (Neon Postgres; extends kb_articles from kb-module)
ALTER TABLE kb_articles ADD COLUMN meta_title       TEXT;
ALTER TABLE kb_articles ADD COLUMN meta_description TEXT;
-- view_count is owned by kb-module (in its kb_articles CREATE TABLE) — NOT re-added here.
ALTER TABLE kb_articles ADD COLUMN deleted_at       TIMESTAMPTZ;
ALTER TABLE kb_articles ADD COLUMN search_text      TEXT GENERATED ALWAYS AS (
  title || ' ' || COALESCE(meta_title, '') || ' ' || COALESCE(meta_description, '')
) STORED;

-- live (non-deleted) tree lookups
CREATE INDEX idx_kb_articles_live
  ON kb_articles (space_id, parent_id, position)
  WHERE deleted_at IS NULL;

-- search support (used by search-completeness, exact-prefix + trigram-ready)
CREATE INDEX idx_kb_articles_search_text
  ON kb_articles (tenant_id)
  INCLUDE (search_text)
  WHERE deleted_at IS NULL;
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon after the kb-module migration; `search_text` is `STORED GENERATED` and auto-updates when `title`/`meta_*` change.
- [ ] `deleted_at` is nullable `TIMESTAMPTZ`; existing rows are unaffected (all `NULL` = live).
- [ ] Drizzle schema compiles and the generated column is marked read-only (never written by inserts/updates).

### Task 2: Tiptap content validator + allowed-node schema (`@zync/types`)
**Blocks:** 5, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/kb-editor.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define and export `ALLOWED_NODE_TYPES` (the spec's allowlist) and `validateTiptapContent(content: unknown): TiptapDoc` which: (1) Zod-validates the top-level `{ type: 'doc', content: TiptapNode[] }` shape; (2) recursively walks every node + mark and throws a `ZodError` if any `type` is not in `ALLOWED_NODE_TYPES`; (3) returns the typed doc on success.
- [ ] Export a recursive `tiptapNodeSchema` (Zod) and the `TiptapDoc`/`TiptapNode`/`TiptapJSON` types.
- [ ] Export `updateArticleMetaSchema` (Zod) covering the editor-only metadata fields (`metaTitle`, `metaDescription`, `slug`, `parentId`) so routes validate SEO/settings-panel writes.
- [ ] This validator is import-safe in the Worker runtime (no DOM, no Node APIs) so the API can call it.
**Schema / Interfaces:**
```ts
import { z } from 'zod';

export const ALLOWED_NODE_TYPES = new Set<string>([
  'doc', 'paragraph', 'heading', 'text', 'hardBreak',
  'bold', 'italic', 'underline', 'strike', 'link', 'code',
  'bulletList', 'orderedList', 'listItem', 'blockquote',
  'codeBlock', 'image', 'table', 'tableRow', 'tableCell', 'tableHeader',
]);

export type TiptapMark = { type: string; attrs?: Record<string, unknown> };
export type TiptapNode = {
  type: string;
  attrs?: Record<string, unknown>;
  marks?: TiptapMark[];
  content?: TiptapNode[];
  text?: string;
};
export type TiptapDoc = { type: 'doc'; content: TiptapNode[] };
export type TiptapJSON = TiptapDoc;

export const tiptapNodeSchema: z.ZodType<TiptapNode> = z.lazy(() =>
  z.object({
    type: z.string(),
    attrs: z.record(z.unknown()).optional(),
    marks: z.array(z.object({ type: z.string(), attrs: z.record(z.unknown()).optional() })).optional(),
    content: z.array(tiptapNodeSchema).optional(),
    text: z.string().optional(),
  })
);

export const tiptapDocSchema = z.object({
  type: z.literal('doc'),
  content: z.array(tiptapNodeSchema),
});

/** Throws ZodError on invalid structure OR unknown node/mark type. */
export function validateTiptapContent(content: unknown): TiptapDoc {
  const doc = tiptapDocSchema.parse(content);
  const walk = (node: TiptapNode) => {
    if (!ALLOWED_NODE_TYPES.has(node.type)) {
      throw new z.ZodError([{ code: 'custom', path: ['type'], message: `Disallowed node type: ${node.type}` }]);
    }
    node.marks?.forEach((m) => {
      if (!ALLOWED_NODE_TYPES.has(m.type)) {
        throw new z.ZodError([{ code: 'custom', path: ['marks'], message: `Disallowed mark type: ${m.type}` }]);
      }
    });
    node.content?.forEach(walk);
  };
  doc.content.forEach(walk);
  return doc as TiptapDoc;
}

export const updateArticleMetaSchema = z.object({
  metaTitle: z.string().max(70).nullish(),
  metaDescription: z.string().max(160).nullish(),
  slug: z.string().min(1).max(200).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'kebab-case').optional(),
  parentId: z.string().uuid().nullish(),
});
```
**Acceptance:**
- [ ] `validateTiptapContent` throws on a doc containing a `script` node or any type outside the allowlist; passes on a valid `doc` tree.
- [ ] Module imports cleanly inside the Cloudflare Worker (no DOM globals referenced).

### Task 3: SEO/slug helpers + editor request Zod schemas (`@zync/types`)
**Blocks:** 5  ·  **Blocked by:** —
**Files:**
- Modify: `packages/types/src/kb-editor.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Export `slugifyTitle(title: string): string` — kebab-case, strips non `[a-z0-9-]`, collapses dashes, trims, lowercases, truncates to 200 chars; falls back to `'untitled'` when the result is empty.
- [ ] Export `duplicateSlug(slug: string): string` → `` `${slug}-copy` ``.
- [ ] Export `MAX_TREE_DEPTH = 3` and `MAX_KB_IMAGE_BYTES = 5 * 1024 * 1024`.
- [ ] Export `KB_IMAGE_MIME_ALLOWLIST = ['image/jpeg','image/png','image/webp','image/gif']` (SVG intentionally excluded — XSS risk, per kb-module security note).
- [ ] Extend (or re-declare compatibly with kb-module) the article update body: define `editorPatchSchema` = kb-module `updateArticleSchema` merged with `updateArticleMetaSchema` and `{ content: z.unknown().optional(), title: z.string().min(1).max(200).optional(), position: z.number().optional() }`. Routes use this; `content` is later passed through `validateTiptapContent`.
**Schema / Interfaces:**
```ts
export const MAX_TREE_DEPTH = 3;
export const MAX_KB_IMAGE_BYTES = 5 * 1024 * 1024;
export const KB_IMAGE_MIME_ALLOWLIST = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;

export function slugifyTitle(title: string): string {
  const s = title.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 200);
  return s || 'untitled';
}
export function duplicateSlug(slug: string): string { return `${slug}-copy`; }

export const editorPatchSchema = updateArticleMetaSchema.extend({
  title: z.string().min(1).max(200).optional(),
  content: z.unknown().optional(),            // validated via validateTiptapContent
  position: z.number().optional(),
});
export type EditorPatchBody = z.infer<typeof editorPatchSchema>;
```
**Acceptance:**
- [ ] `slugifyTitle('Invoice Management!')` === `'invoice-management'`; empty/symbol-only input yields `'untitled'`.
- [ ] `editorPatchSchema` accepts a partial body (any subset of fields) and rejects a non-kebab `slug`.

### Task 4: Extend kb query namespace (publish/duplicate/move/soft-delete/meta)
**Blocks:** 5, 6  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `packages/db/src/queries/kb.ts` (extend the existing `tenantQuery(db, tenantId).kb` namespace from kb-module)
**Steps:**
- [ ] All existing list/get queries must add `WHERE deleted_at IS NULL` (live filter). Update `listArticleTree`, `getArticleById`, `getArticleBySlug`, `siblingArticles` accordingly.
- [ ] Add `publishArticle(id)` → set `status='PUBLISHED'`, `published_at=now()`, `updated_by`, `updated_at=now()`; return serialized article.
- [ ] Add `unpublishArticle(id)` → set `status='DRAFT'`, leave `published_at` intact (history), `updated_at=now()`.
- [ ] Add `duplicateArticle(id, userId)` → load source (must be live + same tenant), insert a new row copying `title`, `content`, `space_id`, `parent_id`, `meta_title`, `meta_description`; force `status='DRAFT'`, `published_at=NULL`, `view_count=0`, `slug=duplicateSlug(source.slug)` (uniquified within space — append `-2`, `-3`… on conflict with the `(space_id, slug)` UNIQUE), `position=fractionalIndexAfter(lastSiblingPosition)`, `created_by=userId`. Return the new article.
- [ ] Add `softDeleteArticle(id, userId)` → set `deleted_at=now()`, `updated_by=userId`. Children are also soft-deleted (recursive: set `deleted_at` on the whole subtree).
- [ ] Add `moveArticle(id, { parentId, position })` → validate the move keeps subtree depth ≤ `MAX_TREE_DEPTH` (reject otherwise), set `parent_id`, recompute `position`, `updated_at`.
- [ ] Add `treeDepth(spaceId, articleId): Promise<number>` — depth of the article within the space tree (root=1); plus `subtreeHeight(articleId)` to validate nesting drops.
- [ ] Add `updateArticleMeta(id, { title?, slug?, parentId?, metaTitle?, metaDescription?, content?, position? })` — generic editor patch writer; never writes `search_text`/`view_count`. Sets `updated_by`/`updated_at`.
- [ ] Add `incrementViewCount(id)` if not already present from kb-module (fire-and-forget UPDATE `view_count = view_count + 1`; no transaction).
**Schema / Interfaces:**
```ts
// tenantQuery(db, tenantId).kb — additions (KbArticle from @zync/types / kb-module)
publishArticle(id: string, userId: string): Promise<KbArticle>;
unpublishArticle(id: string, userId: string): Promise<KbArticle>;
duplicateArticle(id: string, userId: string): Promise<KbArticle>;
softDeleteArticle(id: string, userId: string): Promise<void>;     // recursive subtree
moveArticle(id: string, input: { parentId: string | null; position: number }): Promise<KbArticle>;
treeDepth(spaceId: string, articleId: string): Promise<number>;
subtreeHeight(articleId: string): Promise<number>;
updateArticleMeta(id: string, input: EditorPatchBody & { content?: unknown }): Promise<KbArticle>;
incrementViewCount(id: string): Promise<void>;
```
**Acceptance:**
- [ ] All article reads exclude soft-deleted rows; a deleted article 404s on `getArticleById`.
- [ ] `duplicateArticle` produces a DRAFT copy with a unique in-space slug and `view_count=0`, `published_at=NULL`.
- [ ] `softDeleteArticle` soft-deletes the entire subtree, not just the target.
- [ ] `moveArticle` rejects a drop that would push any descendant past depth 3.

### Task 5: PATCH article enrichment + dedicated publish/unpublish/duplicate routes
**Blocks:** 8  ·  **Blocked by:** 3, 4
**Files:**
- Modify: `apps/zync-api/src/routes/kb.ts`
**Steps:**
- [ ] Enrich the existing `PATCH /api/kb/articles/:id` (`kb:write`): validate body with `editorPatchSchema`; if `content` present, call `validateTiptapContent(body.content)` and return `400` (ApiError) on `ZodError`; on first save when `slug` is absent and the article slug is still the auto value, generate via `slugifyTitle(title)` and uniquify within space; persist via `updateArticleMeta`. Returns serialized `KbArticle`.
- [ ] Add `POST /api/kb/articles/:id/publish` (`kb:write`) → `publishArticle(id, userId)`; return serialized article. Article becomes visible on public KB.
- [ ] Add `POST /api/kb/articles/:id/unpublish` (`kb:write`) → `unpublishArticle(id, userId)`.
- [ ] Add `POST /api/kb/articles/:id/duplicate` (`kb:write`) → `duplicateArticle(id, userId)`; return the new serialized article (client navigates to it).
- [ ] Convert `DELETE /api/kb/articles/:id` to **soft delete** (`kb:delete`) → `softDeleteArticle(id, userId)`; return `{ ok: true }`.
- [ ] Reorder/nest: when `PATCH` body carries `parentId`/`position`, route through `moveArticle` so depth validation runs; reject depth>3 with `422`.
- [ ] Every route validated with Zod (`require-zod-validation-in-routes`); no raw Drizzle from the route (`no-raw-drizzle-from-routes`) — all via `tenantQuery(...).kb`.
**Schema / Interfaces:**
```
PATCH  /api/kb/articles/:id            (kb:write)  body: editorPatchSchema → KbArticle   (content validated)
POST   /api/kb/articles/:id/publish    (kb:write)  → KbArticle
POST   /api/kb/articles/:id/unpublish  (kb:write)  → KbArticle
POST   /api/kb/articles/:id/duplicate  (kb:write)  → KbArticle   (new DRAFT copy)
DELETE /api/kb/articles/:id            (kb:delete) → { ok: true } (soft delete, deleted_at=now())
```
**Acceptance:**
- [ ] A `PATCH` with a `content` doc containing a disallowed node type returns `400` and writes nothing.
- [ ] `publish` sets `status='PUBLISHED'` + `published_at`; `unpublish` sets `status='DRAFT'` and keeps `published_at`.
- [ ] `duplicate` returns a new article id with slug `{slug}-copy` (uniquified) and `status='DRAFT'`.
- [ ] `DELETE` performs a soft delete (row remains, `deleted_at` set) and the article disappears from the tree.

### Task 6: Image upload route (R2)
**Blocks:** 8  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-api/src/routes/kb.ts`
- Modify: `apps/zync-api/src/lib/kb-storage.ts` (reuse kb-module helpers `kbR2Key`, `putKbObject`, `signKbUrl`, `assertAllowedFileType`)
**Steps:**
- [ ] Add `POST /api/kb/articles/:id/images` (`kb:write`), `multipart/form-data` with field `file`.
- [ ] Verify the target article is live + tenant-owned (`getArticleById`) else `404`.
- [ ] Reject MIME not in `KB_IMAGE_MIME_ALLOWLIST` with `415` (call `assertAllowedFileType` and additionally enforce the image allowlist — **SVG rejected**).
- [ ] Reject body larger than `MAX_KB_IMAGE_BYTES` (5 MB) with `413`.
- [ ] Compute key via `kbR2Key(tenantId, articleId, crypto.randomUUID(), filename)` under a **public images prefix** (e.g. `kb/{tenantId}/images/...`); `putKbObject(env, key, bytes, contentType)`; return `{ url }` where `url` is a **stable public-read CDN URL** on the tenant R2/CDN domain (NOT a 60-min signed URL).
- [ ] Rationale: the URL is persisted into `content` JSONB and must render for **anonymous public-KB readers** indefinitely — an expiring signed URL would 404 after an hour and is inaccessible to anonymous callers. The 60-min signed-URL flow (kb-module `signKbUrl` / `GET /api/kb/attachments/:id/url`) is reserved for confidential `kb_attachments` **downloads** only; embedded content images are a distinct, publicly-readable class.
- [ ] The returned URL host MUST equal the configured tenant R2/CDN image domain so the Task 9 render-time `<img>` SSRF allowlist accepts it (the allowlist domain and this upload domain are the same single config value).
**Schema / Interfaces:**
```
POST /api/kb/articles/:id/images   (kb:write)   multipart field `file`
     → { url: string }   // STABLE public R2/CDN URL (not signed), max 5 MB, jpg|png|webp|gif only
```
**Acceptance:**
- [ ] Uploading a 6 MB PNG returns `413`; uploading an `.svg` returns `415`.
- [ ] A valid PNG stores to R2 and returns a **stable** public URL on the tenant R2/CDN image domain (no expiry query params) that still resolves after 60 minutes.

### Task 7: Public/portal read visibility + single view-count path
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-api/src/routes/kb.ts`
**Steps:**
- [ ] **Do NOT add a second increment path.** kb-module already owns `PUT /api/kb/articles/:id/view` (client-called, fire-and-forget) as the single view-count trigger; this task reuses it unchanged. The only addition here is that `PUT /:id/view` now calls the shared `incrementViewCount` query helper from Task 4 (no transaction, `c.executionCtx.waitUntil`), and must no-op for soft-deleted articles.
- [ ] Ensure published-only visibility on the public/portal read path (`status='PUBLISHED'` AND `deleted_at IS NULL`); DRAFT/deleted return `404` to anonymous/portal callers. Staff (`kb:read`) may still read DRAFT.
- [ ] Confirm the `GET` read path performs **no** view increment (avoids double-counting against the existing `PUT /:id/view`).
**Acceptance:**
- [ ] There is exactly one view-increment trigger in the codebase (`PUT /:id/view`); the `GET` read does not increment.
- [ ] `view_count` increments outside any DB transaction and never blocks the response.
- [ ] A DRAFT or soft-deleted article is `404` on the public/portal path.

### Task 8: App data hooks for editor actions
**Blocks:** 11, 12, 13, 14  ·  **Blocked by:** 5, 6
**Files:**
- Modify: `apps/zync-app/src/features/kb/api/useKbArticles.ts` (extend kb-module hooks)
- Create: `apps/zync-app/src/features/kb/api/useKbEditorMutations.ts`
**Steps:**
- [ ] Add `usePublishArticle()`, `useUnpublishArticle()`, `useDuplicateArticle()`, `useSoftDeleteArticle()`, `useMoveArticle()` (TanStack Query mutations) hitting the Task 5 routes; invalidate `['kb','tree',spaceId]` and `['kb','article',id]` on success; surface errors via `toast`.
- [ ] Add `useUploadImage(articleId)` → `POST /api/kb/articles/:id/images`, returns the URL for Tiptap insertion.
- [ ] Add a `useAutoSave(articleId)` wrapper around kb-module's `useUpdateArticle`: debounce **30 s** (per this spec; note kb-module's read view uses 2 s — the editor surface uses 30 s) and only fire when content/title changed; expose `{ saveState: 'idle'|'saving'|'saved'|'error', lastSavedAt, flush() }`.
**Schema / Interfaces:**
```ts
function usePublishArticle(): UseMutationResult<KbArticle, ApiError, { id: string }>;
function useUnpublishArticle(): UseMutationResult<KbArticle, ApiError, { id: string }>;
function useDuplicateArticle(): UseMutationResult<KbArticle, ApiError, { id: string }>;
function useSoftDeleteArticle(): UseMutationResult<{ ok: true }, ApiError, { id: string }>;
function useMoveArticle(): UseMutationResult<KbArticle, ApiError, { id: string; parentId: string | null; position: number }>;
function useUploadImage(articleId: string): (file: File) => Promise<string>;
function useAutoSave(articleId: string): {
  saveState: 'idle' | 'saving' | 'saved' | 'error';
  lastSavedAt: Date | null;
  scheduleSave(payload: { title?: string; content?: unknown }): void;
  flush(): Promise<void>;
};
```
**Acceptance:**
- [ ] Editing content fires at most one save per 30 s window; `saveState` transitions `saving`→`saved` with a timestamp.
- [ ] Publish/duplicate/soft-delete mutations invalidate the tree query and reflect immediately.

### Task 9: Centralized editor extensions + hardened render pipeline
**Blocks:** 11, 12  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/features/kb/editor/editor-extensions.ts`
- Create: `apps/zync-app/src/features/kb/editor/render-article.ts`
- Modify: `apps/zync-app/src/features/kb/components/ArticleRenderer.tsx` (consume `renderArticleContent`)
**Steps:**
- [ ] Export `buildExtensions(locale: Locale)` returning the canonical extension list: `StarterKit` (Heading levels 1–4, Bold, Italic, BulletList, OrderedList, Blockquote, CodeBlock), `Underline`, `Link`, `Image`, `Table` + `TableRow`/`TableCell`/`TableHeader`, and `Direction.configure({ defaultDirection: locale === 'he-IL' ? 'rtl' : 'ltr' })` from `@tiptap/extension-text-direction` (per-paragraph `dir` persists in JSONB; new articles default to tenant locale direction).
- [ ] Export `renderArticleContent(content: TiptapJSON, locale: Locale): string` implementing `generateHTML(content, buildExtensions(locale))` → `DOMPurify.sanitize(...)` with the exact `ALLOWED_TAGS`/`ALLOWED_ATTR`/`FORBID_ATTR` allowlist from the spec.
- [ ] Add a DOMPurify `uponSanitizeAttribute`/`afterSanitizeAttributes` hook that drops any `img` whose `src` is not on the tenant's R2/CDN domain (SSRF-via-image-embed guard). Tenant R2 domain injected via app config.
- [ ] `ArticleRenderer` uses `renderArticleContent` then `dangerouslySetInnerHTML`; this file is the canonical reference cited by spec 11/12/48/130.
**Schema / Interfaces:**
```ts
import DOMPurify from 'dompurify';
import { generateHTML } from '@tiptap/html';
import type { TiptapJSON, Locale } from '@zync/types';

export function buildExtensions(locale: Locale): Extension[];

export function renderArticleContent(content: TiptapJSON, locale: Locale): string {
  const html = generateHTML(content, buildExtensions(locale));
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p','br','strong','em','u','s','h1','h2','h3','h4',
                   'ul','ol','li','blockquote','code','pre','a','img',
                   'table','thead','tbody','tr','th','td'],
    ALLOWED_ATTR: ['href','src','alt','target','rel','class','dir'],
    FORBID_ATTR: ['onerror','onload','onclick'],
  });
}
// afterSanitizeAttributes hook: strip <img> whose src host !== tenant R2 domain.
```
**Acceptance:**
- [ ] `renderArticleContent` strips a `<script>`/`onerror` even if it bypassed server validation.
- [ ] An `<img>` pointing off the tenant R2 domain is removed from rendered output.
- [ ] Hebrew-locale articles render with `dir="rtl"` on direction-tagged paragraphs.

### Task 10: Title input + article tree drag/nest behaviour
**Blocks:** 11  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/kb/components/TitleInput.tsx`
- Modify: `apps/zync-app/src/features/kb/components/ArticleTree.tsx` (add drag-reorder/nest + "New article"/"New section")
**Steps:**
- [ ] `TitleInput`: large plain-text `Input` above the editor (not inside Tiptap). On blur/first save, triggers slug generation (server-side via PATCH). `aria-label="Article title"`.
- [ ] `ArticleTree`: add `@dnd-kit` sortable behaviour. Drag to reorder within a parent → `useMoveArticle({ parentId: sameParent, position: fractionalBetween(...) })`. Drag onto another node → set that node as `parentId` (nesting), validated to **max depth 3** on drop (block + toast if exceeded).
- [ ] Add `[+ New article]` (under a parent) → `useCreateArticle({ title: 'Untitled', parentId, status: 'DRAFT' })` then navigate to `/kb/:spaceSlug/:newSlug/edit`.
- [ ] Add `[+ New section]` → create a folder article (title only, empty content `doc`) used as a grouping parent.
- [ ] Clicking a tree node navigates between articles **without leaving the editor** (in-place route swap).
- [ ] Tree is keyboard-navigable; drag handles have `aria-label`; nodes expose `aria-level`/`role="treeitem"` within `role="tree"`.
**Acceptance:**
- [ ] Dragging an article to a new position persists `position` and re-sorts the tree.
- [ ] A drop that would create depth 4 is blocked with a toast; the tree is unchanged.
- [ ] "New article"/"New section" create DRAFT nodes and (for article) navigate into the editor.

### Task 11: ArticleEditor enrichment (title, autosave, RTL, a11y, image paste)
**Blocks:** —  ·  **Blocked by:** 9, 10
**Files:**
- Modify: `apps/zync-app/src/features/kb/components/ArticleEditor.tsx`
- Modify: `apps/zync-app/src/features/kb/components/EditorToolbar.tsx`
**Steps:**
- [ ] Mount `TitleInput` above the Tiptap `EditorContent`; wire both into `useAutoSave(articleId)` (30 s debounce) and the manual `[Save draft]` button (calls `flush()`), showing a "Saved {time}" indicator.
- [ ] Build the editor from `buildExtensions(locale)` (Task 9) so editor and renderer share one extension list.
- [ ] Image paste/drop: intercept in the editor, call `useUploadImage(articleId)`, insert the returned URL as a Tiptap `image` node; show a spinner placeholder while uploading; enforce 5 MB / allowed MIME client-side before upload (server re-checks).
- [ ] A11y: `<EditorContent aria-label="Article body" aria-multiline="true" role="textbox">`. Every icon-only toolbar button has an explicit `aria-label` (e.g. `"Bold (Ctrl+B)"`) and an `aria-hidden="true"` icon. Do **not** override Tiptap's built-in `Ctrl+B`/`Ctrl+I` shortcuts.
- [ ] RTL: toolbar exposes a per-paragraph direction toggle (↔ icon) backed by the `Direction` extension; respect `prefers-reduced-motion` for any save-indicator/transition animations.
**Acceptance:**
- [ ] Pasting an image uploads to R2 and embeds the returned URL; oversized/SVG paste is rejected client-side with a toast.
- [ ] All toolbar buttons expose `aria-label`; `EditorContent` exposes `role="textbox"` + `aria-multiline`.
- [ ] Editing pauses then resumes → exactly one autosave per 30 s; `[Save draft]` flushes immediately.

### Task 12: Article settings panel (slug, parent, SEO, stats, delete)
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/kb/components/ArticleSettingsPanel.tsx`
**Steps:**
- [ ] Slide-in `Sheet` from the right, toggled by a gear icon in the editor header; `aria-label="Article settings"`, focus-trapped, ESC closes.
- [ ] Fields: **Slug** (`Input`, kebab-case, editable; PATCH on save), **Parent** (`Select` of sibling sections in the space → PATCH `parentId` via `useMoveArticle`, depth-3 validated), **SEO** (`Input` meta title ≤70, `Textarea` meta description ≤160 → PATCH `metaTitle`/`metaDescription`).
- [ ] **Stats** (read-only): `Views: {view_count}`, `Last published: {published_at|—}`.
- [ ] `[Delete article]` → confirm `Dialog` → `useSoftDeleteArticle`; on success navigate back to the space.
- [ ] All writes go through the Task 8 hooks; show field-level validation from `editorPatchSchema`/`updateArticleMetaSchema`.
**Acceptance:**
- [ ] Editing slug/meta persists via PATCH and reflects on reload.
- [ ] Changing parent to one that would exceed depth 3 is rejected with an inline error.
- [ ] Delete shows a confirm dialog, soft-deletes, and returns to the space.

### Task 13: Publish menu + editor header
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/kb/components/PublishMenu.tsx`
- Modify: `apps/zync-app/src/features/kb/ArticleEditPage.tsx` (header: Back, Save draft, Publish ▾, settings gear)
**Steps:**
- [ ] `PublishMenu` (`DropdownMenu`): **Publish now** (`usePublishArticle`), **Unpublish** (shown only when `status='PUBLISHED'`, `useUnpublishArticle`), **Duplicate** (`useDuplicateArticle` → navigate to the new article's editor).
- [ ] Header layout: `← Back to space` (`Breadcrumb`/link), `[Save draft]` (flush autosave), `[Publish ▾]`, settings gear (opens Task 12 panel).
- [ ] Gate publish/unpublish/duplicate controls behind `kb:write`; gate delete behind `kb:delete` (hide controls the user lacks permission for).
- [ ] Menu items have `aria-label`s; respect `prefers-reduced-motion` on the dropdown animation.
**Acceptance:**
- [ ] "Unpublish" only appears for PUBLISHED articles; "Publish now" sets status and shows confirmation toast.
- [ ] "Duplicate" creates a DRAFT copy and navigates into its editor.
- [ ] A user without `kb:write` sees no publish controls.

### Task 14: Editor + create routes wired into app shell
**Blocks:** —  ·  **Blocked by:** 11, 12, 13
**Files:**
- Modify: `apps/zync-app/src/features/kb/ArticleEditPage.tsx`
- Modify: `apps/zync-app/src/router.tsx` (or kb feature route registry) — add `/kb/:spaceSlug/new`
**Steps:**
- [ ] Route `/kb/:spaceSlug/:articleSlug/edit` renders the enriched `ArticleEditPage` (tree sidebar + `TitleInput` + `ArticleEditor` + `EditorToolbar` + header `PublishMenu` + settings gear); guard with `kb:write` (redirect/forbid otherwise).
- [ ] Route `/kb/:spaceSlug/new` creates a fresh DRAFT article (`useCreateArticle`) then redirects to its `/edit` route; guard with `kb:write`.
- [ ] Editor mounts inside the app shell (sidebar/topbar) and reuses kb-module's `KbLayout` spaces sidebar where applicable.
- [ ] Loading states use `Skeleton`/`Spinner`; error states use the standard `ErrorState`/`EmptyState` from `@zync/ui`.
**Acceptance:**
- [ ] Navigating to `/kb/:spaceSlug/new` creates a DRAFT and lands in the editor at its slug.
- [ ] `/kb/:spaceSlug/:articleSlug/edit` loads the article, tree, settings panel, and publish menu; a user without `kb:write` cannot reach it.
- [ ] Clicking a different tree node swaps the loaded article without a full page navigation.
