From 70c26f356af0ba200122426e2c4f66311a7ba9c6 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 02:04:36 +0300 Subject: [PATCH 01/12] docs(security): warn that APP_SECRET must never change after setup APP_SECRET does double duty: it signs JWTs and derives the AES-256-GCM key that encrypts stored AI-provider credentials. Rotating it makes every saved AI API key undecryptable and invalidates existing sessions. Document this footgun where operators set the value (RT-30 from the red-team report). - .env.example: dual-role warning block above APP_SECRET - README.md / README.ru.md: warning callout in the upgrade section Co-Authored-By: Claude Opus 4.8 --- .env.example | 5 +++++ README.md | 5 +++++ README.ru.md | 6 ++++++ 3 files changed, 16 insertions(+) diff --git a/.env.example b/.env.example index a19fd2d7..fae646b1 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ PORT=3000 # (The /mcp limiter keeps a global per-email key as an IP-independent backstop, # but the per-IP and per-IP+email keys rely on a trustworthy X-Forwarded-For.) +# APP_SECRET has a DUAL role: it signs JWTs AND derives the AES-256-GCM key that +# encrypts stored AI-provider credentials (API keys) at rest. CONSEQUENCE: if you +# change APP_SECRET after setup, every stored AI API key becomes undecryptable — +# you must re-enter them in AI settings — and all existing sessions/JWTs are +# invalidated. Choose it ONCE, keep it stable, and back it up alongside your DB. # minimum of 32 characters. Generate one with: openssl rand -hex 32 APP_SECRET=REPLACE_WITH_LONG_SECRET diff --git a/README.md b/README.md index 578790f0..c1689068 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,11 @@ the existing data directory is reused as-is: start the new migrations apply on top of your existing schema (`CREATE EXTENSION vector` plus the `page_embeddings` and AI tables); watch the logs for `Migration "..." executed successfully`. +> ⚠️ **Never change `APP_SECRET` after setup.** It does double duty: it signs JWTs *and* derives the +> AES-256-GCM key that encrypts stored AI-provider credentials (API keys). Rotating it makes every +> saved AI API key undecryptable (you'd have to re-enter them in AI settings) and invalidates all +> existing sessions. Pick it once, keep it stable, and back it up together with your database. + ### Notes - **Back up first.** Take a `pg_dump` before swapping — migrations apply in place, and the diff --git a/README.ru.md b/README.ru.md index 0bd9a5de..0b16ad62 100644 --- a/README.ru.md +++ b/README.ru.md @@ -159,6 +159,12 @@ dump/restore, существующий каталог данных переис новые миграции применяются поверх вашей схемы (`CREATE EXTENSION vector` плюс таблицы `page_embeddings` и AI-таблицы); следите в логах за строками `Migration "..." executed successfully`. +> ⚠️ **Никогда не меняйте `APP_SECRET` после установки.** Он выполняет двойную роль: подписывает JWT +> *и* служит материалом для ключа AES-256-GCM, которым шифруются сохранённые ключи AI-провайдеров +> (API-ключи). Смена секрета сделает все сохранённые AI-ключи нерасшифровываемыми (придётся вводить +> их заново в настройках AI) и инвалидирует все текущие сессии. Задайте его один раз, держите +> неизменным и бэкапьте вместе с базой данных. + ## Возможности From 262a0707d97ccc82e252181f73caed28423d9ab7 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 02:13:04 +0300 Subject: [PATCH 02/12] feat(share-ai): cap per-request output tokens and fail closed on Redis loss Harden the anonymous public-share AI assistant against token-cost abuse before exposing it to the internet: - Add an env-tunable per-request output ceiling (maxOutputTokens) to the public-share streamText call so one anonymous request cannot run up the provider bill even if the per-IP throttle is evaded. New resolveShareAiMaxOutputTokens() / SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT (env SHARE_AI_MAX_OUTPUT_TOKENS, default 512), mirroring resolveShareAiWorkspaceMax(). - Flip the per-workspace cost limiter to FAIL CLOSED on Redis failure (was fail-open): if Redis is unavailable we cannot prove the workspace is under its cap, so deny rather than admit an unmetered, billable call. - Update the limiter spec (fail-open -> fail-closed) and add resolver tests; document both knobs in .env.example. Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 +++- .../core/ai-chat/public-share-chat.service.ts | 19 +++++++ .../core/ai-chat/public-share-chat.spec.ts | 53 ++++++++++++++++--- .../ai-chat/public-share-workspace-limiter.ts | 21 ++++---- 4 files changed, 85 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index b04078e3..11e9fc13 100644 --- a/.env.example +++ b/.env.example @@ -112,7 +112,12 @@ MCP_DOCMOST_PASSWORD= # # Backstop: a cluster-wide, sliding-window cap per workspace (IP-independent, # keyed by the server-resolved workspace id) bounds the owner's bill even if the -# per-IP limit is fully evaded. It is a COST backstop, not an access control, -# and FAILS OPEN if Redis is unavailable. Override the hourly cap below +# per-IP limit is fully evaded. It is a COST backstop, not an access control, and +# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going +# offline is safer than an unbounded bill). Override the hourly cap below # (default: 300 calls per workspace per rolling hour). # SHARE_AI_WORKSPACE_MAX_PER_HOUR=300 +# +# Per-request output-token ceiling for the anonymous assistant (default: 512). +# Worst-case output per accepted call = agent steps (5) × this value. +# SHARE_AI_MAX_OUTPUT_TOKENS=512 diff --git a/apps/server/src/core/ai-chat/public-share-chat.service.ts b/apps/server/src/core/ai-chat/public-share-chat.service.ts index d385c4f0..380d2fd7 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.service.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.service.ts @@ -63,6 +63,22 @@ export interface PublicShareChatStreamArgs { export const MAX_SHARE_MESSAGES = 30; export const MAX_SHARE_MESSAGE_CHARS = 8000; +/** + * Per-request output-token ceiling for the anonymous assistant. `streamText` + * runs up to `stepCountIs(5)` steps, so the worst-case output of one accepted + * request is bounded by (steps × this). The per-workspace cap bounds the COUNT + * of calls; this bounds the SIZE of each, so a single anonymous call cannot run + * up the provider bill even if the per-IP throttle is evaded. Env-overridable + * seam; a non-positive or unparseable value falls back to the default. + */ +export const SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT = 512; +export function resolveShareAiMaxOutputTokens(): number { + const raw = Number(process.env.SHARE_AI_MAX_OUTPUT_TOKENS); + return Number.isFinite(raw) && raw > 0 + ? Math.floor(raw) + : SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT; +} + /** * Keep ONLY genuine conversation turns from the client-held transcript. The * payload is fully attacker-controlled; a forged `system` turn could try to @@ -204,6 +220,9 @@ export class PublicShareChatService { tools, // Bound the agent loop for anonymous callers. stopWhen: stepCountIs(5), + // Cap per-request output so one anonymous call cannot run up the provider + // bill even if the per-IP throttle is evaded; worst case = steps × this. + maxOutputTokens: resolveShareAiMaxOutputTokens(), abortSignal: signal, onError: ({ error }) => { const e = error as { diff --git a/apps/server/src/core/ai-chat/public-share-chat.spec.ts b/apps/server/src/core/ai-chat/public-share-chat.spec.ts index 2be6a5f4..3ac99f66 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.spec.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.spec.ts @@ -5,6 +5,8 @@ import { buildShareSystemPrompt } from './public-share-chat.prompt'; import { PublicShareChatService, filterShareTranscript, + resolveShareAiMaxOutputTokens, + SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT, } from './public-share-chat.service'; import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service'; import { @@ -396,6 +398,44 @@ describe('resolveShareAiWorkspaceMax (env-overridable per-workspace cap)', () => }); }); +describe('resolveShareAiMaxOutputTokens (env-overridable per-request output cap)', () => { + const ENV = 'SHARE_AI_MAX_OUTPUT_TOKENS'; + const original = process.env[ENV]; + + afterEach(() => { + if (original === undefined) delete process.env[ENV]; + else process.env[ENV] = original; + }); + + it('falls back to the default when unset', () => { + delete process.env[ENV]; + expect(resolveShareAiMaxOutputTokens()).toBe( + SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT, + ); + expect(SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT).toBe(512); + }); + + it('uses (and floors) a valid positive value from the env', () => { + process.env[ENV] = '1024.9'; + expect(resolveShareAiMaxOutputTokens()).toBe(1024); + }); + + it('falls back to the default for zero, a negative, or a non-numeric value', () => { + process.env[ENV] = '0'; + expect(resolveShareAiMaxOutputTokens()).toBe( + SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT, + ); + process.env[ENV] = '-5'; + expect(resolveShareAiMaxOutputTokens()).toBe( + SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT, + ); + process.env[ENV] = 'not-a-number'; + expect(resolveShareAiMaxOutputTokens()).toBe( + SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT, + ); + }); +}); + describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace cap)', () => { it('allows up to the cap within a window, then 429s (returns false)', async () => { const limiter = makeLimiter(3, 60_000, () => 1_000); @@ -477,11 +517,12 @@ describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace expect(await limiter.tryConsume('ws-1')).toBe(true); }); - it('FAILS OPEN (returns true) when the Redis eval rejects', async () => { - // The per-workspace cap is a COST backstop, not an access boundary: the - // funnel access gates and the per-IP throttle still apply. A transient - // Redis failure must therefore ADMIT the call (true) rather than 500/429, - // so a Redis blip cannot take the public-share assistant fully offline. + it('FAILS CLOSED (returns false) when the Redis eval rejects', async () => { + // The per-workspace cap is the COST backstop for an OPTIONAL anonymous + // assistant. If Redis is unavailable we cannot prove the workspace is under + // its cap, so we DENY (controller 429s) rather than admit an unmetered, + // billable call — a brief Redis blip disabling the assistant is safer than + // an unbounded provider bill. const failingRedis = { eval: () => Promise.reject(new Error('redis down')), } as unknown as import('ioredis').Redis; @@ -495,7 +536,7 @@ describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace const errSpy = jest .spyOn(Logger.prototype, 'error') .mockImplementation(() => undefined); - expect(await limiter.tryConsume('ws-1')).toBe(true); + expect(await limiter.tryConsume('ws-1')).toBe(false); expect(errSpy).toHaveBeenCalled(); // the failure MUST be logged, not swallowed errSpy.mockRestore(); }); diff --git a/apps/server/src/core/ai-chat/public-share-workspace-limiter.ts b/apps/server/src/core/ai-chat/public-share-workspace-limiter.ts index bf14d7d6..bcc40c5a 100644 --- a/apps/server/src/core/ai-chat/public-share-workspace-limiter.ts +++ b/apps/server/src/core/ai-chat/public-share-workspace-limiter.ts @@ -99,9 +99,11 @@ export class PublicShareWorkspaceLimiter { /** * Account one call for `key`. Returns true if it is within the cap (allowed), * false if the cap over the trailing window is exceeded (caller must 429). - * On a Redis failure we FAIL OPEN (return true): the cap is a cost backstop, - * not an auth boundary, and the access funnel + per-IP throttle still apply — - * we never want a transient Redis blip to take the assistant fully offline. + * On a Redis failure we FAIL CLOSED (return false): this cap is the COST + * backstop for an OPTIONAL anonymous assistant, so when Redis is unavailable we + * cannot prove the workspace is under its cap and therefore DENY rather than + * admit an unmetered, billable anonymous call. A transient Redis blip briefly + * disabling the assistant is preferable to an unbounded provider bill. */ async tryConsume(key: string): Promise { const t = this.now(); @@ -120,15 +122,16 @@ export class PublicShareWorkspaceLimiter { ); return admitted === 1; } catch (err) { - // Fail OPEN: this per-workspace cap is a COST backstop, not an access - // control — the funnel access gates and the per-IP throttle still apply. - // A transient Redis failure must not take the public-share assistant - // fully offline, so we admit the call rather than 500 the request. + // FAIL CLOSED: when Redis is unavailable we cannot prove the workspace is + // under its cap, so we DENY (the controller 429s) rather than admit an + // unmetered, billable anonymous call. The assistant is optional, so a + // transient Redis blip briefly disabling it is the safer failure mode than + // an unbounded provider bill. this.logger.error( - `share-ai workspace limiter Redis failure for key "${key}"; failing open`, + `share-ai workspace limiter Redis failure for key "${key}"; failing closed`, err as Error, ); - return true; + return false; } } } From 75c7c29cc866e2031fec3bbafe54e6c5c9baf490 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 02:36:54 +0300 Subject: [PATCH 03/12] docs: remove outdated backlog and RAG plan docs --- docs/backlog/pages-import-broken-400.md | 121 -------------------- docs/rag-improvements-plan.md | 145 ------------------------ 2 files changed, 266 deletions(-) delete mode 100644 docs/backlog/pages-import-broken-400.md delete mode 100644 docs/rag-improvements-plan.md diff --git a/docs/backlog/pages-import-broken-400.md b/docs/backlog/pages-import-broken-400.md deleted file mode 100644 index f3975af0..00000000 --- a/docs/backlog/pages-import-broken-400.md +++ /dev/null @@ -1,121 +0,0 @@ -# /pages/import отдаёт 400 «Error processing file content» (регресс) - -Статус: **диагностируемость починена** (fix #1 применён); корневая причина **не -подтверждена** — на текущем коде локально баг воспроизвести не удалось. -Ниже — что удалось выяснить, главный подозреваемый и что проверить дальше. - -## Симптом - -На задеплоенном инстансе эндпоинт `POST /pages/import` отдаёт -`400 BadRequest` с телом «Error processing file content». Раньше работал — -похоже на регресс после редеплоя гитмоста. - -Через этот эндпоинт грузит контент MCP-инструмент `create_page` (это -единственный эндпоинт, принимающий контент при создании страницы — -см. комментарий в `packages/mcp/src/client.ts:961`). - -Что при этом **исправно** (важно для локализации): -- `POST /pages/create` — создание пустой страницы. -- `update_page_json` — запись контента через realtime-коллаборацию (Yjs). - -## Где именно бросается ошибка - -`apps/server/src/integrations/import/services/import.service.ts:93-97` — -`try/catch` вокруг обработки контента: - -```ts -} catch (err) { - const message = 'Error processing file content'; - this.logger.error(message, err); // реальная причина логируется ТОЛЬКО в логи - throw new BadRequestException(message); // наружу уходит generic-строка -} -``` - -Реальный текст ошибки/стек **проглатывается** (наружу — generic-строка), что -нарушает конвенцию проекта (см. CLAUDE.md, «Errors must never be swallowed»). -Поэтому по ответу 400 причину не видно — её надо читать в логах сервера -(`logger.error(message, err)` пишет полный err) ИЛИ воспроизвести локально. - -## Цепочка обработки для .md (что внутри try) - -`importPage` → `processMarkdown(fileContent)`: -1. `markdownToHtml` (`packages/editor-ext/.../marked.utils.ts`) — marked, чистый JS, без DOM. -2. `processHTML`: cheerio `load` → `normalizeImportHtml` (`utils/import-formatter.ts`) — чистый JS. -3. `htmlToJson` (`apps/server/src/collaboration/collaboration.util.ts:118`) → - `generateJSON(html, tiptapExtensions)`. - -## Ключевая зацепка: путь импорта зависит от happy-dom, рабочие пути — нет - -`generateJSON` (`apps/server/src/common/helpers/prosemirror/html/generateJSON.ts`) -парсит HTML через **happy-dom**: `new Window()` + `new localWindow.DOMParser()` + -`parseFromString(...)`, затем `PMDOMParser.fromSchema(schema).parse(doc.body)`. - -А исправные пути DOM-парсер НЕ используют: -- `/pages/create` — пустая страница, контент не парсится. -- `update_page_json` — пишет готовый ProseMirror-JSON в Yjs - (`TiptapTransformer.toYdoc`), без HTML→DOM. - -То есть единственное, что есть в сломанном пути и отсутствует в рабочих, — -**серверный парсинг HTML через happy-dom**. - -## Главный подозреваемый: бамп happy-dom (14 → 20) - -- Изначально было `"happy-dom": "^14.12.3"`. -- Сейчас запинено `"happy-dom": "20.8.9"` в `apps/server/package.json:83` - (+ override в корневом `package.json`). -- Пин на `20.8.9` пришёл в коммите `17da7629 "overrides"` - (Philipinho, 2026-03-28), где `20.8.4` → `20.8.9`. -- Скачок 14 → 20 — это 6 мажоров; у happy-dom между мажорами ломающие - изменения в API `Window`/`DOMParser` и в поведении парсинга HTML. Очень - вероятно, что `generateJSON` ломается на новом happy-dom. - -Версия в node_modules подтверждена: `happy-dom@20.8.9` (симлинк свежий). - -## Второстепенный подозреваемый - -`getSchema(tiptapExtensions)` / `PMDOMParser.parse(...)` могут спотыкаться на -`parseHTML`-правилах недавно добавленных нод (synced blocks/transclusion, -page break, indent, columns, status — все они в `tiptapExtensions`). Но -`getSchema` используется и в рабочем пути (`createYdoc`/`update_page_json`), -поэтому сам по себе билд схемы скорее всего цел — под подозрением именно -DOM-парс-ветка, уникальная для импорта. - -## Направления фикса - -1. **Диагностируемость — ✅ СДЕЛАНО (по конвенции проекта).** В catch-блоках - `import.service.ts` (обработка контента + вставка страницы) реальная - причина теперь прокидывается наружу: `BadRequestException` несёт - `${err.name}: ${err.message}`, а в лог пишется полный `err` со стеком. - Раньше наружу уходила generic-строка "Error processing file content". - Теперь при повторе 400 на проде реальный reason будет виден прямо в теле - ответа — без необходимости лезть в логи. -2. **Корневой фикс — ⏳ НЕ ПОДТВЕРЖДЁН.** Гипотеза happy-dom 14→20 **не - подтвердилась** при локальном воспроизведении на текущем коде (см. ниже). - Применять блайнд-даунгрейд happy-dom нельзя — нужен реальный stack из - логов/ответа после повторения. - -## Локальное воспроизведение (выполнено) - -На текущем `main` (happy-dom 20.8.9) вся цепочка импорта `.md` отработала -без ошибок через `tsx` (импорты прямо из source, не из dist): - -- `markdownToHtml` → cheerio `load` → `normalizeImportHtml` → `generateJSON` - с полным набором из 44 `tiptapExtensions` — **OK** для: - - базового markdown (заголовки, bold/italic, списки, таблицы, code-block, - blockquote) - - edge-cases: пустой контент, whitespace, HTML-сущности, вложенные списки, - task-list, emoji, кириллица, спецсимволы в code, ссылки, изображения, hr -- API happy-dom 20.8.9, используемые в `generateJSON`, существуют и работают: - `new Window()`, `new localWindow.DOMParser()`, `parseFromString('…', - 'text/html')`, `happyDOM.abort()` (async), `happyDOM.close()` (async). -- Блок `finally` в `generateJSON` вызывает `abort()/close()` без `await` и без - `try/catch`, но эти методы не бросают синхронно и не перезаписывают - результат — **не является** причиной 400 (проверено отдельным тестом). -- Все `parseHTML`-правила расширений (status, transclusion, page-break, - columns, subpages и т.д.) участвуют в успешном тесте — ни одно не падает. - -Вывод: на текущем коде баг **не воспроизводится**. Вероятные объяснения — -контент-специфичный кейс, которого нет в тестах; разница между source и -собранным `dist`; либо временное состояние задеплоенного инстанса. После -применения fix #1 повторный 400 покажет реальный reason — по нему и искать -корень. diff --git a/docs/rag-improvements-plan.md b/docs/rag-improvements-plan.md deleted file mode 100644 index fbbb51df..00000000 --- a/docs/rag-improvements-plan.md +++ /dev/null @@ -1,145 +0,0 @@ -# Улучшение качества RAG-поиска агента — план по итерациям - -> Статус: живой документ. Итерация 1 **реализована** (см. ниже). Остальное — -> бэклог на следующие итерации, отсортированный по «качество / усилие». -> Контекст: gitmost — форк Docmost. Семантический поиск агента: per-workspace -> эмбеддинги в `page_embeddings` (pgvector, dimension-agnostic колонка, seq-scan -> с `<=>`), индексация через BullMQ (`reindexPage` / `reindexWorkspace`). -> Активная embedding-модель деплоя: OpenAI `text-embedding-3-large` (3072d). - -## Как сверялось с реальным кодом - -Внешнее предложение по улучшению RAG было сверено с кодовой базой. Точные факты -на момент итерации 1: - -- Хранилище: [page_embeddings](../apps/server/src/database/migrations/20260617T120000-page-embeddings.ts), - колонка `embedding` сделана dimension-agnostic в - [20260617T140000](../apps/server/src/database/migrations/20260617T140000-page-embeddings-dimension-agnostic.ts); - `model_name` / `model_dimensions` хранятся по строке. -- Полнотекстовые индексы **уже существуют** (предложение ошибочно утверждало - обратное): `pages_tsv_idx` на `pages.tsv` и `attachments_tsv_idx`. Конфигурация — - `to_tsvector('english', f_unaccent(...))` + `setweight` - ([тут](../apps/server/src/database/migrations/20250729T213756-add-unaccent-pg_trm-update-tsvector..ts)). -- Чанкинг: `RecursiveCharacterTextSplitter` 1000/200, без префиксов. -- Префиксы `query:` / `passage:` **не нужны**: они требуются для e5/bge/gte/Qwen3, - а деплой на OpenAI `text-embedding-3-large` (этот пункт предложения неприменим). -- Вложения (`attachment_id` в схеме есть) **не индексируются** — индексатор всегда - пишет `attachmentId: null`. - ---- - -## Итерация 1 — РЕАЛИЗОВАНО - -Три «низковисящих фрукта»: - -### 1. Хлебные крошки заголовков в чанках -Файл: [embedding-indexer.service.ts](../apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts). -Каждый чанк префиксуется путём заголовков `«Заголовок страницы > H1 > H2»` перед -эмбеддингом. Крошки строятся обходом **ProseMirror JSON** (`heading`-ноды с -`attrs.level`), а не markdown-текста — поэтому `#` внутри fenced-код-блока (типичный -bash-сниппет в WirenBoard-вики) **никогда** не принимается за заголовок. Деградация -к старому plain-text чанкингу при отсутствии/сбое `content`. Префикс попадает и в -эмбеддинг, и в `content` (а значит — в лексический индекс `fts` и в сниппет агента). - -### 2. Гибридный поиск (RRF), слияние двух инструментов в один -- Миграция [20260618T150000-page-embeddings-fts.ts](../apps/server/src/database/migrations/20260618T150000-page-embeddings-fts.ts): - генерируемая колонка `fts tsvector GENERATED ALWAYS AS (to_tsvector('english', - f_unaccent(content))) STORED` + GIN-индекс. Конфиг совпадает с `pages.tsv` (та же - обработка unaccent/Cyrillic); `f_unaccent` IMMUTABLE → триггер не нужен. -- Репозиторий: метод `hybridSearch` в - [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) — - один SQL-запрос, два CTE (cosine + `websearch_to_tsquery`), слияние Reciprocal Rank - Fusion через FULL OUTER JOIN на уровне чанков. `k=60` (дефолт Cormack 2009 / - ES / OpenSearch / Weaviate), равные веса 1.0/1.0. RRF сливает **ранги**, поэтому - несовместимость шкал BM25 и косинуса не требует нормализации. Dimension-фильтр — - только на семантической стороне. -- Инструменты: `semanticSearch` удалён, `searchPages` стал единым гибридным - инструментом ([ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts)). - Контроль доступа сохранён 1-в-1 (scope по доступным спейсам + пост-фильтр прав - страниц). Если эмбеддинги не настроены / эмбеддинг упал / нет доступных спейсов / - гибрид пуст → graceful fallback на прежний REST-полнотекст (CASL-enforced). - -### 3. Переписывание запроса + описания инструментов -- Описание `searchPages` теперь явно просит агента переформулировать вопрос в - сфокусированный поисковый запрос и переискивать при слабой выдаче (это переживает - кастомный admin-промпт, т.к. лежит в описании инструмента). -- Одна строка-подсказка добавлена в `DEFAULT_PROMPT` - ([ai-chat.prompt.ts](../apps/server/src/core/ai-chat/ai-chat.prompt.ts)). - -> ВАЖНО после деплоя: чтобы крошки и `fts` появились у существующих страниц, нужна -> **переиндексация корпуса** (кнопка «Reindex now» / `WORKSPACE_CREATE_EMBEDDINGS`). -> Миграция заполнит `fts` у текущих строк автоматически, но крошки добавляются только -> при переиндексации (она же перезапишет `content`). - -### Известные нюансы текущей реализации (осознанные компромиссы) -- Гибрид покрывает только проиндексированные чанки. Свежесозданная страница - становится искомой после отработки её BullMQ-`reindexPage`. Пока эмбеддинги не - настроены — работает только REST-fallback (полнотекст уровня страницы по `pages.tsv`). -- Если **весь** пул кандидатов гибрида (до 200 чанков) оказался из закрытых для - пользователя страниц, инструмент вернёт пусто, а не уйдёт в keyword-fallback. - Узкий кейс; возможное улучшение — fallback и при пустом результате пост-фильтра. -- `fts` использует конфиг `english` (как и `pages.tsv`) — без русской стеммизации. - Для русской вики это консистентно с текущим поиском; переход на `simple`/`russian` - конфиг — отдельная задача с переиндексацией. -- `candidates` (=clamp(limit×5, 50, 200)) служит и per-CTE лимитом, и финальным - лимитом слияния; веса RRF равные. Тюнится после появления оценочного харнесса. - ---- - -## Бэклог следующих итераций (по приоритету «качество / усилие») - -### A. Реранкер (cross-encoder) — наибольший ROI после гибрида -Вставить между over-fetch гибрида и дедупом: брать топ-50–100 кандидатов от -`hybridSearch`, реранкать, оставлять топ-5–10. Ожидаемый прирост precision/MRR -+10–25 %. Точка вставки уже готова — это шаг между `hybridSearch(... candidates)` и -циклом дедупа в `searchPages`. -- Хостовый старт (раз уже на OpenAI-инфраструктуре): **Cohere Rerank** или - **Voyage `rerank-2.5`** — провайдер по аналогии с текущим pluggable embedding-конфигом. -- Self-hosted (под Ollama-этос): **BGE-reranker-v2-m3** через HF Text Embeddings - Inference (`/rerank`), либо FlashRank (ONNX/CPU, ~15–30 мс). -- Диагностика: если реранк не двигает метрики — узкое место в recall (чанкинг/гибрид), - а не в ранжировании. - -### B. Индексация вложений — закрыть пробел покрытия -Схема уже готова (`attachment_id`). Добавить в BullMQ-flow шаг извлечения текста из -PDF/документов (PyMuPDF для цифровых PDF; OCR для сканов; для таблиц — markdown через -LLM-парсер) и вливать его в тот же путь чанк→эмбеддинг→`fts`, помечая `attachment_id`. -Структура извлечённых данных важнее голой точности OCR. - -### C. Тюнинг гибрида и оценочный харнесс -- Золотой датасет 30–100 примеров (вопрос → нужная страница/чанк) + Ragas/DeepEval - (Recall@k, MRR/nDCG, context precision/recall, faithfulness). Прогон до/после - каждого изменения. **Прерогатива пропущена в итерации 1 осознанно** — без неё все - нижеследующие тюнинги делаются «на глаз». -- После харнесса: тюнить веса RRF (старт 1.0/1.0), `k` (старт 60), число `candidates`. -- Эксперимент: чанки ~512 симв. против 1000 (предложение указывает на рост precision). - -### D. Contextual Retrieval (Anthropic), если крошек мало -Один LLM-вызов на чанк добавляет предложение-контекст. Снижение провалов выдачи -на 35–49 %. Ложится в BullMQ-`reindexPage`; на сотнях страниц с prompt caching — копейки. -Применять, только если хлебных крошек окажется недостаточно против потери контекста. - -### E. ParadeDB `pg_search` (настоящий BM25), если лексика станет узким местом -Нативный `ts_rank` использует только TF и длину документа, без IDF. `pg_search` -(Rust/Tantivy) даёт честный BM25-индекс. Не drop-in (свои операторы вместо `@@`) — -это изменение кода, а не флаг. На сотнях страниц нативного `tsvector` хватает; брать -только если качество лексического ранжирования упрётся в потолок. - -### F. Прочее -- **Префиксы query/passage** — НЕ нужны на OpenAI. Внедрять только при переходе на - e5/bge/gte/Qwen3 (тогда индексатор ставит `passage:`, запрос — `query:`; BGE-v1.5, - наоборот, префиксов НЕ должна получать). Зафиксировано как ловушка на будущее. -- **Апгрейд embedding-модели** — уже на `text-embedding-3-large` (топ среди закрытых). - Matryoshka (обрезка размерности) — запас на будущее; dimension-agnostic колонка - делает миграцию тривиальной (цена — переэмбеддинг корпуса). -- **HyDE и широкий multi-query/RAG-Fusion** — НЕ рекомендуются как дефолт: в свежих - бенчмарках уступали и добавляют задержку/галлюцинации. - -## Оговорки -- Все внешние числа (62→84 % precision, +17 % Recall@5, −35…49 % провалов, +10–25 % - от реранка) получены на ДРУГИХ корпусах (SEC-отчёты, финтекст, право, медицина). - На этой вики величины будут иными — поэтому пункт C (свой датасет) обязателен перед - тонким тюнингом. Внешние числа — направление, не гарантия величины. -- Часть источников предложения — вендорский маркетинг (Cohere, Voyage, ParadeDB); - направление подтверждается независимыми (T2-RAGBench, оценка Anthropic), но величины - у вендоров могут быть завышены. From b98c9d51c6543494acf3ba4fa5e530f701bb23fc Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 02:44:38 +0300 Subject: [PATCH 04/12] docs(readme): sync roadmap with develop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Page templates (#17), Public-share AI assistant (#14/#25/#41) and Footnotes (#18) from "Planned" to "Done" in both README.md and README.ru.md — they are already implemented on develop. Drop their stale links to deleted plan docs (page-templates-plan.md, footnotes-plan.md, public-share-assistant-plan.md). Offline mode and the rest of the list are left unchanged. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +++--- README.ru.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c1689068..b63b76f5 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ community feature, with no enterprise license. Open it from the page header; the - ✅ **macOS app** — native macOS app ([gitmost-app](https://github.com/vvzvlad/gitmost-app)) that embeds the UI with multi-server tabs. - ✅ **AI chat** — built-in AI agent chat over your wiki content (read + write, RAG search, configurable provider, optional web access via external MCP). - ✅ **Voice dictation** — microphone button in the AI agent chat and the page editor; audio is transcribed server-side (Whisper / OpenAI-compatible STT) via the workspace AI provider, with an admin toggle to show/hide it. +- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks). +- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. +- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP. ### In progress @@ -108,14 +111,11 @@ community feature, with no enterprise license. Open it from the page header; the ### Planned -- 🔭 **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks). See [docs/page-templates-plan.md](docs/page-templates-plan.md). - 🔭 **Viewer comments** — let read-only viewers leave comments. -- 🔭 **Public-share AI assistant** — let anonymous visitors of a shared page ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. See [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md). - 🔭 **Password-protected pages** — protect individual pages / shares with a password. - 🔭 **Windows / Linux app** — native desktop app for Windows and Linux. - 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md). - 🔭 **Offline mode** — offline sync & PWA support. -- 🔭 **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP. See [docs/footnotes-plan.md](docs/footnotes-plan.md). - 🔭 **Editor & UX improvements** — blocks inside tables (lists, to-do items), column layout, additional heading levels, highlight blocks, custom emoji in callouts, floating images, anchor links for page mentions, toggles (shared-page width, aside/sidebar, spellcheck, ligatures), sanitized space-tree export, and mentions in breadcrumbs. ## Getting started diff --git a/README.ru.md b/README.ru.md index 0b16ad62..cb0d12ad 100644 --- a/README.ru.md +++ b/README.ru.md @@ -102,6 +102,9 @@ real-time-коллаборации Docmost, поэтому запись нико - ✅ **Приложение для macOS** — нативное приложение для macOS ([gitmost-app](https://github.com/vvzvlad/gitmost-app)), встраивающее UI с вкладками для нескольких серверов. - ✅ **AI-чат** — встроенный чат с AI-агентом по содержимому вики (чтение + запись, RAG-поиск, настраиваемый провайдер, опциональный доступ в интернет через внешние MCP). - ✅ **Голосовая диктовка** — кнопка-микрофон в чате AI-агента и в редакторе страниц; аудио распознаётся на сервере (Whisper / OpenAI-совместимый STT) через AI-провайдер воркспейса, с тумблером админа для показа/скрытия. +- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). +- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. +- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. ### В процессе @@ -109,14 +112,11 @@ real-time-коллаборации Docmost, поэтому запись нико ### В планах -- 🔭 **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). См. [docs/page-templates-plan.md](docs/page-templates-plan.md). - 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение. -- 🔭 **AI-ассистент на публичных шарах** — возможность анонимному зрителю расшаренной страницы спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. См. [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md). - 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем. - 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux. - 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md). - 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA. -- 🔭 **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. См. [docs/footnotes-plan.md](docs/footnotes-plan.md). - 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках. ## С чего начать From 81823fce1e1a497b557a3cdafedacba917a3a279 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 02:48:41 +0300 Subject: [PATCH 05/12] feat(html-embed): sandbox the embed block; split trusted trackers into an admin field Convert the htmlEmbed node from same-origin raw-HTML execution to a sandboxed iframe (sandbox="allow-scripts allow-popups allow-forms", no allow-same-origin, srcdoc) with postMessage auto-resize (validated by event.source) and an optional manual height attr. The block now runs in an opaque origin and cannot reach the viewer's cookies/session/API, so it is safe for any member. Because the block is now harmless, remove the entire admin/role gating apparatus: drop htmlEmbedAllowed/canAuthorHtmlEmbed/stripDisallowedHtmlEmbedNodes/ collectHtmlEmbedSources and every role-based strip on the write paths (collab REST/MCP + socket, page create/duplicate, import x2, transclusion unsync), along with the now-unused WorkspaceRepo/UserRepo injections and the PageService.create callerRole param. Keep one strip: prepareContentForShare still removes htmlEmbed on the anonymous public-share read path when the workspace master toggle is OFF. The workspace settings.htmlEmbed toggle is now a plain feature switch (gates the slash-menu and share rendering); when ON the block is available to all members. Add settings.trackerHead: an admin-only raw HTML/JS analytics snippet injected verbatim into the of public share pages only (ShareSeoController), for trackers that genuinely need same-origin. Admin-gated via the existing CASL Manage/Settings ability; never injected into the authenticated app shell. Closes security-review findings #1, #2, #4, #5, #10 (and #3 as a security issue). Co-Authored-By: Claude Opus 4.8 --- .../public/locales/en-US/translation.json | 17 +- .../html-embed/html-embed-view.module.css | 9 +- .../components/html-embed/html-embed-view.tsx | 139 ++++++--- .../html-embed/render-raw-html.test.ts | 121 +++----- .../components/html-embed/render-raw-html.ts | 103 ++++--- .../components/slash-menu/menu-items.ts | 32 +- .../editor/components/slash-menu/types.ts | 10 +- .../components/html-embed-settings.tsx | 20 +- .../settings/components/tracker-settings.tsx | 98 ++++++ .../workspace/types/workspace.types.ts | 10 +- .../settings/workspace/workspace-settings.tsx | 2 + .../collaboration.handler.html-embed.spec.ts | 120 -------- .../collaboration/collaboration.handler.ts | 32 +- .../persistence.extension.html-embed.spec.ts | 280 ------------------ .../extensions/persistence.extension.ts | 64 +--- .../html-embed-import-detect.spec.ts | 17 +- .../helpers/prosemirror/html-embed.spec.ts | 65 +--- .../helpers/prosemirror/html-embed.util.ts | 51 +--- apps/server/src/core/page/page.controller.ts | 3 - .../page-service-html-embed-identity.spec.ts | 102 ------- .../src/core/page/services/page.service.ts | 65 +--- .../spec/page-template-access.spec.ts | 4 - .../spec/page-template-lookup.spec.ts | 1 - .../transclusion-unsync-html-embed.spec.ts | 145 --------- .../page/transclusion/transclusion.service.ts | 26 -- .../src/core/share/share-html-embed.spec.ts | 14 +- .../src/core/share/share-seo.controller.ts | 16 +- apps/server/src/core/share/share.service.ts | 19 +- .../workspace/dto/update-workspace.dto.ts | 16 +- .../services/workspace-html-embed.spec.ts | 34 +++ .../workspace/services/workspace.service.ts | 17 ++ .../services/file-import-task.service.ts | 47 +-- .../import-html-embed-identity.spec.ts | 123 -------- .../import/services/import.service.ts | 33 +-- .../src/lib/html-embed/html-embed.ts | 14 +- 35 files changed, 482 insertions(+), 1387 deletions(-) create mode 100644 apps/client/src/features/workspace/components/settings/components/tracker-settings.tsx delete mode 100644 apps/server/src/collaboration/collaboration.handler.html-embed.spec.ts delete mode 100644 apps/server/src/collaboration/extensions/persistence.extension.html-embed.spec.ts delete mode 100644 apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts delete mode 100644 apps/server/src/core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts delete mode 100644 apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index c04fc72d..7d4dbc79 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1237,5 +1237,20 @@ "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.", "No roles configured": "No roles configured", "Delete role": "Delete role", - "Are you sure you want to delete this role?": "Are you sure you want to delete this role?" + "Are you sure you want to delete this role?": "Are you sure you want to delete this role?", + "HTML embed": "HTML embed", + "Edit HTML embed": "Edit HTML embed", + "HTML embed is disabled in this workspace": "HTML embed is disabled in this workspace", + "Click to add HTML / CSS / JS": "Click to add HTML / CSS / JS", + "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.", + "": "", + "Height (px, blank = auto)": "Height (px, blank = auto)", + "advanced": "advanced", + "Enable HTML embed": "Enable HTML embed", + "Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.", + "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.", + "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.", + "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.", + "Analytics / tracker": "Analytics / tracker", + "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only." } diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css b/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css index 75304685..2ff32e3a 100644 --- a/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css +++ b/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css @@ -2,11 +2,18 @@ position: relative; } -/* The container the raw source is injected into. */ +/* Fallback container used only for the empty, non-editor case. */ .htmlEmbedContent { width: 100%; } +/* The sandboxed iframe the embed source is rendered into. */ +.htmlEmbedFrame { + display: block; + width: 100%; + border: none; +} + /* Edit affordance overlay, only shown while editing the document. */ .htmlEmbedToolbar { position: absolute; diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx b/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx index 273fbaff..0e6633b1 100644 --- a/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx +++ b/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx @@ -1,85 +1,114 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import clsx from "clsx"; import { ActionIcon, Button, Group, Modal, + NumberInput, Text, Textarea, } from "@mantine/core"; import { IconCode, IconEdit } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useAtomValue } from "jotai"; -import useUserRole from "@/hooks/use-user-role.tsx"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import classes from "./html-embed-view.module.css"; import { + buildSandboxSrcdoc, canEdit as computeCanEdit, - renderRawHtml, + HTML_EMBED_HEIGHT_MESSAGE, shouldExecute as computeShouldExecute, } from "./render-raw-html.ts"; +// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the +// page layout, and a sensible default before the first height message arrives. +const MIN_IFRAME_HEIGHT = 40; +const MAX_IFRAME_HEIGHT = 4000; +const DEFAULT_IFRAME_HEIGHT = 150; + export default function HtmlEmbedView(props: NodeViewProps) { const { t } = useTranslation(); const { node, selected, updateAttributes, editor } = props; - const { source } = node.attrs as { source: string }; - const { isAdmin } = useUserRole(); + const { source, height } = node.attrs as { + source: string; + height: number | null; + }; - // Defense in depth: only execute the raw HTML/JS when the workspace HTML embed - // feature toggle is ON. When OFF (the default), we render a neutral disabled - // placeholder and inject nothing — so turning the feature off neutralizes - // existing embeds at render time as well as on the next server-side save. + // The HTML embed renders inside a SANDBOXED iframe (no same-origin access), so + // the workspace toggle is a feature switch, not a security gate. When OFF (the + // default) we render a neutral placeholder in the editor and nothing else. const workspace = useAtomValue(workspaceAtom); const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true; - // Execution policy split by editor mode: - // - READ-ONLY / public-share view: the SERVER already decided whether to - // include the embed (it strips htmlEmbed from shared content when the - // workspace toggle is OFF). An anonymous viewer has no workspace and thus - // reads `htmlEmbedEnabled` as false, so we must NOT gate execution on it - // here — we execute exactly the `source` the server chose to serve. - // - EDITABLE editor (admin authoring): keep gating on the per-workspace - // toggle so an admin sees the inert placeholder when the feature is OFF. const shouldExecute = computeShouldExecute( editor.isEditable, htmlEmbedEnabled, ); - const contentRef = useRef(null); + const iframeRef = useRef(null); const [modalOpen, setModalOpen] = useState(false); const [draft, setDraft] = useState(source || ""); + const [draftHeight, setDraftHeight] = useState(height ?? ""); - // (Re)render the raw source whenever it changes. This runs in BOTH the - // editable editor and the read-only / public-share editor (same NodeView), - // so trackers fire for readers too — that is the intended behaviour. When the - // feature toggle is OFF we clear the container and inject/execute nothing. + // Auto-resize height tracked in state (used only when no fixed height is set). + const [autoHeight, setAutoHeight] = useState( + height ?? DEFAULT_IFRAME_HEIGHT, + ); + + const srcdoc = useMemo(() => buildSandboxSrcdoc(source || ""), [source]); + + // Auto-resize: accept height messages ONLY from this iframe's own content + // window. The sandboxed srcdoc has an opaque ("null") origin, so we cannot + // match by event.origin — we match by event.source instead. No-op when a + // fixed height is configured. useEffect(() => { - if (!contentRef.current) return; - if (shouldExecute) { - renderRawHtml(contentRef.current, source || ""); - } else { - contentRef.current.innerHTML = ""; + if (typeof height === "number") return; + function onMessage(event: MessageEvent) { + if (event.source !== iframeRef.current?.contentWindow) return; + const data = event.data as { type?: string; height?: number }; + if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return; + const next = Number(data.height); + if (!Number.isFinite(next)) return; + setAutoHeight( + Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, next)), + ); } - }, [source, shouldExecute]); + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [height]); + + const effectiveHeight = + typeof height === "number" + ? Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, height)) + : autoHeight; const openEditor = useCallback(() => { setDraft(source || ""); + setDraftHeight(height ?? ""); setModalOpen(true); - }, [source]); + }, [source, height]); const onSave = useCallback(() => { if (editor.isEditable) { - updateAttributes({ source: draft }); + updateAttributes({ + source: draft, + height: draftHeight === "" ? null : Number(draftHeight), + }); } setModalOpen(false); - }, [draft, editor.isEditable, updateAttributes]); + }, [draft, draftHeight, editor.isEditable, updateAttributes]); - // The edit affordance is only meaningful in edit mode, is restricted to admins - // (the server strips the node for non-admins anyway), and is offered only when - // the workspace feature toggle is ON. - const canEdit = computeCanEdit(editor.isEditable, isAdmin, htmlEmbedEnabled); + // The edit affordance is only meaningful in edit mode and is offered only when + // the workspace master toggle is ON. Any member can edit (sandboxed = safe). + const canEdit = computeCanEdit(editor.isEditable, htmlEmbedEnabled); return ( @@ -114,9 +143,18 @@ export default function HtmlEmbedView(props: NodeViewProps) { ) : source ? ( - // Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created - // in renderRawHtml so they execute. -
+ // Raw HTML/CSS/JS rendered inside a sandboxed iframe (no same-origin): + // scripts run in an opaque origin and cannot touch the viewer's + // session/cookies/API. +