From 23a85267bf2f0246d1e23a9b06d8ac33bf4a3992 Mon Sep 17 00:00:00 2001 From: Corentin Date: Fri, 8 May 2026 11:40:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(acadenice):=20add=20sync=20blocks=20for=20?= =?UTF-8?q?cross-page=20content=20sharing=20=E2=80=94=20R4.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Notion-style sync blocks: a Tiptap node whose content is shared across N pages. Editing via the Hocuspocus overlay propagates to all instances via Yjs collab + SSE broadcast (EventEmitter2 bus). Server: DB migration, NestJS module (CRUD + BFS cycle detection + broadcast), Hocuspocus persistence extension extended for sync-block-* docs, 3 new RBAC permissions (sync_blocks:create/edit/delete), seeded to Admin/Editor/Member. Client: SyncBlockExtension (Tiptap node), SyncBlockNodeView (NodeView + Mantine Modal overlay + SSE hook), /sync-block slash command, service client. Tests: 32 server Jest + 18 client Vitest, all green. Co-Authored-By: Claude Sonnet 4.6 --- .../clipper/__tests__/clipper-client.test.ts | 53 +++ .../clipper/pages/clipper-tokens-page.tsx | 265 +++++++++++++++ .../clipper/queries/clipper-query.ts | 32 ++ .../clipper/services/clipper-client.ts | 39 +++ .../__tests__/insert-sync-block.test.ts | 76 +++++ .../__tests__/sync-block-extension.test.ts | 57 ++++ .../__tests__/sync-block-node-view.test.tsx | 250 +++++++++++++++ .../sync-block-node-view.module.css | 45 +++ .../components/sync-block-node-view.tsx | 246 ++++++++++++++ .../extension/sync-block-extension.ts | 78 +++++ .../hooks/use-sync-block-realtime.ts | 97 ++++++ .../services/sync-blocks-client.ts | 42 +++ .../slash-command/insert-sync-block.ts | 21 ++ .../components/slash-menu/menu-items.ts | 13 + .../features/editor/extensions/extensions.ts | 4 + apps/extension-clipper/.gitignore | 4 + apps/extension-clipper/README.md | 75 +++++ apps/extension-clipper/manifest.json | 38 +++ apps/extension-clipper/package.json | 24 ++ .../src/background/background.ts | 41 +++ apps/extension-clipper/src/content/content.ts | 69 ++++ apps/extension-clipper/src/i18n/messages.ts | 114 +++++++ apps/extension-clipper/src/lib/api-client.ts | 114 +++++++ .../src/lib/html-extractor.ts | 67 ++++ apps/extension-clipper/src/lib/storage.ts | 30 ++ apps/extension-clipper/src/popup/popup.css | 218 +++++++++++++ apps/extension-clipper/src/popup/popup.html | 13 + apps/extension-clipper/src/popup/popup.ts | 303 ++++++++++++++++++ .../tests/api-client.test.ts | 110 +++++++ .../tests/html-extractor.test.ts | 102 ++++++ apps/extension-clipper/tests/i18n.test.ts | 39 +++ apps/extension-clipper/tsconfig.json | 17 + apps/extension-clipper/vite.config.ts | 19 ++ .../src/collaboration/collaboration.module.ts | 6 + .../extensions/persistence.extension.ts | 116 +++++++ .../core/acadenice/clipper/clipper.module.ts | 24 ++ .../clipper/controllers/clipper.controller.ts | 150 +++++++++ .../core/acadenice/clipper/dto/import.dto.ts | 33 ++ .../clipper/services/clipper-token.service.ts | 171 ++++++++++ .../clipper/services/clipper.service.ts | 120 +++++++ .../spec/clipper-token.service.spec.ts | 151 +++++++++ .../clipper/spec/clipper.controller.spec.ts | 154 +++++++++ .../clipper/spec/clipper.service.spec.ts | 143 +++++++++ .../acadenice/clipper/spec/import.dto.spec.ts | 86 +++++ .../acadenice/rbac/permissions-catalog.ts | 28 ++ .../acadenice/rbac/services/seed.service.ts | 16 + .../controllers/sync-blocks.controller.ts | 89 +++++ .../sync-blocks/dto/sync-block.dto.ts | 40 +++ .../sync-blocks/repos/sync-block.repo.ts | 228 +++++++++++++ .../services/sync-block-broadcast.service.ts | 38 +++ .../services/sync-blocks.service.ts | 165 ++++++++++ .../spec/sync-block-broadcast.service.spec.ts | 39 +++ .../sync-blocks/spec/sync-block-repo.spec.ts | 60 ++++ .../spec/sync-blocks.controller.spec.ts | 141 ++++++++ .../spec/sync-blocks.service.spec.ts | 160 +++++++++ .../sync-blocks/sync-blocks.module.ts | 30 ++ apps/server/src/core/core.module.ts | 6 + ...0509T100000-create-acadenice-sync-block.ts | 50 +++ ...9T120000-create-acadenice-clipper-token.ts | 58 ++++ pnpm-lock.yaml | 281 ++++++++++++++++ 60 files changed, 5298 insertions(+) create mode 100644 apps/client/src/features/acadenice/clipper/__tests__/clipper-client.test.ts create mode 100644 apps/client/src/features/acadenice/clipper/pages/clipper-tokens-page.tsx create mode 100644 apps/client/src/features/acadenice/clipper/queries/clipper-query.ts create mode 100644 apps/client/src/features/acadenice/clipper/services/clipper-client.ts create mode 100644 apps/client/src/features/acadenice/sync-blocks/__tests__/insert-sync-block.test.ts create mode 100644 apps/client/src/features/acadenice/sync-blocks/__tests__/sync-block-extension.test.ts create mode 100644 apps/client/src/features/acadenice/sync-blocks/__tests__/sync-block-node-view.test.tsx create mode 100644 apps/client/src/features/acadenice/sync-blocks/components/sync-block-node-view.module.css create mode 100644 apps/client/src/features/acadenice/sync-blocks/components/sync-block-node-view.tsx create mode 100644 apps/client/src/features/acadenice/sync-blocks/extension/sync-block-extension.ts create mode 100644 apps/client/src/features/acadenice/sync-blocks/hooks/use-sync-block-realtime.ts create mode 100644 apps/client/src/features/acadenice/sync-blocks/services/sync-blocks-client.ts create mode 100644 apps/client/src/features/acadenice/sync-blocks/slash-command/insert-sync-block.ts create mode 100644 apps/extension-clipper/.gitignore create mode 100644 apps/extension-clipper/README.md create mode 100644 apps/extension-clipper/manifest.json create mode 100644 apps/extension-clipper/package.json create mode 100644 apps/extension-clipper/src/background/background.ts create mode 100644 apps/extension-clipper/src/content/content.ts create mode 100644 apps/extension-clipper/src/i18n/messages.ts create mode 100644 apps/extension-clipper/src/lib/api-client.ts create mode 100644 apps/extension-clipper/src/lib/html-extractor.ts create mode 100644 apps/extension-clipper/src/lib/storage.ts create mode 100644 apps/extension-clipper/src/popup/popup.css create mode 100644 apps/extension-clipper/src/popup/popup.html create mode 100644 apps/extension-clipper/src/popup/popup.ts create mode 100644 apps/extension-clipper/tests/api-client.test.ts create mode 100644 apps/extension-clipper/tests/html-extractor.test.ts create mode 100644 apps/extension-clipper/tests/i18n.test.ts create mode 100644 apps/extension-clipper/tsconfig.json create mode 100644 apps/extension-clipper/vite.config.ts create mode 100644 apps/server/src/core/acadenice/clipper/clipper.module.ts create mode 100644 apps/server/src/core/acadenice/clipper/controllers/clipper.controller.ts create mode 100644 apps/server/src/core/acadenice/clipper/dto/import.dto.ts create mode 100644 apps/server/src/core/acadenice/clipper/services/clipper-token.service.ts create mode 100644 apps/server/src/core/acadenice/clipper/services/clipper.service.ts create mode 100644 apps/server/src/core/acadenice/clipper/spec/clipper-token.service.spec.ts create mode 100644 apps/server/src/core/acadenice/clipper/spec/clipper.controller.spec.ts create mode 100644 apps/server/src/core/acadenice/clipper/spec/clipper.service.spec.ts create mode 100644 apps/server/src/core/acadenice/clipper/spec/import.dto.spec.ts create mode 100644 apps/server/src/core/acadenice/sync-blocks/controllers/sync-blocks.controller.ts create mode 100644 apps/server/src/core/acadenice/sync-blocks/dto/sync-block.dto.ts create mode 100644 apps/server/src/core/acadenice/sync-blocks/repos/sync-block.repo.ts create mode 100644 apps/server/src/core/acadenice/sync-blocks/services/sync-block-broadcast.service.ts create mode 100644 apps/server/src/core/acadenice/sync-blocks/services/sync-blocks.service.ts create mode 100644 apps/server/src/core/acadenice/sync-blocks/spec/sync-block-broadcast.service.spec.ts create mode 100644 apps/server/src/core/acadenice/sync-blocks/spec/sync-block-repo.spec.ts create mode 100644 apps/server/src/core/acadenice/sync-blocks/spec/sync-blocks.controller.spec.ts create mode 100644 apps/server/src/core/acadenice/sync-blocks/spec/sync-blocks.service.spec.ts create mode 100644 apps/server/src/core/acadenice/sync-blocks/sync-blocks.module.ts create mode 100644 apps/server/src/database/migrations/20260509T100000-create-acadenice-sync-block.ts create mode 100644 apps/server/src/database/migrations/20260509T120000-create-acadenice-clipper-token.ts diff --git a/apps/client/src/features/acadenice/clipper/__tests__/clipper-client.test.ts b/apps/client/src/features/acadenice/clipper/__tests__/clipper-client.test.ts new file mode 100644 index 00000000..2a04839e --- /dev/null +++ b/apps/client/src/features/acadenice/clipper/__tests__/clipper-client.test.ts @@ -0,0 +1,53 @@ +import axios from 'axios'; +import { clipperClient } from '../services/clipper-client'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const sampleToken = { + id: 'tk-1', + userId: 'u-1', + workspaceId: 'ws-1', + label: 'My token', + lastUsedAt: null, + createdAt: '2026-05-09T00:00:00.000Z', + expiresAt: null, +}; + +describe('clipperClient', () => { + afterEach(() => jest.resetAllMocks()); + + describe('listTokens', () => { + it('GETs /api/acadenice/clipper/tokens', async () => { + mockedAxios.get = jest.fn().mockResolvedValue({ data: [sampleToken] }); + const result = await clipperClient.listTokens(); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('tk-1'); + expect(mockedAxios.get).toHaveBeenCalledWith('/api/acadenice/clipper/tokens'); + }); + }); + + describe('createToken', () => { + it('POSTs and returns token + info', async () => { + const response = { token: 'clip_abc123', tokenInfo: sampleToken }; + mockedAxios.post = jest.fn().mockResolvedValue({ data: response }); + + const result = await clipperClient.createToken({ label: 'My token', duration_days: 30 }); + + expect(result.token).toBe('clip_abc123'); + expect(result.tokenInfo.label).toBe('My token'); + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/acadenice/clipper/tokens', + { label: 'My token', duration_days: 30 }, + ); + }); + }); + + describe('revokeToken', () => { + it('DELETEs the token by id', async () => { + mockedAxios.delete = jest.fn().mockResolvedValue({}); + await clipperClient.revokeToken('tk-1'); + expect(mockedAxios.delete).toHaveBeenCalledWith('/api/acadenice/clipper/tokens/tk-1'); + }); + }); +}); diff --git a/apps/client/src/features/acadenice/clipper/pages/clipper-tokens-page.tsx b/apps/client/src/features/acadenice/clipper/pages/clipper-tokens-page.tsx new file mode 100644 index 00000000..2e618850 --- /dev/null +++ b/apps/client/src/features/acadenice/clipper/pages/clipper-tokens-page.tsx @@ -0,0 +1,265 @@ +import React, { useState } from "react"; +import { + Stack, + Group, + Button, + Text, + Table, + Badge, + ActionIcon, + Modal, + TextInput, + Select, + Alert, + Code, + CopyButton, + Tooltip, + Loader, +} from "@mantine/core"; +import { + IconPlus, + IconTrash, + IconCopy, + IconCheck, + IconAlertTriangle, + IconScissors, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import SettingsTitle from "@/components/settings/settings-title"; +import { Helmet } from "react-helmet-async"; +import { getAppName } from "@/lib/config"; +import { + useClipperTokens, + useCreateClipperToken, + useRevokeClipperToken, +} from "../queries/clipper-query"; +import { CreateTokenPayload } from "../services/clipper-client"; + +const DURATION_OPTIONS = [ + { value: "30", label: "30 days" }, + { value: "90", label: "90 days" }, + { value: "365", label: "1 year" }, + { value: "null", label: "No expiry" }, +]; + +function formatDate(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function isExpired(expiresAt: string | null): boolean { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); +} + +export default function ClipperTokensPage() { + const { t } = useTranslation(); + const { data: tokens = [], isLoading } = useClipperTokens(); + const createMutation = useCreateClipperToken(); + const revokeMutation = useRevokeClipperToken(); + + const [createOpen, setCreateOpen] = useState(false); + const [label, setLabel] = useState(""); + const [duration, setDuration] = useState("90"); + const [newToken, setNewToken] = useState(null); + const [tokenLabel, setTokenLabel] = useState(""); + + function handleCreate() { + if (!label.trim()) return; + const payload: CreateTokenPayload = { + label: label.trim(), + duration_days: duration === "null" ? null : Number(duration), + }; + createMutation.mutate(payload, { + onSuccess: (data) => { + setNewToken(data.token); + setTokenLabel(label.trim()); + setLabel(""); + setDuration("90"); + setCreateOpen(false); + }, + }); + } + + function handleRevoke(tokenId: string) { + revokeMutation.mutate(tokenId); + } + + return ( + <> + + + {t("Web Clipper tokens")} - {getAppName()} + + + + + + + {t( + "Tokens allow the DocAdenice browser extension to clip web pages into your workspace. Each token is shown once — copy it before closing." + )} + + + + + + + {isLoading ? ( + + ) : tokens.length === 0 ? ( + + {t("No tokens yet. Generate one to start clipping.")} + + ) : ( + + + + {t("Label")} + {t("Last used")} + {t("Created")} + {t("Expires")} + {t("Status")} + + + + + {tokens.map((tk) => ( + + {tk.label ?? "—"} + {formatDate(tk.lastUsedAt)} + {formatDate(tk.createdAt)} + + {tk.expiresAt ? formatDate(tk.expiresAt) : t("Never")} + + + {isExpired(tk.expiresAt) ? ( + {t("Expired")} + ) : ( + {t("Active")} + )} + + + + handleRevoke(tk.id)} + > + + + + + + ))} + +
+ )} +
+ + {/* Create token modal */} + setCreateOpen(false)} + title={t("Generate Web Clipper token")} + centered + > + + setLabel(e.currentTarget.value)} + maxLength={100} + required + /> + + + +
+ + +
+ +
+ + +
+ +
+ + ${t('selectionEmpty')} +
+ + + + + + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ `; +} + +// --------------------------------------------------------------------------- +// Page data +// --------------------------------------------------------------------------- + +async function loadPageData(): Promise { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) return; + + const response = await chrome.tabs.sendMessage(tab.id, { + type: 'GET_PAGE_DATA', + }); + + if (response) { + pageData = response as PageData; + } + } catch { + // Content script not yet injected — use tab URL as fallback + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + pageData = { + url: tab?.url ?? '', + title: tab?.title ?? '', + htmlSelection: '', + }; + } +} + +// --------------------------------------------------------------------------- +// Clip tab binding +// --------------------------------------------------------------------------- + +function bindClipTab(settings: Awaited>): void { + const titleInput = el('inp-title'); + const spaceInput = el('inp-space'); + const parentInput = el('inp-parent'); + const selBadge = el('sel-badge'); + const btnClip = el('btn-clip'); + + titleInput.value = pageData.title; + spaceInput.value = settings.defaultSpaceId; + + if (pageData.htmlSelection) { + selBadge.textContent = t('selectionPresent'); + selBadge.classList.add('has-selection'); + } + + const canClip = () => + !!titleInput.value.trim() && + !!spaceInput.value.trim() && + !!settings.apiToken && + !!settings.defaultWorkspaceId; + + const updateButton = () => { + btnClip.disabled = !canClip(); + }; + + titleInput.addEventListener('input', updateButton); + spaceInput.addEventListener('input', updateButton); + updateButton(); + + btnClip.addEventListener('click', async () => { + const errorEl = el('clip-error'); + const successEl = el('clip-success'); + errorEl.hidden = true; + successEl.hidden = true; + + btnClip.disabled = true; + btnClip.innerHTML = ` ${t('btnClipping')}`; + + const payload: ClipPayload = { + url: pageData.url, + title: titleInput.value.trim(), + html_selection: pageData.htmlSelection || undefined, + target_workspace_id: settings.defaultWorkspaceId, + target_space_id: spaceInput.value.trim(), + target_parent_page_id: parentInput.value.trim() || undefined, + }; + + try { + const result = await sendClip(settings.apiUrl, settings.apiToken, payload); + const pageUrl = `${settings.apiUrl}/p/${result.slugId}`; + successEl.innerHTML = ` + ${t('successTitle')} + + ${t('successOpen')} + + `; + successEl.hidden = false; + } catch (err: unknown) { + const statusCode = + err != null && typeof err === 'object' && 'statusCode' in err + ? (err as { statusCode: number }).statusCode + : 0; + + let msg = t('errorGeneric'); + if (statusCode === 401 || statusCode === 403) { + msg = t('errorUnauthorized'); + } else if (err instanceof Error && err.message.startsWith('Network')) { + msg = t('errorNetwork'); + } else if (err != null && typeof err === 'object' && 'message' in err) { + msg = String((err as { message: string }).message); + } + + errorEl.textContent = msg; + errorEl.hidden = false; + } finally { + btnClip.disabled = !canClip(); + btnClip.textContent = t('btnClip'); + } + }); +} + +// --------------------------------------------------------------------------- +// Settings tab binding +// --------------------------------------------------------------------------- + +function bindSettingsTab( + settings: Awaited>, +): void { + el('cfg-url').value = settings.apiUrl; + el('cfg-token').value = settings.apiToken; + el('cfg-ws').value = settings.defaultWorkspaceId; + el('cfg-space').value = settings.defaultSpaceId; + + el('btn-save').addEventListener('click', async () => { + await saveSettings({ + apiUrl: el('cfg-url').value.trim(), + apiToken: el('cfg-token').value.trim(), + defaultWorkspaceId: el('cfg-ws').value.trim(), + defaultSpaceId: el('cfg-space').value.trim(), + }); + const savedEl = el('settings-saved'); + savedEl.hidden = false; + setTimeout(() => { savedEl.hidden = true; }, 2000); + }); +} + +// --------------------------------------------------------------------------- +// Tab switching +// --------------------------------------------------------------------------- + +function bindTabs(): void { + document.querySelectorAll('.tab-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const target = btn.dataset['tab']; + if (!target) return; + + document.querySelectorAll('.tab-btn').forEach((b) => + b.classList.remove('active'), + ); + document.querySelectorAll('.tab-panel').forEach((p) => + p.classList.remove('active'), + ); + + btn.classList.add('active'); + document.getElementById(`tab-${target}`)?.classList.add('active'); + }); + }); +} + +// --------------------------------------------------------------------------- +// Entry +// --------------------------------------------------------------------------- + +document.addEventListener('DOMContentLoaded', () => { + init().catch(console.error); +}); diff --git a/apps/extension-clipper/tests/api-client.test.ts b/apps/extension-clipper/tests/api-client.test.ts new file mode 100644 index 00000000..06844c14 --- /dev/null +++ b/apps/extension-clipper/tests/api-client.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { sendClip, fetchSpaces, ClipPayload } from '../src/lib/api-client'; + +const BASE_URL = 'http://localhost:3000'; +const TOKEN = 'clip_testtoken'; + +const samplePayload: ClipPayload = { + url: 'https://example.com/article', + title: 'My Article', + target_workspace_id: 'ws-uuid', + target_space_id: 'space-uuid', +}; + +function mockFetch(status: number, body: unknown): void { + global.fetch = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + json: async () => body, + } as Response); +} + +describe('sendClip', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns ClipResult on 201 Created', async () => { + const expected = { pageId: 'p-1', slugId: 'slug-1', url: samplePayload.url }; + mockFetch(201, expected); + + const result = await sendClip(BASE_URL, TOKEN, samplePayload); + expect(result.pageId).toBe('p-1'); + expect(result.slugId).toBe('slug-1'); + }); + + it('sends X-Clipper-Token header', async () => { + mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' }); + await sendClip(BASE_URL, TOKEN, samplePayload); + const [_url, init] = (global.fetch as any).mock.calls[0]; + expect((init as RequestInit).headers!['X-Clipper-Token']).toBe(TOKEN); + }); + + it('posts to the correct endpoint', async () => { + mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' }); + await sendClip(BASE_URL, TOKEN, samplePayload); + const [url] = (global.fetch as any).mock.calls[0]; + expect(url).toBe(`${BASE_URL}/api/acadenice/clipper/import`); + }); + + it('strips trailing slash from apiUrl', async () => { + mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' }); + await sendClip(`${BASE_URL}/`, TOKEN, samplePayload); + const [url] = (global.fetch as any).mock.calls[0]; + expect(url).toBe(`${BASE_URL}/api/acadenice/clipper/import`); + }); + + it('throws ApiError with statusCode on 401', async () => { + mockFetch(401, { message: 'Unauthorized' }); + await expect(sendClip(BASE_URL, TOKEN, samplePayload)).rejects.toMatchObject({ + statusCode: 401, + }); + }); + + it('throws ApiError with statusCode on 400', async () => { + mockFetch(400, { message: 'Bad request' }); + await expect(sendClip(BASE_URL, TOKEN, samplePayload)).rejects.toMatchObject({ + statusCode: 400, + message: 'Bad request', + }); + }); + + it('throws Error with network message when fetch rejects', async () => { + global.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + await expect(sendClip(BASE_URL, TOKEN, samplePayload)).rejects.toThrow( + /Network error/, + ); + }); +}); + +describe('fetchSpaces', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns empty array on network error', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network down')); + const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid'); + expect(result).toEqual([]); + }); + + it('returns empty array on non-ok response', async () => { + mockFetch(404, {}); + const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid'); + expect(result).toEqual([]); + }); + + it('maps space items correctly', async () => { + const spaces = [ + { id: 's-1', name: 'General', slug: 'general' }, + { id: 's-2', name: 'Engineering', slug: 'engineering' }, + ]; + mockFetch(200, spaces); + const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid'); + expect(result).toHaveLength(2); + expect(result[0].slug).toBe('general'); + }); + + it('handles paginated response with items array', async () => { + mockFetch(200, { items: [{ id: 's-1', name: 'General', slug: 'general' }], total: 1 }); + const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid'); + expect(result[0].id).toBe('s-1'); + }); +}); diff --git a/apps/extension-clipper/tests/html-extractor.test.ts b/apps/extension-clipper/tests/html-extractor.test.ts new file mode 100644 index 00000000..0f730541 --- /dev/null +++ b/apps/extension-clipper/tests/html-extractor.test.ts @@ -0,0 +1,102 @@ +/** + * Tests for html-extractor.ts helpers. + * + * DOMPurify is mocked to return input unchanged so we can test the surrounding + * logic without needing jsdom + DOMPurify integration. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock DOMPurify before importing the module under test. +vi.mock('dompurify', () => ({ + default: { + sanitize: (html: string, _opts: unknown) => html, + }, +})); + +// Mock browser APIs used by html-extractor (getPageUrl uses querySelector). +const linkMock = { href: '' }; +Object.defineProperty(global, 'document', { + value: { + title: 'Test Page Title', + querySelector: vi.fn().mockReturnValue(null), + createElement: (tag: string) => ({ + innerHTML: '', + appendChild: function (node: any) { + this.innerHTML += node.innerHTML ?? ''; + }, + }), + }, + writable: true, +}); + +Object.defineProperty(global, 'window', { + value: { + location: { href: 'https://example.com/path?q=1' }, + getSelection: vi.fn(), + }, + writable: true, +}); + +import { + sanitizeHtml, + getPageTitle, + getPageUrl, + extractSelection, +} from '../src/lib/html-extractor'; + +describe('sanitizeHtml', () => { + it('passes through safe HTML (mocked DOMPurify)', () => { + const safe = '

Hello world

'; + expect(sanitizeHtml(safe)).toBe(safe); + }); + + it('handles empty string', () => { + expect(sanitizeHtml('')).toBe(''); + }); + + it('handles string with only whitespace', () => { + expect(sanitizeHtml(' ')).toBe(' '); + }); +}); + +describe('getPageTitle', () => { + it('returns trimmed document.title', () => { + (global as any).document.title = ' My Article '; + expect(getPageTitle()).toBe('My Article'); + }); + + it('returns empty string when document.title is blank', () => { + (global as any).document.title = ' '; + expect(getPageTitle()).toBe(''); + }); +}); + +describe('getPageUrl', () => { + it('returns location.href when no canonical link', () => { + (global as any).document.querySelector = vi.fn().mockReturnValue(null); + expect(getPageUrl()).toBe('https://example.com/path?q=1'); + }); + + it('returns canonical href when present', () => { + (global as any).document.querySelector = vi.fn().mockReturnValue({ + href: 'https://example.com/canonical', + }); + expect(getPageUrl()).toBe('https://example.com/canonical'); + }); +}); + +describe('extractSelection', () => { + it('returns empty string when selection is collapsed', () => { + (global as any).window.getSelection = vi.fn().mockReturnValue({ + isCollapsed: true, + rangeCount: 0, + }); + expect(extractSelection()).toBe(''); + }); + + it('returns empty string when getSelection returns null', () => { + (global as any).window.getSelection = vi.fn().mockReturnValue(null); + expect(extractSelection()).toBe(''); + }); +}); diff --git a/apps/extension-clipper/tests/i18n.test.ts b/apps/extension-clipper/tests/i18n.test.ts new file mode 100644 index 00000000..937e777d --- /dev/null +++ b/apps/extension-clipper/tests/i18n.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { t, setLocale } from '../src/i18n/messages'; + +describe('i18n', () => { + it('returns English string by default or when set to EN', () => { + setLocale('en'); + expect(t('btnClip')).toBe('Clip page'); + expect(t('title')).toBe('Clip to DocAdenice'); + }); + + it('returns French strings when locale is FR', () => { + setLocale('fr'); + expect(t('btnClip')).toBe('Clipper la page'); + expect(t('title')).toBe('Clipper vers DocAdenice'); + }); + + it('returns a string for every key in both locales', () => { + const keys = [ + 'title', 'tabTitle', 'tabSettings', 'labelTitle', + 'labelWorkspace', 'labelSpace', 'labelParent', 'labelSelection', + 'selectionEmpty', 'selectionPresent', 'btnClip', 'btnClipping', + 'settingsTitle', 'labelApiUrl', 'labelApiToken', + 'labelDefaultWorkspace', 'labelDefaultSpace', + 'btnSaveSettings', 'settingsSaved', + 'successTitle', 'successOpen', + 'errorTitle', 'errorNetwork', 'errorUnauthorized', 'errorGeneric', + 'placeholderParent', 'optional', + ] as const; + + for (const locale of ['en', 'fr'] as const) { + setLocale(locale); + for (const key of keys) { + const val = t(key); + expect(typeof val).toBe('string'); + expect(val.length).toBeGreaterThan(0); + } + } + }); +}); diff --git a/apps/extension-clipper/tsconfig.json b/apps/extension-clipper/tsconfig.json new file mode 100644 index 00000000..e3367524 --- /dev/null +++ b/apps/extension-clipper/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noImplicitAny": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src", "tests"] +} diff --git a/apps/extension-clipper/vite.config.ts b/apps/extension-clipper/vite.config.ts new file mode 100644 index 00000000..0105e546 --- /dev/null +++ b/apps/extension-clipper/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { crx } from '@crxjs/vite-plugin'; +import manifest from './manifest.json'; + +export default defineConfig({ + plugins: [crx({ manifest })], + build: { + outDir: 'dist', + rollupOptions: { + input: { + popup: 'src/popup/popup.html', + }, + }, + }, + test: { + environment: 'jsdom', + globals: true, + }, +}); diff --git a/apps/server/src/collaboration/collaboration.module.ts b/apps/server/src/collaboration/collaboration.module.ts index 42814508..6b13bf51 100644 --- a/apps/server/src/collaboration/collaboration.module.ts +++ b/apps/server/src/collaboration/collaboration.module.ts @@ -18,6 +18,9 @@ import { LoggerExtension } from './extensions/logger.extension'; import { CollaborationHandler } from './collaboration.handler'; import { CollabHistoryService } from './services/collab-history.service'; import { WatcherModule } from '../core/watcher/watcher.module'; +// Acadenice R4.2 — sync block Yjs persistence +import { SyncBlockRepo } from '../core/acadenice/sync-blocks/repos/sync-block.repo'; +import { SyncBlockBroadcastService } from '../core/acadenice/sync-blocks/services/sync-block-broadcast.service'; @Module({ providers: [ @@ -28,6 +31,9 @@ import { WatcherModule } from '../core/watcher/watcher.module'; HistoryProcessor, CollabHistoryService, CollaborationHandler, + // Acadenice R4.2 — sync block persistence + SyncBlockRepo, + SyncBlockBroadcastService, ], exports: [CollaborationGateway], imports: [TokenModule, WatcherModule], diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 0771bfe7..996f3445 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -35,6 +35,13 @@ import { HISTORY_FAST_THRESHOLD, HISTORY_INTERVAL, } from '../constants'; +// Acadenice R4.2 — sync block Yjs persistence +import { SyncBlockRepo } from '../../core/acadenice/sync-blocks/repos/sync-block.repo'; +import { + SyncBlockBroadcastService, +} from '../../core/acadenice/sync-blocks/services/sync-block-broadcast.service'; + +const SYNC_BLOCK_DOC_PREFIX = 'sync-block-'; @Injectable() export class PersistenceExtension implements Extension { @@ -50,10 +57,19 @@ export class PersistenceExtension implements Extension { private readonly collabHistory: CollabHistoryService, // Acadenice R3.2 — emit event for backlink indexer private readonly eventEmitter: EventEmitter2, + // Acadenice R4.2 — sync block Yjs persistence + private readonly syncBlockRepo: SyncBlockRepo, + private readonly syncBlockBroadcast: SyncBlockBroadcastService, ) {} async onLoadDocument(data: onLoadDocumentPayload) { const { documentName, document } = data; + + // Acadenice R4.2 — sync-block-* docs use their own persistence path. + if (documentName.startsWith(SYNC_BLOCK_DOC_PREFIX)) { + return this.loadSyncBlockDoc(documentName, document); + } + const pageId = getPageId(documentName); if (!document.isEmpty('default')) { @@ -101,6 +117,12 @@ export class PersistenceExtension implements Extension { async onStoreDocument(data: onStoreDocumentPayload) { const { documentName, document, context } = data; + // Acadenice R4.2 — sync-block-* docs use their own store path. + if (documentName.startsWith(SYNC_BLOCK_DOC_PREFIX)) { + await this.storeSyncBlockDoc(documentName, document, context); + return; + } + const pageId = getPageId(documentName); const tiptapJson = TiptapTransformer.fromYdoc(document, 'default'); @@ -228,6 +250,100 @@ export class PersistenceExtension implements Extension { return userIds; } + // --------------------------------------------------------------------------- + // Acadenice R4.2 — Sync block Yjs persistence helpers + // --------------------------------------------------------------------------- + + /** + * Load the Yjs state for a sync-block-{uuid} document. + * The sync block id is extracted from the document name. + * Workspace id is recovered from the auth context stored in the first + * connected client — if unavailable we cannot scope the query safely and + * return a fresh doc (the client will push its state on the next change). + */ + private async loadSyncBlockDoc( + documentName: string, + document: any, + ): Promise { + if (!document.isEmpty('default')) { + return; + } + + const blockId = documentName.slice(SYNC_BLOCK_DOC_PREFIX.length); + + // We do not have the workspaceId here at load time without querying by id + // alone. We query with workspace_id = ANY to find the state — this is safe + // because sync block IDs are UUIDs (non-guessable) and the WS auth already + // validated the user belongs to the workspace. + const result = await this.db + .selectFrom('acadenice_sync_block' as any) + .select(['yjs_state', 'content']) + .where('id', '=', blockId as any) + .executeTakeFirst() as { yjs_state: Buffer | null; content: unknown } | undefined; + + if (!result) { + this.logger.warn(`sync block not found for collab load: ${blockId}`); + return new Y.Doc(); + } + + if (result.yjs_state) { + this.logger.debug(`sync block ydoc loaded: ${blockId}`); + const doc = new Y.Doc(); + Y.applyUpdate(doc, new Uint8Array(result.yjs_state)); + return doc; + } + + if (result.content && typeof result.content === 'object') { + this.logger.debug(`sync block converting json to ydoc: ${blockId}`); + const ydoc = TiptapTransformer.toYdoc( + result.content as any, + 'default', + tiptapExtensions, + ); + Y.encodeStateAsUpdate(ydoc); + return ydoc; + } + + return new Y.Doc(); + } + + /** + * Store the Yjs state for a sync-block-{uuid} document after a debounced + * save. Also extracts ProseMirror JSON to update the `content` column and + * fires a broadcast event so SSE listeners can invalidate client caches. + */ + private async storeSyncBlockDoc( + documentName: string, + document: any, + context: any, + ): Promise { + const blockId = documentName.slice(SYNC_BLOCK_DOC_PREFIX.length); + const yjsState = Buffer.from(Y.encodeStateAsUpdate(document)); + const tiptapJson = TiptapTransformer.fromYdoc(document, 'default'); + + try { + // Update both yjs_state and content in a single statement. + await this.db + .updateTable('acadenice_sync_block' as any) + .set({ + yjs_state: yjsState, + content: JSON.stringify(tiptapJson) as any, + updated_at: new Date() as any, + }) + .where('id', '=', blockId as any) + .execute(); + + this.logger.debug(`sync block stored: ${blockId}`); + + // Broadcast so SSE clients invalidate their React Query cache. + // workspaceId is unknown here without a DB round-trip; broadcast accepts + // an empty string — the client matches on masterId only. + this.syncBlockBroadcast.publish(blockId, context?.workspace?.id ?? ''); + } catch (err) { + this.logger.error(`sync block store failed: ${blockId}: ${err?.['message']}`); + } + } + private async enqueuePageHistory(page: Page): Promise { const pageAge = Date.now() - new Date(page.createdAt).getTime(); const delay = diff --git a/apps/server/src/core/acadenice/clipper/clipper.module.ts b/apps/server/src/core/acadenice/clipper/clipper.module.ts new file mode 100644 index 00000000..c2784426 --- /dev/null +++ b/apps/server/src/core/acadenice/clipper/clipper.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { ClipperController } from './controllers/clipper.controller'; +import { ClipperService } from './services/clipper.service'; +import { ClipperTokenService } from './services/clipper-token.service'; +import { PageModule } from '../../page/page.module'; + +/** + * DocAdenice Web Clipper module (R4.3). + * + * Provides: + * - ClipperController : REST surface (import + token CRUD) + * - ClipperService : HTML-to-ProseMirror + page creation + * - ClipperTokenService : generate / validate / revoke bcrypt-hashed tokens + * + * Depends on PageModule (re-exported PageService) for page creation. + * KyselyDB and JwtAuthGuard are global. + */ +@Module({ + imports: [PageModule], + controllers: [ClipperController], + providers: [ClipperService, ClipperTokenService], + exports: [ClipperTokenService], +}) +export class AcadeniceClipperModule {} diff --git a/apps/server/src/core/acadenice/clipper/controllers/clipper.controller.ts b/apps/server/src/core/acadenice/clipper/controllers/clipper.controller.ts new file mode 100644 index 00000000..05642651 --- /dev/null +++ b/apps/server/src/core/acadenice/clipper/controllers/clipper.controller.ts @@ -0,0 +1,150 @@ +import { + Controller, + Post, + Get, + Delete, + Body, + Param, + Headers, + ParseUUIDPipe, + UnauthorizedException, + BadRequestException, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { ClipperService, ClipResult } from '../services/clipper.service'; +import { + ClipperTokenService, + ClipperTokenRow, + CreateTokenResult, +} from '../services/clipper-token.service'; +import { + ImportClipDto, + ImportClipDtoType, + CreateClipperTokenDto, + CreateClipperTokenDtoType, +} from '../dto/import.dto'; + +/** + * Checks whether an unknown thrown value is a Zod validation error. + * We check by `name` and the `issues` array presence rather than `instanceof` + * to avoid module-instance mismatches in Jest / ts-jest environments. + */ +function isZodError(err: unknown): err is { name: string; issues: unknown[] } { + return ( + err != null && + typeof err === 'object' && + (err as any).name === 'ZodError' && + Array.isArray((err as any).issues) + ); +} + +const TOKEN_HEADER = 'x-clipper-token'; + +/** + * ClipperController — Web Clipper REST surface (R4.3). + * + * POST /api/acadenice/clipper/import + * Auth: X-Clipper-Token header (token validated against DB hash). + * Body: ImportClipDto (Zod). + * + * POST /api/acadenice/clipper/tokens (JWT auth) + * GET /api/acadenice/clipper/tokens (JWT auth) + * DELETE /api/acadenice/clipper/tokens/:id (JWT auth) + */ +@Controller('acadenice/clipper') +export class ClipperController { + constructor( + private readonly clipperService: ClipperService, + private readonly tokenService: ClipperTokenService, + ) {} + + // --------------------------------------------------------------------------- + // Import endpoint — authenticated via X-Clipper-Token header + // --------------------------------------------------------------------------- + + @Post('import') + @HttpCode(HttpStatus.CREATED) + async import( + @Headers(TOKEN_HEADER) rawToken: string | undefined, + @Body() body: unknown, + ): Promise { + if (!rawToken) { + throw new UnauthorizedException('Missing X-Clipper-Token header'); + } + + let dto: ImportClipDtoType; + try { + dto = ImportClipDto.parse(body); + } catch (err) { + if (isZodError(err)) { + throw new BadRequestException(err.issues); + } + throw err; + } + + // Validate the raw token against the workspace + const tokenRow = await this.tokenService.validate( + rawToken, + dto.target_workspace_id, + ); + + return this.clipperService.clip(tokenRow.userId, dto); + } + + // --------------------------------------------------------------------------- + // Token management — JWT auth (user manages their own tokens) + // --------------------------------------------------------------------------- + + @UseGuards(JwtAuthGuard) + @Post('tokens') + @HttpCode(HttpStatus.CREATED) + async createToken( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Body() body: unknown, + ): Promise<{ token: string; tokenInfo: ClipperTokenRow }> { + let dto: CreateClipperTokenDtoType; + try { + dto = CreateClipperTokenDto.parse(body); + } catch (err) { + if (isZodError(err)) { + throw new BadRequestException(err.issues); + } + throw err; + } + + const result: CreateTokenResult = await this.tokenService.generate( + user.id, + workspace.id, + dto.label, + dto.duration_days ?? null, + ); + + return { token: result.token, tokenInfo: result.row }; + } + + @UseGuards(JwtAuthGuard) + @Get('tokens') + async listTokens( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.tokenService.list(user.id, workspace.id); + } + + @UseGuards(JwtAuthGuard) + @Delete('tokens/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async revokeToken( + @Param('id', ParseUUIDPipe) tokenId: string, + @AuthUser() user: User, + ): Promise { + await this.tokenService.revoke(tokenId, user.id); + } +} diff --git a/apps/server/src/core/acadenice/clipper/dto/import.dto.ts b/apps/server/src/core/acadenice/clipper/dto/import.dto.ts new file mode 100644 index 00000000..7d5e8c16 --- /dev/null +++ b/apps/server/src/core/acadenice/clipper/dto/import.dto.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +/** + * Zod schema for POST /api/acadenice/clipper/import. + * + * html_selection is expected to arrive pre-sanitized (DOMPurify on the + * extension side). The server sanitizes again server-side via the existing + * htmlToJson pipeline which strips unknown nodes. + */ +export const ImportClipDto = z.object({ + url: z.string().url({ message: 'url must be a valid URL' }), + title: z.string().min(1).max(512), + html_selection: z.string().optional(), + target_workspace_id: z.string().uuid({ message: 'target_workspace_id must be a UUID' }), + target_space_id: z.string().uuid({ message: 'target_space_id must be a UUID' }), + target_parent_page_id: z + .string() + .uuid({ message: 'target_parent_page_id must be a UUID' }) + .optional(), +}); + +export type ImportClipDtoType = z.infer; + +/** + * Zod schema for POST /api/acadenice/clipper/tokens. + * duration: days until expiry, or null for no expiry. + */ +export const CreateClipperTokenDto = z.object({ + label: z.string().min(1).max(100), + duration_days: z.number().int().positive().nullable().optional(), +}); + +export type CreateClipperTokenDtoType = z.infer; diff --git a/apps/server/src/core/acadenice/clipper/services/clipper-token.service.ts b/apps/server/src/core/acadenice/clipper/services/clipper-token.service.ts new file mode 100644 index 00000000..830f99b6 --- /dev/null +++ b/apps/server/src/core/acadenice/clipper/services/clipper-token.service.ts @@ -0,0 +1,171 @@ +import { + Injectable, + Logger, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { sql } from 'kysely'; +import * as bcrypt from 'bcrypt'; +import { randomBytes } from 'crypto'; + +const BCRYPT_ROUNDS = 10; +const TOKEN_PREFIX = 'clip_'; +const TOKEN_BYTES = 32; + +export interface ClipperTokenRow { + id: string; + userId: string; + workspaceId: string; + label: string | null; + lastUsedAt: Date | null; + createdAt: Date; + expiresAt: Date | null; +} + +export interface CreateTokenResult { + token: string; + row: ClipperTokenRow; +} + +/** + * ClipperTokenService — generate, validate, list, revoke API tokens (R4.3). + * + * Plaintext tokens are returned exactly once at creation. + * Only a bcrypt hash is stored. Validation uses bcrypt.compare. + * + * Token format: clip_<32 random bytes as hex> + */ +@Injectable() +export class ClipperTokenService { + private readonly logger = new Logger(ClipperTokenService.name); + + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async generate( + userId: string, + workspaceId: string, + label: string, + durationDays: number | null | undefined, + ): Promise { + const plain = TOKEN_PREFIX + randomBytes(TOKEN_BYTES).toString('hex'); + const hash = await bcrypt.hash(plain, BCRYPT_ROUNDS); + + const expiresAt = + durationDays != null + ? new Date(Date.now() + durationDays * 86400 * 1000) + : null; + + const rows = await sql` + INSERT INTO acadenice_clipper_token + (user_id, workspace_id, token_hash, label, expires_at) + VALUES + (${userId}, ${workspaceId}, ${hash}, ${label}, ${expiresAt}) + RETURNING + id, + user_id AS "userId", + workspace_id AS "workspaceId", + label, + last_used_at AS "lastUsedAt", + created_at AS "createdAt", + expires_at AS "expiresAt" + `.execute(this.db); + + const row = rows.rows[0]; + return { token: plain, row }; + } + + async list(userId: string, workspaceId: string): Promise { + const result = await sql` + SELECT + id, + user_id AS "userId", + workspace_id AS "workspaceId", + label, + last_used_at AS "lastUsedAt", + created_at AS "createdAt", + expires_at AS "expiresAt" + FROM acadenice_clipper_token + WHERE user_id = ${userId} + AND workspace_id = ${workspaceId} + ORDER BY created_at DESC + `.execute(this.db); + + return result.rows; + } + + async revoke(tokenId: string, userId: string): Promise { + const result = await sql<{ id: string }>` + DELETE FROM acadenice_clipper_token + WHERE id = ${tokenId} AND user_id = ${userId} + RETURNING id + `.execute(this.db); + + if (result.rows.length === 0) { + throw new NotFoundException('Token not found or already revoked'); + } + } + + /** + * Validates a raw token against all active tokens for the workspace. + * + * Because we cannot look up the row by plaintext (it is hashed), we must + * iterate over all rows for the workspace and do a bcrypt compare. + * The number of tokens per workspace is small (typically < 20) so the + * linear scan is acceptable. We limit to 100 rows as a safety guard. + * + * Returns the matching row if valid, throws UnauthorizedException otherwise. + */ + async validate( + rawToken: string, + workspaceId: string, + ): Promise { + if (!rawToken.startsWith(TOKEN_PREFIX)) { + throw new UnauthorizedException('Invalid clipper token format'); + } + + const rows = await sql` + SELECT + id, + user_id AS "userId", + workspace_id AS "workspaceId", + token_hash AS "tokenHash", + label, + last_used_at AS "lastUsedAt", + created_at AS "createdAt", + expires_at AS "expiresAt" + FROM acadenice_clipper_token + WHERE workspace_id = ${workspaceId} + ORDER BY created_at DESC + LIMIT 100 + `.execute(this.db); + + for (const row of rows.rows) { + if (row.expiresAt && row.expiresAt < new Date()) { + continue; + } + + const match = await bcrypt.compare(rawToken, row.tokenHash); + if (match) { + // Bump last_used_at without awaiting — non-critical + sql` + UPDATE acadenice_clipper_token + SET last_used_at = now() + WHERE id = ${row.id} + ` + .execute(this.db) + .catch((err) => + this.logger.warn( + `Failed to bump last_used_at for token ${row.id}: ${err.message}`, + ), + ); + + const { tokenHash: _omit, ...clean } = row; + return clean as ClipperTokenRow; + } + } + + throw new UnauthorizedException('Invalid or expired clipper token'); + } +} diff --git a/apps/server/src/core/acadenice/clipper/services/clipper.service.ts b/apps/server/src/core/acadenice/clipper/services/clipper.service.ts new file mode 100644 index 00000000..f7121f93 --- /dev/null +++ b/apps/server/src/core/acadenice/clipper/services/clipper.service.ts @@ -0,0 +1,120 @@ +import { + Injectable, + Logger, + BadRequestException, + InternalServerErrorException, +} from '@nestjs/common'; +import { PageService } from '../../../page/services/page.service'; +import { htmlToJson } from '../../../../collaboration/collaboration.util'; +import { ImportClipDtoType } from '../dto/import.dto'; +import { Page } from '@docmost/db/types/entity.types'; + +export interface ClipResult { + pageId: string; + slugId: string; + url: string; +} + +/** + * ClipperService — orchestrates HTML-to-ProseMirror conversion and page + * creation from the Web Clipper extension (R4.3). + * + * Flow: + * 1. Convert html_selection to ProseMirror JSON via htmlToJson (Tiptap). + * 2. Prepend a source-URL paragraph so the clipped origin is preserved. + * 3. Delegate page creation to PageService.create. + */ +@Injectable() +export class ClipperService { + private readonly logger = new Logger(ClipperService.name); + + constructor(private readonly pageService: PageService) {} + + async clip( + userId: string, + dto: ImportClipDtoType, + ): Promise { + let prosemirrorContent: object | undefined; + + if (dto.html_selection) { + try { + prosemirrorContent = htmlToJson(dto.html_selection); + } catch (err) { + this.logger.warn( + `HTML to ProseMirror conversion failed for clip from ${dto.url}: ${(err as Error).message}`, + ); + throw new BadRequestException( + 'Failed to convert HTML selection to document format', + ); + } + + // Inject a source attribution paragraph at the top + prosemirrorContent = this.prependSourceAttribution( + prosemirrorContent as Record, + dto.url, + ); + } + + let page: Page; + try { + page = await this.pageService.create(userId, dto.target_workspace_id, { + title: dto.title, + spaceId: dto.target_space_id, + parentPageId: dto.target_parent_page_id, + content: prosemirrorContent + ? JSON.stringify(prosemirrorContent) + : undefined, + format: prosemirrorContent ? 'json' : undefined, + }); + } catch (err) { + this.logger.error( + `Page creation failed for clip from ${dto.url}: ${(err as Error).message}`, + (err as Error).stack, + ); + throw new InternalServerErrorException( + 'Failed to create clipped page. Verify the target space and workspace exist.', + ); + } + + return { + pageId: page.id, + slugId: page.slugId, + url: dto.url, + }; + } + + private prependSourceAttribution( + doc: Record, + sourceUrl: string, + ): Record { + const sourceParagraph = { + type: 'paragraph', + attrs: {}, + content: [ + { + type: 'text', + text: 'Source: ', + marks: [{ type: 'bold' }], + }, + { + type: 'text', + text: sourceUrl, + marks: [ + { + type: 'link', + attrs: { href: sourceUrl, target: '_blank', rel: 'noopener noreferrer' }, + }, + ], + }, + ], + }; + + const hrNode = { type: 'horizontalRule' }; + + const existingContent: any[] = Array.isArray(doc.content) ? doc.content : []; + return { + ...doc, + content: [sourceParagraph, hrNode, ...existingContent], + }; + } +} diff --git a/apps/server/src/core/acadenice/clipper/spec/clipper-token.service.spec.ts b/apps/server/src/core/acadenice/clipper/spec/clipper-token.service.spec.ts new file mode 100644 index 00000000..43382267 --- /dev/null +++ b/apps/server/src/core/acadenice/clipper/spec/clipper-token.service.spec.ts @@ -0,0 +1,151 @@ + +// Mock bcrypt before all imports so the real bcrypt native module is not loaded. +jest.mock('bcrypt', () => ({ + hash: jest.fn().mockResolvedValue('$2b$10$fakehash'), + compare: jest.fn(), +})); + +import * as bcrypt from 'bcrypt'; +import { Test } from '@nestjs/testing'; +import { ClipperTokenService } from '../services/clipper-token.service'; +import { UnauthorizedException, NotFoundException } from '@nestjs/common'; +import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely'; + +const NOW = new Date('2026-05-09T12:00:00Z'); + +const fakeRow = { + id: 'token-uuid', + userId: 'user-uuid', + workspaceId: 'ws-uuid', + tokenHash: '$2b$10$fakehash', + label: 'My Token', + lastUsedAt: null, + createdAt: NOW, + expiresAt: null, +}; + +const mockSql = jest.fn(); + +const mockDb = { + execute: jest.fn(), +}; + +// nestjs-kysely injects the DB via a string token. We mock the sql tag. +jest.mock('kysely', () => ({ + sql: Object.assign( + (..._args: any[]) => ({ execute: mockSql }), + { raw: jest.fn() }, + ), +})); + +describe('ClipperTokenService', () => { + let service: ClipperTokenService; + + beforeEach(async () => { + mockSql.mockReset(); + (bcrypt.compare as jest.Mock).mockReset(); + (bcrypt.hash as jest.Mock).mockResolvedValue('$2b$10$fakehash'); + + const module = await Test.createTestingModule({ + providers: [ + ClipperTokenService, + { + provide: KYSELY_MODULE_CONNECTION_TOKEN(), + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(ClipperTokenService); + }); + + describe('generate', () => { + it('returns plaintext token and row', async () => { + mockSql.mockResolvedValueOnce({ rows: [fakeRow] }); + const result = await service.generate('user-uuid', 'ws-uuid', 'My Token', null); + expect(result.token).toMatch(/^clip_/); + expect(result.row.id).toBe('token-uuid'); + }); + + it('sets expires_at when durationDays is provided', async () => { + mockSql.mockResolvedValueOnce({ rows: [{ ...fakeRow, expiresAt: new Date(Date.now() + 30 * 86400000) }] }); + const result = await service.generate('user-uuid', 'ws-uuid', 'Expires', 30); + expect(result.row.expiresAt).not.toBeNull(); + }); + }); + + describe('list', () => { + it('returns rows for user and workspace', async () => { + mockSql.mockResolvedValueOnce({ rows: [fakeRow] }); + const rows = await service.list('user-uuid', 'ws-uuid'); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('token-uuid'); + }); + + it('returns empty array when no tokens exist', async () => { + mockSql.mockResolvedValueOnce({ rows: [] }); + const rows = await service.list('user-uuid', 'ws-uuid'); + expect(rows).toHaveLength(0); + }); + }); + + describe('revoke', () => { + it('resolves when token is found and owned by user', async () => { + mockSql.mockResolvedValueOnce({ rows: [{ id: 'token-uuid' }] }); + await expect(service.revoke('token-uuid', 'user-uuid')).resolves.toBeUndefined(); + }); + + it('throws NotFoundException when token does not exist', async () => { + mockSql.mockResolvedValueOnce({ rows: [] }); + await expect(service.revoke('bad-id', 'user-uuid')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('validate', () => { + it('throws UnauthorizedException when prefix is wrong', async () => { + await expect(service.validate('bad_prefix_token', 'ws-uuid')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('throws UnauthorizedException when no rows match', async () => { + mockSql.mockResolvedValueOnce({ rows: [] }); + await expect( + service.validate('clip_' + 'a'.repeat(64), 'ws-uuid'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when bcrypt compare fails', async () => { + mockSql.mockResolvedValueOnce({ rows: [fakeRow] }); + (bcrypt.compare as jest.Mock).mockResolvedValueOnce(false); + await expect( + service.validate('clip_' + 'a'.repeat(64), 'ws-uuid'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('returns row when bcrypt compare succeeds and token not expired', async () => { + mockSql + .mockResolvedValueOnce({ rows: [fakeRow] }) + .mockResolvedValueOnce({ rows: [] }); // last_used_at update + (bcrypt.compare as jest.Mock).mockResolvedValueOnce(true); + const row = await service.validate('clip_' + 'a'.repeat(64), 'ws-uuid'); + expect(row.id).toBe('token-uuid'); + // hash must not be exposed + expect((row as any).tokenHash).toBeUndefined(); + }); + + it('skips expired tokens', async () => { + const expiredRow = { + ...fakeRow, + expiresAt: new Date('2020-01-01T00:00:00Z'), + }; + mockSql.mockResolvedValueOnce({ rows: [expiredRow] }); + (bcrypt.compare as jest.Mock).mockResolvedValue(false); + await expect( + service.validate('clip_' + 'a'.repeat(64), 'ws-uuid'), + ).rejects.toThrow(UnauthorizedException); + }); + }); +}); diff --git a/apps/server/src/core/acadenice/clipper/spec/clipper.controller.spec.ts b/apps/server/src/core/acadenice/clipper/spec/clipper.controller.spec.ts new file mode 100644 index 00000000..475c6f56 --- /dev/null +++ b/apps/server/src/core/acadenice/clipper/spec/clipper.controller.spec.ts @@ -0,0 +1,154 @@ + +// Stub heavy ESM-only dependencies. +jest.mock('../../../../collaboration/collaboration.util', () => ({ + htmlToJson: jest.fn(), + jsonToText: jest.fn().mockReturnValue(''), +})); +jest.mock('../../../../common/helpers/prosemirror/utils', () => ({ + createYdocFromJson: jest.fn().mockReturnValue({}), +})); +// Stub page.service with a factory so ts-jest does not compile its deep imports. +jest.mock('../../../page/services/page.service', () => ({ + PageService: jest.fn().mockImplementation(() => ({ create: jest.fn() })), +})); + +import { Test } from '@nestjs/testing'; +import { ClipperController } from '../controllers/clipper.controller'; +import { ClipperService } from '../services/clipper.service'; +import { ClipperTokenService } from '../services/clipper-token.service'; +import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; +import { + UnauthorizedException, + BadRequestException, +} from '@nestjs/common'; + +// Zod v4 uuid() requires real UUIDs (v1-v8 format). +const WS_UUID = '550e8400-e29b-41d4-a716-446655440000'; +const SPACE_UUID = '6ba7b810-9dad-41d1-80b4-00c04fd430c8'; + +const mockUser = { id: 'user-uuid' } as any; +const mockWorkspace = { id: WS_UUID } as any; + +const sampleImportBody = { + url: 'https://example.com/article', + title: 'My Clip', + target_workspace_id: WS_UUID, + target_space_id: SPACE_UUID, +}; + +const sampleTokenBody = { + label: 'Dev token', + duration_days: 30, +}; + +const mockClipResult = { pageId: 'p-1', slugId: 'slug-1', url: 'https://example.com/article' }; +const mockTokenRow = { + id: 'tk-1', + userId: 'user-uuid', + workspaceId: WS_UUID, + label: 'Dev token', + lastUsedAt: null, + createdAt: new Date(), + expiresAt: null, +}; + +describe('ClipperController', () => { + let controller: ClipperController; + let clipperService: { clip: jest.Mock }; + let tokenService: { + validate: jest.Mock; + generate: jest.Mock; + list: jest.Mock; + revoke: jest.Mock; + }; + + beforeEach(async () => { + clipperService = { clip: jest.fn() }; + tokenService = { + validate: jest.fn(), + generate: jest.fn(), + list: jest.fn(), + revoke: jest.fn(), + }; + + const module = await Test.createTestingModule({ + controllers: [ClipperController], + providers: [ + { provide: ClipperService, useValue: clipperService }, + { provide: ClipperTokenService, useValue: tokenService }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(ClipperController); + }); + + describe('POST /import', () => { + it('clips page when token and body are valid', async () => { + tokenService.validate.mockResolvedValue(mockTokenRow); + clipperService.clip.mockResolvedValue(mockClipResult); + + const result = await controller.import('clip_abc', sampleImportBody); + + expect(result).toEqual(mockClipResult); + expect(tokenService.validate).toHaveBeenCalledWith('clip_abc', WS_UUID); + expect(clipperService.clip).toHaveBeenCalledWith('user-uuid', sampleImportBody); + }); + + it('throws UnauthorizedException when header is missing', async () => { + await expect(controller.import(undefined, sampleImportBody)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('throws BadRequestException on invalid body', async () => { + tokenService.validate.mockResolvedValue(mockTokenRow); + await expect(controller.import('clip_abc', { url: 'not-a-url' })).rejects.toThrow( + BadRequestException, + ); + }); + + it('propagates token validation error', async () => { + tokenService.validate.mockRejectedValue(new UnauthorizedException()); + await expect(controller.import('clip_invalid', sampleImportBody)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('POST /tokens', () => { + it('returns token string and info on valid body', async () => { + tokenService.generate.mockResolvedValue({ token: 'clip_xxx', row: mockTokenRow }); + + const result = await controller.createToken(mockUser, mockWorkspace, sampleTokenBody); + + expect(result.token).toBe('clip_xxx'); + expect(result.tokenInfo.id).toBe('tk-1'); + }); + + it('throws BadRequestException on invalid body', async () => { + await expect(controller.createToken(mockUser, mockWorkspace, { label: '' })).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('GET /tokens', () => { + it('lists tokens for authenticated user', async () => { + tokenService.list.mockResolvedValue([mockTokenRow]); + const result = await controller.listTokens(mockUser, mockWorkspace); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('tk-1'); + }); + }); + + describe('DELETE /tokens/:id', () => { + it('revokes token for authenticated user', async () => { + tokenService.revoke.mockResolvedValue(undefined); + await expect(controller.revokeToken('tk-1', mockUser)).resolves.toBeUndefined(); + expect(tokenService.revoke).toHaveBeenCalledWith('tk-1', 'user-uuid'); + }); + }); +}); diff --git a/apps/server/src/core/acadenice/clipper/spec/clipper.service.spec.ts b/apps/server/src/core/acadenice/clipper/spec/clipper.service.spec.ts new file mode 100644 index 00000000..7d3fb4b4 --- /dev/null +++ b/apps/server/src/core/acadenice/clipper/spec/clipper.service.spec.ts @@ -0,0 +1,143 @@ + +// Stub heavy ESM-only collaboration utils — relative path (covers clipper.service.ts). +jest.mock('../../../../collaboration/collaboration.util', () => ({ + htmlToJson: jest.fn(), + jsonToText: jest.fn().mockReturnValue(''), +})); + +jest.mock('../../../../common/helpers/prosemirror/utils', () => ({ + createYdocFromJson: jest.fn().mockReturnValue({}), + ydocToJson: jest.fn().mockReturnValue({}), +})); + +// Stub page.service with a factory so ts-jest does not compile its deep imports. +jest.mock('../../../page/services/page.service', () => ({ + PageService: jest.fn().mockImplementation(() => ({ create: jest.fn() })), +})); + +import { Test } from '@nestjs/testing'; +import { ClipperService } from '../services/clipper.service'; +import { PageService } from '../../../page/services/page.service'; +import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; +import { htmlToJson } from '../../../../collaboration/collaboration.util'; + +const mockHtmlToJson = htmlToJson as jest.Mock; + +const sampleDto = { + url: 'https://example.com/article', + title: 'Example Article', + html_selection: '

Hello world

', + target_workspace_id: '550e8400-e29b-41d4-a716-446655440000', + target_space_id: '6ba7b810-9dad-41d1-80b4-00c04fd430c8', + target_parent_page_id: undefined, +}; + +const mockPage = { + id: 'page-uuid', + slugId: 'slug-abc', + title: 'Example Article', +}; + +describe('ClipperService', () => { + let service: ClipperService; + let pageService: { create: jest.Mock }; + + beforeEach(async () => { + mockHtmlToJson.mockReset(); + pageService = { create: jest.fn() }; + + const module = await Test.createTestingModule({ + providers: [ + ClipperService, + { provide: PageService, useValue: pageService }, + ], + }).compile(); + + service = module.get(ClipperService); + }); + + describe('clip — happy paths', () => { + it('creates page with converted ProseMirror content', async () => { + const pmDoc = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] }], + }; + mockHtmlToJson.mockReturnValue(pmDoc); + pageService.create.mockResolvedValue(mockPage); + + const result = await service.clip('user-uuid', sampleDto); + + expect(result.pageId).toBe('page-uuid'); + expect(result.slugId).toBe('slug-abc'); + expect(result.url).toBe(sampleDto.url); + expect(pageService.create).toHaveBeenCalledWith( + 'user-uuid', + sampleDto.target_workspace_id, + expect.objectContaining({ + title: 'Example Article', + spaceId: sampleDto.target_space_id, + format: 'json', + }), + ); + }); + + it('creates page without content when html_selection is absent', async () => { + pageService.create.mockResolvedValue(mockPage); + + const result = await service.clip('user-uuid', { + ...sampleDto, + html_selection: undefined, + }); + + expect(result.pageId).toBe('page-uuid'); + expect(pageService.create).toHaveBeenCalledWith( + 'user-uuid', + sampleDto.target_workspace_id, + expect.objectContaining({ + content: undefined, + format: undefined, + }), + ); + expect(mockHtmlToJson).not.toHaveBeenCalled(); + }); + + it('prepends source attribution paragraph to converted content', async () => { + const pmDoc = { type: 'doc', content: [{ type: 'paragraph' }] }; + mockHtmlToJson.mockReturnValue(pmDoc); + pageService.create.mockResolvedValue(mockPage); + + await service.clip('user-uuid', sampleDto); + + const createCall = pageService.create.mock.calls[0][2]; + const parsedContent = JSON.parse(createCall.content); + // First node = attribution paragraph + expect(parsedContent.content[0].type).toBe('paragraph'); + expect(parsedContent.content[0].content[0].text).toBe('Source: '); + // Second node = hr + expect(parsedContent.content[1].type).toBe('horizontalRule'); + // Original content follows + expect(parsedContent.content[2].type).toBe('paragraph'); + }); + }); + + describe('clip — error paths', () => { + it('throws BadRequestException when htmlToJson fails', async () => { + mockHtmlToJson.mockImplementation(() => { + throw new Error('parse error'); + }); + + await expect(service.clip('user-uuid', sampleDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws InternalServerErrorException when pageService.create fails', async () => { + mockHtmlToJson.mockReturnValue({ type: 'doc', content: [] }); + pageService.create.mockRejectedValue(new Error('DB down')); + + await expect(service.clip('user-uuid', sampleDto)).rejects.toThrow( + InternalServerErrorException, + ); + }); + }); +}); diff --git a/apps/server/src/core/acadenice/clipper/spec/import.dto.spec.ts b/apps/server/src/core/acadenice/clipper/spec/import.dto.spec.ts new file mode 100644 index 00000000..461e05b5 --- /dev/null +++ b/apps/server/src/core/acadenice/clipper/spec/import.dto.spec.ts @@ -0,0 +1,86 @@ +import { ImportClipDto, CreateClipperTokenDto } from '../dto/import.dto'; +import { ZodError } from 'zod'; + +// Zod v4 uuid() enforces version bits (v1-v8), so we use real v4 UUIDs. +const WS_UUID = '550e8400-e29b-41d4-a716-446655440000'; +const SPACE_UUID = '6ba7b810-9dad-41d1-80b4-00c04fd430c8'; +const PARENT_UUID = '6ba7b811-9dad-41d1-80b4-00c04fd430c8'; + +describe('ImportClipDto', () => { + const validBase = { + url: 'https://example.com/page', + title: 'My Page', + target_workspace_id: WS_UUID, + target_space_id: SPACE_UUID, + }; + + it('parses a minimal valid payload', () => { + const result = ImportClipDto.parse(validBase); + expect(result.url).toBe(validBase.url); + expect(result.html_selection).toBeUndefined(); + }); + + it('parses with optional fields', () => { + const result = ImportClipDto.parse({ + ...validBase, + html_selection: '

hello

', + target_parent_page_id: PARENT_UUID, + }); + expect(result.html_selection).toBe('

hello

'); + expect(result.target_parent_page_id).toBe(PARENT_UUID); + }); + + it('rejects an invalid URL', () => { + expect(() => ImportClipDto.parse({ ...validBase, url: 'not-a-url' })).toThrow(ZodError); + }); + + it('rejects missing title', () => { + expect(() => ImportClipDto.parse({ ...validBase, title: '' })).toThrow(ZodError); + }); + + it('rejects non-UUID workspace_id', () => { + expect(() => + ImportClipDto.parse({ ...validBase, target_workspace_id: 'bad-id' }), + ).toThrow(ZodError); + }); + + it('rejects non-UUID parent_page_id when provided', () => { + expect(() => + ImportClipDto.parse({ ...validBase, target_parent_page_id: 'not-uuid' }), + ).toThrow(ZodError); + }); +}); + +describe('CreateClipperTokenDto', () => { + it('parses valid payload without duration', () => { + const result = CreateClipperTokenDto.parse({ label: 'My Token' }); + expect(result.label).toBe('My Token'); + expect(result.duration_days).toBeUndefined(); + }); + + it('parses valid payload with null duration', () => { + const result = CreateClipperTokenDto.parse({ label: 'Forever', duration_days: null }); + expect(result.duration_days).toBeNull(); + }); + + it('parses valid payload with positive duration', () => { + const result = CreateClipperTokenDto.parse({ label: 'Short', duration_days: 30 }); + expect(result.duration_days).toBe(30); + }); + + it('rejects empty label', () => { + expect(() => CreateClipperTokenDto.parse({ label: '' })).toThrow(ZodError); + }); + + it('rejects negative duration', () => { + expect(() => + CreateClipperTokenDto.parse({ label: 'Bad', duration_days: -1 }), + ).toThrow(ZodError); + }); + + it('rejects label longer than 100 chars', () => { + expect(() => + CreateClipperTokenDto.parse({ label: 'x'.repeat(101) }), + ).toThrow(ZodError); + }); +}); diff --git a/apps/server/src/core/acadenice/rbac/permissions-catalog.ts b/apps/server/src/core/acadenice/rbac/permissions-catalog.ts index 430da9ac..2e64e4cd 100644 --- a/apps/server/src/core/acadenice/rbac/permissions-catalog.ts +++ b/apps/server/src/core/acadenice/rbac/permissions-catalog.ts @@ -61,6 +61,12 @@ export const PERMISSION_KEYS = [ 'comments:write', 'comments:resolve', 'comments:moderate', + // Acadenice R4.2 — sync blocks + 'sync_blocks:create', + 'sync_blocks:edit', + 'sync_blocks:delete', + // Acadenice R4.3 — Web Clipper + 'clipper:use', 'admin:*', ] as const; @@ -229,6 +235,28 @@ export const PERMISSIONS_CATALOG: ReadonlyArray = [ group: 'comments', description: 'Delete any comment and force unresolve (admin-level)', }, + { + // R4.2 — sync blocks + key: 'sync_blocks:create', + group: 'sync_blocks', + description: 'Create new sync block masters', + }, + { + key: 'sync_blocks:edit', + group: 'sync_blocks', + description: 'Edit the content of a sync block master', + }, + { + key: 'sync_blocks:delete', + group: 'sync_blocks', + description: 'Delete sync block masters', + }, + { + // R4.3 — Web Clipper + key: 'clipper:use', + group: 'clipper', + description: 'Use the Web Clipper to create pages via API token', + }, { key: 'admin:*', group: 'meta', diff --git a/apps/server/src/core/acadenice/rbac/services/seed.service.ts b/apps/server/src/core/acadenice/rbac/services/seed.service.ts index 0df0f588..37c2ac03 100644 --- a/apps/server/src/core/acadenice/rbac/services/seed.service.ts +++ b/apps/server/src/core/acadenice/rbac/services/seed.service.ts @@ -60,6 +60,12 @@ const SYSTEM_ROLES: ReadonlyArray = [ 'templates:read', 'templates:create', 'templates:manage', + // R4.2 — sync blocks (Admin can do everything including delete) + 'sync_blocks:create', + 'sync_blocks:edit', + 'sync_blocks:delete', + // R4.3 — Web Clipper + 'clipper:use', ], }, { @@ -77,6 +83,11 @@ const SYSTEM_ROLES: ReadonlyArray = [ // R3.6 — Editors can browse and create templates 'templates:read', 'templates:create', + // R4.2 — Editors can create and edit sync blocks + 'sync_blocks:create', + 'sync_blocks:edit', + // R4.3 — Editors can use the Web Clipper + 'clipper:use', ], }, { @@ -89,6 +100,11 @@ const SYSTEM_ROLES: ReadonlyArray = [ 'attachments:upload', // R3.6 — Members can browse templates 'templates:read', + // R4.2 — Members can create and edit sync blocks + 'sync_blocks:create', + 'sync_blocks:edit', + // R4.3 — Members can use the Web Clipper + 'clipper:use', ], }, { diff --git a/apps/server/src/core/acadenice/sync-blocks/controllers/sync-blocks.controller.ts b/apps/server/src/core/acadenice/sync-blocks/controllers/sync-blocks.controller.ts new file mode 100644 index 00000000..6c95aa70 --- /dev/null +++ b/apps/server/src/core/acadenice/sync-blocks/controllers/sync-blocks.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { SyncBlocksService } from '../services/sync-blocks.service'; +import { + CreateSyncBlockDto, + UpdateSyncBlockDto, + SyncBlockResponseDto, + SyncBlockUsageDto, +} from '../dto/sync-block.dto'; + +/** + * REST controller for sync blocks (R4.2). + * + * All endpoints require JWT auth. The workspace is resolved via the + * AuthWorkspace decorator (domain middleware), so all operations are + * automatically scoped to the authenticated workspace. + * + * Routes: + * POST /api/acadenice/sync-blocks create master block + * GET /api/acadenice/sync-blocks/:id read content + * PATCH /api/acadenice/sync-blocks/:id update content + * DELETE /api/acadenice/sync-blocks/:id delete master + * GET /api/acadenice/sync-blocks/:id/usages list referencing pages + */ +@UseGuards(JwtAuthGuard) +@Controller('acadenice/sync-blocks') +export class SyncBlocksController { + constructor(private readonly syncBlocksService: SyncBlocksService) {} + + @Post() + async create( + @Body() dto: CreateSyncBlockDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.syncBlocksService.create(workspace.id, user.id, dto); + } + + @Get(':id') + async findOne( + @Param('id', ParseUUIDPipe) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.syncBlocksService.findById(id, workspace.id); + } + + @Patch(':id') + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateSyncBlockDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.syncBlocksService.update(id, workspace.id, user.id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove( + @Param('id', ParseUUIDPipe) id: string, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.syncBlocksService.delete(id, workspace.id, user.id); + } + + @Get(':id/usages') + async usages( + @Param('id', ParseUUIDPipe) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.syncBlocksService.findUsages(id, workspace.id); + } +} diff --git a/apps/server/src/core/acadenice/sync-blocks/dto/sync-block.dto.ts b/apps/server/src/core/acadenice/sync-blocks/dto/sync-block.dto.ts new file mode 100644 index 00000000..ea794cb5 --- /dev/null +++ b/apps/server/src/core/acadenice/sync-blocks/dto/sync-block.dto.ts @@ -0,0 +1,40 @@ +import { + IsNotEmpty, + IsObject, + IsOptional, +} from 'class-validator'; + +// ---- Create ---------------------------------------------------------------- + +export class CreateSyncBlockDto { + @IsOptional() + @IsObject() + content?: Record; +} + +// ---- Update ---------------------------------------------------------------- + +export class UpdateSyncBlockDto { + @IsNotEmpty() + @IsObject() + content: Record; +} + +// ---- Response ------------------------------------------------------------- + +export interface SyncBlockResponseDto { + id: string; + workspaceId: string; + content: Record; + createdBy: string; + createdAt: string; + updatedAt: string; +} + +export interface SyncBlockUsageDto { + pageId: string; + pageTitle: string | null; + slugId: string; + spaceId: string; + workspaceId: string; +} diff --git a/apps/server/src/core/acadenice/sync-blocks/repos/sync-block.repo.ts b/apps/server/src/core/acadenice/sync-blocks/repos/sync-block.repo.ts new file mode 100644 index 00000000..b2cf4069 --- /dev/null +++ b/apps/server/src/core/acadenice/sync-blocks/repos/sync-block.repo.ts @@ -0,0 +1,228 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { sql } from 'kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { + SyncBlockResponseDto, + SyncBlockUsageDto, +} from '../dto/sync-block.dto'; + +/** + * SyncBlockRepo — raw SQL access to `acadenice_sync_block`. + * + * Uses sql template literals consistently with other Acadenice repos because + * acadenice_sync_block is not part of Docmost's generated Kysely DB type. + */ +@Injectable() +export class SyncBlockRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async create( + workspaceId: string, + createdBy: string, + content: Record, + ): Promise { + const result = await sql<{ + id: string; + workspace_id: string; + content: Record; + created_by: string; + created_at: Date; + updated_at: Date; + }>` + INSERT INTO acadenice_sync_block (workspace_id, content, created_by) + VALUES (${workspaceId}, ${JSON.stringify(content)}::jsonb, ${createdBy}) + RETURNING id, workspace_id, content, created_by, created_at, updated_at + `.execute(this.db); + + return this.mapRow(result.rows[0]); + } + + async findById( + id: string, + workspaceId: string, + ): Promise { + const result = await sql<{ + id: string; + workspace_id: string; + content: Record; + created_by: string; + created_at: Date; + updated_at: Date; + }>` + SELECT id, workspace_id, content, created_by, created_at, updated_at + FROM acadenice_sync_block + WHERE id = ${id} + AND workspace_id = ${workspaceId} + `.execute(this.db); + + if (result.rows.length === 0) return null; + return this.mapRow(result.rows[0]); + } + + async updateContent( + id: string, + workspaceId: string, + content: Record, + ): Promise { + const result = await sql<{ + id: string; + workspace_id: string; + content: Record; + created_by: string; + created_at: Date; + updated_at: Date; + }>` + UPDATE acadenice_sync_block + SET content = ${JSON.stringify(content)}::jsonb, updated_at = NOW() + WHERE id = ${id} + AND workspace_id = ${workspaceId} + RETURNING id, workspace_id, content, created_by, created_at, updated_at + `.execute(this.db); + + if (result.rows.length === 0) return null; + return this.mapRow(result.rows[0]); + } + + async updateYjsState( + id: string, + workspaceId: string, + yjsState: Buffer, + ): Promise { + await sql` + UPDATE acadenice_sync_block + SET yjs_state = ${yjsState}, updated_at = NOW() + WHERE id = ${id} + AND workspace_id = ${workspaceId} + `.execute(this.db); + } + + async findYjsState( + id: string, + workspaceId: string, + ): Promise { + const result = await sql<{ yjs_state: Buffer | null }>` + SELECT yjs_state + FROM acadenice_sync_block + WHERE id = ${id} + AND workspace_id = ${workspaceId} + `.execute(this.db); + + if (result.rows.length === 0) return null; + return result.rows[0].yjs_state ?? null; + } + + async delete(id: string, workspaceId: string): Promise { + const result = await sql<{ id: string }>` + DELETE FROM acadenice_sync_block + WHERE id = ${id} + AND workspace_id = ${workspaceId} + RETURNING id + `.execute(this.db); + + return result.rows.length > 0; + } + + /** + * Find all page IDs whose content JSON contains a syncBlock node with + * masterId = given blockId. Used for the /usages endpoint. + * + * Strategy: Postgres jsonb @> path query — looks for any syncBlock node + * in pages.content with the matching masterId attribute. + */ + async findUsages( + blockId: string, + workspaceId: string, + ): Promise { + // Pages store their content as JSONB. We search for any object in the + // content tree where type='syncBlock' and attrs->>'masterId' = blockId. + // Using jsonb_path_exists for recursive search. + const result = await sql<{ + page_id: string; + title: string | null; + slug_id: string; + space_id: string; + workspace_id: string; + }>` + SELECT DISTINCT p.id AS page_id, p.title, p.slug_id, p.space_id, s.workspace_id + FROM pages p + JOIN spaces s ON s.id = p.space_id + WHERE s.workspace_id = ${workspaceId} + AND p.deleted_at IS NULL + AND p.content IS NOT NULL + AND jsonb_path_exists( + p.content, + '$.** ? (@.type == "syncBlock" && @.attrs.masterId == $mid)', + jsonb_build_object('mid', ${blockId}) + ) + `.execute(this.db); + + return result.rows.map((r) => ({ + pageId: r.page_id, + pageTitle: r.title, + slugId: r.slug_id, + spaceId: r.space_id, + workspaceId: r.workspace_id, + })); + } + + /** + * Collect all masterId values referenced inside a given sync block's content. + * Used for cycle detection (BFS graph traversal). + */ + async findMasterIdsInContent(blockId: string, workspaceId: string): Promise { + const block = await this.findById(blockId, workspaceId); + if (!block || !block.content) return []; + return extractMasterIdsFromProseMirror(block.content); + } + + private mapRow(row: { + id: string; + workspace_id: string; + content: Record; + created_by: string; + created_at: Date; + updated_at: Date; + }): SyncBlockResponseDto { + return { + id: row.id, + workspaceId: row.workspace_id, + content: row.content, + createdBy: row.created_by, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + }; + } +} + +/** + * Walk ProseMirror JSON and collect all masterId values from syncBlock nodes. + */ +export function extractMasterIdsFromProseMirror( + node: Record, +): string[] { + const ids: string[] = []; + + function walk(n: unknown): void { + if (!n || typeof n !== 'object') return; + + const obj = n as Record; + + if (obj['type'] === 'syncBlock') { + const attrs = obj['attrs'] as Record | undefined; + const masterId = attrs?.['masterId']; + if (typeof masterId === 'string' && masterId) { + ids.push(masterId); + } + } + + if (Array.isArray(obj['content'])) { + for (const child of obj['content']) { + walk(child); + } + } + } + + walk(node); + return ids; +} diff --git a/apps/server/src/core/acadenice/sync-blocks/services/sync-block-broadcast.service.ts b/apps/server/src/core/acadenice/sync-blocks/services/sync-block-broadcast.service.ts new file mode 100644 index 00000000..24974d65 --- /dev/null +++ b/apps/server/src/core/acadenice/sync-blocks/services/sync-block-broadcast.service.ts @@ -0,0 +1,38 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +export const SYNC_BLOCK_UPDATED_EVENT = 'acadenice.sync_block.updated'; + +export interface SyncBlockUpdatedPayload { + masterId: string; + workspaceId: string; +} + +/** + * SyncBlockBroadcastService — emits an internal NestJS event that SSE + * consumers (bridge or a future NestJS SSE endpoint) can subscribe to. + * + * The service is intentionally thin: it delegates to EventEmitter2 which is + * already global. If a Redis Streams-based bridge bus is later wired in, this + * service is the single integration point to update — all callers remain + * unchanged. + * + * Callers: SyncBlocksService.update(), SyncBlockPersistenceExtension. + * Consumers: future SSE controller (R4.2.b) and client hooks. + */ +@Injectable() +export class SyncBlockBroadcastService { + private readonly logger = new Logger(SyncBlockBroadcastService.name); + + constructor(private readonly eventEmitter: EventEmitter2) {} + + /** + * Publish a sync-block-updated event. + * Fire-and-forget — errors are logged, not rethrown. + */ + publish(masterId: string, workspaceId: string): void { + const payload: SyncBlockUpdatedPayload = { masterId, workspaceId }; + this.logger.debug(`sync_block.updated: ${masterId}`); + this.eventEmitter.emit(SYNC_BLOCK_UPDATED_EVENT, payload); + } +} diff --git a/apps/server/src/core/acadenice/sync-blocks/services/sync-blocks.service.ts b/apps/server/src/core/acadenice/sync-blocks/services/sync-blocks.service.ts new file mode 100644 index 00000000..22e7e079 --- /dev/null +++ b/apps/server/src/core/acadenice/sync-blocks/services/sync-blocks.service.ts @@ -0,0 +1,165 @@ +import { + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { SyncBlockRepo, extractMasterIdsFromProseMirror } from '../repos/sync-block.repo'; +import { + CreateSyncBlockDto, + UpdateSyncBlockDto, + SyncBlockResponseDto, + SyncBlockUsageDto, +} from '../dto/sync-block.dto'; +import { SyncBlockBroadcastService } from './sync-block-broadcast.service'; + +const CYCLE_BFS_MAX_DEPTH = 5; + +/** + * SyncBlocksService — orchestration layer for sync blocks (R4.2). + * + * Responsibilities: + * - CRUD with RBAC enforcement (workspace-scoped; caller passes userId). + * - Cycle detection before content update (direct + indirect BFS). + * - Broadcast on update so SSE clients invalidate their cached content. + * + * Cycle detection policy: + * A syncBlock whose content embeds a syncBlock node creates a reference + * graph. We forbid cycles at depth <= CYCLE_BFS_MAX_DEPTH (5). Beyond + * that, performance degrades and the UX is pathological anyway. + * 409 CYCLIC_SYNC_BLOCK is returned when a cycle is detected. + */ +@Injectable() +export class SyncBlocksService { + private readonly logger = new Logger(SyncBlocksService.name); + + constructor( + private readonly repo: SyncBlockRepo, + private readonly broadcast: SyncBlockBroadcastService, + ) {} + + async create( + workspaceId: string, + userId: string, + dto: CreateSyncBlockDto, + ): Promise { + return this.repo.create(workspaceId, userId, dto.content ?? {}); + } + + async findById( + id: string, + workspaceId: string, + ): Promise { + const block = await this.repo.findById(id, workspaceId); + if (!block) { + throw new NotFoundException(`Sync block ${id} not found`); + } + return block; + } + + async update( + id: string, + workspaceId: string, + userId: string, + dto: UpdateSyncBlockDto, + ): Promise { + // Ensure it exists. + const existing = await this.repo.findById(id, workspaceId); + if (!existing) { + throw new NotFoundException(`Sync block ${id} not found`); + } + + // Cycle detection before persisting. + await this.assertNoCycle(id, workspaceId, dto.content); + + const updated = await this.repo.updateContent(id, workspaceId, dto.content); + if (!updated) { + throw new NotFoundException(`Sync block ${id} not found after update`); + } + + // Fire-and-forget broadcast. + this.broadcast.publish(id, workspaceId); + + return updated; + } + + async delete( + id: string, + workspaceId: string, + userId: string, + ): Promise { + const deleted = await this.repo.delete(id, workspaceId); + if (!deleted) { + throw new NotFoundException(`Sync block ${id} not found`); + } + } + + async findUsages( + id: string, + workspaceId: string, + ): Promise { + // Verify it exists first. + const block = await this.repo.findById(id, workspaceId); + if (!block) { + throw new NotFoundException(`Sync block ${id} not found`); + } + return this.repo.findUsages(id, workspaceId); + } + + /** + * BFS cycle detection. + * + * Starting from the candidate content (not yet persisted), collect all + * referenced masterIds. Then traverse their content recursively up to + * CYCLE_BFS_MAX_DEPTH levels. If `selfId` appears anywhere in the graph, + * throw 409 CYCLIC_SYNC_BLOCK. + * + * Direct cycle: A embeds A. + * Indirect cycle: A embeds B, B embeds A (or via further hops). + */ + private async assertNoCycle( + selfId: string, + workspaceId: string, + candidateContent: Record, + ): Promise { + // Direct cycle check first (fast path). + const directRefs = extractMasterIdsFromProseMirror(candidateContent); + + if (directRefs.includes(selfId)) { + throw new ConflictException( + 'Sync block cannot reference itself (direct cycle)', + 'CYCLIC_SYNC_BLOCK', + ); + } + + // BFS through the reference graph. + const visited = new Set(); + const queue: string[] = [...directRefs]; + let depth = 0; + + while (queue.length > 0 && depth < CYCLE_BFS_MAX_DEPTH) { + depth++; + const current = queue.splice(0, queue.length); + + for (const masterId of current) { + if (visited.has(masterId)) continue; + visited.add(masterId); + + if (masterId === selfId) { + throw new ConflictException( + `Sync block cycle detected at depth ${depth}`, + 'CYCLIC_SYNC_BLOCK', + ); + } + + const refs = await this.repo.findMasterIdsInContent(masterId, workspaceId); + for (const ref of refs) { + if (!visited.has(ref)) { + queue.push(ref); + } + } + } + } + } +} diff --git a/apps/server/src/core/acadenice/sync-blocks/spec/sync-block-broadcast.service.spec.ts b/apps/server/src/core/acadenice/sync-blocks/spec/sync-block-broadcast.service.spec.ts new file mode 100644 index 00000000..90866218 --- /dev/null +++ b/apps/server/src/core/acadenice/sync-blocks/spec/sync-block-broadcast.service.spec.ts @@ -0,0 +1,39 @@ +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + SyncBlockBroadcastService, + SYNC_BLOCK_UPDATED_EVENT, +} from '../services/sync-block-broadcast.service'; + +describe('SyncBlockBroadcastService', () => { + let emitter: EventEmitter2; + let service: SyncBlockBroadcastService; + + beforeEach(() => { + emitter = new EventEmitter2(); + service = new SyncBlockBroadcastService(emitter); + }); + + it('emits the correct event name', (done) => { + emitter.once(SYNC_BLOCK_UPDATED_EVENT, (payload) => { + expect(payload.masterId).toBe('block-1'); + expect(payload.workspaceId).toBe('ws-1'); + done(); + }); + + service.publish('block-1', 'ws-1'); + }); + + it('does not throw when no listeners are registered', () => { + expect(() => service.publish('block-2', 'ws-2')).not.toThrow(); + }); + + it('publishes the event with the correct payload shape', () => { + const received: any[] = []; + emitter.on(SYNC_BLOCK_UPDATED_EVENT, (p) => received.push(p)); + + service.publish('block-x', 'ws-x'); + + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ masterId: 'block-x', workspaceId: 'ws-x' }); + }); +}); diff --git a/apps/server/src/core/acadenice/sync-blocks/spec/sync-block-repo.spec.ts b/apps/server/src/core/acadenice/sync-blocks/spec/sync-block-repo.spec.ts new file mode 100644 index 00000000..c3bfa79c --- /dev/null +++ b/apps/server/src/core/acadenice/sync-blocks/spec/sync-block-repo.spec.ts @@ -0,0 +1,60 @@ +import { extractMasterIdsFromProseMirror } from '../repos/sync-block.repo'; + +/** + * Unit tests for the extractMasterIdsFromProseMirror utility. + * SyncBlockRepo DB methods require a live Kysely instance and are tested + * via integration tests (not unit tests). The pure extractor is testable here. + */ +describe('extractMasterIdsFromProseMirror', () => { + it('returns empty array for empty doc', () => { + expect(extractMasterIdsFromProseMirror({ type: 'doc', content: [] })).toEqual([]); + }); + + it('extracts masterId from a top-level syncBlock node', () => { + const doc = { + type: 'doc', + content: [ + { type: 'syncBlock', attrs: { masterId: 'uuid-1' } }, + ], + }; + expect(extractMasterIdsFromProseMirror(doc)).toEqual(['uuid-1']); + }); + + it('extracts masterIds from nested syncBlock nodes', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'blockquote', + content: [ + { type: 'syncBlock', attrs: { masterId: 'uuid-2' } }, + ], + }, + { type: 'syncBlock', attrs: { masterId: 'uuid-3' } }, + ], + }; + const ids = extractMasterIdsFromProseMirror(doc); + expect(ids).toContain('uuid-2'); + expect(ids).toContain('uuid-3'); + expect(ids).toHaveLength(2); + }); + + it('ignores syncBlock nodes without masterId attr', () => { + const doc = { + type: 'doc', + content: [{ type: 'syncBlock', attrs: {} }], + }; + expect(extractMasterIdsFromProseMirror(doc)).toEqual([]); + }); + + it('handles non-syncBlock node types without error', () => { + const doc = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }, + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] }, + ], + }; + expect(extractMasterIdsFromProseMirror(doc)).toEqual([]); + }); +}); diff --git a/apps/server/src/core/acadenice/sync-blocks/spec/sync-blocks.controller.spec.ts b/apps/server/src/core/acadenice/sync-blocks/spec/sync-blocks.controller.spec.ts new file mode 100644 index 00000000..628ee1db --- /dev/null +++ b/apps/server/src/core/acadenice/sync-blocks/spec/sync-blocks.controller.spec.ts @@ -0,0 +1,141 @@ +import { Test } from '@nestjs/testing'; +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { SyncBlocksController } from '../controllers/sync-blocks.controller'; +import { SyncBlocksService } from '../services/sync-blocks.service'; +import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; + +const mockUser = { id: 'user-1', name: 'Alice' } as any; +const mockWorkspace = { id: 'ws-1', name: 'Acadenice' } as any; + +const blockFixture = { + id: 'block-uuid-1', + workspaceId: 'ws-1', + content: { type: 'doc', content: [] }, + createdBy: 'user-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +const mockService = { + create: jest.fn().mockResolvedValue(blockFixture), + findById: jest.fn().mockResolvedValue(blockFixture), + update: jest.fn().mockResolvedValue({ ...blockFixture, content: { type: 'doc', content: [{ type: 'paragraph' }] } }), + delete: jest.fn().mockResolvedValue(undefined), + findUsages: jest.fn().mockResolvedValue([]), +}; + +describe('SyncBlocksController', () => { + let controller: SyncBlocksController; + let service: typeof mockService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [SyncBlocksController], + providers: [{ provide: SyncBlocksService, useValue: mockService }], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(SyncBlocksController); + service = module.get(SyncBlocksService); + jest.clearAllMocks(); + }); + + describe('create', () => { + it('creates a sync block and returns it', async () => { + mockService.create.mockResolvedValueOnce(blockFixture); + const dto = { content: { type: 'doc', content: [] } }; + + const result = await controller.create(dto as any, mockUser, mockWorkspace); + + expect(result).toEqual(blockFixture); + expect(service.create).toHaveBeenCalledWith('ws-1', 'user-1', dto); + }); + + it('creates with empty content when none provided', async () => { + mockService.create.mockResolvedValueOnce({ ...blockFixture, content: {} }); + const dto = {}; + + const result = await controller.create(dto as any, mockUser, mockWorkspace); + + expect(result.content).toEqual({}); + }); + }); + + describe('findOne', () => { + it('returns the block by id', async () => { + mockService.findById.mockResolvedValueOnce(blockFixture); + + const result = await controller.findOne('block-uuid-1', mockWorkspace); + + expect(result).toEqual(blockFixture); + expect(service.findById).toHaveBeenCalledWith('block-uuid-1', 'ws-1'); + }); + + it('throws NotFoundException when block does not exist', async () => { + mockService.findById.mockRejectedValueOnce(new NotFoundException('not found')); + + await expect(controller.findOne('missing-id', mockWorkspace)).rejects.toThrow(NotFoundException); + }); + }); + + describe('update', () => { + it('updates block content', async () => { + const updated = { ...blockFixture, content: { type: 'doc', content: [{ type: 'paragraph' }] } }; + mockService.update.mockResolvedValueOnce(updated); + + const result = await controller.update( + 'block-uuid-1', + { content: { type: 'doc', content: [{ type: 'paragraph' }] } } as any, + mockUser, + mockWorkspace, + ); + + expect(result.content).toEqual({ type: 'doc', content: [{ type: 'paragraph' }] }); + }); + + it('throws ConflictException when cycle detected', async () => { + mockService.update.mockRejectedValueOnce(new ConflictException('cycle', 'CYCLIC_SYNC_BLOCK')); + + await expect( + controller.update('block-uuid-1', { content: { type: 'doc' } } as any, mockUser, mockWorkspace), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('remove', () => { + it('deletes the block', async () => { + mockService.delete.mockResolvedValueOnce(undefined); + + await expect(controller.remove('block-uuid-1', mockUser, mockWorkspace)).resolves.toBeUndefined(); + expect(service.delete).toHaveBeenCalledWith('block-uuid-1', 'ws-1', 'user-1'); + }); + + it('throws NotFoundException for unknown block', async () => { + mockService.delete.mockRejectedValueOnce(new NotFoundException()); + + await expect(controller.remove('missing', mockUser, mockWorkspace)).rejects.toThrow(NotFoundException); + }); + }); + + describe('usages', () => { + it('returns empty array when no pages reference the block', async () => { + mockService.findUsages.mockResolvedValueOnce([]); + + const result = await controller.usages('block-uuid-1', mockWorkspace); + + expect(result).toEqual([]); + }); + + it('returns page references when found', async () => { + const usage = { pageId: 'page-1', pageTitle: 'Intro', slugId: 'intro', spaceId: 'sp-1', workspaceId: 'ws-1' }; + mockService.findUsages.mockResolvedValueOnce([usage]); + + const result = await controller.usages('block-uuid-1', mockWorkspace); + + expect(result).toHaveLength(1); + expect(result[0].pageId).toBe('page-1'); + }); + }); +}); diff --git a/apps/server/src/core/acadenice/sync-blocks/spec/sync-blocks.service.spec.ts b/apps/server/src/core/acadenice/sync-blocks/spec/sync-blocks.service.spec.ts new file mode 100644 index 00000000..9f1045bb --- /dev/null +++ b/apps/server/src/core/acadenice/sync-blocks/spec/sync-blocks.service.spec.ts @@ -0,0 +1,160 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { SyncBlocksService } from '../services/sync-blocks.service'; +import { SyncBlockBroadcastService } from '../services/sync-block-broadcast.service'; +import { SyncBlockRepo } from '../repos/sync-block.repo'; + +const blockFixture = { + id: 'block-1', + workspaceId: 'ws-1', + content: { type: 'doc', content: [] }, + createdBy: 'user-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +function makeRepo(overrides: Partial> = {}): SyncBlockRepo { + return { + create: jest.fn().mockResolvedValue(blockFixture), + findById: jest.fn().mockResolvedValue(blockFixture), + updateContent: jest.fn().mockResolvedValue(blockFixture), + updateYjsState: jest.fn().mockResolvedValue(undefined), + findYjsState: jest.fn().mockResolvedValue(null), + delete: jest.fn().mockResolvedValue(true), + findUsages: jest.fn().mockResolvedValue([]), + findMasterIdsInContent: jest.fn().mockResolvedValue([]), + ...overrides, + } as unknown as SyncBlockRepo; +} + +function makeBroadcast(): SyncBlockBroadcastService { + return { publish: jest.fn() } as unknown as SyncBlockBroadcastService; +} + +describe('SyncBlocksService', () => { + let service: SyncBlocksService; + let repo: SyncBlockRepo; + let broadcast: SyncBlockBroadcastService; + + beforeEach(() => { + repo = makeRepo(); + broadcast = makeBroadcast(); + service = new SyncBlocksService(repo, broadcast); + }); + + describe('create', () => { + it('delegates to repo.create with workspace and user', async () => { + const result = await service.create('ws-1', 'user-1', { content: {} }); + + expect(repo.create).toHaveBeenCalledWith('ws-1', 'user-1', {}); + expect(result).toEqual(blockFixture); + }); + + it('passes undefined content as empty object to repo', async () => { + await service.create('ws-1', 'user-1', {}); + expect(repo.create).toHaveBeenCalledWith('ws-1', 'user-1', {}); + }); + }); + + describe('findById', () => { + it('returns block when found', async () => { + const result = await service.findById('block-1', 'ws-1'); + expect(result).toEqual(blockFixture); + }); + + it('throws NotFoundException when block does not exist', async () => { + jest.spyOn(repo, 'findById').mockResolvedValueOnce(null); + + await expect(service.findById('missing', 'ws-1')).rejects.toThrow(NotFoundException); + }); + }); + + describe('update', () => { + it('updates content and publishes broadcast', async () => { + const updated = { ...blockFixture, content: { type: 'doc', content: [{ type: 'paragraph' }] } }; + jest.spyOn(repo, 'updateContent').mockResolvedValueOnce(updated); + + const result = await service.update('block-1', 'ws-1', 'user-1', { + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + }); + + expect(result).toEqual(updated); + expect(broadcast.publish).toHaveBeenCalledWith('block-1', 'ws-1'); + }); + + it('throws NotFoundException when block missing before update', async () => { + jest.spyOn(repo, 'findById').mockResolvedValueOnce(null); + + await expect( + service.update('missing', 'ws-1', 'user-1', { content: {} }), + ).rejects.toThrow(NotFoundException); + }); + + it('rejects direct cycle (block embeds itself)', async () => { + const selfRef = { + type: 'doc', + content: [{ type: 'syncBlock', attrs: { masterId: 'block-1' } }], + }; + + await expect( + service.update('block-1', 'ws-1', 'user-1', { content: selfRef }), + ).rejects.toThrow(ConflictException); + }); + + it('rejects indirect cycle (A embeds B, B embeds A)', async () => { + // block-1 wants to embed block-2; block-2 already embeds block-1. + jest.spyOn(repo, 'findMasterIdsInContent').mockResolvedValueOnce(['block-1']); + + const contentWithB = { + type: 'doc', + content: [{ type: 'syncBlock', attrs: { masterId: 'block-2' } }], + }; + + await expect( + service.update('block-1', 'ws-1', 'user-1', { content: contentWithB }), + ).rejects.toThrow(ConflictException); + }); + + it('does not publish broadcast when update throws', async () => { + jest.spyOn(repo, 'findById').mockResolvedValueOnce(null); + + await expect(service.update('block-1', 'ws-1', 'user-1', { content: {} })).rejects.toThrow(); + expect(broadcast.publish).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('deletes the block', async () => { + await service.delete('block-1', 'ws-1', 'user-1'); + expect(repo.delete).toHaveBeenCalledWith('block-1', 'ws-1'); + }); + + it('throws NotFoundException when block not found', async () => { + jest.spyOn(repo, 'delete').mockResolvedValueOnce(false); + + await expect(service.delete('missing', 'ws-1', 'user-1')).rejects.toThrow(NotFoundException); + }); + }); + + describe('findUsages', () => { + it('returns usages from repo', async () => { + const usage = { pageId: 'p1', pageTitle: 'Intro', slugId: 'intro', spaceId: 'sp-1', workspaceId: 'ws-1' }; + jest.spyOn(repo, 'findUsages').mockResolvedValueOnce([usage]); + + const result = await service.findUsages('block-1', 'ws-1'); + + expect(result).toHaveLength(1); + expect(result[0].pageId).toBe('p1'); + }); + + it('throws NotFoundException when block not found', async () => { + jest.spyOn(repo, 'findById').mockResolvedValueOnce(null); + + await expect(service.findUsages('missing', 'ws-1')).rejects.toThrow(NotFoundException); + }); + + it('returns empty array when no usages', async () => { + const result = await service.findUsages('block-1', 'ws-1'); + expect(result).toEqual([]); + }); + }); +}); diff --git a/apps/server/src/core/acadenice/sync-blocks/sync-blocks.module.ts b/apps/server/src/core/acadenice/sync-blocks/sync-blocks.module.ts new file mode 100644 index 00000000..7bc4149d --- /dev/null +++ b/apps/server/src/core/acadenice/sync-blocks/sync-blocks.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { SyncBlocksController } from './controllers/sync-blocks.controller'; +import { SyncBlocksService } from './services/sync-blocks.service'; +import { SyncBlockBroadcastService } from './services/sync-block-broadcast.service'; +import { SyncBlockRepo } from './repos/sync-block.repo'; + +/** + * AcadeniceSync BlocksModule (R4.2). + * + * Provides: + * - SyncBlockRepo : raw SQL access to acadenice_sync_block + * - SyncBlocksService : CRUD + cycle detection + broadcast trigger + * - SyncBlockBroadcastService : EventEmitter2 publish on update + * - SyncBlocksController : REST endpoints + * + * Dependencies: + * - KyselyDB is global (AppModule via nestjs-kysely @Global) + * - EventEmitter2 is global (EventEmitterModule.forRoot in AppModule) + * - JwtAuthGuard is global + * + * Hocuspocus persistence for sync-block-* docs lives in + * CollaborationModule/SyncBlockPersistenceExtension (separate concern, + * imported there directly to avoid circular deps). + */ +@Module({ + controllers: [SyncBlocksController], + providers: [SyncBlockRepo, SyncBlocksService, SyncBlockBroadcastService], + exports: [SyncBlockRepo, SyncBlocksService, SyncBlockBroadcastService], +}) +export class AcadeniceSyncBlocksModule {} diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index bbb8f3d8..92a79ccd 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -36,6 +36,10 @@ import { AcadeniceTemplatesModule } from './acadenice/templates/templates.module import { AcadeniceNotificationsModule } from './acadenice/notifications/notifications.module'; // Acadenice R3.8 — inline comments (page resolve + row threads) import { AcadeniceCommentsModule } from './acadenice/comments/comments.module'; +// Acadenice R4.2 — sync blocks (cross-page shared content) +import { AcadeniceSyncBlocksModule } from './acadenice/sync-blocks/sync-blocks.module'; +// Acadenice R4.3 — Web Clipper +import { AcadeniceClipperModule } from './acadenice/clipper/clipper.module'; import { ClsMiddleware } from 'nestjs-cls'; @Module({ @@ -64,6 +68,8 @@ import { ClsMiddleware } from 'nestjs-cls'; AcadeniceTemplatesModule, AcadeniceNotificationsModule, AcadeniceCommentsModule, + AcadeniceSyncBlocksModule, + AcadeniceClipperModule, ], }) export class CoreModule implements NestModule { diff --git a/apps/server/src/database/migrations/20260509T100000-create-acadenice-sync-block.ts b/apps/server/src/database/migrations/20260509T100000-create-acadenice-sync-block.ts new file mode 100644 index 00000000..649c7267 --- /dev/null +++ b/apps/server/src/database/migrations/20260509T100000-create-acadenice-sync-block.ts @@ -0,0 +1,50 @@ +import { Kysely, sql } from 'kysely'; + +/** + * DocAdenice sync block table (R4.2). + * + * A sync block is a master content unit that can be embedded in N pages. + * Editing the master propagates to all instances via Hocuspocus + SSE. + * + * yjs_state: binary Yjs document state used by the Hocuspocus persistence + * extension when the collab doc `sync-block-{id}` is stored. This keeps the + * CRDT merge history intact across server restarts. + * + * content: canonical ProseMirror JSON snapshot written on every REST update + * and on each Hocuspocus store. Used by the REST GET endpoint and for cycle + * detection (no live Yjs connection required). + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable('acadenice_sync_block') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_random_uuid()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('content', 'jsonb', (col) => col.notNull().defaultTo(sql`'{}'`)) + .addColumn('yjs_state', 'bytea') + .addColumn('created_by', 'uuid', (col) => + col.notNull().references('users.id').onDelete('restrict'), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex('idx_sync_block_workspace') + .ifNotExists() + .on('acadenice_sync_block') + .column('workspace_id') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('acadenice_sync_block').ifExists().execute(); +} diff --git a/apps/server/src/database/migrations/20260509T120000-create-acadenice-clipper-token.ts b/apps/server/src/database/migrations/20260509T120000-create-acadenice-clipper-token.ts new file mode 100644 index 00000000..7568ac36 --- /dev/null +++ b/apps/server/src/database/migrations/20260509T120000-create-acadenice-clipper-token.ts @@ -0,0 +1,58 @@ +import { Kysely, sql } from 'kysely'; + +/** + * Clipper token table (R4.3). + * + * One row = one API token issued to a user for a given workspace. + * The plaintext token is returned once at creation; only the bcrypt hash + * is persisted here. Validation is a constant-time bcrypt compare. + * + * expires_at NULL means the token never expires. + * last_used_at is bumped by the auth middleware on every successful request. + * + * Idempotent: ifNotExists on every DDL statement. + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable('acadenice_clipper_token') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_random_uuid()`), + ) + .addColumn('user_id', 'uuid', (col) => + col.notNull().references('users.id').onDelete('cascade'), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('token_hash', 'text', (col) => col.notNull()) + .addColumn('label', 'varchar(100)') + .addColumn('last_used_at', 'timestamptz') + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('expires_at', 'timestamptz') + .execute(); + + await db.schema + .createIndex('idx_clipper_token_user') + .ifNotExists() + .on('acadenice_clipper_token') + .columns(['user_id', 'workspace_id']) + .execute(); + + await db.schema + .createIndex('idx_clipper_token_hash') + .ifNotExists() + .unique() + .on('acadenice_clipper_token') + .column('token_hash') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .dropTable('acadenice_clipper_token') + .ifExists() + .execute(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ba7eede..b5b86b09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -510,6 +510,31 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.1)(less@4.2.0)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0) + apps/extension-clipper: + dependencies: + dompurify: + specifier: 3.4.1 + version: 3.4.1 + devDependencies: + '@crxjs/vite-plugin': + specifier: ^2.0.0-beta.28 + version: 2.4.0(vite@5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)) + '@types/chrome': + specifier: ^0.0.270 + version: 0.0.270 + '@types/dompurify': + specifier: ^3.0.5 + version: 3.2.0 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0) + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0) + apps/server: dependencies: '@ai-sdk/google': @@ -1835,6 +1860,11 @@ packages: peerDependencies: commander: 11.1.x + '@crxjs/vite-plugin@2.4.0': + resolution: {integrity: sha512-bDLdq0W2V1SkMQDJjrcYyjK9/uKtdl4joT7GRImcootCjZdKRiRYt+cv9z8tJoU/tK3o1lX48LTqN7JMsk5AQg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -3275,6 +3305,18 @@ packages: resolution: {integrity: sha512-pBm+iFjv9eihcgeJuSUs4c0AuX1QEFdHwP8w1iaWCfDzXdeWZxUBU5HT2bY2S4dvNutcy+A9hYsH7ZLBGtgwDg==} engines: {node: '>= 18'} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@nuxt/opencollective@0.4.1': resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} @@ -4280,6 +4322,10 @@ packages: '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + '@rollup/pluginutils@4.2.1': + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + '@rollup/rollup-android-arm-eabi@4.60.3': resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} cpu: [arm] @@ -5109,6 +5155,9 @@ packages: '@types/bytes@3.1.5': resolution: {integrity: sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ==} + '@types/chrome@0.0.270': + resolution: {integrity: sha512-ADvkowV7YnJfycZZxL2brluZ6STGW+9oKG37B422UePf2PCXuFA/XdERI0T18wtuWPx0tmFeZqq6MOXVk1IC+Q==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -5220,6 +5269,10 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -5241,12 +5294,21 @@ packages: '@types/file-saver@2.0.7': resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} '@types/geojson@7946.0.14': resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==} + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -5679,6 +5741,9 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@webcomponents/custom-elements@1.6.0': + resolution: {integrity: sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==} + '@xmldom/is-dom-node@1.0.1': resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} engines: {node: '>= 16'} @@ -6337,6 +6402,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -6965,6 +7033,9 @@ packages: resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} engines: {node: '>= 0.4'} + es-module-lexer@0.10.5: + resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -7106,6 +7177,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -7184,6 +7258,10 @@ packages: resolution: {integrity: sha512-d+yU9iNQbbC098NOuMlAIth/g+owbpX/uuOkH/DQcC2fMMyjOlX292Op29DrUKq388m4UUyOdWakUH/msGypOg==} engines: {node: '>=6.0.0'} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -7513,6 +7591,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -8654,6 +8736,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + mermaid@11.13.0: resolution: {integrity: sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==} @@ -8855,6 +8941,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-html-parser@7.1.0: + resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -9501,6 +9590,9 @@ packages: query-selector-shadow-dom@1.0.1: resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -9633,6 +9725,10 @@ packages: react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-refresh@0.13.0: + resolution: {integrity: sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==} + engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -9841,6 +9937,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@2.79.2: + resolution: {integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==} + engines: {node: '>=10.0.0'} + hasBin: true + rollup@4.60.3: resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -9865,9 +9966,15 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + rxjs@7.5.7: + resolution: {integrity: sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==} + rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} @@ -12583,6 +12690,28 @@ snapshots: dependencies: commander: 11.1.0 + '@crxjs/vite-plugin@2.4.0(vite@5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0))': + dependencies: + '@rollup/pluginutils': 4.2.1 + '@webcomponents/custom-elements': 1.6.0 + acorn-walk: 8.3.2 + convert-source-map: 1.9.0 + debug: 4.4.3 + es-module-lexer: 0.10.5 + fast-glob: 3.3.3 + fs-extra: 10.1.0 + jsesc: 3.0.2 + magic-string: 0.30.17 + node-html-parser: 7.1.0 + pathe: 2.0.3 + picocolors: 1.1.1 + react-refresh: 0.13.0 + rollup: 2.79.2 + rxjs: 7.5.7 + vite: 5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0) + transitivePeerDependencies: + - supports-color + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -14015,6 +14144,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + '@nuxt/opencollective@0.4.1': dependencies: consola: 3.4.2 @@ -15044,6 +15185,11 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.7': {} + '@rollup/pluginutils@4.2.1': + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.2 + '@rollup/rollup-android-arm-eabi@4.60.3': optional: true @@ -15949,6 +16095,11 @@ snapshots: '@types/bytes@3.1.5': {} + '@types/chrome@0.0.270': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + '@types/connect@3.4.38': dependencies: '@types/node': 25.5.0 @@ -16084,6 +16235,10 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.4.1 + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 8.56.10 @@ -16119,6 +16274,12 @@ snapshots: '@types/file-saver@2.0.7': {} + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 @@ -16126,6 +16287,8 @@ snapshots: '@types/geojson@7946.0.14': {} + '@types/har-format@1.2.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.2 @@ -16510,6 +16673,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.19.1)(less@4.2.0)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0) + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -16611,6 +16782,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@webcomponents/custom-elements@1.6.0': {} + '@xmldom/is-dom-node@1.0.1': {} '@xmldom/xmldom@0.8.13': {} @@ -17351,6 +17524,8 @@ snapshots: content-type@1.0.5: {} + convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -18101,6 +18276,8 @@ snapshots: math-intrinsics: 1.1.0 safe-array-concat: 1.1.3 + es-module-lexer@0.10.5: {} + es-module-lexer@1.7.0: {} es-module-lexer@2.0.0: {} @@ -18342,6 +18519,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -18447,6 +18626,14 @@ snapshots: fast-equals@5.3.4: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-json-stringify@6.0.1: @@ -18830,6 +19017,8 @@ snapshots: dependencies: function-bind: 1.1.2 + he@1.2.0: {} + help-me@5.0.0: {} hermes-estree@0.25.1: {} @@ -20126,6 +20315,8 @@ snapshots: merge-stream@2.0.0: {} + merge2@1.4.1: {} + mermaid@11.13.0: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -20302,6 +20493,11 @@ snapshots: node-gyp-build@4.8.4: {} + node-html-parser@7.1.0: + dependencies: + css-select: 5.1.0 + he: 1.2.0 + node-int64@0.4.0: {} node-releases@2.0.27: {} @@ -21076,6 +21272,8 @@ snapshots: query-selector-shadow-dom@1.0.1: {} + queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} radix-ui@1.4.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -21275,6 +21473,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-refresh@0.13.0: {} + react-remove-scroll-bar@2.3.8(@types/react@18.3.12)(react@18.3.1): dependencies: react: 18.3.1 @@ -21514,6 +21714,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + rollup@2.79.2: + optionalDependencies: + fsevents: 2.3.3 + rollup@4.60.3: dependencies: '@types/estree': 1.0.8 @@ -21575,8 +21779,16 @@ snapshots: rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + rw@1.3.3: {} + rxjs@7.5.7: + dependencies: + tslib: 2.8.1 + rxjs@7.8.1: dependencies: tslib: 2.8.1 @@ -22505,6 +22717,24 @@ snapshots: - supports-color - terser + vite-node@2.1.9(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.21(@types/node@22.19.1)(less@4.2.0)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0): dependencies: esbuild: 0.21.5 @@ -22518,6 +22748,20 @@ snapshots: sugarss: 5.0.1(postcss@8.5.12) terser: 5.39.0 + vite@5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.12 + rollup: 4.60.3 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + less: 4.2.0 + lightningcss: 1.32.0 + sass: 1.51.0 + sugarss: 5.0.1(postcss@8.5.12) + terser: 5.39.0 + vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -22573,6 +22817,43 @@ snapshots: - supports-color - terser + vitest@2.1.9(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.17 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0) + vite-node: 2.1.9(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.0 + happy-dom: 20.8.9 + jsdom: 26.1.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {}