# KB Article Editor

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 101  
**Tier:** All tiers  
**Depends on:** `kb-module`, `email-template-editor`, `foundation-auth-rbac`  
**Referenced by:** `kb-module`

---

## Overview

Spec `kb-module` defines `kb_articles` with Tiptap v2 JSON content and `status = 'DRAFT' | 'PUBLISHED'`. This spec defines the article creation and editing UI: the full-page editor, tree navigation sidebar, publish flow, SEO metadata, and article version history.

---

## Data Model

No new tables. Schema from `kb-module`:

```sql
-- kb_articles (existing):
-- title TEXT, slug TEXT, content JSONB (Tiptap), status TEXT, space_id UUID,
-- parent_id UUID (tree), position NUMERIC, published_at TIMESTAMPTZ,
-- created_by UUID, updated_by UUID
```

Schema delta: add SEO and metadata fields:

```sql
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;
```

`view_count` incremented on public reads (Cloudflare Worker; fire-and-forget, not transactional).

---

## Editor Layout

`/kb/:spaceSlug/:articleSlug/edit` (and `/kb/:spaceSlug/new`):

```
┌──────────────────────────────────────────────────────────────┐
│  ← Back to space                    [Save draft]  [Publish ▾]│
│                                                              │
│  ┌─ Tree ──────────────┐  ┌─ Editor ─────────────────────┐  │
│  │ 📁 Getting started  │  │                               │  │
│  │   📄 Introduction   │  │  [Title here                ]│  │
│  │   📄 Quick start    │  │                               │  │
│  │ 📁 Billing          │  │  ─────────────────────────── │  │
│  │   📄 Invoices  ←    │  │                               │  │
│  │   📄 Payments       │  │  Start writing or paste...    │  │
│  │ [+ New article]     │  │                               │  │
│  │ [+ New section]     │  │                               │  │
│  └─────────────────────┘  └───────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘
```

Left sidebar: article tree within the space. Drag to reorder (fractional indexing via `position`). Click to navigate between articles without leaving editor.

Right: full-width Tiptap editor (same component as spec 43 `email-template-editor` with extensions: Heading H1-H4, Bold, Italic, Link, BulletList, OrderedList, Blockquote, CodeBlock, Image upload to R2, Table).

---

## Title Editing

Article title is a large plain text input above the editor (not inside Tiptap content):

```
┌──────────────────────────────────────────────────────────────┐
│  [Invoice management                                        ] │
│  ──────────────────────────────────────────────────────────  │
│  Tiptap editor content area                                  │
└──────────────────────────────────────────────────────────────┘
```

Title auto-generates slug on first save (kebab-case, unique within space). Slug can be manually edited in article settings panel.

---

## Publish Flow

**[Publish ▾]** dropdown:
- **Publish now** — sets `status = 'PUBLISHED'`, `published_at = now()`. Article immediately visible on public KB (`/kb/:tenantSlug`).
- **Unpublish** (if published) — sets `status = 'DRAFT'`. Article hidden from public KB.
- **Duplicate** — creates copy with `status = 'DRAFT'`, slug `{original-slug}-copy`.

**[Save draft]** — `PATCH /api/kb/articles/:id` with current title + content JSON. No status change. Shows "Saved" indicator with timestamp.

Auto-save: every 30 seconds if content changed (debounced). Same endpoint.

---

## Article Settings Panel

Slide-in panel from the right (toggle via gear icon):

```
┌──────────────────────────────────────────────────────────────┐
│  Article settings                                    [✕]     │
│                                                              │
│  Slug: [invoice-management_________________________]         │
│                                                              │
│  Parent:  [Billing ▾]                                        │
│  (Move article under a different section)                    │
│                                                              │
│  ── SEO ─────────────────────────────────────────────────── │
│  Meta title:  [_________________________________________]    │
│  Meta desc:   [_________________________________________]    │
│               [_________________________________________]    │
│                                                              │
│  ── Stats ────────────────────────────────────────────────  │
│  Views: 1,247   Last published: 2026-05-30                   │
│                                                              │
│  [Delete article]                                            │
└──────────────────────────────────────────────────────────────┘
```

---

## Tree Management

**Drag-to-reorder:** Drag article card in tree → updates `position` via `PATCH /api/kb/articles/:id` (`{ position: newFractionalValue }`).

**Nesting:** Drag article onto another → sets `parent_id`. Max depth: 3 levels (validated on drop).

**New article:** [+ New article] under a parent → creates `{ title: 'Untitled', status: 'DRAFT', parent_id, position: end }`, navigates to editor.

**New section:** [+ New section] → creates a "folder" article with no content (title only), used as parent grouping.

---

## Image Upload

Drag-drop or paste into Tiptap → uploads to R2 via `POST /api/kb/articles/:id/images` (multipart). Returns stable proxy URL `/api/kb/images/{r2Key}` (raw key with slashes). Max 5 MB per image. The proxy re-validates article visibility on each GET; attachments keep the 60min signed-URL download pattern.

---

## API

```
GET /api/kb/spaces/:id/articles
    → tree of articles (with title, status, position, parent_id)
      Requires: knowledge:read

POST /api/kb/spaces/:id/articles
     → create article
       body: { title, parent_id?, position? }
       Requires: knowledge:write

GET /api/kb/articles/:id
    → article detail (title, content, status, metadata)
      Requires: knowledge:read

PATCH /api/kb/articles/:id
      → update title, content, slug, parent_id, position, meta_*
        Requires: knowledge:write

POST /api/kb/articles/:id/publish
     → set status = 'PUBLISHED'
       Requires: knowledge:write

POST /api/kb/articles/:id/unpublish
     → set status = 'DRAFT'
       Requires: knowledge:write

DELETE /api/kb/articles/:id
       → soft-delete (sets deleted_at = now())
         Requires: knowledge:delete

POST /api/kb/articles/:id/images
     → upload image to R2
       Requires: knowledge:write
```

---

## Content Security

### Tiptap JSONB Validation (server-side)

Before storing or rendering `content` JSONB, validate it against the Tiptap node schema. Reject content with unknown node types — these can produce unexpected HTML during serialization and may contain XSS vectors:

```ts
import { generateHTML } from '@tiptap/html'
import { extensions } from './editor-extensions' // same extensions list as client

// On PATCH /api/kb/articles/:id
function validateTiptapContent(content: unknown): void {
  // 1. Zod schema: { type: 'doc', content: array } top-level shape
  // 2. Walk node types — reject any type not in ALLOWED_NODE_TYPES
  const ALLOWED_NODE_TYPES = new Set([
    'doc', 'paragraph', 'heading', 'text', 'hardBreak',
    'bold', 'italic', 'underline', 'strike', 'link', 'code',
    'bulletList', 'orderedList', 'listItem', 'blockquote',
    'codeBlock', 'image', 'table', 'tableRow', 'tableCell', 'tableHeader',
  ])
  // throw ZodError on invalid structure or unknown node type
}
```

Call `validateTiptapContent(body.content)` before any `INSERT` or `UPDATE`. Return `400` on validation failure.

### HTML Sanitization (client-side rendering)

KB articles are rendered in the portal and public KB (`/kb/{tenantSlug}`). The rendering pipeline:

```
JSONB in DB → generateHTML(@tiptap/html) → DOMPurify.sanitize() → dangerouslySetInnerHTML
```

`DOMPurify` is defense-in-depth: even if a malformed node bypasses server-side validation, it is stripped before insertion into the DOM.

```ts
import DOMPurify from 'dompurify'
import { generateHTML } from '@tiptap/html'

export function renderArticleContent(content: TiptapJSON): string {
  const html = generateHTML(content, extensions)
  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'],
    FORBID_ATTR: ['onerror', 'onload', 'onclick'],
  })
}
```

`img src` must be restricted to the tenant's R2 domain at the DOMPurify hook level to prevent SSRF-via-image-embed.

**This pattern applies to all Tiptap-powered editors in the codebase** (task descriptions: spec 11, task comments: spec 12, contract body: spec 48, proposal editor: spec 130). KB editor is the canonical reference implementation.

### Tiptap Editor Accessibility

The Tiptap `<EditorContent>` element renders a `contenteditable` `div`. For screen reader compatibility:

```tsx
<EditorContent
  editor={editor}
  aria-label="Article body"
  aria-multiline="true"
  role="textbox"
/>
```

Toolbar buttons must have explicit `aria-label` attributes (icon-only buttons have no visible text):

```tsx
<button aria-label="Bold (Ctrl+B)" onClick={() => editor.chain().focus().toggleBold().run()}>
  <BoldIcon aria-hidden="true" />
</button>
```

Keyboard shortcuts (`Ctrl+B`, `Ctrl+I`, etc.) are built into Tiptap by default and must not be overridden.

### RTL Configuration

```ts
import { Direction } from '@tiptap/extension-text-direction'

const extensions = [
  // ...other extensions (after StarterKit, Image, etc.)
  Direction.configure({
    defaultDirection: locale === 'he-IL' ? 'rtl' : 'ltr',
    // Per-paragraph direction override via toolbar toggle (↔ icon)
  }),
]
// Direction persists as dir attribute on paragraph nodes in JSONB
// New articles default to tenant locale direction
```

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Tiptap JSON in JSONB | Not HTML | Tiptap JSON is portable and re-renderable; raw HTML is fragile across editor versions and harder to sanitize |
| Title outside Tiptap | Not H1 inside content | Dedicated title field enables clean slug generation, SEO meta-title default, and search indexing without parsing Tiptap JSON |
| Auto-save 30s | Not on every keypress | Debounced saves reduce API load; 30s window acceptable for draft content |
| Max depth 3 | Not unlimited | Deep nesting is confusing for readers and editors; 3 levels (space > section > article) covers most KB structures |
| `view_count` fire-and-forget | Not transactional | Counting every page view in Postgres creates hot-row contention; increment via AE or queue; approximate count is fine for KB analytics |
