Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c4fc565b6 | |||
| a990ebd604 | |||
| 94ca907476 | |||
| fff772cbe2 | |||
| 16c2a4b623 | |||
| 58f13a7efd | |||
| 2bb48f841f | |||
| daa96eb132 | |||
| 4c5230d677 | |||
| 50ca27c5d4 | |||
| de25c258f9 | |||
| afc50ead38 | |||
| ec932e89a3 | |||
| 490d7965a1 | |||
| 32c9361969 | |||
| c30f910d66 | |||
| 7007f6bcf9 | |||
| 4bf06c68cf | |||
| af44736fb9 | |||
| 2a4d1acfd7 | |||
| 11eb87d58a | |||
| f2c8aa70f3 | |||
| 661ea8ba07 | |||
| 059edccb64 | |||
| 693a9a350b | |||
| 79b2da686b | |||
| ed3a8f8174 | |||
| 8503ff1f3d | |||
| c18b4c132c | |||
| 3163f50c98 | |||
| 3e81f64415 | |||
| d36021a111 | |||
| 1f2999e5ad | |||
| c674db2b2f | |||
| 44cdb5a4c3 | |||
| 03af65dd06 | |||
| dead5fa8a8 | |||
| 5b17503df7 | |||
| 46bb55dbd1 | |||
| 8b34a428f4 | |||
| e236782260 | |||
| 12ed3a0332 | |||
| b3cc6a7ff8 | |||
| c42cb42413 | |||
| ae3dfd8de6 | |||
| 41030b2c78 | |||
| 515a1aaf1d | |||
| 03eafa6c68 | |||
| a42f1ead48 | |||
| b7a3ec227d | |||
| 74387bb047 | |||
| 6ec2981743 | |||
| dd1fe90515 | |||
| 846341d7d4 | |||
| 32e10ca6d3 | |||
| 74d212cfd3 | |||
| c96fafc4ad | |||
| 9a6389133b | |||
| 64566e9327 | |||
| 10a4326fbf | |||
| 68409a8ae9 | |||
| fc624f5a4b | |||
| 0b8497e496 | |||
| 433252bdb1 | |||
| 2f8c5d9a98 | |||
| 55e8f61b3c | |||
| d42ca8dc57 | |||
| 0af7eb30c3 | |||
| 5bd5995ef0 | |||
| 575125a5dc | |||
| c765483947 | |||
| 0ddeaadeee | |||
| bc632f9d56 | |||
| d3a049d176 | |||
| d738780370 |
@@ -251,6 +251,16 @@ MCP_DOCMOST_PASSWORD=
|
||||
# Default 120000 (2 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=120000
|
||||
|
||||
# Kill-switch for the agent API-key feature (#501). Default ON when unset — a
|
||||
# deploy that never sets it must NOT silently kill every agent. STRICT parse:
|
||||
# only the literals `true` / `false` are accepted; a typo like `=0`/`=off`/`=False`
|
||||
# FAILS AT BOOT by design (never silently read as "enabled"), so the switch is
|
||||
# guaranteed to actually flip when an operator flips it during an incident. When
|
||||
# set to `false`: all api-key auth is DENIED (every api-key token is rejected) and
|
||||
# the api-key management endpoints return 404. The resolved state is logged at boot
|
||||
# (`API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`) so it is verifiable per deploy.
|
||||
# API_KEYS_ENABLED=true
|
||||
|
||||
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
||||
# small for a long AI-chat research turn: the client resends the FULL message
|
||||
# history (every tool call + search result) on each turn, so a deep conversation's
|
||||
|
||||
@@ -226,6 +226,13 @@ jobs:
|
||||
- name: Build mcp
|
||||
run: pnpm --filter @docmost/mcp build
|
||||
|
||||
# apps/server imports @docmost/token-estimate at runtime (history-budget.ts,
|
||||
# #490); its dist/ is gitignored and `test:e2e` type-checks + runs the code,
|
||||
# so build it here or tsc fails with TS2307 Cannot find module
|
||||
# '@docmost/token-estimate' (mirrors the editor-ext / mcp build steps above).
|
||||
- name: Build token-estimate
|
||||
run: pnpm --filter @docmost/token-estimate build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
|
||||
@@ -159,6 +159,14 @@ jobs:
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
# @docmost/token-estimate is a shared workspace package the client vitest
|
||||
# suite resolves via its dist build (main: ./dist/index.js); dist/ is
|
||||
# gitignored and `pnpm -r test` does NOT honour nx `dependsOn: ^build`, so
|
||||
# build it before the recursive test run or the client suite fails with
|
||||
# "Failed to resolve import '@docmost/token-estimate'" (#490).
|
||||
- name: Build token-estimate
|
||||
run: pnpm --filter @docmost/token-estimate build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
@@ -463,6 +463,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -142,6 +142,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
snapshots switched from a fixed interval to a trailing idle-flush with a
|
||||
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
|
||||
changes (e.g. a person's edits followed by the AI agent). (#370)
|
||||
- **Open tabs pick up a new deploy on their own.** After the server is
|
||||
redeployed while a tab is left open for hours, the tab now learns the new
|
||||
build version over the existing WebSocket (announced per-connect, so a natural
|
||||
reconnect delivers it) and shows a "A new version is available" banner with an
|
||||
Update button. To avoid dropping a half-written comment or form, the tab is
|
||||
not reloaded when you merely switch away from it; instead it auto-reloads at
|
||||
the next safe point — the next in-app navigation (or immediately if you click
|
||||
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
|
||||
reload happens per 5-minute window, shared with the existing chunk-load
|
||||
recovery, so a permanent version skew degrades to the banner rather than a
|
||||
reload loop while a second deploy in the same tab still recovers. When the
|
||||
build carries no version info the feature stays inert. (#481)
|
||||
|
||||
- **Place several images side by side in a row.** A new "Inline (side by
|
||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||
@@ -392,6 +404,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`@docmost/token-estimate` package) so they can never diverge. Deferred-tool
|
||||
activation is also cached in the chat metadata to avoid re-resolving it each
|
||||
turn. (#490)
|
||||
- **Cyrillic (and any non-ASCII) draw.io labels no longer turn into mojibake
|
||||
when a diagram is opened in the draw.io editor.** Agent-created diagrams
|
||||
(`drawioCreate`) and Confluence-imported diagrams stored their model in the
|
||||
SVG's `content=` attribute as base64; the draw.io editor decodes that via
|
||||
Latin-1 `atob` (no UTF-8 step), so every non-ASCII char (e.g. `Старт-бит`,
|
||||
`ё`, `—`) split into garbage and the editor's autosave then persisted the
|
||||
corrupted model, breaking the page preview too. Both write paths
|
||||
(`buildDrawioSvg`, the import service's `createDrawioSvg`) now write `content=`
|
||||
as XML-entity-escaped mxfile XML — draw.io's own native form, decoded by the
|
||||
DOM as UTF-8 — so labels open intact. The decoder reads both the new
|
||||
entity-encoded form and the old base64 form, so existing diagrams still open.
|
||||
*Healing pre-fix diagrams:* only a diagram that still holds its original
|
||||
(correct-UTF-8) base64 — i.e. one not yet opened/autosaved in the draw.io
|
||||
editor — can be repaired in place by `drawioGet` → `drawioUpdate` with the
|
||||
same XML (rewrites the attachment in the new form); no migration script is
|
||||
needed. A diagram that was already opened in the editor persisted the
|
||||
mojibake at rest, so `drawioGet` reads the already-corrupted text and
|
||||
`drawioUpdate` faithfully rewrites it — that text is lost and is not
|
||||
recoverable by a rewrite. (#507)
|
||||
- **A chat with one malformed message part no longer 500s on every turn, and a
|
||||
failed send no longer duplicates the user's message.** Incoming client parts
|
||||
are now whitelisted to `text` (a forged tool-result part can no longer reach
|
||||
|
||||
@@ -59,6 +59,14 @@ COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||
|
||||
# apps/server imports @docmost/token-estimate (workspace:*) at runtime
|
||||
# (history-budget.ts, #490). tsc emits only dist/ and dist/ is gitignored, so the
|
||||
# prod install would resolve a broken workspace symlink and the server would die
|
||||
# with ERR_MODULE_NOT_FOUND on the first history-budget call. Ship the built
|
||||
# package + its manifest, mirroring prosemirror-markdown above.
|
||||
COPY --from=builder /app/packages/token-estimate/dist /app/packages/token-estimate/dist
|
||||
COPY --from=builder /app/packages/token-estimate/package.json /app/packages/token-estimate/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/pnpm*.yaml /app/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"A new version is available": "A new version is available",
|
||||
"Account": "Account",
|
||||
"Active": "Active",
|
||||
"Add": "Add",
|
||||
@@ -239,6 +240,8 @@
|
||||
"Comment re-opened successfully": "Comment re-opened successfully",
|
||||
"Comment unresolved successfully": "Comment unresolved successfully",
|
||||
"Failed to resolve comment": "Failed to resolve comment",
|
||||
"Failed to re-open comment": "Failed to re-open comment",
|
||||
"Comment no longer exists": "Comment no longer exists",
|
||||
"Resolve comment": "Resolve comment",
|
||||
"Unresolve comment": "Unresolve comment",
|
||||
"Resolve Comment Thread": "Resolve Comment Thread",
|
||||
@@ -1427,5 +1430,20 @@
|
||||
"Boundary": "Boundary",
|
||||
"Autosave": "Autosave",
|
||||
"Only versions": "Only versions",
|
||||
"No saved versions yet.": "No saved versions yet."
|
||||
"No saved versions yet.": "No saved versions yet.",
|
||||
"Time worked on this article": "Time worked on this article",
|
||||
"Show time worked on this page": "Show time worked on this page",
|
||||
"Estimated time worked (inactivity gap {{gap}} min)": "Estimated time worked (inactivity gap {{gap}} min)",
|
||||
"Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Estimate · timezone {{tz}} · inactivity gap {{gap}} min",
|
||||
"No editing activity recorded yet.": "No editing activity recorded yet.",
|
||||
"× {{count}} days without edits": "× {{count}} days without edits",
|
||||
"agent: {{value}}": "agent: {{value}}",
|
||||
"Work": "Work",
|
||||
"Agent": "Agent",
|
||||
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m",
|
||||
"≈ {{hours}}h": "≈ {{hours}}h",
|
||||
"≈ {{minutes}}m": "≈ {{minutes}}m",
|
||||
"{{hours}}h {{minutes}}m": "{{hours}}h {{minutes}}m",
|
||||
"{{hours}}h": "{{hours}}h",
|
||||
"{{minutes}}m": "{{minutes}}m"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"A new version is available": "Доступна новая версия",
|
||||
"Account": "Аккаунт",
|
||||
"Active": "Активный",
|
||||
"Add": "Добавить",
|
||||
@@ -239,6 +240,8 @@
|
||||
"Comment re-opened successfully": "Комментарий успешно открыт повторно",
|
||||
"Comment unresolved successfully": "Комментарий успешно переведён в нерешённые",
|
||||
"Failed to resolve comment": "Не удалось разрешить комментарий",
|
||||
"Failed to re-open comment": "Не удалось переоткрыть комментарий",
|
||||
"Comment no longer exists": "Комментарий больше не существует",
|
||||
"Resolve comment": "Решить комментарий",
|
||||
"Unresolve comment": "Снять статус решённого с комментария",
|
||||
"Resolve Comment Thread": "Решить ветку комментариев",
|
||||
@@ -1442,5 +1445,20 @@
|
||||
"Boundary": "Граница",
|
||||
"Autosave": "Автосейв",
|
||||
"Only versions": "Только версии",
|
||||
"No saved versions yet.": "Пока нет сохранённых версий."
|
||||
"No saved versions yet.": "Пока нет сохранённых версий.",
|
||||
"Time worked on this article": "Время работы над статьёй",
|
||||
"Show time worked on this page": "Показать время работы над страницей",
|
||||
"Estimated time worked (inactivity gap {{gap}} min)": "Оценка времени работы (порог паузы {{gap}} мин)",
|
||||
"Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Оценка · таймзона {{tz}} · порог паузы {{gap}} мин",
|
||||
"No editing activity recorded yet.": "Правок пока нет.",
|
||||
"× {{count}} days without edits": "× {{count}} дн. без правок",
|
||||
"agent: {{value}}": "агент: {{value}}",
|
||||
"Work": "Работа",
|
||||
"Agent": "Агент",
|
||||
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин",
|
||||
"≈ {{hours}}h": "≈ {{hours}} ч",
|
||||
"≈ {{minutes}}m": "≈ {{minutes}} мин",
|
||||
"{{hours}}h {{minutes}}m": "{{hours}} ч {{minutes}} м",
|
||||
"{{hours}}h": "{{hours}} ч",
|
||||
"{{minutes}}m": "{{minutes}} м"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
|
||||
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
||||
|
||||
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||
@@ -35,31 +35,3 @@ describe("isChunkLoadError", () => {
|
||||
expect(isChunkLoadError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The window gate replaces the old one-shot flag: it must permit recovery across
|
||||
// several deploys in one tab (each > window apart) while still stopping an infinite
|
||||
// reload loop when a lazy chunk is permanently broken (a second failure < window).
|
||||
describe("shouldAutoReload", () => {
|
||||
const WINDOW = 5 * 60 * 1000;
|
||||
const NOW = 1_000_000_000_000;
|
||||
|
||||
it("allows a reload when we have never auto-reloaded", () => {
|
||||
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||
|
||||
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
|
||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||
// by a single reload, so anything inside the window is treated as a reload loop
|
||||
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
|
||||
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
|
||||
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
|
||||
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
|
||||
// window. Anything inside the window is suppressed to break an infinite reload loop.
|
||||
export function shouldAutoReload(
|
||||
now: number,
|
||||
lastReloadAt: number | null,
|
||||
windowMs: number,
|
||||
): boolean {
|
||||
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||
return now - lastReloadAt > windowMs;
|
||||
}
|
||||
import {
|
||||
hasAutoReloaded,
|
||||
markAutoReloaded,
|
||||
recordReloadBreadcrumb,
|
||||
} from "@/lib/reload-guard";
|
||||
|
||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||
@@ -39,24 +24,26 @@ export function isChunkLoadError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function handleError(error: unknown) {
|
||||
// Exported for tests: the reactive chunk-load reload decision, so the shared
|
||||
// window budget (invariant: ≤1 auto-reload per window across this path AND the
|
||||
// proactive version-coherence path) can be exercised against the real guard.
|
||||
export function handleError(error: unknown) {
|
||||
if (!isChunkLoadError(error)) return;
|
||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
|
||||
// recovers across multiple deploys in a single tab's lifetime, yet a
|
||||
// permanently-broken lazy chunk (which would loop) is stopped after the first
|
||||
// reload and falls through to the manual recovery UI below.
|
||||
try {
|
||||
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||
const now = Date.now();
|
||||
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
|
||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||
return;
|
||||
}
|
||||
// the new chunk manifest. Auto-reload at most once per window via the SHARED
|
||||
// window-based reload guard (see @/lib/reload-guard — the same budget the
|
||||
// proactive version-coherence path consumes, so a mismatch that arrives on
|
||||
// both paths reloads at most once per window across BOTH). This recovers
|
||||
// across multiple deploys in a single tab's lifetime, yet a permanently-broken
|
||||
// lazy chunk (which would loop) is stopped after the first reload and falls
|
||||
// through to the manual recovery UI below. If the shared budget is already
|
||||
// spent this window, or the stamp write fails (storage unavailable), we return
|
||||
// without reloading rather than risk a loop.
|
||||
if (hasAutoReloaded()) return;
|
||||
if (!markAutoReloaded()) return;
|
||||
// Trace before the reload clears the console (same diagnostic breadcrumb the
|
||||
// proactive version-coherence path writes, tagged with this path).
|
||||
recordReloadBreadcrumb({ path: "chunk-boundary" });
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
||||
|
||||
import MessageItem from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
import { splitPlainChunks } from "./streaming-plain-text";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
@@ -114,3 +115,89 @@ describe("MessageItem markdown memoization", () => {
|
||||
expect(queryByText("streamed answer")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// PERF SMOKE (#492): the whole point of the incremental streaming render is that
|
||||
// the ANSWER path costs O(number of markdown blocks), NOT O(number of throttled
|
||||
// ~20Hz ticks). Pre-#492 the finalized MarkdownPart re-parsed the WHOLE growing
|
||||
// answer on every delta — a synthetic ~100 KB stream measured 394 renderChatMarkdown
|
||||
// calls (one per tick). With the incremental render each STABILIZED block is parsed
|
||||
// exactly once (memoized in MarkdownChunk) and the live tail is cheap plain text, so
|
||||
// the call count collapses to ~= the block count regardless of tick granularity.
|
||||
describe("MessageItem streaming answer render is O(blocks), not O(ticks)", () => {
|
||||
// ~100 KB answer. Each section is a heading + a paragraph — TWO blank-line
|
||||
// delimited markdown blocks — so the safe-cut block count is ~2× the section
|
||||
// count. The perf claim is about the BLOCK count (the memoization granularity),
|
||||
// measured directly with splitPlainChunks below, not the section count.
|
||||
const buildAnswer = () => {
|
||||
const SECTIONS = 100;
|
||||
const paragraphs: string[] = [];
|
||||
for (let i = 0; i < SECTIONS; i++) {
|
||||
paragraphs.push(`## Section ${i}\n\n` + "lorem ipsum dolor ".repeat(55));
|
||||
}
|
||||
const full = paragraphs.join("\n\n");
|
||||
// The number of memoized markdown blocks the incremental render splits into
|
||||
// (all but the live tail are parsed once each).
|
||||
return { full, blocks: splitPlainChunks(full).length };
|
||||
};
|
||||
|
||||
const streamMsg = (text: string, state: "streaming" | "done"): UIMessage =>
|
||||
({
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text, state }],
|
||||
}) as UIMessage;
|
||||
|
||||
it("parses each block ~once over a 100KB stream (≈blocks, ≪ ticks)", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
const { full, blocks } = buildAnswer();
|
||||
const CHUNK = 128; // a realistic ~20Hz throttled delta size
|
||||
const ticks = Math.ceil(full.length / CHUNK);
|
||||
|
||||
let msg = streamMsg(full.slice(0, CHUNK), "streaming");
|
||||
const { rerender } = render(
|
||||
<MantineProvider>
|
||||
<MessageItem
|
||||
message={msg}
|
||||
signature={messageSignature(msg)}
|
||||
turnStreaming
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
for (let end = 2 * CHUNK; end < full.length; end += CHUNK) {
|
||||
msg = streamMsg(full.slice(0, end), "streaming");
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem
|
||||
message={msg}
|
||||
signature={messageSignature(msg)}
|
||||
turnStreaming
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
// Finalize: the streaming→done flip renders the whole answer through ONE
|
||||
// canonical pass (visual parity), so the finished DOM matches the pre-#492
|
||||
// output. This is the single extra parse on top of the per-block ones.
|
||||
const done = streamMsg(full, "done");
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={done} signature={messageSignature(done)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
const calls = renderChatMarkdownSpy.mock.calls.length;
|
||||
// Sanity: the stream really had far more ticks than blocks (else the test is
|
||||
// vacuous — the point is that calls scale with blocks, not ticks).
|
||||
expect(ticks).toBeGreaterThan(blocks * 3);
|
||||
// O(blocks): each stabilized block parsed once + the single final whole-text
|
||||
// parse. A small constant absorbs the finalize render and the live-tail block;
|
||||
// the load-bearing claim is the bound below.
|
||||
expect(calls).toBeLessThanOrEqual(blocks + 2);
|
||||
// ≪ ticks — and, non-vacuously, the blocks WERE parsed (not skipped entirely).
|
||||
expect(calls).toBeLessThan(ticks / 3);
|
||||
expect(calls).toBeGreaterThan(blocks / 2);
|
||||
// MUTATION-VERIFY (documented, not run here): dropping the `memo()` wrapper on
|
||||
// MarkdownChunk (so every stable block re-parses each tick) drives `calls`
|
||||
// toward `ticks` (~394), reddening both upper-bound assertions above.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
// Stub react-i18next (the component reads `useTranslation`). Mirrors the other
|
||||
// message-item specs.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import MessageItem from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
// The REAL canonical renderer (NOT the spy the memo test installs): this file
|
||||
// exercises the actual markdown output so the visual-regression assertions below
|
||||
// compare against genuine HTML (incl. the schema's `<li><p>` wrappers).
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import classes from "./ai-chat.module.css";
|
||||
|
||||
const msg = (
|
||||
parts: UIMessage["parts"],
|
||||
extra?: Partial<UIMessage>,
|
||||
): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts, ...extra }) as UIMessage;
|
||||
|
||||
const renderRow = (message: UIMessage, turnStreaming = false) =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MessageItem
|
||||
message={message}
|
||||
signature={messageSignature(message)}
|
||||
turnStreaming={turnStreaming}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// A rich multi-block answer that exercises headings, a list (the `<li><p>` case
|
||||
// the scoped CSS tightens), inline emphasis, and multiple paragraphs.
|
||||
const ANSWER = [
|
||||
"# Заголовок",
|
||||
"",
|
||||
"Первый абзац с **жирным** и `кодом`.",
|
||||
"",
|
||||
"- пункт один",
|
||||
"- пункт два",
|
||||
"",
|
||||
"Второй абзац.",
|
||||
].join("\n");
|
||||
|
||||
describe("MessageItem final render — visual parity with the canonical pipeline", () => {
|
||||
it("a finalized text part renders exactly renderChatMarkdown(text)", () => {
|
||||
const { container } = renderRow(
|
||||
msg([{ type: "text", text: ANSWER, state: "done" }]),
|
||||
);
|
||||
const block = container.querySelector(`.${classes.markdown}`);
|
||||
expect(block).not.toBeNull();
|
||||
// Byte-for-byte the canonical output (the SAME whole-text pass the pre-#492
|
||||
// MarkdownPart produced), including `<li><p>…</p></li>` wrappers.
|
||||
expect(block!.innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
|
||||
// The list wrapper is really present (guards against a vacuous empty render).
|
||||
expect(container.querySelectorAll("li p").length).toBe(2);
|
||||
});
|
||||
|
||||
it("the streaming incremental view CONVERGES to the canonical render on finish", () => {
|
||||
// Mount mid-stream (live tail) — the DOM here is the incremental view.
|
||||
const { container, rerender } = render(
|
||||
<MantineProvider>
|
||||
<MessageItem
|
||||
message={msg([{ type: "text", text: ANSWER, state: "streaming" }])}
|
||||
signature={messageSignature(
|
||||
msg([{ type: "text", text: ANSWER, state: "streaming" }]),
|
||||
)}
|
||||
turnStreaming
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Finish the turn: state flips to done AND the turn is no longer streaming.
|
||||
const done = msg([{ type: "text", text: ANSWER, state: "done" }]);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={done} signature={messageSignature(done)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// After finish there is exactly ONE canonical markdown container whose HTML is
|
||||
// the whole-text render — identical to the non-streaming path above.
|
||||
const blocks = container.querySelectorAll(`.${classes.markdown}`);
|
||||
expect(blocks.length).toBe(1);
|
||||
expect(blocks[0].innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
|
||||
});
|
||||
|
||||
it("neutralizeInternalLinks is honored on the finalized render", () => {
|
||||
const linkAnswer = "См. [страницу](/p/abc).";
|
||||
const { container } = render(
|
||||
<MantineProvider>
|
||||
<MessageItem
|
||||
message={msg([{ type: "text", text: linkAnswer, state: "done" }])}
|
||||
signature={messageSignature(
|
||||
msg([{ type: "text", text: linkAnswer, state: "done" }]),
|
||||
)}
|
||||
neutralizeInternalLinks
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
const block = container.querySelector(`.${classes.markdown}`);
|
||||
expect(block!.innerHTML).toBe(
|
||||
renderChatMarkdown(linkAnswer, { neutralizeInternalLinks: true }),
|
||||
);
|
||||
// The internal link was made inert (no href) by the neutralization flag.
|
||||
const a = container.querySelector("a");
|
||||
expect(a?.hasAttribute("href")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
|
||||
import { StreamingMarkdownText } from "@/features/ai-chat/components/streaming-markdown-text.tsx";
|
||||
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
@@ -86,17 +87,39 @@ interface MessageItemProps {
|
||||
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
|
||||
* so a finalized text part is NOT re-parsed on every streamed delta: during a
|
||||
* turn only the actively-growing tail part changes its `text`, so every earlier
|
||||
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
|
||||
* primitives, so React.memo's default shallow compare is exactly right (the
|
||||
* `text` string is compared by value).
|
||||
* part hits the memo and skips the expensive canonical parse + DOMPurify pass.
|
||||
* Props are primitives, so React.memo's default shallow compare is exactly right
|
||||
* (the `text` string is compared by value).
|
||||
*
|
||||
* Streaming gate (#492) — mirrors ReasoningBlock:
|
||||
* - `streaming` (this is the live, actively-growing tail part of an in-flight
|
||||
* turn): render incrementally via StreamingMarkdownText — the stabilized blocks
|
||||
* go through the canonical pipeline (each parsed ONCE, memoized) and only the
|
||||
* live tail is cheap plain text. This makes the per-tick cost O(new blocks),
|
||||
* not the pre-#492 O(ticks) whole-answer re-parse on every ~20Hz delta.
|
||||
* - finalized (the common case, and the turn-end flip): render the WHOLE text
|
||||
* through ONE canonical pass — byte-identical to the pre-#492 output (visual
|
||||
* parity). The row re-renders on the streaming→done flip because
|
||||
* `messageSignature` tracks each part's `state` (and `turnStreaming` flips at
|
||||
* turn end), so the incremental view always converges to this single render.
|
||||
*/
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
neutralizeInternalLinks,
|
||||
streaming,
|
||||
}: {
|
||||
text: string;
|
||||
neutralizeInternalLinks: boolean;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
if (streaming) {
|
||||
return (
|
||||
<StreamingMarkdownText
|
||||
text={text}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||
if (html) {
|
||||
return (
|
||||
@@ -179,47 +202,10 @@ function MessageItem({
|
||||
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||
</Text>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === "reasoning") {
|
||||
// Reasoning ("thinking") -> a collapsible block with its own token
|
||||
// count. Empty/whitespace reasoning with no authoritative count carries
|
||||
// nothing to show, so skip it (avoids an empty 0-token block).
|
||||
const text = (part as { text?: string }).text ?? "";
|
||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||
return null;
|
||||
// Absent state (persisted rows) and "done" both mean finalized.
|
||||
// `messageSignature` already includes each part's `state`, so the
|
||||
// streaming→done flip changes the row signature and re-renders this
|
||||
// row — which is what lets ReasoningBlock switch from chunked plain
|
||||
// text to its one-time markdown parse (see reasoning-block.tsx).
|
||||
// ALSO require the turn to be live: a part stranded at
|
||||
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
||||
// the `turnStreaming` prop doc) must still finalize and parse.
|
||||
const streaming =
|
||||
turnStreaming && (part as { state?: string }).state === "streaming";
|
||||
return (
|
||||
<ReasoningBlock
|
||||
key={index}
|
||||
text={text}
|
||||
tokens={reasoningTokens}
|
||||
streaming={streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
// Skip empty/whitespace-only text parts (a streaming message often
|
||||
// starts with an empty text part before the first token arrives); the
|
||||
// typing indicator covers that gap until real content streams in.
|
||||
if (!part.text.trim()) return null;
|
||||
return (
|
||||
<MarkdownPart
|
||||
key={index}
|
||||
text={part.text}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool parts (`tool-*` / `dynamic-tool`) are template-literal kinds, so
|
||||
// they cannot be a `switch` case; the runtime guard handles them, and the
|
||||
// switch below covers every CLOSED (literal-typed) part kind with a
|
||||
// compile-time exhaustiveness check in its default.
|
||||
if (isToolPart(part.type)) {
|
||||
return (
|
||||
<ToolCallCard
|
||||
@@ -232,7 +218,76 @@ function MessageItem({
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
switch (part.type) {
|
||||
case "reasoning": {
|
||||
// Reasoning ("thinking") -> a collapsible block with its own token
|
||||
// count. Empty/whitespace reasoning with no authoritative count
|
||||
// carries nothing to show, so skip it (avoids an empty 0-token block).
|
||||
const text = part.text ?? "";
|
||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||
return null;
|
||||
// Absent state (persisted rows) and "done" both mean finalized.
|
||||
// `messageSignature` already includes each part's `state`, so the
|
||||
// streaming→done flip changes the row signature and re-renders this
|
||||
// row — which is what lets ReasoningBlock switch from chunked plain
|
||||
// text to its one-time markdown parse (see reasoning-block.tsx).
|
||||
// ALSO require the turn to be live: a part stranded at
|
||||
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
||||
// the `turnStreaming` prop doc) must still finalize and parse.
|
||||
const streaming = turnStreaming && part.state === "streaming";
|
||||
return (
|
||||
<ReasoningBlock
|
||||
key={index}
|
||||
text={text}
|
||||
tokens={reasoningTokens}
|
||||
streaming={streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "text": {
|
||||
// Skip empty/whitespace-only text parts (a streaming message often
|
||||
// starts with an empty text part before the first token arrives); the
|
||||
// typing indicator covers that gap until real content streams in.
|
||||
if (!part.text.trim()) return null;
|
||||
// The live, actively-growing tail part of the in-flight turn renders
|
||||
// incrementally (see MarkdownPart); a finalized part (persisted, or
|
||||
// the turn-end flip) renders the whole text through one canonical
|
||||
// pass. Same liveness rule as the reasoning branch above.
|
||||
const streaming = turnStreaming && part.state === "streaming";
|
||||
return (
|
||||
<MarkdownPart
|
||||
key={index}
|
||||
text={part.text}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
streaming={streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "source-url":
|
||||
case "source-document":
|
||||
case "file":
|
||||
case "step-start":
|
||||
// Not surfaced in the chat bubble (v1) — same as the pre-#492 default.
|
||||
return null;
|
||||
|
||||
default: {
|
||||
// Compile-time exhaustiveness over the CLOSED union members: every
|
||||
// literal-typed part kind is handled above, so the only kinds that
|
||||
// can reach here are the OPEN template-literal ones (`tool-*` — caught
|
||||
// by the guard at runtime — and `data-*`) plus `dynamic-tool`. Adding
|
||||
// a NEW closed part kind to UIMessagePart makes this assignment fail
|
||||
// to compile, forcing it to be handled instead of silently ignored
|
||||
// (this replaces the pre-#492 fall-through `return null` + WARNING).
|
||||
const _exhaustive:
|
||||
| `tool-${string}`
|
||||
| "dynamic-tool"
|
||||
| `data-${string}` = part.type;
|
||||
void _exhaustive;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})}
|
||||
{/* A persisted turn error (server stored it in metadata.error). Rendered
|
||||
here so it survives a thread remount and shows in reopened history. */}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { splitPlainChunks } from "@/features/ai-chat/components/streaming-plain-text.tsx";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
/**
|
||||
* One STABILIZED markdown block, rendered through the canonical pipeline and
|
||||
* memoized on its string prop. During streaming only the TAIL chunk grows (the
|
||||
* `splitPlainChunks` append-only invariant guarantees every earlier chunk is
|
||||
* byte-identical across deltas), so React skips every stable block and each one
|
||||
* is parsed by `renderChatMarkdown` EXACTLY ONCE — turning the pre-#492
|
||||
* "re-parse the whole accumulated answer on every ~20Hz tick" (O(ticks)) into
|
||||
* O(number of blocks). The markup is DOMPurify-sanitized inside renderChatMarkdown
|
||||
* before it reaches `dangerouslySetInnerHTML`.
|
||||
*
|
||||
* NOTE (transient streaming-only artifact): a safe cut is a blank-line boundary,
|
||||
* so a construct that legitimately contains a blank line (e.g. a fenced code block
|
||||
* with an empty line) can be split across chunks and render oddly WHILE it is still
|
||||
* streaming. This is cosmetic and self-heals: the moment the part finalizes,
|
||||
* MarkdownPart renders the WHOLE text through one canonical pass (visual parity
|
||||
* with the pre-#492 output). The reasoning path makes the same trade (plain text
|
||||
* while streaming, one markdown parse at the end).
|
||||
*/
|
||||
const MarkdownChunk = memo(function MarkdownChunk({
|
||||
text,
|
||||
neutralizeInternalLinks,
|
||||
}: {
|
||||
text: string;
|
||||
neutralizeInternalLinks: boolean;
|
||||
}) {
|
||||
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
className={classes.markdown}
|
||||
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Malformed/unsupported markdown could not render synchronously: raw text.
|
||||
return (
|
||||
<div className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The cheap streaming-time stand-in for the finalized answer's one-time markdown
|
||||
* parse (see MarkdownPart in message-item.tsx). Mirrors StreamingPlainText's
|
||||
* chunked-memo pattern but renders the STABILIZED prefix as real markdown (each
|
||||
* block parsed once, memoized) and only the LIVE tail as flat plain text — so the
|
||||
* user sees formatted output for everything up to the last safe cut, and the not-
|
||||
* yet-stable tail (which markdown-parsing every tick would make O(ticks)) stays a
|
||||
* single cheap escaped text node until it stabilizes into a new block.
|
||||
*
|
||||
* `splitPlainChunks` yields chunks where, under append-only growth, every chunk
|
||||
* except the LAST is immutable; the last chunk is the live tail. Index keys are
|
||||
* therefore stable (a given index never changes to a different chunk's content).
|
||||
*/
|
||||
export function StreamingMarkdownText({
|
||||
text,
|
||||
neutralizeInternalLinks,
|
||||
}: {
|
||||
text: string;
|
||||
neutralizeInternalLinks: boolean;
|
||||
}) {
|
||||
const chunks = useMemo(() => splitPlainChunks(text), [text]);
|
||||
return (
|
||||
<>
|
||||
{chunks.map((chunk, index) =>
|
||||
index < chunks.length - 1 ? (
|
||||
<MarkdownChunk
|
||||
key={index}
|
||||
text={chunk}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
/>
|
||||
) : (
|
||||
// The live tail: flat, React-escaped plain text (no markdown parse, no
|
||||
// sanitizer, no innerHTML). `pre-wrap` preserves its newlines; trailing
|
||||
// separator newlines are dropped at display time so the block gap comes
|
||||
// from the markdown margins, not a doubled empty line (mirrors
|
||||
// PlainChunk in streaming-plain-text.tsx).
|
||||
<div
|
||||
key={index}
|
||||
className={classes.markdown}
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{chunk.replace(/\n+$/, "")}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,11 +27,15 @@ vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
import {
|
||||
buildChildrenByParent,
|
||||
CommentEditorWithActions,
|
||||
sortResolvedByResolvedAt,
|
||||
} from "./comment-list-with-tabs";
|
||||
|
||||
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||
({ id, parentCommentId }) as IComment;
|
||||
|
||||
const resolvedAtComment = (id: string, resolvedAt: unknown): IComment =>
|
||||
({ id, resolvedAt }) as unknown as IComment;
|
||||
|
||||
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||
it("returns an empty map for undefined or empty input", () => {
|
||||
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||
@@ -71,6 +75,48 @@ describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortResolvedByResolvedAt (Resolved tab order, #542)", () => {
|
||||
it("orders by resolvedAt DESC — newest resolve first — with ISO-STRING values", () => {
|
||||
// At runtime resolvedAt is an ISO string (axios JSON / WS subscription), so
|
||||
// the sort must coerce with new Date(...) before .getTime().
|
||||
const older = resolvedAtComment("older", "2026-07-10T10:00:00.000Z");
|
||||
const newest = resolvedAtComment("newest", "2026-07-12T10:00:00.000Z");
|
||||
const middle = resolvedAtComment("middle", "2026-07-11T10:00:00.000Z");
|
||||
|
||||
const out = sortResolvedByResolvedAt([older, newest, middle]);
|
||||
expect(out.map((x) => x.id)).toEqual(["newest", "middle", "older"]);
|
||||
});
|
||||
|
||||
it("also handles Date instances (optimistic onMutate window)", () => {
|
||||
const older = resolvedAtComment("older", new Date("2026-01-01T00:00:00Z"));
|
||||
const newer = resolvedAtComment("newer", new Date("2026-06-01T00:00:00Z"));
|
||||
expect(sortResolvedByResolvedAt([older, newer]).map((x) => x.id)).toEqual([
|
||||
"newer",
|
||||
"older",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const a = resolvedAtComment("a", "2026-01-01T00:00:00.000Z");
|
||||
const b = resolvedAtComment("b", "2026-02-01T00:00:00.000Z");
|
||||
const input = [a, b];
|
||||
sortResolvedByResolvedAt(input);
|
||||
expect(input.map((x) => x.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("keeps stable order for equal resolvedAt timestamps", () => {
|
||||
const ts = "2026-03-03T03:03:03.000Z";
|
||||
const x = resolvedAtComment("x", ts);
|
||||
const y = resolvedAtComment("y", ts);
|
||||
const z = resolvedAtComment("z", ts);
|
||||
expect(sortResolvedByResolvedAt([x, y, z]).map((c) => c.id)).toEqual([
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function renderReplyEditor() {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
|
||||
@@ -53,6 +53,22 @@ export function buildChildrenByParent(
|
||||
return m;
|
||||
}
|
||||
|
||||
// Sort the Resolved tab by resolve time, newest first, on a COPY (never mutate
|
||||
// the react-query cache array). `resolvedAt` is typed `Date` but at runtime it
|
||||
// is an ISO STRING (from the axios-JSON onSuccess and the WS subscription) — a
|
||||
// real Date only during the optimistic onMutate window — so it MUST be coerced
|
||||
// with `new Date(...)` before `.getTime()`, or a raw `.getTime()` on the string
|
||||
// throws / yields NaN. ES2019's stable sort preserves order for equal
|
||||
// timestamps. Callers pass a list already filtered to a truthy `resolvedAt`, so
|
||||
// the non-null assertion is safe.
|
||||
// Exported for unit testing.
|
||||
export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] {
|
||||
return [...resolved].sort(
|
||||
(a, b) =>
|
||||
new Date(b.resolvedAt!).getTime() - new Date(a.resolvedAt!).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
@@ -91,7 +107,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
(comment: IComment) => comment.resolvedAt,
|
||||
);
|
||||
|
||||
return { activeComments: active, resolvedComments: resolved };
|
||||
return {
|
||||
activeComments: active,
|
||||
resolvedComments: sortResolvedByResolvedAt(resolved),
|
||||
};
|
||||
}, [comments]);
|
||||
|
||||
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Coverage for the resolve/reopen mutation (#542): the Undo-in-toast reopen and
|
||||
* its double-click guard, the terminal 404 branch (drop from cache + clear the
|
||||
* inline mark, no rollback), and the directional error copy.
|
||||
*/
|
||||
|
||||
// A fake TipTap editor injected via the mocked pageEditorAtom, so we can assert
|
||||
// the mutation clears the inline comment mark (unsetComment / setCommentResolved).
|
||||
const editorMock = vi.hoisted(() => ({
|
||||
current: {
|
||||
isDestroyed: false,
|
||||
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
|
||||
} as {
|
||||
isDestroyed: boolean;
|
||||
commands: {
|
||||
unsetComment: (id: string) => void;
|
||||
setCommentResolved: (id: string, v: boolean) => void;
|
||||
};
|
||||
} | null,
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn(), hide: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("jotai", () => ({
|
||||
atom: (v: unknown) => v,
|
||||
useAtomValue: () => editorMock.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
applySuggestion: vi.fn(),
|
||||
dismissSuggestion: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
deleteComment: vi.fn(),
|
||||
resolveComment: vi.fn(),
|
||||
getPageComments: vi.fn(),
|
||||
}));
|
||||
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { resolveComment } from "@/features/comment/services/comment-service";
|
||||
import {
|
||||
useResolveCommentMutation,
|
||||
RESOLVE_UNDO_AUTOCLOSE_MS,
|
||||
RQ_KEY,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
const PAGE_ID = "page-1";
|
||||
|
||||
function seededClient(comment: IComment) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const seed: InfiniteData<any> = {
|
||||
pageParams: [undefined],
|
||||
pages: [
|
||||
{ items: [comment], meta: { hasNextPage: false, nextCursor: null } },
|
||||
],
|
||||
};
|
||||
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
|
||||
function items(queryClient: QueryClient): IComment[] {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||
| InfiniteData<any>
|
||||
| undefined;
|
||||
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||
}
|
||||
|
||||
const comment = (over?: Partial<IComment>): IComment =>
|
||||
({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
content: "{}",
|
||||
creatorId: "u-1",
|
||||
workspaceId: "ws-1",
|
||||
createdAt: new Date(),
|
||||
resolvedAt: null,
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
// Pull the inline Undo button's onClick out of the success toast's message tree.
|
||||
function undoOnClickFromToast(): () => void {
|
||||
const call = vi
|
||||
.mocked(notifications.show)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.find((arg: any) => arg?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
|
||||
expect(call).toBeTruthy();
|
||||
const message: any = (call as any).message;
|
||||
// message = Group( Text, Button ); grab the Button element's onClick.
|
||||
const children = message.props.children as any[];
|
||||
const button = children[1];
|
||||
return button.props.onClick;
|
||||
}
|
||||
|
||||
describe("useResolveCommentMutation — Undo toast (#542)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
editorMock.current = {
|
||||
isDestroyed: false,
|
||||
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
it("resolve shows an Undo toast with autoClose=10000ms; reopen shows NO Undo", async () => {
|
||||
vi.mocked(resolveComment).mockImplementation(async (data) =>
|
||||
comment({
|
||||
resolvedAt: data.resolved ? (new Date() as any) : null,
|
||||
}),
|
||||
);
|
||||
const { wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({
|
||||
commentId: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
resolved: true,
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const resolveToast = vi
|
||||
.mocked(notifications.show)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.find((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
|
||||
expect(resolveToast).toBeTruthy();
|
||||
expect((resolveToast as any).id).toBe("resolve-undo-c-1");
|
||||
expect((resolveToast as any).autoClose).toBe(10000);
|
||||
|
||||
// Now a reopen → plain toast, no autoClose/Undo, no id.
|
||||
vi.clearAllMocks();
|
||||
await result.current.mutateAsync({
|
||||
commentId: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
resolved: false,
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
|
||||
expect(
|
||||
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
|
||||
).toBe(false);
|
||||
expect(calls).toContainEqual({ message: "Comment re-opened successfully" });
|
||||
});
|
||||
|
||||
it("double/fast Undo click fires reopen EXACTLY once (guard)", async () => {
|
||||
vi.mocked(resolveComment).mockImplementation(async (data) =>
|
||||
comment({ resolvedAt: data.resolved ? (new Date() as any) : null }),
|
||||
);
|
||||
const { wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({
|
||||
commentId: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
resolved: true,
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const onClick = undoOnClickFromToast();
|
||||
// Fire twice synchronously (notifications.hide is not synchronous).
|
||||
onClick();
|
||||
onClick();
|
||||
|
||||
await waitFor(() => {
|
||||
const reopenCalls = vi
|
||||
.mocked(resolveComment)
|
||||
.mock.calls.filter(([d]) => d.resolved === false);
|
||||
expect(reopenCalls).toHaveLength(1);
|
||||
});
|
||||
// The mark was cleared once via setCommentResolved(id, false).
|
||||
expect(editorMock.current!.commands.setCommentResolved).toHaveBeenCalledWith(
|
||||
"c-1",
|
||||
false,
|
||||
);
|
||||
// The toast was hidden.
|
||||
expect(notifications.hide).toHaveBeenCalledWith("resolve-undo-c-1");
|
||||
});
|
||||
|
||||
it("404 → drops the comment from cache, clears the inline mark, no rollback, no Undo", async () => {
|
||||
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// Removed from cache (NOT rolled back to a phantom).
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
// Inline mark cleared via unsetComment (mandatory — no panel row left to do it).
|
||||
expect(editorMock.current!.commands.unsetComment).toHaveBeenCalledWith(
|
||||
"c-1",
|
||||
);
|
||||
// Neutral message, red, and crucially NOT the success copy and NO Undo toast.
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Comment no longer exists",
|
||||
color: "red",
|
||||
});
|
||||
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
|
||||
expect(
|
||||
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("404 does not crash when the editor is gone (read-only / panel closed)", async () => {
|
||||
editorMock.current = null;
|
||||
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Comment no longer exists",
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
|
||||
it("non-404 error on REOPEN shows 'Failed to re-open comment' and rolls back", async () => {
|
||||
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||
// Seed a RESOLVED comment (the reopen target).
|
||||
const resolved = comment({ resolvedAt: new Date() as any });
|
||||
const { queryClient, wrapper } = seededClient(resolved);
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: false })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Failed to re-open comment",
|
||||
color: "red",
|
||||
});
|
||||
expect(notifications.show).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Failed to resolve comment" }),
|
||||
);
|
||||
// Rolled back: the comment is still present and still resolved.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reopen via Undo FAILS (non-404) → inline mark is NOT left cleared (doc↔panel stay consistent)", async () => {
|
||||
// First resolve succeeds → produces the Undo toast (no mark change on resolve).
|
||||
vi.mocked(resolveComment).mockResolvedValueOnce(
|
||||
comment({ resolvedAt: new Date() as any }),
|
||||
);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({
|
||||
commentId: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
resolved: true,
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
// Now the reopen fired by Undo fails with a 500.
|
||||
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||
const onClick = undoOnClickFromToast();
|
||||
onClick();
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// Core F1 guarantee: the mark-clear now lives in the reopen onSuccess, so a
|
||||
// FAILED reopen must never flip the inline mark to unresolved — otherwise the
|
||||
// doc would show an active highlight the panel still treats as resolved and
|
||||
// the collab mark would diverge with nothing committed on the server.
|
||||
expect(
|
||||
editorMock.current!.commands.setCommentResolved,
|
||||
).not.toHaveBeenCalledWith("c-1", false);
|
||||
// Cache rolled back: the comment stays resolved and present.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reopen success with a null editorRef degrades gracefully (no throw, no-op)", async () => {
|
||||
// Read-only view / panel closed: pageEditorAtom is null on the success path.
|
||||
editorMock.current = null;
|
||||
vi.mocked(resolveComment).mockResolvedValue(comment({ resolvedAt: null }));
|
||||
const resolved = comment({ resolvedAt: new Date() as any });
|
||||
const { queryClient, wrapper } = seededClient(resolved);
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({
|
||||
commentId: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
resolved: false,
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
// No crash from the reopen mark-clear; the plain reopen toast is still shown.
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Comment re-opened successfully",
|
||||
});
|
||||
// Cache updated to reopened (resolvedAt cleared by the server payload).
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
|
||||
});
|
||||
|
||||
it("non-404 error on RESOLVE shows 'Failed to resolve comment' and rolls back", async () => {
|
||||
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Failed to resolve comment",
|
||||
color: "red",
|
||||
});
|
||||
// Rolled back to open (previousCache), still present.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -20,12 +20,19 @@ import {
|
||||
ISuggestionOutcome,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
|
||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||
|
||||
// How long the resolve success toast (with its inline Undo) stays up before it
|
||||
// auto-closes. Policy constant — no env override.
|
||||
export const RESOLVE_UNDO_AUTOCLOSE_MS = 10000;
|
||||
|
||||
export function useCommentsQuery(params: ICommentParams) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: RQ_KEY(params.pageId),
|
||||
@@ -376,7 +383,25 @@ export function useResolveCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
// Keep the live editor in a ref: the toast's Undo (and the 404 branch) must
|
||||
// clear the inline comment mark AFTER the originating CommentListItem has
|
||||
// unmounted (resolving pulls the comment out of the Open list, so its item is
|
||||
// already gone by the time the 10s toast is clicked). In read-only view
|
||||
// pageEditorAtom is null and the mark converges via the server's
|
||||
// COMMENT_MARK_UPDATE job instead.
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const editorRef = useRef(editor);
|
||||
editorRef.current = editor;
|
||||
|
||||
// Self-reference the mutation so the toast's Undo can re-invoke it (reopen)
|
||||
// long after the triggering component unmounted. Declared BEFORE useMutation
|
||||
// and assigned AFTER; the onClick reads mutationRef.current at CALL time, not
|
||||
// definition time, so there is no initialization cycle.
|
||||
const mutationRef = useRef<{
|
||||
mutate: (vars: IResolveComment) => void;
|
||||
} | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
||||
@@ -401,7 +426,39 @@ export function useResolveCommentMutation() {
|
||||
|
||||
return { previousCache };
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
onError: (err: any, variables, context) => {
|
||||
// Terminal 404: the comment was really deleted (missing comment or deleted
|
||||
// page — access denial is 403, resolve is idempotent so no 400). Do NOT
|
||||
// roll back (that would resurrect a phantom row in Resolved); instead drop
|
||||
// it from the cache and clear its now-orphaned inline mark. Mirrors
|
||||
// handleDeleteComment and the dismiss-mutation 404 branch.
|
||||
if (err?.response?.status === 404) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
removeCommentFromCache(cache, variables.commentId),
|
||||
);
|
||||
}
|
||||
const ed = editorRef.current;
|
||||
if (ed && !ed.isDestroyed) {
|
||||
try {
|
||||
ed.commands.unsetComment(variables.commentId);
|
||||
} catch {
|
||||
/* editor gone / mark already removed */
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Comment no longer exists"),
|
||||
color: "red",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic failure: roll back the optimistic update and show a DIRECTIONAL
|
||||
// error (resolve vs. reopen), not always "resolve".
|
||||
if (context?.previousCache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
@@ -409,7 +466,9 @@ export function useResolveCommentMutation() {
|
||||
);
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to resolve comment"),
|
||||
message: variables.resolved
|
||||
? t("Failed to resolve comment")
|
||||
: t("Failed to re-open comment"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
@@ -430,11 +489,72 @@ export function useResolveCommentMutation() {
|
||||
);
|
||||
}
|
||||
|
||||
// Reopen keeps the plain toast without an Undo.
|
||||
if (!variables.resolved) {
|
||||
// Clear the inline mark ONLY after the server confirms the reopen, so a
|
||||
// failed reopen never leaves an active highlight the panel still treats
|
||||
// as resolved. Mirrors the 404 branch's editor-liveness guard/try-catch.
|
||||
// The button-triggered reopen already set the mark, so this is an
|
||||
// idempotent no-op there.
|
||||
const ed = editorRef.current;
|
||||
if (ed && !ed.isDestroyed) {
|
||||
try {
|
||||
ed.commands.setCommentResolved(variables.commentId, false);
|
||||
} catch {
|
||||
/* editor gone — server COMMENT_MARK_UPDATE converges it */
|
||||
}
|
||||
}
|
||||
notifications.show({ message: t("Comment re-opened successfully") });
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve: attach an inline Undo (reopen) to the success toast. Built with
|
||||
// React.createElement because this is a .ts module (no JSX).
|
||||
const { commentId, pageId } = variables;
|
||||
const notificationId = `resolve-undo-${commentId}`;
|
||||
// Double-click guard: notifications.hide is NOT synchronous, so the button
|
||||
// stays clickable for a frame or two — without this a fast double-click
|
||||
// would fire reopen twice.
|
||||
let done = false;
|
||||
notifications.show({
|
||||
message: variables.resolved
|
||||
? t("Comment resolved successfully")
|
||||
: t("Comment re-opened successfully"),
|
||||
id: notificationId,
|
||||
autoClose: RESOLVE_UNDO_AUTOCLOSE_MS,
|
||||
message: React.createElement(
|
||||
Group,
|
||||
{ justify: "space-between", wrap: "nowrap", gap: "md" },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ size: "sm" },
|
||||
t("Comment resolved successfully"),
|
||||
),
|
||||
React.createElement(
|
||||
Button,
|
||||
{
|
||||
variant: "subtle",
|
||||
size: "compact-sm",
|
||||
onClick: () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
// Reopen via the SAME mutation (read at click time — the
|
||||
// originating item is already unmounted).
|
||||
mutationRef.current?.mutate({
|
||||
commentId,
|
||||
pageId,
|
||||
resolved: false,
|
||||
});
|
||||
// The inline mark is cleared in the reopen mutation's onSuccess
|
||||
// (bound to server confirmation), NOT here — clearing it eagerly
|
||||
// would desync the doc from the panel if reopen then fails.
|
||||
notifications.hide(notificationId);
|
||||
},
|
||||
},
|
||||
t("Undo"),
|
||||
),
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
mutationRef.current = mutation;
|
||||
return mutation;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatHeadline,
|
||||
formatDayTotal,
|
||||
formatGapMinutes,
|
||||
} from "./format-work-time";
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
// Fake translator: renders the key with {{tokens}} substituted, so the tests
|
||||
// assert the rounding + branch selection without depending on the i18n catalogue.
|
||||
const t = (key: string, opts?: Record<string, unknown>) =>
|
||||
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
|
||||
|
||||
describe("formatHeadline", () => {
|
||||
it("prefixes ≈ and rounds to a 5-minute step", () => {
|
||||
expect(formatHeadline(4 * 60 * MIN + 27 * MIN, t)).toBe("≈ 4h 25m");
|
||||
expect(formatHeadline(90 * MIN, t)).toBe("≈ 1h 30m");
|
||||
});
|
||||
|
||||
it("shows hours only / minutes only cleanly", () => {
|
||||
expect(formatHeadline(120 * MIN, t)).toBe("≈ 2h");
|
||||
expect(formatHeadline(35 * MIN, t)).toBe("≈ 35m");
|
||||
});
|
||||
|
||||
it("floors a tiny non-zero estimate to 5m, never 0", () => {
|
||||
expect(formatHeadline(2 * MIN, t)).toBe("≈ 5m");
|
||||
});
|
||||
|
||||
it("empty string for zero (widget hidden)", () => {
|
||||
expect(formatHeadline(0, t)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDayTotal", () => {
|
||||
it('renders "h m" and shows — for empty days', () => {
|
||||
expect(formatDayTotal(3 * 60 * MIN + 17 * MIN, t)).toBe("3h 17m");
|
||||
expect(formatDayTotal(0, t)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGapMinutes", () => {
|
||||
it("converts the tGap ms threshold to whole minutes", () => {
|
||||
expect(formatGapMinutes(15 * MIN)).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// #395 — display formatting for the work-time estimate. Pure functions that take
|
||||
// a translator so ru-RU / en-US wording lives in the i18n catalogue and the
|
||||
// rounding logic stays unit-testable.
|
||||
|
||||
type Translate = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
|
||||
function hm(totalMinutes: number): { hours: number; minutes: number } {
|
||||
return {
|
||||
hours: Math.floor(totalMinutes / 60),
|
||||
minutes: totalMinutes % 60,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Headline number (§6.1): an ESTIMATE, so rounded to a coarse 5-minute step and
|
||||
* prefixed with "≈". A non-zero-but-tiny estimate floors to 5m rather than
|
||||
* rounding down to "0" (which would read as "no work"). Zero → empty string
|
||||
* (the caller hides the widget).
|
||||
*/
|
||||
export function formatHeadline(workMs: number, t: Translate): string {
|
||||
if (workMs <= 0) return "";
|
||||
let minutes = Math.round(workMs / MIN / 5) * 5;
|
||||
if (minutes === 0) minutes = 5;
|
||||
const { hours, minutes: m } = hm(minutes);
|
||||
if (hours > 0 && m > 0) return t("≈ {{hours}}h {{minutes}}m", { hours, minutes: m });
|
||||
if (hours > 0) return t("≈ {{hours}}h", { hours });
|
||||
return t("≈ {{minutes}}m", { minutes: m });
|
||||
}
|
||||
|
||||
/** Per-day sum (§6.2), rounded to the minute. Zero → "—". */
|
||||
export function formatDayTotal(activeMs: number, t: Translate): string {
|
||||
if (activeMs <= 0) return "—";
|
||||
const minutes = Math.max(1, Math.round(activeMs / MIN));
|
||||
const { hours, minutes: m } = hm(minutes);
|
||||
if (hours > 0 && m > 0) return t("{{hours}}h {{minutes}}m", { hours, minutes: m });
|
||||
if (hours > 0) return t("{{hours}}h", { hours });
|
||||
return t("{{minutes}}m", { minutes: m });
|
||||
}
|
||||
|
||||
/** The inactivity threshold, for the "estimate · gap = N min" caption. */
|
||||
export function formatGapMinutes(tGapMs: number): number {
|
||||
return Math.round(tGapMs / MIN);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { IPageWorkTime } from "./work-time.types";
|
||||
import { getPageWorkTime, viewerTimezone } from "./work-time-service";
|
||||
|
||||
const WORK_TIME_STALE_TIME = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* #395 — the "time worked on this article" estimate + per-day punch-card
|
||||
* buckets. The buckets are computed server-side in the viewer's timezone (so a
|
||||
* midnight-crossing session lands on the right calendar day for the reader).
|
||||
* `enabled` is opt-in so the (cheap but non-trivial) projection query only fires
|
||||
* when the number is actually shown.
|
||||
*/
|
||||
export function usePageWorkTime(
|
||||
pageId: string,
|
||||
enabled = true,
|
||||
): UseQueryResult<IPageWorkTime, Error> {
|
||||
const tz = viewerTimezone();
|
||||
return useQuery({
|
||||
queryKey: ["page-work-time", pageId, tz],
|
||||
queryFn: () => getPageWorkTime(pageId, tz),
|
||||
enabled: enabled && !!pageId,
|
||||
staleTime: WORK_TIME_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
|
||||
import {
|
||||
formatDayTotal,
|
||||
formatGapMinutes,
|
||||
formatHeadline,
|
||||
} from "./format-work-time";
|
||||
import classes from "./work-time.module.css";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Collapse a run of this many (or more) consecutive edit-free days into a single
|
||||
// "× N days" separator (§6.2 long-range) — the row is still always one day.
|
||||
const EMPTY_RUN_COLLAPSE = 8;
|
||||
|
||||
type Row =
|
||||
| { type: "day"; day: IPerDay }
|
||||
| { type: "gap"; count: number };
|
||||
|
||||
function collapseEmptyRuns(perDay: IPerDay[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
let emptyRun: IPerDay[] = [];
|
||||
const flush = () => {
|
||||
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
|
||||
rows.push({ type: "gap", count: emptyRun.length });
|
||||
} else {
|
||||
for (const d of emptyRun) rows.push({ type: "day", day: d });
|
||||
}
|
||||
emptyRun = [];
|
||||
};
|
||||
for (const d of perDay) {
|
||||
if (d.activeMs === 0 && d.agentMs === 0) {
|
||||
emptyRun.push(d);
|
||||
} else {
|
||||
flush();
|
||||
rows.push({ type: "day", day: d });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return rows;
|
||||
}
|
||||
|
||||
function dayHeading(day: number): string {
|
||||
return new Date(day).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
function DayTrack({
|
||||
day,
|
||||
pSingle,
|
||||
}: {
|
||||
day: IPerDay;
|
||||
pSingle: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ticks = [6, 12, 18];
|
||||
return (
|
||||
<div className={classes.row}>
|
||||
<span className={classes.dayLabel}>{dayHeading(day.day)}</span>
|
||||
<div className={classes.track}>
|
||||
{ticks.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className={classes.hourTick}
|
||||
style={{ left: `${(h / 24) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
{day.windows.map((w: IDayWindow, i) => {
|
||||
const leftPct = ((w.start - day.day) / DAY_MS) * 100;
|
||||
const widthPct = ((w.end - w.start) / DAY_MS) * 100;
|
||||
const isSingle = w.end - w.start <= pSingle;
|
||||
const cls = [
|
||||
classes.window,
|
||||
w.class === "work" ? classes.windowWork : classes.windowAgent,
|
||||
isSingle ? classes.windowSingle : "",
|
||||
].join(" ");
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cls}
|
||||
style={{
|
||||
left: `${Math.max(0, Math.min(100, leftPct))}%`,
|
||||
width: `${Math.max(0, Math.min(100, widthPct))}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={classes.daySum}>
|
||||
{formatDayTotal(day.activeMs, t)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: IPageWorkTime;
|
||||
}
|
||||
|
||||
export default function WorkTimePunchCard({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => collapseEmptyRuns(data.perDay), [data.perDay]);
|
||||
const gapMin = formatGapMinutes(data.config.tGap);
|
||||
|
||||
if (data.workMs <= 0 && data.agentOnlyMs <= 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="md">
|
||||
{t("No editing activity recorded yet.")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group gap="lg">
|
||||
<Text size="sm" fw={500}>
|
||||
{formatHeadline(data.workMs, t)}
|
||||
</Text>
|
||||
{data.agentOnlyMs > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
<span
|
||||
className={`${classes.legendSwatch} ${classes.windowWork}`}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
{t("Work")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<span
|
||||
className={`${classes.legendSwatch} ${classes.windowAgent}`}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
{t("Agent")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
{rows.map((row, i) =>
|
||||
row.type === "day" ? (
|
||||
<DayTrack
|
||||
key={row.day.dayISO}
|
||||
day={row.day}
|
||||
pSingle={data.config.pSingle}
|
||||
/>
|
||||
) : (
|
||||
<div key={`gap-${i}`} className={classes.gapRow}>
|
||||
{t("× {{count}} days without edits", { count: row.count })}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", {
|
||||
tz: data.tz,
|
||||
gap: gapMin,
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IPageWorkTime } from "./work-time.types";
|
||||
|
||||
/** The viewer's IANA timezone (browser locale) — the punch-card lays days out
|
||||
* in "my evenings", per §6.3/§10. Falls back to UTC if the runtime hides it. */
|
||||
export function viewerTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPageWorkTime(
|
||||
pageId: string,
|
||||
tz: string,
|
||||
): Promise<IPageWorkTime> {
|
||||
const req = await api.post<IPageWorkTime>("/pages/history/time", {
|
||||
pageId,
|
||||
tz,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Modal, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconClockHour4 } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePageWorkTime } from "./use-page-work-time";
|
||||
import { formatGapMinutes, formatHeadline } from "./format-work-time";
|
||||
import WorkTimePunchCard from "./work-time-punch-card";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* #395 — the clickable "time worked on this article" headline (§6.1). Renders
|
||||
* the `work` estimate with a "≈" sign and the inactivity threshold in a tooltip
|
||||
* (it is an estimate, not a stopwatch). Clicking opens the daily punch-card
|
||||
* (§6.2). Renders nothing until there is a non-zero human OR agent estimate, so a
|
||||
* brand-new / never-edited page shows no widget. For an agent-only-edited page
|
||||
* (workMs===0, agentOnlyMs>0) the headline shows the agent estimate (labelled
|
||||
* `agent:`, matching the punch-card) so the punch-card stays reachable (#395:
|
||||
* "how much a HUMAN and separately the AGENT").
|
||||
*/
|
||||
export default function WorkTimeStat({ pageId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const { data } = usePageWorkTime(pageId);
|
||||
|
||||
if (!data || (data.workMs <= 0 && data.agentOnlyMs <= 0)) return null;
|
||||
|
||||
const agentOnly = data.workMs <= 0;
|
||||
const label = agentOnly
|
||||
? t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })
|
||||
: formatHeadline(data.workMs, t);
|
||||
const gapMin = formatGapMinutes(data.config.tGap);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
label={t("Estimated time worked (inactivity gap {{gap}} min)", {
|
||||
gap: gapMin,
|
||||
})}
|
||||
position="bottom"
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={open}
|
||||
aria-label={t("Show time worked on this page")}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<IconClockHour4 size={14} />
|
||||
{label}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={t("Time worked on this article")}
|
||||
size="lg"
|
||||
>
|
||||
<WorkTimePunchCard data={data} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* #395 — 24h × days punch-card. Custom CSS segments on a fixed 24-hour track
|
||||
(position = offset-in-day / 24h, width = duration / 24h), no chart library. */
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr 64px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.dayLabel {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-dimmed);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Faint hour grid so the eye can read "morning vs evening". */
|
||||
.hourTick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-3),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
}
|
||||
|
||||
.window {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
border-radius: 3px;
|
||||
min-width: 3px;
|
||||
}
|
||||
|
||||
.windowWork {
|
||||
background-color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.windowAgent {
|
||||
background-color: var(--mantine-color-grape-5);
|
||||
}
|
||||
|
||||
/* A lone single-sample (P_single) window: minimal + dimmed, so it neither
|
||||
vanishes nor fakes dense work (§6.2). */
|
||||
.windowSingle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.daySum {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gapRow {
|
||||
padding: 6px 0 6px 108px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.legendSwatch {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// #395 — client-side mirror of the server work-time payload
|
||||
// (apps/server/src/core/page/work-time). Shapes returned by POST /pages/history/time.
|
||||
|
||||
export type WorkSessionClass = "work" | "agent_only";
|
||||
|
||||
export interface IDayWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
class: WorkSessionClass;
|
||||
}
|
||||
|
||||
export interface IPerDay {
|
||||
day: number;
|
||||
dayISO: string;
|
||||
activeMs: number;
|
||||
agentMs: number;
|
||||
windows: IDayWindow[];
|
||||
}
|
||||
|
||||
export interface IWorkTimeConfig {
|
||||
tGap: number;
|
||||
agentTGap: number;
|
||||
pIn: number;
|
||||
pOut: number;
|
||||
pSingle: number;
|
||||
excludeGit: boolean;
|
||||
burstCapMs?: number;
|
||||
dedupRoundMs: number;
|
||||
}
|
||||
|
||||
export interface IPageWorkTime {
|
||||
workMs: number;
|
||||
agentOnlyMs: number;
|
||||
perDay: IPerDay[];
|
||||
config: IWorkTimeConfig;
|
||||
tz: string;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import WorkTimeStat from "@/features/page-history/work-time/work-time-stat.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import {
|
||||
useFavoriteIds,
|
||||
@@ -265,6 +266,8 @@ function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{page?.id && <WorkTimeStat pageId={page.id} />}
|
||||
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
|
||||
@@ -281,10 +281,12 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
|
||||
if (isOpen) {
|
||||
const node = treeModel.find(data, id) as SpaceTreeNode | null;
|
||||
if (
|
||||
node?.hasChildren &&
|
||||
(!node.children || node.children.length === 0)
|
||||
) {
|
||||
// Same "unloaded branch" predicate the realtime insert paths use
|
||||
// (`isUnloadedBranch`) so the lazy-load gate and the realtime inserts
|
||||
// (`insertByPosition` / `placeByPosition`) can never disagree about what
|
||||
// counts as unloaded (#525). Note: local raw `insert` (DnD/create-page)
|
||||
// does not yet route through it — see #525 follow-up.
|
||||
if (treeModel.isUnloadedBranch(node)) {
|
||||
const fetched = await fetchAllAncestorChildren({
|
||||
pageId: id,
|
||||
spaceId: node.spaceId,
|
||||
|
||||
@@ -74,6 +74,48 @@ describe("treeModel.isDescendant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #525: the single "is this branch unloaded?" predicate shared by the lazy-load
|
||||
// gate and the insert paths. Unloaded == server says hasChildren but none are
|
||||
// present locally (canonical form `children: []`, also `undefined`). A parent
|
||||
// without hasChildren is genuinely empty, not unloaded.
|
||||
describe("treeModel.isUnloadedBranch", () => {
|
||||
type PH = TreeNode<{ name: string; hasChildren?: boolean }>;
|
||||
it("true for hasChildren + empty array (canonical unloaded form)", () => {
|
||||
const n: PH = { id: "p", name: "P", hasChildren: true, children: [] };
|
||||
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
||||
});
|
||||
it("true for hasChildren + undefined children", () => {
|
||||
const n: PH = { id: "p", name: "P", hasChildren: true };
|
||||
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
||||
});
|
||||
it("false for hasChildren + already-loaded children", () => {
|
||||
const n: PH = {
|
||||
id: "p",
|
||||
name: "P",
|
||||
hasChildren: true,
|
||||
children: [{ id: "c", name: "C" }],
|
||||
};
|
||||
expect(treeModel.isUnloadedBranch(n)).toBe(false);
|
||||
});
|
||||
it("false for a genuinely-empty parent (no hasChildren)", () => {
|
||||
expect(
|
||||
treeModel.isUnloadedBranch({
|
||||
id: "p",
|
||||
name: "P",
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
} as PH),
|
||||
).toBe(false);
|
||||
expect(
|
||||
treeModel.isUnloadedBranch({ id: "p", name: "P" } as PH),
|
||||
).toBe(false);
|
||||
});
|
||||
it("false for null/undefined", () => {
|
||||
expect(treeModel.isUnloadedBranch(null)).toBe(false);
|
||||
expect(treeModel.isUnloadedBranch(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("treeModel.visible", () => {
|
||||
it("returns only root nodes when no openIds", () => {
|
||||
const v = treeModel.visible(fixture, new Set());
|
||||
@@ -197,43 +239,64 @@ describe("treeModel.insertByPosition", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// #159 #1: inserting/moving a node under a parent whose children are NOT
|
||||
// loaded (`children === undefined`, e.g. a collapsed page) must NOT materialize
|
||||
// a partial `[node]` list — that would defeat the lazy-load gate and hide the
|
||||
// parent's other real children. The node is left to be lazy-loaded; only
|
||||
// `hasChildren` is flagged so the chevron appears.
|
||||
it("does NOT materialize a child under an UNLOADED parent (children undefined)", () => {
|
||||
type PH = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
hasChildren?: boolean;
|
||||
}>;
|
||||
type PH = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
hasChildren?: boolean;
|
||||
}>;
|
||||
|
||||
// #159 #1 / #525: inserting/moving a node under an UNLOADED parent must NOT
|
||||
// materialize a partial `[node]` list — that would defeat the lazy-load gate and
|
||||
// hide the parent's other real children. The canonical unloaded form here is
|
||||
// `children: []` + `hasChildren: true` (from `pageToTreeNode` /
|
||||
// `pruneCollapsedChildren`), which the pre-#525 `=== undefined` guard MISSED.
|
||||
// The node is left to be lazy-loaded; the chevron stays enabled.
|
||||
it("does NOT materialize a child under an UNLOADED parent (children: [], hasChildren: true)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: true, children: [] },
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
const parent = treeModel.find(t, "p");
|
||||
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
|
||||
// full set, including this node, on expand).
|
||||
expect(parent?.children).toBeUndefined();
|
||||
// full set, including this node, on expand). MUTATION: the pre-#525 predicate
|
||||
// `children === undefined` does not fire for `[]`, so it would insert `[x]`
|
||||
// here and reredden this expectation.
|
||||
expect(parent?.children).toEqual([]);
|
||||
expect(treeModel.find(t, "x")).toBeNull();
|
||||
// ...but the chevron is enabled so the user can expand to load it.
|
||||
// ...and the chevron stays enabled so the user can expand to load it.
|
||||
expect((parent as PH).hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("DOES insert under a LOADED-but-empty parent (children: [])", () => {
|
||||
type PH = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
hasChildren?: boolean;
|
||||
}>;
|
||||
it("does NOT materialize a child under an UNLOADED parent (children undefined, hasChildren: true)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: true }, // children: undefined
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
const parent = treeModel.find(t, "p");
|
||||
expect(parent?.children).toBeUndefined();
|
||||
expect(treeModel.find(t, "x")).toBeNull();
|
||||
expect((parent as PH).hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("DOES insert under a genuinely-empty parent (children: [], hasChildren: false)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
// A loaded (empty) child list is complete, so the node IS inserted.
|
||||
// No server children (`hasChildren: false`), so materializing the first child
|
||||
// is correct — nothing is hidden.
|
||||
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("DOES insert under a genuinely-empty parent (children undefined, hasChildren: false)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,26 @@ export const treeModel = {
|
||||
};
|
||||
},
|
||||
|
||||
// A branch is "unloaded" when the server says it HAS children (`hasChildren`)
|
||||
// but none are present locally. The canonical unloaded form in this codebase
|
||||
// is `children: []` (produced by `pageToTreeNode` and by `pruneCollapsedChildren`
|
||||
// resetting collapsed branches), NOT `children: undefined` — so a predicate that
|
||||
// only checks `=== undefined` misses the real case and materializes a misleading
|
||||
// partial list (#525). This is the SINGLE source of truth for "should a
|
||||
// fetch/materialize be deferred?", shared by the lazy-load gate (`handleToggle`)
|
||||
// and the realtime insert paths (`insertByPosition` / `placeByPosition`), so they
|
||||
// can never drift apart again. (The local raw `insert` primitive and its DnD/
|
||||
// create-page callers do NOT yet route through this predicate — see #525
|
||||
// follow-up.) A parent WITHOUT `hasChildren` is genuinely empty
|
||||
// (no server children) — inserting its first child is correct, not deferred.
|
||||
isUnloadedBranch<T extends object>(
|
||||
node: TreeNode<T> | null | undefined,
|
||||
): boolean {
|
||||
if (!node) return false;
|
||||
const hasChildren = (node as { hasChildren?: boolean }).hasChildren === true;
|
||||
return hasChildren && (node.children == null || node.children.length === 0);
|
||||
},
|
||||
|
||||
isDescendant<T extends object>(
|
||||
tree: TreeNode<T>[],
|
||||
ancestorId: string,
|
||||
@@ -127,14 +147,15 @@ export const treeModel = {
|
||||
}
|
||||
const parent = treeModel.find(tree, parentId);
|
||||
// The parent is in the tree but its children have NOT been lazy-loaded yet
|
||||
// (`children === undefined`, distinct from a loaded-but-empty `[]`). Inserting
|
||||
// (`hasChildren` set + children absent/empty — see `isUnloadedBranch`; the
|
||||
// canonical unloaded form is `children: []`, NOT just `undefined`). Inserting
|
||||
// here would MATERIALIZE a misleading partial child list (`[node]`) that
|
||||
// defeats the lazy-load gate — which fetches only when children are
|
||||
// absent/empty — so the parent's OTHER real children would never load and the
|
||||
// moved/added node would be the only one shown (a silent data loss, #159 #1).
|
||||
// Instead, leave the children unloaded and just flag `hasChildren` so the
|
||||
// chevron appears; expanding fetches the FULL set (including this node).
|
||||
if (parent && parent.children === undefined) {
|
||||
if (parent && treeModel.isUnloadedBranch(parent)) {
|
||||
return treeModel.update(
|
||||
tree,
|
||||
parentId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import { Group, VisuallyHidden } from "@mantine/core";
|
||||
import { Group, Text, VisuallyHidden } from "@mantine/core";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -84,6 +84,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
onFiltersChange={handleFiltersChange}
|
||||
spaceId={spaceId}
|
||||
/>
|
||||
{/* #529: operator hint — matches ANY word by default; "…" for an exact
|
||||
phrase, +term to require, -term to exclude. */}
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('Tip: "exact phrase", +required, -excluded')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<VisuallyHidden role="status" aria-live="polite">
|
||||
|
||||
@@ -5,6 +5,9 @@ import { IPage } from "@/features/page/types/page.types.ts";
|
||||
|
||||
export interface IPageSearch {
|
||||
id: string;
|
||||
// #529 A7 superset: `pageId` aliases `id`; `rank`/`highlight` are null for
|
||||
// substring-only hits (the UI already falls back to the title/snippet).
|
||||
pageId?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
parentPageId: string;
|
||||
@@ -12,9 +15,36 @@ export interface IPageSearch {
|
||||
creatorId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
rank: string;
|
||||
highlight: string;
|
||||
rank: string | number | null;
|
||||
highlight: string | null;
|
||||
space: Partial<ISpace>;
|
||||
// New #529 fields (present from the native Postgres search driver).
|
||||
snippet?: string;
|
||||
score?: number;
|
||||
path?: string[];
|
||||
matchedFields?: string[];
|
||||
matchedTerms?: string[];
|
||||
}
|
||||
|
||||
// #529 A5 pagination envelope returned by POST /search (native driver). The web
|
||||
// list helpers read `items`; these travel alongside for pagination + diagnostics.
|
||||
export interface IPageSearchResponse {
|
||||
items: IPageSearch[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
truncatedAtCap: boolean;
|
||||
offset: number;
|
||||
query?: {
|
||||
raw: string;
|
||||
parsed: {
|
||||
positive: string[];
|
||||
required: string[];
|
||||
excluded: string[];
|
||||
reason?: string;
|
||||
};
|
||||
mode: "or" | "and";
|
||||
match: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchSuggestionParams {
|
||||
@@ -37,6 +67,10 @@ export interface IPageSearchParams {
|
||||
query: string;
|
||||
spaceId?: string;
|
||||
shareId?: string;
|
||||
// #529 A9: match mode (auto default) + pagination.
|
||||
match?: "auto" | "word" | "prefix" | "substring";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface IAttachmentSearch {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, act, cleanup } from "@testing-library/react";
|
||||
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||
|
||||
// Mocks for the dirty shell's side-effecting collaborators.
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
||||
vi.mock("@/lib/reload-guard", () => ({
|
||||
hasAutoReloaded: vi.fn(() => false),
|
||||
markAutoReloaded: vi.fn(() => true),
|
||||
recordReloadBreadcrumb: vi.fn(),
|
||||
takeReloadBreadcrumb: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
|
||||
import {
|
||||
triggerGuardedReload,
|
||||
useVersionReloadOnNavigation,
|
||||
__resetGuardedReloadForTests,
|
||||
} from "./guarded-reload";
|
||||
|
||||
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
let visibility: DocumentVisibilityState;
|
||||
|
||||
// Test harness mounted inside a router: it installs the navigation hook and
|
||||
// exposes `navigate` so a test can drive an in-app router navigation.
|
||||
let doNavigate: (to: string) => void;
|
||||
function Harness() {
|
||||
useVersionReloadOnNavigation();
|
||||
const navigate = useNavigate();
|
||||
doNavigate = navigate;
|
||||
return null;
|
||||
}
|
||||
|
||||
function mountHarness() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/start"]}>
|
||||
<Harness />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
act(() => {
|
||||
doNavigate(path);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetGuardedReloadForTests();
|
||||
vi.clearAllMocks();
|
||||
mockHasAutoReloaded.mockReturnValue(false);
|
||||
mockMarkAutoReloaded.mockReturnValue(true);
|
||||
|
||||
vi.stubGlobal("APP_VERSION", "test-A");
|
||||
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { reload },
|
||||
});
|
||||
|
||||
visibility = "visible";
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => visibility,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("triggerGuardedReload (variant C)", () => {
|
||||
it("noop when versions match: no banner, no reload", () => {
|
||||
triggerGuardedReload("test-A");
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("noop when the server version is empty (fail-safe)", () => {
|
||||
triggerGuardedReload("");
|
||||
triggerGuardedReload(undefined);
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("real mismatch shows the banner but does NOT reload immediately", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
expect(show.mock.calls[0][0]).toMatchObject({
|
||||
id: "app-version-reload",
|
||||
autoClose: false,
|
||||
withCloseButton: true,
|
||||
});
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A second navigation must NOT reload again (one-shot was consumed).
|
||||
navigateTo("/again");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Another app-version mismatch arrives (reconnect): must not re-arm.
|
||||
triggerGuardedReload("test-C");
|
||||
navigateTo("/again");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT reload merely from the tab going to the background", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
visibility = "hidden";
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
|
||||
visibility = "hidden";
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("the banner's Update button reloads immediately", () => {
|
||||
triggerGuardedReload("test-B");
|
||||
const message = show.mock.calls[0][0].message as {
|
||||
props: { onClick: () => void };
|
||||
};
|
||||
message.props.onClick();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
|
||||
mockHasAutoReloaded.mockReturnValue(true);
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
|
||||
navigateTo("/next");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT reload when the flag write fails; falls back to the banner", () => {
|
||||
mockMarkAutoReloaded.mockReturnValue(false);
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
// performAutoReload falls back to showing the banner (initial + fallback).
|
||||
expect(show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
|
||||
triggerGuardedReload("test-B");
|
||||
triggerGuardedReload("test-B");
|
||||
triggerGuardedReload("test-C");
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Button } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import i18n from "@/i18n.ts";
|
||||
import {
|
||||
hasAutoReloaded,
|
||||
markAutoReloaded,
|
||||
recordReloadBreadcrumb,
|
||||
takeReloadBreadcrumb,
|
||||
} from "@/lib/reload-guard";
|
||||
import { decideVersionAction } from "@/features/user/version-coherence";
|
||||
|
||||
// Dirty shell around the pure `decideVersionAction`: it reads globals
|
||||
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
|
||||
// Mantine notification, and arms the router-navigation reload hook. Kept
|
||||
// separate from the pure module so the decision stays unit-testable without a
|
||||
// DOM.
|
||||
|
||||
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
|
||||
// single banner instead of stacking a new one each time.
|
||||
const BANNER_ID = "app-version-reload";
|
||||
|
||||
// Module-level idempotency for the current tab-load: once a mismatch has been
|
||||
// handled we don't re-arm the navigation reload or re-show the banner on
|
||||
// subsequent app-version emits.
|
||||
let handled = false;
|
||||
|
||||
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
|
||||
// the background (that would silently drop a half-written comment/form). Instead
|
||||
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
|
||||
// the user is already leaving the current page, so an in-app navigation would
|
||||
// discard that unsaved component-state anyway and the reload adds no extra loss.
|
||||
let pendingNavReload = false;
|
||||
|
||||
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
|
||||
// the (already-visible) banner.
|
||||
let lastServerVersion = "";
|
||||
let lastClientVersion = "";
|
||||
|
||||
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
|
||||
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
|
||||
// where Vite's `define` did not run) — an unknown client version makes the
|
||||
// pure decision no-op (fail-safe).
|
||||
function readClientVersion(): string {
|
||||
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
|
||||
}
|
||||
|
||||
// Perform the actual reload — but only after the shared one-shot flag is
|
||||
// persisted. If the write fails (storage unavailable) we must NOT reload
|
||||
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
|
||||
// to the manual banner so the user can still recover.
|
||||
function performAutoReload(): void {
|
||||
if (!markAutoReloaded()) {
|
||||
showReloadBanner();
|
||||
return;
|
||||
}
|
||||
// Trace right before the reload (which clears the console): a persistent
|
||||
// breadcrumb + a log line so the auto-reload is observable in a field report.
|
||||
recordReloadBreadcrumb({
|
||||
path: "proactive",
|
||||
serverVersion: lastServerVersion,
|
||||
clientVersion: lastClientVersion,
|
||||
});
|
||||
console.warn(
|
||||
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
|
||||
);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function showReloadBanner(): void {
|
||||
notifications.show({
|
||||
id: BANNER_ID,
|
||||
title: i18n.t("A new version is available"),
|
||||
message: (
|
||||
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
|
||||
{i18n.t("Update")}
|
||||
</Button>
|
||||
),
|
||||
autoClose: false,
|
||||
withCloseButton: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a server `app-version` announcement: compare it to this bundle's
|
||||
* version and, on a real mismatch, show the banner and arm a guarded reload for
|
||||
* the next in-app navigation (variant C).
|
||||
*
|
||||
* - real mismatch (window budget available) → banner + arm navigation reload.
|
||||
* The banner's "Update" button reloads immediately (same shared window guard).
|
||||
* The tab is NOT reloaded on visibility change.
|
||||
* - auto-reload already used this window / storage error → banner only (no arm),
|
||||
* so there is at most one automatic reload per RELOAD_WINDOW_MS window (loop
|
||||
* safety).
|
||||
* - in sync / unknown version → noop (fail-safe).
|
||||
*/
|
||||
export function triggerGuardedReload(
|
||||
rawServerVersion: string | undefined | null,
|
||||
): void {
|
||||
const serverVersion = (rawServerVersion ?? "").trim();
|
||||
const clientVersion = readClientVersion();
|
||||
|
||||
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
|
||||
// reloading (banner only).
|
||||
const autoReloadUsed = hasAutoReloaded();
|
||||
|
||||
const action = decideVersionAction({
|
||||
serverVersion,
|
||||
clientVersion,
|
||||
autoReloadUsed,
|
||||
});
|
||||
if (action === "noop") return;
|
||||
|
||||
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
|
||||
// emits (reconnects) once we've already acted.
|
||||
if (handled) return;
|
||||
handled = true;
|
||||
|
||||
lastServerVersion = serverVersion;
|
||||
lastClientVersion = clientVersion;
|
||||
|
||||
if (action === "banner") {
|
||||
// Entered banner-only (permanent skew, node oscillation, or the window's
|
||||
// auto-reload budget already spent). Log for diagnosability; show the banner.
|
||||
console.warn(
|
||||
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
|
||||
"auto-reload budget already spent this window — showing manual banner",
|
||||
);
|
||||
showReloadBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
// action === "reload" (variant C): show the banner and defer the auto-reload
|
||||
// to the next in-app navigation instead of reloading now / on visibility.
|
||||
showReloadBanner();
|
||||
pendingNavReload = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the armed one-shot navigation reload, if any. Called by
|
||||
* `useVersionReloadOnNavigation` on each in-app router navigation.
|
||||
*/
|
||||
export function consumeNavigationReload(): void {
|
||||
if (!pendingNavReload) return;
|
||||
pendingNavReload = false;
|
||||
performAutoReload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
|
||||
* NEXT in-app router navigation after a version mismatch. Skips the initial
|
||||
* render so it only reacts to real navigations, not the first location.
|
||||
*/
|
||||
export function useVersionReloadOnNavigation(): void {
|
||||
const location = useLocation();
|
||||
const firstRender = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstRender.current) {
|
||||
firstRender.current = false;
|
||||
return;
|
||||
}
|
||||
consumeNavigationReload();
|
||||
}, [location.key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
|
||||
* load — the reload cleared the console, so this makes a "tab reloaded itself"
|
||||
* report diagnosable. Call once on app startup.
|
||||
*/
|
||||
export function surfacePreviousReloadBreadcrumb(): void {
|
||||
const crumb = takeReloadBreadcrumb();
|
||||
if (!crumb) return;
|
||||
console.info(
|
||||
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
|
||||
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
|
||||
`at=${new Date(crumb.at).toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Test-only: reset module-level latches between cases.
|
||||
export function __resetGuardedReloadForTests(): void {
|
||||
handled = false;
|
||||
pendingNavReload = false;
|
||||
lastServerVersion = "";
|
||||
lastClientVersion = "";
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, act, cleanup } from "@testing-library/react";
|
||||
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||
|
||||
// Integration test for the SHARED, window-based auto-reload budget (invariant a):
|
||||
// the reactive chunk-load boundary and the proactive version-coherence path both
|
||||
// route through the REAL @/lib/reload-guard, so at most one automatic reload
|
||||
// happens per RELOAD_WINDOW_MS across BOTH paths combined. Only the two paths'
|
||||
// side-effecting collaborators are mocked — the reload guard is intentionally
|
||||
// REAL so this exercises the actual shared sessionStorage budget.
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
||||
|
||||
import { handleError } from "@/components/chunk-load-error-boundary";
|
||||
import {
|
||||
triggerGuardedReload,
|
||||
useVersionReloadOnNavigation,
|
||||
__resetGuardedReloadForTests,
|
||||
} from "./guarded-reload";
|
||||
import { RELOAD_WINDOW_MS } from "@/lib/reload-guard";
|
||||
|
||||
const CHUNK_ERROR = { name: "ChunkLoadError", message: "boom" };
|
||||
const T0 = 1_000_000_000_000;
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
let nowMock: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
// Harness mounted inside a router: installs the navigation hook and exposes
|
||||
// `navigate` so a test can drive an in-app router navigation (the point where the
|
||||
// proactive path fires its armed reload).
|
||||
let doNavigate: (to: string) => void;
|
||||
function Harness() {
|
||||
useVersionReloadOnNavigation();
|
||||
doNavigate = useNavigate();
|
||||
return null;
|
||||
}
|
||||
function mountHarness() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/start"]}>
|
||||
<Harness />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
function navigateTo(path: string) {
|
||||
act(() => {
|
||||
doNavigate(path);
|
||||
});
|
||||
}
|
||||
function setNow(t: number) {
|
||||
nowMock.mockReturnValue(t);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
__resetGuardedReloadForTests();
|
||||
vi.clearAllMocks();
|
||||
nowMock = vi.spyOn(Date, "now").mockReturnValue(T0);
|
||||
vi.stubGlobal("APP_VERSION", "test-A");
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { reload },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("shared window-based reload budget (invariant a)", () => {
|
||||
it("a chunk-load reload spends the budget: a version-coherence mismatch within the window shows the banner but does NOT reload", () => {
|
||||
// Path 1 (reactive): a stale-chunk 404 auto-reloads once and stamps the window.
|
||||
handleError(CHUNK_ERROR);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
reload.mockClear();
|
||||
|
||||
// Path 2 (proactive), 1 min later — still inside the window. The shared budget
|
||||
// is spent, so the version mismatch degrades to the banner and never reloads.
|
||||
setNow(T0 + 60_000);
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a version-coherence reload spends the SAME budget: a chunk-load error within the window does NOT reload", () => {
|
||||
// Path 2 (proactive) first: real mismatch → arm → fire on navigation.
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
reload.mockClear();
|
||||
|
||||
// Path 1 (reactive), 2 min later — inside the window. Budget already spent by
|
||||
// the proactive path, so the stale-chunk error must NOT trigger a second reload.
|
||||
setNow(T0 + 2 * 60_000);
|
||||
handleError(CHUNK_ERROR);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers after the window: a reload strictly older than the window is allowed again (second deploy)", () => {
|
||||
// First auto-reload (reactive) stamps the window.
|
||||
handleError(CHUNK_ERROR);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
reload.mockClear();
|
||||
|
||||
// A second deploy arrives after the window has fully elapsed → the proactive
|
||||
// path is allowed to reload again (window, not a permanent one-shot).
|
||||
__resetGuardedReloadForTests();
|
||||
setNow(T0 + RELOAD_WINDOW_MS + 1);
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reactive path fails closed when storage READS but cannot WRITE (quota / Safari private): no unguarded reload", () => {
|
||||
// getItem→null makes hasAutoReloaded() report the budget as available, so
|
||||
// handleError passes the first guard and reaches `if (!markAutoReloaded())
|
||||
// return;`. setItem throws → the stamp cannot stick, so markAutoReloaded()
|
||||
// returns false and that guard MUST bail — otherwise the reactive path would
|
||||
// reload on every stale-chunk error with no persisted budget (an unguarded
|
||||
// loop). This is the asymmetric gap: the proactive path's equivalent is
|
||||
// covered by guarded-reload.test.tsx "does NOT reload when the flag write
|
||||
// fails".
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("quota exceeded");
|
||||
},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
});
|
||||
try {
|
||||
handleError(CHUNK_ERROR);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("sessionStorage unavailable: neither path performs an unguarded reload", () => {
|
||||
// The real guard fails toward NOT reloading when storage throws.
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
});
|
||||
try {
|
||||
handleError(CHUNK_ERROR);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,12 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||
import {
|
||||
triggerGuardedReload,
|
||||
useVersionReloadOnNavigation,
|
||||
surfacePreviousReloadBreadcrumb,
|
||||
} from "@/features/user/guarded-reload.tsx";
|
||||
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
|
||||
|
||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||
@@ -22,6 +28,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
// fetch collab token on load
|
||||
const { data: collab } = useCollabToken();
|
||||
|
||||
// version-coherence: fire the armed one-shot reload on the next in-app
|
||||
// navigation (variant C — a safe point, not on tab backgrounding).
|
||||
useVersionReloadOnNavigation();
|
||||
|
||||
// Surface any breadcrumb left by an auto-reload in the previous page load
|
||||
// (the reload cleared the console) so a field report stays diagnosable.
|
||||
useEffect(() => {
|
||||
surfacePreviousReloadBreadcrumb();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isError) {
|
||||
return;
|
||||
@@ -47,6 +63,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
handleConnect();
|
||||
});
|
||||
|
||||
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
|
||||
// connects: the server emits `app-version` immediately in handleConnection,
|
||||
// so a listener attached after connect would miss it on a fast localhost
|
||||
// connect. On a version mismatch the client shows a banner and defers the
|
||||
// auto-reload to the next in-app navigation (variant C — avoids reloading a
|
||||
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
|
||||
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
|
||||
triggerGuardedReload(payload?.version);
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log("ws disconnected");
|
||||
newSocket.disconnect();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideVersionAction } from "./version-coherence";
|
||||
|
||||
describe("decideVersionAction", () => {
|
||||
it("noop when the server version is empty (fail-safe)", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "",
|
||||
clientVersion: "v1",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
|
||||
it("noop when the client version is empty (fail-safe)", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "v1",
|
||||
clientVersion: "",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
|
||||
it("noop when versions are equal (in sync)", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "v1",
|
||||
clientVersion: "v1",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
|
||||
it("reload on a real mismatch the first time this session", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("reload");
|
||||
});
|
||||
|
||||
it("banner on a mismatch once the session auto-reload is spent", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
autoReloadUsed: true,
|
||||
}),
|
||||
).toBe("banner");
|
||||
});
|
||||
|
||||
it("equal versions stay noop even if auto-reload was already used", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "v1",
|
||||
clientVersion: "v1",
|
||||
autoReloadUsed: true,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Payload of the per-connect `app-version` socket.io event announced by the
|
||||
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
|
||||
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
|
||||
// `operation`), so it never touches use-query-subscription.
|
||||
export type AppVersionSocketPayload = { version: string };
|
||||
|
||||
/**
|
||||
* Pure decision for the version-coherence guard.
|
||||
*
|
||||
* All inputs are injected (no globals, no side effects) so it is unit-testable
|
||||
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
|
||||
*
|
||||
* - `autoReloadUsed` = an automatic reload has already happened within the
|
||||
* current ~5-min window, so we must not auto-reload again (loop safety,
|
||||
* shared window budget with the reactive chunk-load boundary).
|
||||
*
|
||||
* Returns:
|
||||
* - "noop" — do nothing (unknown version on either side, or already in sync).
|
||||
* - "banner" — show the manual "update available" banner only (no auto-reload).
|
||||
* - "reload" — real first-time mismatch: eligible for a guarded auto-reload.
|
||||
*/
|
||||
export function decideVersionAction(args: {
|
||||
serverVersion: string;
|
||||
clientVersion: string;
|
||||
autoReloadUsed: boolean;
|
||||
}): "reload" | "banner" | "noop" {
|
||||
const { serverVersion, clientVersion, autoReloadUsed } = args;
|
||||
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
|
||||
if (serverVersion === clientVersion) return "noop"; // in sync
|
||||
if (autoReloadUsed) return "banner"; // one auto-reload per RELOAD_WINDOW_MS window already spent
|
||||
return "reload"; // real mismatch, window budget available
|
||||
}
|
||||
@@ -82,17 +82,19 @@ describe("applyMoveTreeNode", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
|
||||
// `dstCollapsed` is in the tree but its children were never lazy-loaded
|
||||
// (children === undefined). The OLD behavior inserted `src` as the ONLY
|
||||
// child ([src]), which defeated the lazy-load gate and HID the parent's
|
||||
// other real children. Now the move leaves children unloaded (so expanding
|
||||
// fetches the FULL set, including src) and just flags hasChildren.
|
||||
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159 #525)", () => {
|
||||
// `dstCollapsed` is in the tree but its children were never lazy-loaded. The
|
||||
// CANONICAL unloaded form here is `hasChildren: true` + `children: []` (from
|
||||
// `pageToTreeNode` / `pruneCollapsedChildren`), NOT `children: undefined`.
|
||||
// The pre-#525 predicate (`children === undefined`) missed this form and
|
||||
// inserted `src` as the ONLY child ([src]), defeating the lazy-load gate and
|
||||
// HIDING the parent's other real children. Now the move leaves children
|
||||
// unloaded (so expanding fetches the FULL set, including src).
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("dstCollapsed", {
|
||||
position: "a0",
|
||||
hasChildren: false,
|
||||
children: undefined as unknown as SpaceTreeNode[],
|
||||
hasChildren: true,
|
||||
children: [],
|
||||
}),
|
||||
node("src", { position: "a9" }),
|
||||
];
|
||||
@@ -105,9 +107,10 @@ describe("applyMoveTreeNode", () => {
|
||||
pageData: {},
|
||||
});
|
||||
const dst = treeModel.find(next, "dstCollapsed");
|
||||
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
|
||||
// src) on expand, rather than showing a misleading partial [src] list.
|
||||
expect(dst?.children).toBeUndefined();
|
||||
// Children stay unloaded ([] not materialized to [src]) -> the lazy-load gate
|
||||
// fetches the FULL set (incl. src) on expand. MUTATION: the pre-#525
|
||||
// `=== undefined` predicate would insert [src] here and redden this.
|
||||
expect(dst?.children).toEqual([]);
|
||||
expect(dst?.hasChildren).toBe(true);
|
||||
// src moved away from its old root slot (it lives under dstCollapsed
|
||||
// server-side and reappears when the parent is expanded/loaded).
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
hasAutoReloaded,
|
||||
markAutoReloaded,
|
||||
shouldAutoReload,
|
||||
recordReloadBreadcrumb,
|
||||
takeReloadBreadcrumb,
|
||||
RELOAD_WINDOW_MS,
|
||||
} from "./reload-guard";
|
||||
|
||||
// The shared budget is a single sessionStorage timestamp keyed here; both the
|
||||
// reactive chunk-load boundary and the proactive version-coherence path read and
|
||||
// stamp it, so at most one auto-reload happens per RELOAD_WINDOW_MS across BOTH.
|
||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||
const NOW = 1_000_000_000_000;
|
||||
|
||||
describe("reload-guard", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("hasAutoReloaded is false before any reload, true within the window after mark", () => {
|
||||
expect(hasAutoReloaded(NOW)).toBe(false);
|
||||
expect(markAutoReloaded(NOW)).toBe(true);
|
||||
// Same key both paths share; stores the reload timestamp, not a flag.
|
||||
expect(sessionStorage.getItem(RELOAD_AT_KEY)).toBe(String(NOW));
|
||||
// Inside the window → budget spent → true (fall through to manual UI).
|
||||
expect(hasAutoReloaded(NOW)).toBe(true);
|
||||
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it("hasAutoReloaded is false again once the window has elapsed (a later deploy recovers)", () => {
|
||||
markAutoReloaded(NOW);
|
||||
// Strictly older than the window → a new deploy's mismatch may reload again.
|
||||
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(hasAutoReloaded()).toBe(true);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("hasAutoReloaded treats an unparseable stored timestamp as never-reloaded", () => {
|
||||
sessionStorage.setItem(RELOAD_AT_KEY, "not-a-number");
|
||||
expect(hasAutoReloaded(NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("markAutoReloaded returns false when writing storage throws", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(markAutoReloaded()).toBe(false);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("records and then takes a breadcrumb once (cleared on read)", () => {
|
||||
recordReloadBreadcrumb({
|
||||
path: "proactive",
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
});
|
||||
const crumb = takeReloadBreadcrumb();
|
||||
expect(crumb).toMatchObject({
|
||||
path: "proactive",
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
});
|
||||
expect(typeof crumb?.at).toBe("number");
|
||||
// Cleared on read → a second take returns null.
|
||||
expect(takeReloadBreadcrumb()).toBeNull();
|
||||
});
|
||||
|
||||
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
|
||||
expect(takeReloadBreadcrumb()).toBeNull();
|
||||
});
|
||||
|
||||
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
removeItem: () => {},
|
||||
});
|
||||
try {
|
||||
expect(() =>
|
||||
recordReloadBreadcrumb({ path: "chunk-boundary" }),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The pure window gate replaces the old one-shot flag: it must permit recovery
|
||||
// across several deploys in one tab (each > window apart) while still stopping an
|
||||
// infinite reload loop when a lazy chunk is permanently broken (a second failure
|
||||
// < window). Moved here from the chunk-load boundary now that it is the shared
|
||||
// guard both paths route through.
|
||||
describe("shouldAutoReload", () => {
|
||||
const WINDOW = RELOAD_WINDOW_MS;
|
||||
|
||||
it("allows a reload when we have never auto-reloaded", () => {
|
||||
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// Shared, window-based auto-reload budget.
|
||||
//
|
||||
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
|
||||
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
|
||||
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
|
||||
// they share ONE window-scoped reload budget: at most a single automatic
|
||||
// reload per RELOAD_WINDOW_MS across BOTH paths. A window (rather than a
|
||||
// permanent one-shot flag) lets a SECOND deploy in the same tab's lifetime
|
||||
// recover too, while a permanent skew, node oscillation, or a genuinely-missing
|
||||
// chunk still degrades to a manual banner/UI after the first reload instead of
|
||||
// looping. When sessionStorage is unavailable every mismatch degrades to the
|
||||
// manual UI — no unguarded reload.
|
||||
|
||||
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload
|
||||
// (shared by both paths).
|
||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||
|
||||
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||
// by a single reload, so anything inside the window is treated as a reload loop
|
||||
// (permanently-broken chunk / permanent skew) and falls through to the manual UI.
|
||||
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Pure window decision, unit-tested in isolation: auto-reload only if we have
|
||||
* never auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older
|
||||
* than the window. Anything inside the window is suppressed to break an infinite
|
||||
* reload loop.
|
||||
*/
|
||||
export function shouldAutoReload(
|
||||
now: number,
|
||||
lastReloadAt: number | null,
|
||||
windowMs: number,
|
||||
): boolean {
|
||||
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||
return now - lastReloadAt > windowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has an automatic reload already happened within the current window (so the
|
||||
* shared budget is spent right now)? Both paths check this before reloading; a
|
||||
* `true` return means fall through to the manual banner/UI instead of reloading.
|
||||
*
|
||||
* A storage read error (private mode / disabled) is reported as `true` so the
|
||||
* caller fails toward NOT reloading — an unguarded loop is worse than a stale
|
||||
* tab the user can reload manually. Note a window (not a permanent flag): once
|
||||
* the window elapses a later deploy's mismatch is allowed to reload again.
|
||||
*/
|
||||
export function hasAutoReloaded(now: number = Date.now()): boolean {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||
return !shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the shared window as consumed now — record that an automatic reload is
|
||||
* being performed within the current RELOAD_WINDOW_MS window.
|
||||
*
|
||||
* Returns whether the write succeeded. A `false` return (storage unavailable)
|
||||
* means the caller MUST NOT reload — otherwise the stamp would never stick and
|
||||
* the reload could loop.
|
||||
*/
|
||||
export function markAutoReloaded(now: number = Date.now()): boolean {
|
||||
try {
|
||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic breadcrumb for an automatic reload. Written right before
|
||||
// window.location.reload() (which clears the console) and read back on the next
|
||||
// page load, so a "the tab reloaded itself / it's looping" field report is
|
||||
// diagnosable: which path fired (proactive version-coherence vs the reactive
|
||||
// chunk-load boundary) and which version pair triggered it. sessionStorage
|
||||
// survives a same-tab reload, unlike the console.
|
||||
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
|
||||
|
||||
export type ReloadBreadcrumb = {
|
||||
path: "proactive" | "chunk-boundary";
|
||||
serverVersion?: string;
|
||||
clientVersion?: string;
|
||||
at: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist a best-effort breadcrumb just before an automatic reload. Failures
|
||||
* (storage unavailable) are swallowed — this is diagnostics only and must never
|
||||
* block or alter the reload decision.
|
||||
*/
|
||||
export function recordReloadBreadcrumb(
|
||||
entry: Omit<ReloadBreadcrumb, "at">,
|
||||
): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
RELOAD_BREADCRUMB_KEY,
|
||||
JSON.stringify({ ...entry, at: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
// best-effort diagnostics only
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and clear the breadcrumb left by an auto-reload in the previous page
|
||||
* load. Cleared on read so it surfaces exactly once per reload.
|
||||
*/
|
||||
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
|
||||
if (!raw) return null;
|
||||
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
|
||||
return JSON.parse(raw) as ReloadBreadcrumb;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { compression } from "vite-plugin-compression2";
|
||||
import * as path from "path";
|
||||
import * as fs from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const envPath = path.resolve(process.cwd(), "..", "..");
|
||||
@@ -24,7 +25,32 @@ function resolveAppVersion(cwd: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
|
||||
// the exact same build id the bundle was compiled with. The value is the SAME
|
||||
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
|
||||
// global are identical by construction — the single source of truth (no
|
||||
// runtime-env second copy that could drift and cause a false version mismatch).
|
||||
function versionJsonPlugin(version: string): Plugin {
|
||||
let outDir = "dist";
|
||||
return {
|
||||
name: "emit-version-json",
|
||||
apply: "build",
|
||||
configResolved(config) {
|
||||
outDir = config.build.outDir;
|
||||
},
|
||||
writeBundle() {
|
||||
const root = path.resolve(process.cwd(), outDir);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "version.json"),
|
||||
JSON.stringify({ version }),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const appVersion = resolveAppVersion(envPath);
|
||||
const {
|
||||
APP_URL,
|
||||
FILE_UPLOAD_SIZE_LIMIT,
|
||||
@@ -52,10 +78,11 @@ export default defineConfig(({ mode }) => {
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
},
|
||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||
APP_VERSION: JSON.stringify(appVersion),
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
versionJsonPlugin(appVersion),
|
||||
// Emit .br and .gz next to every built asset so the server can serve the
|
||||
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||
compression({
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/token-estimate build",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/token-estimate build && pnpm --filter @docmost/mcp build",
|
||||
"test": "jest",
|
||||
"test:int": "jest --config test/jest-integration.json",
|
||||
"test:watch": "jest --watch",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TransclusionService } from '../core/page/transclusion/transclusion.serv
|
||||
import { TransclusionModule } from '../core/page/transclusion/transclusion.module';
|
||||
import { StorageModule } from '../integrations/storage/storage.module';
|
||||
import { EnvironmentModule } from '../integrations/environment/environment.module';
|
||||
import { ApiKeyModule } from '../core/api-key/api-key.module';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -31,6 +32,7 @@ import { EnvironmentModule } from '../integrations/environment/environment.modul
|
||||
exports: [CollaborationGateway],
|
||||
imports: [
|
||||
TokenModule,
|
||||
ApiKeyModule,
|
||||
WatcherModule,
|
||||
StorageModule.forRootAsync({
|
||||
imports: [EnvironmentModule],
|
||||
|
||||
@@ -141,7 +141,57 @@ export function htmlToJson(html: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonToText(tiptapJson: JSONContent) {
|
||||
/**
|
||||
* Deterministic text-serializer overrides for the `format:"text"` page read
|
||||
* (#502). Non-text nodes render to a STABLE placeholder instead of their
|
||||
* (structure-dependent) inner text, so a machine diff of two text reads is
|
||||
* driven only by the page's actual prose — output stability across package
|
||||
* versions IS the contract (pinned by a snapshot test). Returning a string from
|
||||
* a `textSerializer` also stops `generateText` descending into the node, so a
|
||||
* table renders as ONE token rather than its flattened cell text.
|
||||
*
|
||||
* Only nodes with no meaningful flat-text form are overridden; every other node
|
||||
* (paragraph/heading/list/code/blockquote/callout/…) keeps its natural text so
|
||||
* a config written as markdown reads back byte-identical.
|
||||
*/
|
||||
const TEXT_READ_SERIALIZERS: Record<string, (props: { node: any }) => string> =
|
||||
{
|
||||
// Image atom: no inner text -> a fixed placeholder.
|
||||
image: () => '[image]',
|
||||
// Table: `[table RxC]` where R = row count, C = the first row's cell count
|
||||
// (a table's columns are uniform per the schema). Computed from the PM node,
|
||||
// so it is independent of cell contents.
|
||||
table: ({ node }) => {
|
||||
const rows = node?.childCount ?? 0;
|
||||
const cols = rows > 0 ? (node.child(0)?.childCount ?? 0) : 0;
|
||||
return `[table ${rows}x${cols}]`;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialize a ProseMirror/TipTap document to plain text.
|
||||
*
|
||||
* Default (no options): the long-standing search-index behavior — bare
|
||||
* concatenated node text with `generateText`'s default `\n\n` block separator.
|
||||
* This feeds the page `textContent` tsvector and MUST NOT change.
|
||||
*
|
||||
* `deterministic:true` (#502 `format:"text"` page read): a flat, machine-diffable
|
||||
* rendering — one line per block (`\n` block separator; `hardBreak` already
|
||||
* serializes to `\n`), inline marks/anchors/autoformat dropped, and non-text
|
||||
* nodes replaced by the stable placeholders above (`[image]`, `[table RxC]`).
|
||||
*/
|
||||
export function jsonToText(
|
||||
// `any` (like jsonToHtml/jsonToMarkdown) so a loosely-typed DB `page.content`
|
||||
// (JsonValue) can be passed straight through, as the controller does.
|
||||
tiptapJson: any,
|
||||
options?: { deterministic?: boolean },
|
||||
) {
|
||||
if (options?.deterministic) {
|
||||
return generateText(tiptapJson, tiptapExtensions, {
|
||||
blockSeparator: '\n',
|
||||
textSerializers: TEXT_READ_SERIALIZERS,
|
||||
});
|
||||
}
|
||||
return generateText(tiptapJson, tiptapExtensions);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('AuthenticationExtension.onAuthenticate', () => {
|
||||
let pageRepo: { findById: jest.Mock };
|
||||
let spaceMemberRepo: { getUserSpaceRoles: jest.Mock };
|
||||
let pagePermissionRepo: { canUserEditPage: jest.Mock };
|
||||
let apiKeyService: { validate: jest.Mock };
|
||||
|
||||
// Build the hocuspocus onAuthenticate payload. connectionConfig.readOnly
|
||||
// starts false; the extension flips it to true on a read-only downgrade.
|
||||
@@ -79,12 +80,15 @@ describe('AuthenticationExtension.onAuthenticate', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
apiKeyService = { validate: jest.fn().mockResolvedValue({ user: {}, workspace: {} }) };
|
||||
|
||||
ext = new AuthenticationExtension(
|
||||
tokenService as any,
|
||||
userRepo as any,
|
||||
pageRepo as any,
|
||||
spaceMemberRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
apiKeyService as any,
|
||||
);
|
||||
// Silence the extension's logger (it warns/debugs on denial branches).
|
||||
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
||||
@@ -231,4 +235,73 @@ describe('AuthenticationExtension.onAuthenticate', () => {
|
||||
// No internal ai_chats row for an MCP/service-account collab edit → null.
|
||||
expect(ctx.aiChatId).toBeNull();
|
||||
});
|
||||
|
||||
// --- #501: api-key laundering guard (fail-closed discriminator) ----------
|
||||
describe('api-key laundering guard', () => {
|
||||
it('api_key principal → row-checks the key on connect (valid key proceeds)', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(
|
||||
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
|
||||
);
|
||||
const data = buildData();
|
||||
await ext.onAuthenticate(data as any);
|
||||
|
||||
expect(apiKeyService.validate).toHaveBeenCalledTimes(1);
|
||||
expect(apiKeyService.validate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKeyId: 'key-1', type: JwtType.API_KEY }),
|
||||
);
|
||||
});
|
||||
|
||||
it('REVOKED api_key → Unauthorized on connect, BEFORE any page/user lookup', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(
|
||||
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
|
||||
);
|
||||
// The shared validator denies a revoked key.
|
||||
apiKeyService.validate.mockRejectedValue(new UnauthorizedException());
|
||||
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
// No new collab connection: the key check gates before page access.
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('api_key principal missing apiKeyId → Unauthorized (malformed)', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(buildJwt({ principal: 'api_key' }));
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(apiKeyService.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('session principal → NO api-key check (session-backed, incl. internal agent)', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(buildJwt({ principal: 'session' }));
|
||||
await ext.onAuthenticate(buildData() as any);
|
||||
expect(apiKeyService.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('claimless token WITHIN the grace window → trusted (legacy pre-rollout)', async () => {
|
||||
// Default rolloutAt = now, so we are inside the grace window.
|
||||
tokenService.verifyJwt.mockResolvedValue(buildJwt()); // no principal
|
||||
await expect(ext.onAuthenticate(buildData() as any)).resolves.toBeDefined();
|
||||
expect(apiKeyService.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('claimless token AFTER the grace window → Unauthorized (fail-closed)', async () => {
|
||||
// Move the rollout reference far into the past so the grace has elapsed.
|
||||
(ext as any).rolloutAt = Date.now() - 25 * 60 * 60 * 1000;
|
||||
tokenService.verifyJwt.mockResolvedValue(buildJwt()); // no principal
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('infra error from the api-key row-check propagates (not masked)', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(
|
||||
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
|
||||
);
|
||||
const boom = new Error('db down');
|
||||
apiKeyService.validate.mockRejectedValue(boom);
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toBe(boom);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,20 +14,37 @@ import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
||||
import { SpaceRole } from '../../common/helpers/types/permission';
|
||||
import { isUserDisabled } from '../../common/helpers';
|
||||
import { getPageId } from '../collaboration.util';
|
||||
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
import {
|
||||
JwtApiKeyPayload,
|
||||
JwtCollabPayload,
|
||||
JwtType,
|
||||
} from '../../core/auth/dto/jwt-payload';
|
||||
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
|
||||
import { observeCollabAuth } from '../../integrations/metrics/metrics.registry';
|
||||
import { ApiKeyService } from '../../core/api-key/api-key.service';
|
||||
|
||||
// Max lifetime of a collab token (generateCollabToken uses expiresIn '24h'). Used
|
||||
// as the rollout grace window below: once this long has elapsed since this
|
||||
// process started serving the #501 code, every STILL-VALID collab token was
|
||||
// necessarily minted post-rollout and MUST carry the `principal` discriminator,
|
||||
// so a claimless one is a bug and is rejected (fail-closed) rather than trusted.
|
||||
const COLLAB_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticationExtension implements Extension {
|
||||
private readonly logger = new Logger(AuthenticationExtension.name);
|
||||
|
||||
// Reference instant for the claimless-rejection grace window. Overridable so a
|
||||
// unit test can drive the pre-/post-grace boundary without wall-clock waits.
|
||||
protected rolloutAt = Date.now();
|
||||
|
||||
constructor(
|
||||
private tokenService: TokenService,
|
||||
private userRepo: UserRepo,
|
||||
private pageRepo: PageRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
) {}
|
||||
|
||||
async onAuthenticate(data: onAuthenticatePayload) {
|
||||
@@ -54,6 +71,36 @@ export class AuthenticationExtension implements Extension {
|
||||
throw new UnauthorizedException('Invalid collab token');
|
||||
}
|
||||
|
||||
// #501 — fail-closed api-key laundering guard. A collab token minted by an
|
||||
// api-key principal carries principal='api_key' + apiKeyId; re-check the key
|
||||
// on connect so a REVOKED key gets NO new collab connections (a collab token
|
||||
// outlives its 24h, but a revoked key can no longer open fresh ones). An
|
||||
// api-key token missing its apiKeyId is malformed → reject. A claimless token
|
||||
// (no recognized principal) is trusted only DURING the rollout grace window
|
||||
// (a legacy pre-rollout session token, which api keys could never mint);
|
||||
// once the grace has elapsed every valid token must carry the discriminator,
|
||||
// so a claimless one is a bug and is rejected (not silently trusted for 24h).
|
||||
const principal = jwtPayload.principal;
|
||||
if (principal === 'api_key') {
|
||||
if (!jwtPayload.apiKeyId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
// Row-check via the SHARED validator: throws Unauthorized on a revoked/
|
||||
// expired/disabled key; an infra error propagates (not masked). No new
|
||||
// connection for a dead key.
|
||||
await this.apiKeyService.validate({
|
||||
sub: jwtPayload.sub,
|
||||
workspaceId: jwtPayload.workspaceId,
|
||||
apiKeyId: jwtPayload.apiKeyId,
|
||||
type: JwtType.API_KEY,
|
||||
} as JwtApiKeyPayload);
|
||||
} else if (principal !== 'session') {
|
||||
// Unrecognized/absent discriminator: reject once past the grace window.
|
||||
if (Date.now() - this.rolloutAt >= COLLAB_TOKEN_GRACE_MS) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
|
||||
const userId = jwtPayload.sub;
|
||||
const workspaceId = jwtPayload.workspaceId;
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { jsonToText } from './collaboration.util';
|
||||
|
||||
// #502 READ contract: `jsonToText(json, { deterministic: true })` — the flat,
|
||||
// machine-diffable text rendering behind getPage `format:"text"`. Block-per-line
|
||||
// (`\n` separator), inline marks/anchors dropped, hardBreak -> `\n`, and non-text
|
||||
// nodes replaced by STABLE placeholders (`[image]`, `[table RxC]`). Output
|
||||
// stability across package versions IS the contract, so it is pinned by a
|
||||
// snapshot below. The DEFAULT (no options) path is the search-index serializer
|
||||
// and MUST be unchanged — asserted separately.
|
||||
|
||||
const doc = (...content: any[]) => ({ type: 'doc', content });
|
||||
const para = (...content: any[]) => ({ type: 'paragraph', content });
|
||||
const text = (t: string, marks?: any[]) =>
|
||||
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
||||
|
||||
describe('jsonToText — default (search index) behavior is unchanged', () => {
|
||||
it('uses the `\\n\\n` block separator and drops non-text nodes to empty', () => {
|
||||
const d = doc(para(text('alpha')), para(text('beta')));
|
||||
expect(jsonToText(d)).toBe('alpha\n\nbeta');
|
||||
});
|
||||
|
||||
it('an image contributes no text in the default (tsvector) mode', () => {
|
||||
const d = doc(para(text('cap')), { type: 'image', attrs: { src: 's' } });
|
||||
// No `[image]` placeholder leaks into the search index.
|
||||
expect(jsonToText(d)).not.toContain('[image]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonToText — deterministic:true (getPage format:"text")', () => {
|
||||
it('renders one line per block with `\\n` separators, marks dropped', () => {
|
||||
const d = doc(
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [text('Title')] },
|
||||
para(
|
||||
text('hello ', [{ type: 'bold' }]),
|
||||
text('world', [{ type: 'italic' }]),
|
||||
),
|
||||
);
|
||||
expect(jsonToText(d, { deterministic: true })).toBe('Title\nhello world');
|
||||
});
|
||||
|
||||
it('a hardBreak renders as a newline', () => {
|
||||
const d = doc(para(text('a'), { type: 'hardBreak' }, text('b')));
|
||||
expect(jsonToText(d, { deterministic: true })).toBe('a\nb');
|
||||
});
|
||||
|
||||
it('an image node -> the stable `[image]` placeholder', () => {
|
||||
const d = doc(para(text('before')), { type: 'image', attrs: { src: 's' } });
|
||||
expect(jsonToText(d, { deterministic: true })).toBe('before\n[image]');
|
||||
});
|
||||
|
||||
it('a table -> `[table RxC]` (rows x columns) and its cell text is NOT flattened in', () => {
|
||||
const cell = (t: string) => ({
|
||||
type: 'tableCell',
|
||||
content: [para(text(t))],
|
||||
});
|
||||
const row = (...cells: any[]) => ({ type: 'tableRow', content: cells });
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [
|
||||
row(cell('CELLONE'), cell('CELLTWO'), cell('CELLTHREE')),
|
||||
row(cell('CELLFOUR'), cell('CELLFIVE'), cell('CELLSIX')),
|
||||
],
|
||||
};
|
||||
const d = doc(para(text('grid:')), table);
|
||||
const out = jsonToText(d, { deterministic: true });
|
||||
expect(out).toBe('grid:\n[table 2x3]');
|
||||
expect(out).not.toContain('CELL'); // cell text is not flattened into the read
|
||||
});
|
||||
|
||||
it('SNAPSHOT: a mixed document renders to a stable, deterministic string', () => {
|
||||
const cell = (t: string) => ({
|
||||
type: 'tableCell',
|
||||
content: [para(text(t))],
|
||||
});
|
||||
const d = doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Config')] },
|
||||
para(text('key = ', [{ type: 'bold' }]), text('value')),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('one'))] },
|
||||
{ type: 'listItem', content: [para(text('two'))] },
|
||||
],
|
||||
},
|
||||
{ type: 'image', attrs: { src: 's' } },
|
||||
{
|
||||
type: 'table',
|
||||
content: [{ type: 'tableRow', content: [cell('x'), cell('y')] }],
|
||||
},
|
||||
);
|
||||
expect(jsonToText(d, { deterministic: true })).toMatchInlineSnapshot(`
|
||||
"Config
|
||||
key = value
|
||||
|
||||
|
||||
one
|
||||
|
||||
two
|
||||
[image]
|
||||
[table 1x2]"
|
||||
`);
|
||||
});
|
||||
|
||||
it('SCENARIO: a config written as a code block reads back byte-identical (diff empty)', () => {
|
||||
const config =
|
||||
'export TICKET_LIFETIME=$2592000\nservers:\n - www.internal.host';
|
||||
const d = doc({
|
||||
type: 'codeBlock',
|
||||
attrs: { language: 'yaml' },
|
||||
content: [text(config)],
|
||||
});
|
||||
// The code block is one block; its text (dollars, bare domain, newlines) is
|
||||
// preserved verbatim, so a read-as-text of a stored config diffs empty.
|
||||
expect(jsonToText(d, { deterministic: true })).toBe(config);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { readClientBuildVersion } from './client-version';
|
||||
|
||||
describe('readClientBuildVersion', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const writeVersionJson = (content: string) =>
|
||||
fs.writeFileSync(join(dir, 'version.json'), content);
|
||||
|
||||
it('returns the version from a valid version.json', () => {
|
||||
writeVersionJson(JSON.stringify({ version: 'test-A' }));
|
||||
expect(readClientBuildVersion(dir)).toBe('test-A');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace in the version', () => {
|
||||
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
|
||||
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
|
||||
});
|
||||
|
||||
it('returns "" when version.json is missing', () => {
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" on malformed JSON', () => {
|
||||
writeVersionJson('{ not json');
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when the version field is absent', () => {
|
||||
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when the version field is not a string', () => {
|
||||
writeVersionJson(JSON.stringify({ version: 123 }));
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when the path does not exist at all', () => {
|
||||
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to the built client bundle directory
|
||||
* (`apps/client/dist`) shipped into the runtime image.
|
||||
*
|
||||
* The `../` depth is anchored on THIS module's compiled location
|
||||
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
|
||||
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
|
||||
* MUST share this single helper rather than duplicating the depth — a copy in a
|
||||
* module at a different depth would silently resolve to the wrong directory.
|
||||
*/
|
||||
export function resolveClientDistPath(): string {
|
||||
return join(__dirname, '..', '..', '..', '..', 'client/dist');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the build version the client bundle was compiled with, from
|
||||
* `<clientDistPath>/version.json` (written by the Vite build — the single
|
||||
* source of truth shared by the baked-in `APP_VERSION` global and this file).
|
||||
*
|
||||
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
|
||||
* version) yields `''`. The caller treats an empty version as "unknown" and
|
||||
* the whole version-coherence feature stays silently inert — existing deploys
|
||||
* without the file keep working unchanged.
|
||||
*/
|
||||
export function readClientBuildVersion(clientDistPath: string): string {
|
||||
try {
|
||||
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
|
||||
const version = (JSON.parse(raw) as { version?: unknown }).version;
|
||||
return typeof version === 'string' ? version.trim() : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from './nanoid.utils';
|
||||
export * from './file.helper';
|
||||
export * from './constants';
|
||||
export * from './security-headers';
|
||||
export * from './client-version';
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||
@@ -43,6 +44,8 @@ import {
|
||||
AiChatRunHooks,
|
||||
AiChatService,
|
||||
AiChatStreamBody,
|
||||
rowHasInlineParts,
|
||||
hydrateAssistantParts,
|
||||
} from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import { AiTranscriptionService } from './ai-transcription.service';
|
||||
@@ -129,8 +132,39 @@ export class AiChatController {
|
||||
// production. Only touched on the resumable-stream (flag-on) path.
|
||||
private readonly streamRegistry?: AiChatStreamRegistryService,
|
||||
private readonly environment?: EnvironmentService,
|
||||
// #492: reconstruct a #492 mid-run record's parts from the steps table before
|
||||
// returning rows to the client / export. OPTIONAL so positional controller
|
||||
// specs compile unchanged; when absent, hydration is skipped (old-era rows
|
||||
// already carry inline parts, so nothing to reconstruct).
|
||||
private readonly aiChatRunStepRepo?: AiChatRunStepRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Reconstruct parts for any assistant rows that don't carry them INLINE — a
|
||||
* #492 mid-run record whose per-step parts live in `ai_chat_run_steps` (the
|
||||
* append-persist backend). Every FINISHED row (old-era + #492) and every old-era
|
||||
* streaming snapshot already has inline `metadata.parts`, so the common path
|
||||
* fetches NOTHING and returns the rows untouched; only an actively-streaming
|
||||
* new-style row triggers the batch step fetch. Consumers (seed/poll/export) read
|
||||
* `metadata.parts` off the returned rows exactly as before — the era switch is
|
||||
* invisible to them (reconstructRunParts contract).
|
||||
*/
|
||||
private async withReconstructedParts(
|
||||
rows: AiChatMessage[],
|
||||
workspaceId: string,
|
||||
): Promise<AiChatMessage[]> {
|
||||
if (!this.aiChatRunStepRepo) return rows;
|
||||
const needy = rows.filter(
|
||||
(r) => r.role === 'assistant' && !rowHasInlineParts(r),
|
||||
);
|
||||
if (needy.length === 0) return rows;
|
||||
const stepsByMessage = await this.aiChatRunStepRepo.findByMessageIds(
|
||||
needy.map((r) => r.id),
|
||||
workspaceId,
|
||||
);
|
||||
return hydrateAssistantParts(rows, stepsByMessage);
|
||||
}
|
||||
|
||||
/** List the requesting user's chats in this workspace (paginated). */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('chats')
|
||||
@@ -184,11 +218,17 @@ export class AiChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||
return this.aiChatMessageRepo.findByChat(
|
||||
const page = await this.aiChatMessageRepo.findByChat(
|
||||
dto.chatId,
|
||||
workspace.id,
|
||||
pagination,
|
||||
);
|
||||
// #492: reconstruct parts for any active new-style row so the client seed sees
|
||||
// `metadata.parts` unchanged (a no-op for the finished rows that fill a page).
|
||||
return {
|
||||
...page,
|
||||
items: await this.withReconstructedParts(page.items, workspace.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,7 +265,10 @@ export class AiChatController {
|
||||
workspace.id,
|
||||
);
|
||||
return {
|
||||
rows,
|
||||
// #492: the delta of an actively-streaming new-style row carries its parts
|
||||
// reconstructed from the steps table, so the degraded poll shows persisted
|
||||
// progress exactly as the pre-#492 full-row snapshot did.
|
||||
rows: await this.withReconstructedParts(rows, workspace.id),
|
||||
cursor,
|
||||
run: run ? { id: run.id, status: run.status } : null,
|
||||
};
|
||||
@@ -247,8 +290,10 @@ export class AiChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ markdown: string }> {
|
||||
const chat = await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||
const rows = await this.aiChatMessageRepo.findAllByChat(
|
||||
dto.chatId,
|
||||
const rows = await this.withReconstructedParts(
|
||||
await this.aiChatMessageRepo.findAllByChat(dto.chatId, workspace.id),
|
||||
// #492: an interrupted-but-still-active turn exports its persisted steps
|
||||
// (reconstructed from the steps table) just like the pre-#492 full row did.
|
||||
workspace.id,
|
||||
);
|
||||
const markdown = buildChatMarkdown({
|
||||
@@ -288,7 +333,13 @@ export class AiChatController {
|
||||
workspace.id,
|
||||
)
|
||||
: undefined;
|
||||
return { run, message: message ?? null };
|
||||
// #492: reconnect to an IN-FLIGHT run reconstructs the projection row's parts
|
||||
// from the steps table (the row itself carries only the step marker mid-run);
|
||||
// a finished run's row already has inline parts, so this is a no-op.
|
||||
const [hydrated] = message
|
||||
? await this.withReconstructedParts([message], workspace.id)
|
||||
: [undefined];
|
||||
return { run, message: hydrated ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
|
||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
|
||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
@@ -189,10 +190,11 @@ export function stepBudgetWarning(stepNumber: number): string {
|
||||
//
|
||||
// `system` is the in-scope system prompt; we CONCATENATE so the original
|
||||
// persona/context is preserved — a bare `system` override would REPLACE the
|
||||
// whole system prompt for the step. `activatedTools` is PER-TURN mutable state
|
||||
// owned by the streaming loop (a closure Set grown by loadTools); it is passed
|
||||
// in (not module-global, not persisted) so this stays a pure function of its
|
||||
// arguments.
|
||||
// whole system prompt for the step. `activatedTools` is a closure Set grown by
|
||||
// loadTools and owned by the streaming loop; the caller seeds it from and
|
||||
// persists it to the chat's metadata across turns (#490), but this function only
|
||||
// READS the Set it is handed, so it stays a pure function of its arguments (not
|
||||
// module-global).
|
||||
//
|
||||
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
|
||||
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
|
||||
@@ -517,6 +519,12 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
// constructions compile unchanged; Nest always injects the real singleton, so
|
||||
// reconcile sees the SAME in-memory active/zombie maps the runner mutates.
|
||||
private readonly aiChatRunService?: AiChatRunService,
|
||||
// #492 append-persist: per-step INSERT into the lightweight steps table (the
|
||||
// O(Σ steps) replacement for the O(n²) full-row `metadata.parts` rewrite).
|
||||
// OPTIONAL so existing positional constructions (int-specs) compile unchanged;
|
||||
// Nest injects the real singleton. When ABSENT the per-step path falls back to
|
||||
// the pre-#492 full-row flush (no regression, only no WAL win).
|
||||
private readonly aiChatRunStepRepo?: AiChatRunStepRepo,
|
||||
) {}
|
||||
|
||||
// #487: periodic reconcile timer (single-process phase 1). Started in
|
||||
@@ -1113,8 +1121,34 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
chatId,
|
||||
workspace.id,
|
||||
);
|
||||
// #492: HYDRATE needy assistant rows from the steps table BEFORE the replay
|
||||
// map. A #492 mid-run assistant row carries only a step marker
|
||||
// (metadata.parts:[]); its real per-step parts live in `ai_chat_run_steps`.
|
||||
// The graceful terminal callbacks (onFinish/onError/onAbort -> flushAssistant)
|
||||
// assemble the full inline parts, so a normally-ended turn already has them.
|
||||
// But a HARD crash mid-run (SIGKILL/OOM) fires NO terminal callback, so the
|
||||
// row stays parts:[]; without this, rowToUiMessage falls back to an empty
|
||||
// text part and the partial tool-calls/results/text — durable in the steps
|
||||
// table — would DROP OUT of the model's replay context (regressing #183
|
||||
// step-granular durability for the model consumer). Mirrors the controller's
|
||||
// withReconstructedParts EXACTLY (same needy predicate + hydration helper).
|
||||
// Guarded on the optional repo: absent (positional test builds) degrades to
|
||||
// the current behavior rather than crashing.
|
||||
let replayHistory = oldHistory;
|
||||
if (this.aiChatRunStepRepo) {
|
||||
const needy = oldHistory.filter(
|
||||
(r) => r.role === 'assistant' && !rowHasInlineParts(r),
|
||||
);
|
||||
if (needy.length > 0) {
|
||||
const stepsByMessage = await this.aiChatRunStepRepo.findByMessageIds(
|
||||
needy.map((r) => r.id),
|
||||
workspace.id,
|
||||
);
|
||||
replayHistory = hydrateAssistantParts(oldHistory, stepsByMessage);
|
||||
}
|
||||
}
|
||||
const uiMessages: Array<Omit<UIMessage, 'id'> & { id: string }> = [
|
||||
...oldHistory.map(rowToUiMessage),
|
||||
...replayHistory.map(rowToUiMessage),
|
||||
{
|
||||
id: 'pending-user',
|
||||
role: 'user',
|
||||
@@ -1153,7 +1187,9 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
// hint — confirm it against the persisted history (the preceding assistant
|
||||
// turn must really be aborted/streaming) so a spoofed flag cannot inject the
|
||||
// interrupt note onto an ordinary turn. The partial output the model needs is
|
||||
// already in `messages` (the aborted assistant row replays via findRecent).
|
||||
// already in `messages`: a #492 mid-run row's per-step parts live only in the
|
||||
// `ai_chat_run_steps` table and were hydrated into the replay history above,
|
||||
// so the aborted assistant turn replays WITH its partial parts intact.
|
||||
// Append the new user turn (shape-only) so index -2 is the prior assistant.
|
||||
const interrupted = isInterruptResume(
|
||||
[...oldHistory, { role: 'user', status: null, metadata: null }],
|
||||
@@ -1410,10 +1446,11 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
const baseTools = { ...external.tools, ...docmostTools };
|
||||
|
||||
// Deferred tool loading state (#332), scoped to THIS streaming loop:
|
||||
// - `activatedTools` is per-TURN mutable state — a fresh closure Set created
|
||||
// per streamText call, NOT module-global and NOT persisted, so a new turn
|
||||
// starts cold. loadTools.execute adds to it; prepareAgentStep reads it to
|
||||
// widen `activeTools` on the NEXT step.
|
||||
// - `activatedTools` is a fresh closure Set per streamText call (not
|
||||
// module-global), SEEDED from the chat's persisted metadata.activatedTools
|
||||
// (#490, just below) so activation carries across turns. loadTools.execute
|
||||
// adds to it; prepareAgentStep reads it to widen `activeTools` on the NEXT
|
||||
// step; turn end persists it back.
|
||||
// - `validDeferredNames` = every tool that is NOT core (the in-app deferred
|
||||
// tools + ALL external MCP tools), computed from the ACTUAL toolset so an
|
||||
// external tool is loadable by its namespaced name. loadTools rejects any
|
||||
@@ -1557,17 +1594,57 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
// connection when finalize runs, so the SQL `WHERE status='streaming'`
|
||||
// (not this flag) is what prevents it clobbering the terminal row.
|
||||
if (finalized) return null;
|
||||
// Build the flush ONCE so the returned count is EXACTLY the persisted
|
||||
// `stepsPersisted` (both derive from capturedSteps.length at this instant).
|
||||
const flushed = flushAssistant(capturedSteps, '', 'streaming', {
|
||||
pageChanged,
|
||||
partsCache,
|
||||
});
|
||||
const stepsPersisted = flushed.metadata.stepsPersisted as number;
|
||||
// The count derives from capturedSteps.length at THIS instant, so the
|
||||
// returned value is EXACTLY the persisted `stepsPersisted` the ring rotates
|
||||
// on (whether we take the append-persist path or the legacy fallback).
|
||||
const stepsPersisted = capturedSteps.length;
|
||||
try {
|
||||
await this.aiChatMessageRepo.update(assistantId, workspace.id, flushed, {
|
||||
onlyIfStreaming: true,
|
||||
});
|
||||
if (this.aiChatRunStepRepo) {
|
||||
// #492 APPEND-PERSIST: write only THIS finished step's parts to the
|
||||
// steps table (O(step) WAL), then bump the row's CHEAP step marker —
|
||||
// NO growing `metadata.parts` blob (that O(n²) full-row rewrite is
|
||||
// exactly what this removes). The full `metadata.parts` is assembled
|
||||
// once at finalize; a mid-run resume seed is reconstructed from the
|
||||
// step rows (reconstructRunParts). The INSERT is idempotent
|
||||
// (ON CONFLICT DO NOTHING), so a re-fired step never doubles the parts.
|
||||
const index = stepsPersisted - 1;
|
||||
if (index >= 0) {
|
||||
const stepParts = assistantParts(
|
||||
[capturedSteps[index]],
|
||||
'',
|
||||
partsCache,
|
||||
);
|
||||
await this.aiChatRunStepRepo.insertStep(
|
||||
assistantId,
|
||||
workspace.id,
|
||||
index,
|
||||
stepParts,
|
||||
);
|
||||
}
|
||||
// Marker UPDATE: advance stepsPersisted + keep the toolTrace era marker
|
||||
// (bumps updatedAt so the delta poll observes the step, and carries the
|
||||
// frontier a resuming client attaches from). Scoped onlyIfStreaming so a
|
||||
// late marker never clobbers the terminal finalize.
|
||||
await this.aiChatMessageRepo.update(
|
||||
assistantId,
|
||||
workspace.id,
|
||||
{ metadata: stepMarkerMetadata(stepsPersisted) },
|
||||
{ onlyIfStreaming: true },
|
||||
);
|
||||
} else {
|
||||
// Legacy fallback (no steps table wired — positional test builds): the
|
||||
// pre-#492 full-row flush, so parts still land inline on the row.
|
||||
const flushed = flushAssistant(capturedSteps, '', 'streaming', {
|
||||
pageChanged,
|
||||
partsCache,
|
||||
});
|
||||
await this.aiChatMessageRepo.update(
|
||||
assistantId,
|
||||
workspace.id,
|
||||
flushed,
|
||||
{ onlyIfStreaming: true },
|
||||
);
|
||||
}
|
||||
return stepsPersisted;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
@@ -2747,6 +2824,122 @@ export function rowToUiMessage(row: AiChatMessage): Omit<UIMessage, 'id'> & {
|
||||
return { id: row.id, role, parts: parts as UIMessage['parts'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap step-marker metadata for the #492 per-step UPDATE. Advances
|
||||
* `stepsPersisted` (the resume attach frontier) and keeps the `toolTraceVersion`
|
||||
* era marker, WITHOUT the growing `parts` blob (those live in the steps table
|
||||
* now; the full `metadata.parts` is assembled once at finalize by flushAssistant).
|
||||
* `parts: []` is kept for shape stability — it reads as an empty inline-parts row,
|
||||
* which is exactly the discriminator that routes reconstruction to the steps table.
|
||||
*/
|
||||
export function stepMarkerMetadata(
|
||||
stepsPersisted: number,
|
||||
): Record<string, unknown> {
|
||||
return { parts: [], toolTraceVersion: 2, stepsPersisted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an assistant row already carries its full UI parts INLINE on the row
|
||||
* (`metadata.parts`). TRUE for every FINISHED row — old-era rows AND #492 rows,
|
||||
* whose full parts are assembled once at finalize — and for old-era streaming
|
||||
* snapshots (the pre-#492 per-step full-row flush). FALSE for a #492 MID-RUN
|
||||
* record, whose per-step parts live in the `ai_chat_run_steps` table. This is the
|
||||
* era discriminator the reconstruct seam branches on — no schema flag needed.
|
||||
*/
|
||||
export function rowHasInlineParts(row: { metadata?: unknown }): boolean {
|
||||
const meta = (row.metadata ?? {}) as { parts?: unknown };
|
||||
return Array.isArray(meta.parts) && meta.parts.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate persisted per-step parts (in `stepIndex` order) into the turn's UI
|
||||
* parts (#492). Reproduces EXACTLY what flushAssistant → assistantParts would have
|
||||
* written to `metadata.parts` for those finished steps, since each step row stored
|
||||
* `assistantParts([step])` at persist time.
|
||||
*/
|
||||
export function assembleStepParts(
|
||||
stepRows: ReadonlyArray<{ stepIndex: number; parts: unknown }>,
|
||||
): UIMessage['parts'] {
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
for (const step of [...stepRows].sort((a, b) => a.stepIndex - b.stepIndex)) {
|
||||
if (Array.isArray(step.parts)) {
|
||||
parts.push(...(step.parts as Array<Record<string, unknown>>));
|
||||
}
|
||||
}
|
||||
return parts as UIMessage['parts'];
|
||||
}
|
||||
|
||||
/**
|
||||
* reconstructRunParts (#492) — the single backend-switch seam. Given an assistant
|
||||
* ROW and its persisted step rows, return the turn's UI `parts` + the persisted
|
||||
* step count, reading from the ROW when it already carries inline parts (old-era
|
||||
* records AND every finished record) and from the STEPS TABLE otherwise (a #492
|
||||
* mid-run record). The higher-level consumers (attach seed, delta poll, export)
|
||||
* route their row→parts through this / {@link hydrateAssistantParts}, so old and
|
||||
* new records reconstruct identically WITHOUT the consumers branching on the era.
|
||||
*/
|
||||
export function reconstructRunParts(
|
||||
row: { metadata?: unknown; content?: string | null },
|
||||
stepRows: ReadonlyArray<{ stepIndex: number; parts: unknown }>,
|
||||
): { parts: UIMessage['parts']; stepsPersisted: number } {
|
||||
if (rowHasInlineParts(row)) {
|
||||
const meta = row.metadata as {
|
||||
parts: UIMessage['parts'];
|
||||
stepsPersisted?: number;
|
||||
};
|
||||
return {
|
||||
parts: meta.parts,
|
||||
stepsPersisted:
|
||||
typeof meta.stepsPersisted === 'number'
|
||||
? meta.stepsPersisted
|
||||
: stepRows.length,
|
||||
};
|
||||
}
|
||||
if (stepRows.length > 0) {
|
||||
return {
|
||||
parts: assembleStepParts(stepRows),
|
||||
stepsPersisted: stepRows.length,
|
||||
};
|
||||
}
|
||||
// No inline parts and no step rows: an old-era seed / empty streaming row. Fall
|
||||
// back to a single text part from `content` (mirrors rowToUiMessage).
|
||||
return {
|
||||
parts: textPart(row.content ?? '') as UIMessage['parts'],
|
||||
stepsPersisted: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill each assistant row's `metadata.parts` from its step rows when the row does
|
||||
* not already carry them inline (a #492 mid-run record), so a consumer that reads
|
||||
* `metadata.parts` off the RAW row (the client seed/poll, the Markdown export)
|
||||
* sees the reconstructed parts with NO change to itself. Rows that already have
|
||||
* inline parts (old-era + finished) and non-assistant rows pass through untouched.
|
||||
* Pure: returns new row objects, never mutates the inputs.
|
||||
*/
|
||||
export function hydrateAssistantParts<
|
||||
T extends { id: string; role?: string; metadata?: unknown },
|
||||
>(
|
||||
rows: ReadonlyArray<T>,
|
||||
stepsByMessage: Map<
|
||||
string,
|
||||
ReadonlyArray<{ stepIndex: number; parts: unknown }>
|
||||
>,
|
||||
): T[] {
|
||||
return rows.map((row) => {
|
||||
if (row.role !== 'assistant' || rowHasInlineParts(row)) return row;
|
||||
const steps = stepsByMessage.get(row.id);
|
||||
if (!steps || steps.length === 0) return row;
|
||||
return {
|
||||
...row,
|
||||
metadata: {
|
||||
...((row.metadata ?? {}) as Record<string, unknown>),
|
||||
parts: assembleStepParts(steps),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted-row patch shape produced by {@link flushAssistant}. It is the
|
||||
* SAME shape the assistant repo insert/update consume (content + toolCalls +
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { ApiKeyController } from './api-key.controller';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../casl/interfaces/workspace-ability.type';
|
||||
|
||||
/**
|
||||
* Authorization contract for the /api-keys management surface.
|
||||
*
|
||||
* - A token cannot manage tokens (an api_key PRINCIPAL is 403 on every method)
|
||||
* — GitHub-PAT semantics closing post-revocation laundering.
|
||||
* - admin (CASL Manage on API) sees/revokes all workspace keys; a member only
|
||||
* their own.
|
||||
* - kill-switch OFF -> the surface 404s (looks like the feature does not exist).
|
||||
*/
|
||||
|
||||
function makeController(over: any = {}) {
|
||||
const apiKeyService = {
|
||||
create: jest.fn().mockResolvedValue({
|
||||
token: 'tok',
|
||||
key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() },
|
||||
}),
|
||||
revoke: jest.fn().mockResolvedValue(undefined),
|
||||
...(over.apiKeyService ?? {}),
|
||||
};
|
||||
const apiKeyRepo = {
|
||||
findAllInWorkspace: jest.fn().mockResolvedValue([]),
|
||||
findByCreator: jest.fn().mockResolvedValue([]),
|
||||
findById: jest.fn(),
|
||||
...(over.apiKeyRepo ?? {}),
|
||||
};
|
||||
const workspaceAbility = {
|
||||
createForUser: jest.fn().mockReturnValue({
|
||||
can: (_a: any, _s: any) => over.canManage ?? false,
|
||||
}),
|
||||
...(over.workspaceAbility ?? {}),
|
||||
};
|
||||
const environmentService = {
|
||||
isApiKeysEnabled: jest.fn().mockReturnValue(over.enabled ?? true),
|
||||
...(over.environmentService ?? {}),
|
||||
};
|
||||
const auditService = { log: jest.fn() };
|
||||
const controller = new ApiKeyController(
|
||||
apiKeyService as any,
|
||||
apiKeyRepo as any,
|
||||
workspaceAbility as any,
|
||||
environmentService as any,
|
||||
auditService as any,
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
apiKeyService,
|
||||
apiKeyRepo,
|
||||
workspaceAbility,
|
||||
environmentService,
|
||||
auditService,
|
||||
};
|
||||
}
|
||||
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
const workspace = { id: 'ws-1' } as any;
|
||||
const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any;
|
||||
const reqApiKey = () =>
|
||||
({ raw: { authType: 'api_key', apiKeyId: 'k-self' }, ip: '1.2.3.4', socket: {} }) as any;
|
||||
|
||||
describe('ApiKeyController — a token cannot manage tokens', () => {
|
||||
it('403 on create for an api_key principal', async () => {
|
||||
const { controller, apiKeyService } = makeController();
|
||||
await expect(
|
||||
controller.create({ name: 'x' } as any, user, reqApiKey()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(apiKeyService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 on list for an api_key principal', async () => {
|
||||
const { controller } = makeController();
|
||||
await expect(
|
||||
controller.list(user, workspace, reqApiKey()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('403 on revoke for an api_key principal', async () => {
|
||||
const { controller } = makeController();
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — kill-switch OFF → 404', () => {
|
||||
it('create/list/revoke all 404 when disabled', async () => {
|
||||
const { controller } = makeController({ enabled: false });
|
||||
await expect(
|
||||
controller.create({ name: 'x' } as any, user, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(
|
||||
controller.list(user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — list scoping', () => {
|
||||
it('admin (Manage on API) lists ALL workspace keys', async () => {
|
||||
const { controller, apiKeyRepo } = makeController({ canManage: true });
|
||||
await controller.list(user, workspace, reqAccess());
|
||||
expect(apiKeyRepo.findAllInWorkspace).toHaveBeenCalledWith('ws-1');
|
||||
expect(apiKeyRepo.findByCreator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('member lists ONLY their own keys', async () => {
|
||||
const { controller, apiKeyRepo } = makeController({ canManage: false });
|
||||
await controller.list(user, workspace, reqAccess());
|
||||
expect(apiKeyRepo.findByCreator).toHaveBeenCalledWith('u-1', 'ws-1');
|
||||
expect(apiKeyRepo.findAllInWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — revoke scoping', () => {
|
||||
it("member cannot revoke another user's key (403)", async () => {
|
||||
const { controller, apiKeyRepo, apiKeyService } = makeController({
|
||||
canManage: false,
|
||||
});
|
||||
apiKeyRepo.findById.mockResolvedValue({
|
||||
id: 'k1',
|
||||
creatorId: 'someone-else',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(apiKeyService.revoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('member CAN revoke their own key', async () => {
|
||||
const { controller, apiKeyRepo, apiKeyService } = makeController({
|
||||
canManage: false,
|
||||
});
|
||||
apiKeyRepo.findById.mockResolvedValue({
|
||||
id: 'k1',
|
||||
creatorId: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
|
||||
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
|
||||
});
|
||||
|
||||
it("admin CAN revoke another user's key", async () => {
|
||||
const { controller, apiKeyRepo, apiKeyService } = makeController({
|
||||
canManage: true,
|
||||
});
|
||||
apiKeyRepo.findById.mockResolvedValue({
|
||||
id: 'k1',
|
||||
creatorId: 'someone-else',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
|
||||
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
|
||||
});
|
||||
|
||||
it('404 when the key does not exist', async () => {
|
||||
const { controller, apiKeyRepo } = makeController({ canManage: true });
|
||||
apiKeyRepo.findById.mockResolvedValue(undefined);
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — create emits audit + returns token once', () => {
|
||||
it('logs API_KEY_CREATED and returns the token + computed expiry', async () => {
|
||||
const { controller, auditService } = makeController();
|
||||
const res = await controller.create(
|
||||
{ name: 'ci' } as any,
|
||||
user,
|
||||
reqAccess(),
|
||||
);
|
||||
expect(res.token).toBe('tok');
|
||||
expect(res.apiKey).toMatchObject({ id: 'k1', name: 'n' });
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'api_key.created' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Sanity: the CASL subject used is the workspace API subject.
|
||||
it('uses WorkspaceCaslSubject.API with the Manage action', () => {
|
||||
expect(WorkspaceCaslSubject.API).toBe('api_key');
|
||||
expect(WorkspaceCaslAction.Manage).toBeDefined();
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { RevokeApiKeyDto } from './dto/revoke-api-key.dto';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../casl/interfaces/workspace-ability.type';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||
import { AUTH_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('api-keys')
|
||||
export class ApiKeyController {
|
||||
private readonly logger = new Logger(ApiKeyController.name);
|
||||
|
||||
constructor(
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
private readonly apiKeyRepo: ApiKeyRepo,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
// Kill-switch OFF: the issuance surface disappears entirely (404), not a 403 —
|
||||
// it must look like the feature does not exist.
|
||||
private assertEnabled(): void {
|
||||
if (!this.environmentService.isApiKeysEnabled()) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
// A token cannot manage tokens (GitHub-PAT semantics): an api-key principal is
|
||||
// refused on the management surface, so a leaked key cannot mint a replacement
|
||||
// or revoke the keys that would lock it out (closes post-revocation laundering).
|
||||
private rejectApiKeyPrincipal(req: FastifyRequest): void {
|
||||
if ((req.raw as { authType?: string }).authType === 'api_key') {
|
||||
throw new ForbiddenException('API keys cannot manage API keys');
|
||||
}
|
||||
}
|
||||
|
||||
private clientIp(req: FastifyRequest): string {
|
||||
return req.ip ?? req.socket?.remoteAddress ?? 'unknown';
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [AUTH_THROTTLER]: { limit: 10, ttl: 60_000 } })
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
@AuthUser() user: User,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
// undefined -> service default (1 year); null -> unlimited; string -> Date.
|
||||
const expiresAt =
|
||||
dto.expiresAt === undefined
|
||||
? undefined
|
||||
: dto.expiresAt === null
|
||||
? null
|
||||
: new Date(dto.expiresAt);
|
||||
|
||||
const { token, key } = await this.apiKeyService.create(
|
||||
user,
|
||||
dto.name,
|
||||
expiresAt,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.API_KEY_CREATED,
|
||||
resourceType: AuditResource.API_KEY,
|
||||
resourceId: key.id,
|
||||
});
|
||||
// The durable trail lives in container logs (AUDIT_SERVICE is a Noop in this
|
||||
// build). No token material — the JWT is only ever returned in the response.
|
||||
this.logger.log(
|
||||
`API key created: id=${key.id} name=${JSON.stringify(
|
||||
key.name,
|
||||
)} actor=${user.id} expiresAt=${
|
||||
key.expiresAt ? new Date(key.expiresAt).toISOString() : 'never'
|
||||
} ip=${this.clientIp(req)}`,
|
||||
);
|
||||
|
||||
// Return the token ONCE (never retrievable again) and the computed expiry so
|
||||
// the caller/UI can surface "expires <date>" (the year-default time-bomb
|
||||
// early-warning).
|
||||
return {
|
||||
token,
|
||||
apiKey: {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
expiresAt: key.expiresAt,
|
||||
createdAt: key.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('list')
|
||||
async list(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
// Admin (CASL Manage on API): every key in the workspace, attributed to its
|
||||
// creator (closes "a leaked key of a departed employee is invisible"). A
|
||||
// member sees only their own.
|
||||
const keys = ability.can(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.API,
|
||||
)
|
||||
? await this.apiKeyRepo.findAllInWorkspace(workspace.id)
|
||||
: await this.apiKeyRepo.findByCreator(user.id, workspace.id);
|
||||
|
||||
return keys.map((k) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
expiresAt: k.expiresAt,
|
||||
lastUsedAt: k.lastUsedAt,
|
||||
createdAt: k.createdAt,
|
||||
creator: k.creator,
|
||||
}));
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('revoke')
|
||||
async revoke(
|
||||
@Body() dto: RevokeApiKeyDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
const key = await this.apiKeyRepo.findById(dto.id, workspace.id);
|
||||
// Uniform 404 for a missing/already-revoked key regardless of who asks — no
|
||||
// existence oracle for keys the caller may not own.
|
||||
if (!key) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
const canManageAll = ability.can(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.API,
|
||||
);
|
||||
// Admin may revoke any key in the workspace; a member only their own.
|
||||
if (!canManageAll && key.creatorId !== user.id) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.apiKeyService.revoke(key.id, workspace.id);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.API_KEY_DELETED,
|
||||
resourceType: AuditResource.API_KEY,
|
||||
resourceId: key.id,
|
||||
});
|
||||
this.logger.log(
|
||||
`API key revoked: id=${key.id} name=${JSON.stringify(
|
||||
key.name,
|
||||
)} actor=${user.id} ip=${this.clientIp(req)}`,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { ApiKeyController } from './api-key.controller';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
|
||||
// Core (non-EE) API-key feature: issuance REST endpoints + the shared validator
|
||||
// consumed by jwt.strategy (REST) and McpService (the /mcp Bearer router).
|
||||
// DatabaseModule (global) provides ApiKeyRepo/UserRepo/WorkspaceRepo; CaslModule
|
||||
// (global) provides WorkspaceAbilityFactory; TokenModule provides TokenService
|
||||
// (the no-exp api-key signer). ApiKeyService is exported so AuthModule (for
|
||||
// jwt.strategy) and McpModule (for the /mcp router) can inject it directly,
|
||||
// replacing the absent EE `ee/api-key` dynamic require.
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
controllers: [ApiKeyController],
|
||||
providers: [ApiKeyService],
|
||||
exports: [ApiKeyService],
|
||||
})
|
||||
export class ApiKeyModule {}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { JwtType } from '../auth/dto/jwt-payload';
|
||||
|
||||
/**
|
||||
* Security contract for ApiKeyService.validate — the single validator shared by
|
||||
* jwt.strategy (REST) and the /mcp Bearer router.
|
||||
*
|
||||
* Invariants under test:
|
||||
* - Anti-enumeration: every DEFINITE deny (missing/revoked/expired row, disabled
|
||||
* user, workspace mismatch, kill-switch off) throws the SAME bare
|
||||
* UnauthorizedException — an agent cannot distinguish expired from revoked.
|
||||
* - deny-on-decision / 5xx-on-infra: an UNEXPECTED (infra) error PROPAGATES as
|
||||
* itself (→ 5xx), never masked as a 401.
|
||||
* - Expiry is read from the ROW, never a JWT exp claim.
|
||||
* - No validate cache (each call re-reads the row → immediate revocation).
|
||||
*/
|
||||
|
||||
const APP_SECRET = 'secret';
|
||||
|
||||
function makeDeps(over: Partial<Record<string, any>> = {}) {
|
||||
const apiKeyRepo = {
|
||||
findById: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
touchLastUsed: jest.fn().mockResolvedValue(undefined),
|
||||
...(over.apiKeyRepo ?? {}),
|
||||
};
|
||||
const userRepo = {
|
||||
findById: jest.fn(),
|
||||
...(over.userRepo ?? {}),
|
||||
};
|
||||
const workspaceRepo = {
|
||||
findById: jest.fn().mockResolvedValue({ id: 'ws-1' }),
|
||||
...(over.workspaceRepo ?? {}),
|
||||
};
|
||||
const tokenService = {
|
||||
generateApiToken: jest.fn().mockResolvedValue('minted.jwt.token'),
|
||||
...(over.tokenService ?? {}),
|
||||
};
|
||||
const environmentService = {
|
||||
isApiKeysEnabled: jest.fn().mockReturnValue(true),
|
||||
getApiKeysEnabledRaw: jest.fn().mockReturnValue(undefined),
|
||||
getAppSecret: jest.fn().mockReturnValue(APP_SECRET),
|
||||
...(over.environmentService ?? {}),
|
||||
};
|
||||
const service = new (ApiKeyService as unknown as new (...a: any[]) => ApiKeyService)(
|
||||
apiKeyRepo,
|
||||
userRepo,
|
||||
workspaceRepo,
|
||||
tokenService,
|
||||
environmentService,
|
||||
);
|
||||
return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService, environmentService };
|
||||
}
|
||||
|
||||
const payload = (over: Record<string, any> = {}) => ({
|
||||
sub: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
apiKeyId: 'key-1',
|
||||
type: JwtType.API_KEY,
|
||||
...over,
|
||||
});
|
||||
|
||||
const activeRow = (over: Record<string, any> = {}) => ({
|
||||
id: 'key-1',
|
||||
name: 'k',
|
||||
creatorId: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
deletedAt: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const activeUser = (over: Record<string, any> = {}) => ({
|
||||
id: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
isAgent: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('ApiKeyService.validate', () => {
|
||||
it('returns { user, workspace } for a valid active key', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
|
||||
const res = await service.validate(payload() as any);
|
||||
|
||||
expect(res.user).toMatchObject({ id: 'u-1' });
|
||||
expect(res.workspace).toMatchObject({ id: 'ws-1' });
|
||||
// Loads isAgent so downstream provenance does not silently degrade.
|
||||
expect(userRepo.findById).toHaveBeenCalledWith('u-1', 'ws-1', {
|
||||
includeIsAgent: true,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Anti-enumeration: every deny is the SAME bare 401 -------------------
|
||||
const denyCases: Array<[string, (d: ReturnType<typeof makeDeps>) => void]> = [
|
||||
[
|
||||
'missing/orphaned row',
|
||||
(d) => d.apiKeyRepo.findById.mockResolvedValue(undefined),
|
||||
],
|
||||
[
|
||||
'revoked (soft-deleted → findById returns undefined)',
|
||||
(d) => d.apiKeyRepo.findById.mockResolvedValue(undefined),
|
||||
],
|
||||
[
|
||||
'expired (expires_at in the past)',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(
|
||||
activeRow({ expiresAt: new Date(Date.now() - 1000) }),
|
||||
);
|
||||
d.userRepo.findById.mockResolvedValue(activeUser());
|
||||
},
|
||||
],
|
||||
[
|
||||
'disabled user',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
d.userRepo.findById.mockResolvedValue(
|
||||
activeUser({ deactivatedAt: new Date() }),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'user not found',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
d.userRepo.findById.mockResolvedValue(undefined);
|
||||
},
|
||||
],
|
||||
[
|
||||
'creator/sub mismatch',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow({ creatorId: 'other' }));
|
||||
d.userRepo.findById.mockResolvedValue(activeUser());
|
||||
},
|
||||
],
|
||||
[
|
||||
'workspace row missing',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
d.userRepo.findById.mockResolvedValue(activeUser());
|
||||
d.workspaceRepo.findById.mockResolvedValue(undefined);
|
||||
},
|
||||
],
|
||||
[
|
||||
'kill-switch OFF',
|
||||
(d) => d.environmentService.isApiKeysEnabled.mockReturnValue(false),
|
||||
],
|
||||
[
|
||||
'malformed payload (missing apiKeyId)',
|
||||
() => undefined,
|
||||
],
|
||||
];
|
||||
|
||||
it.each(denyCases)(
|
||||
'throws a bare UnauthorizedException with NO message for: %s',
|
||||
async (label, arrange) => {
|
||||
const deps = makeDeps();
|
||||
arrange(deps);
|
||||
const p =
|
||||
label === 'malformed payload (missing apiKeyId)'
|
||||
? (payload({ apiKeyId: undefined }) as any)
|
||||
: (payload() as any);
|
||||
|
||||
const err = await deps.service.validate(p).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(UnauthorizedException);
|
||||
// Anti-enumeration: identical, class-less message for every failure mode.
|
||||
expect(err.message).toBe('Unauthorized');
|
||||
},
|
||||
);
|
||||
|
||||
it('kill-switch OFF denies BEFORE any DB read', async () => {
|
||||
const { service, apiKeyRepo, environmentService } = makeDeps();
|
||||
environmentService.isApiKeysEnabled.mockReturnValue(false);
|
||||
|
||||
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(apiKeyRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- Infra error propagates (5xx), NOT masked as 401 ---------------------
|
||||
it('PROPAGATES an infra (DB) error instead of masking it as 401', async () => {
|
||||
const { service, apiKeyRepo } = makeDeps();
|
||||
const boom = new Error('connection terminated');
|
||||
apiKeyRepo.findById.mockRejectedValue(boom);
|
||||
|
||||
await expect(service.validate(payload() as any)).rejects.toBe(boom);
|
||||
});
|
||||
|
||||
// --- No cache: revocation is immediate on the next call ------------------
|
||||
it('re-reads the row on every call (no validate cache → immediate revocation)', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
await service.validate(payload() as any);
|
||||
|
||||
// Now the key is revoked (row invisible) — the very next call denies.
|
||||
apiKeyRepo.findById.mockResolvedValue(undefined);
|
||||
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(apiKeyRepo.findById).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// --- last_used_at is throttled + fire-and-forget -------------------------
|
||||
it('touches last_used_at when stale and skips when fresh', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
|
||||
// Stale (never used) -> touch.
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null }));
|
||||
await service.validate(payload() as any);
|
||||
expect(apiKeyRepo.touchLastUsed).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Fresh (used 1 minute ago) -> skip.
|
||||
apiKeyRepo.touchLastUsed.mockClear();
|
||||
apiKeyRepo.findById.mockResolvedValue(
|
||||
activeRow({ lastUsedAt: new Date(Date.now() - 60_000) }),
|
||||
);
|
||||
await service.validate(payload() as any);
|
||||
expect(apiKeyRepo.touchLastUsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a failing last_used_at touch never fails the request', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null }));
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
apiKeyRepo.touchLastUsed.mockRejectedValue(new Error('write failed'));
|
||||
|
||||
await expect(service.validate(payload() as any)).resolves.toMatchObject({
|
||||
user: { id: 'u-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyService.create (mint-then-insert, no exp)', () => {
|
||||
it('mints the JWT BEFORE inserting the row and returns the token once', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
const order: string[] = [];
|
||||
tokenService.generateApiToken.mockImplementation(async () => {
|
||||
order.push('mint');
|
||||
return 'tok';
|
||||
});
|
||||
apiKeyRepo.insert.mockImplementation(async (row: any) => {
|
||||
order.push('insert');
|
||||
return { ...row, createdAt: new Date() };
|
||||
});
|
||||
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
const res = await service.create(user, 'my key');
|
||||
|
||||
expect(order).toEqual(['mint', 'insert']);
|
||||
expect(res.token).toBe('tok');
|
||||
// The id is generated before minting and reused for the row.
|
||||
const mintArg = tokenService.generateApiToken.mock.calls[0][0];
|
||||
const insertArg = apiKeyRepo.insert.mock.calls[0][0];
|
||||
expect(mintArg.apiKeyId).toBe(insertArg.id);
|
||||
});
|
||||
|
||||
it('applies the 1-year default when expiresAt is undefined', async () => {
|
||||
const { service, apiKeyRepo } = makeDeps();
|
||||
apiKeyRepo.insert.mockImplementation(async (row: any) => row);
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
|
||||
await service.create(user, 'k');
|
||||
|
||||
const insertArg = apiKeyRepo.insert.mock.calls[0][0];
|
||||
const days =
|
||||
(insertArg.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000);
|
||||
expect(days).toBeGreaterThan(364);
|
||||
expect(days).toBeLessThan(367);
|
||||
});
|
||||
|
||||
it('honors an explicit null (unlimited)', async () => {
|
||||
const { service, apiKeyRepo } = makeDeps();
|
||||
apiKeyRepo.insert.mockImplementation(async (row: any) => row);
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
|
||||
await service.create(user, 'k', null);
|
||||
|
||||
expect(apiKeyRepo.insert.mock.calls[0][0].expiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT insert a row when minting fails (mint-then-insert → inert)', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
tokenService.generateApiToken.mockRejectedValue(new Error('mint failed'));
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
|
||||
await expect(service.create(user, 'k')).rejects.toThrow('mint failed');
|
||||
expect(apiKeyRepo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { JwtApiKeyPayload } from '../auth/dto/jwt-payload';
|
||||
import { ApiKey, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { isUserDisabled } from '../../common/helpers';
|
||||
|
||||
// Default lifetime for a new key when the caller does not specify one: 1 year.
|
||||
// The owner runs a homelab where agents live for years; forcing rotation is
|
||||
// operational pain, so an explicit `null` (unlimited) is also allowed.
|
||||
const DEFAULT_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// last_used_at is a best-effort forensics stamp, not an access record; we only
|
||||
// refresh it when it is older than this to avoid a write on every request. The
|
||||
// 1h resolution is a deliberate constant (forensics granularity, not accounting).
|
||||
const LAST_USED_THROTTLE_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Core API-key lifecycle service. Owns minting (create), the single validator
|
||||
* shared by BOTH the REST jwt.strategy path and the /mcp Bearer path (validate),
|
||||
* and revocation (revoke). The `api_keys` ROW is the sole source of truth for a
|
||||
* key's lifetime and revocation — never the JWT, which carries no `exp` claim.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ApiKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ApiKeyService.name);
|
||||
|
||||
constructor(
|
||||
private readonly apiKeyRepo: ApiKeyRepo,
|
||||
private readonly userRepo: UserRepo,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
// Boot log so the kill-switch state after each deploy is verifiable in logs.
|
||||
const enabled = this.environmentService.isApiKeysEnabled();
|
||||
const raw = this.environmentService.getApiKeysEnabledRaw();
|
||||
this.logger.log(
|
||||
`API keys: ${enabled ? 'ENABLED' : 'DISABLED'} (API_KEYS_ENABLED=${
|
||||
raw ?? 'unset'
|
||||
})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a new key for `user`. mint-then-insert ordering (R1):
|
||||
* 1. generate the id first — it must be in the JWT payload before the row.
|
||||
* 2. mint the JWT (no `exp` claim). A mint failure aborts before any row is
|
||||
* written (inert), so a half-created key cannot exist.
|
||||
* 3. insert the row last. A lost response leaves an orphaned row that is
|
||||
* visible in `list` and self-heals (the user revokes it).
|
||||
* The token is returned ONCE and never stored — the JWT is self-contained, so
|
||||
* no token material lives in the table.
|
||||
*
|
||||
* `expiresAt`: `undefined` -> default 1 year; `null` -> unlimited (explicit);
|
||||
* a Date -> that instant (a past date is rejected at the DTO layer).
|
||||
*/
|
||||
async create(
|
||||
user: User,
|
||||
name: string,
|
||||
expiresAt?: Date | null,
|
||||
): Promise<{ token: string; key: ApiKey }> {
|
||||
const resolvedExpiresAt =
|
||||
expiresAt === undefined
|
||||
? new Date(Date.now() + DEFAULT_LIFETIME_MS)
|
||||
: expiresAt;
|
||||
|
||||
const apiKeyId = uuid7();
|
||||
|
||||
const token = await this.tokenService.generateApiToken({
|
||||
apiKeyId,
|
||||
user,
|
||||
workspaceId: user.workspaceId,
|
||||
});
|
||||
|
||||
const key = await this.apiKeyRepo.insert({
|
||||
id: apiKeyId,
|
||||
name,
|
||||
creatorId: user.id,
|
||||
workspaceId: user.workspaceId,
|
||||
expiresAt: resolvedExpiresAt,
|
||||
});
|
||||
|
||||
return { token, key };
|
||||
}
|
||||
|
||||
/**
|
||||
* The single validator for an api-key principal, shared by jwt.strategy and the
|
||||
* /mcp Bearer router. Returns `{ user, workspace }` (the same shape the access
|
||||
* path returns) so the AuthUser/AuthWorkspace decorators and MCP identity work
|
||||
* unchanged.
|
||||
*
|
||||
* Failure semantics (R4, anti-enumeration): a DEFINITE negative fact — feature
|
||||
* disabled, missing/revoked/expired row, workspace mismatch, disabled user —
|
||||
* throws a bare `UnauthorizedException` (a single generic 401 for every case;
|
||||
* an agent cannot distinguish expired from revoked, and its reaction is
|
||||
* identical). An UNEXPECTED (infra) error is NOT caught here: it propagates so
|
||||
* the surface returns 5xx, never a masked 401 (deny-on-decision / 5xx-on-infra).
|
||||
* There is NO validate cache: it is 2–3 PK lookups (~1ms), two orders of
|
||||
* magnitude cheaper than the bcrypt it replaces; a cache would only add a
|
||||
* Date-serialization trap and a revocation lag. Revocation is immediate.
|
||||
*/
|
||||
async validate(
|
||||
payload: JwtApiKeyPayload,
|
||||
): Promise<{ user: User; workspace: Workspace }> {
|
||||
// Kill-switch OFF: deny unconditionally (same generic 401). Note the
|
||||
// endpoints additionally 404 at the controller; here we deny the token.
|
||||
if (!this.environmentService.isApiKeysEnabled()) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (!payload?.apiKeyId || !payload?.sub || !payload?.workspaceId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const row = await this.apiKeyRepo.findById(
|
||||
payload.apiKeyId,
|
||||
payload.workspaceId,
|
||||
);
|
||||
// Absent row = revoked (soft-deleted, invisible to findById), orphaned
|
||||
// (creator/workspace cascade-deleted), or never existed. All terminal deny.
|
||||
if (!row) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Expiry is read from the ROW, never an `exp` JWT claim.
|
||||
if (row.expiresAt && row.expiresAt.getTime() <= Date.now()) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const user = await this.userRepo.findById(
|
||||
payload.sub,
|
||||
payload.workspaceId,
|
||||
{ includeIsAgent: true },
|
||||
);
|
||||
if (!user || isUserDisabled(user)) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// The key acts only as its creator (defence in depth against a token whose
|
||||
// signed `sub` ever drifted from the row's owner).
|
||||
if (row.creatorId !== user.id) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
|
||||
if (!workspace) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Best-effort, throttled, fire-and-forget forensics stamp AFTER all checks.
|
||||
this.touchLastUsed(row);
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (soft-delete) a key. Authorization/ownership is decided by the caller
|
||||
* (the controller, via CASL); this only performs the terminal write. Idempotent:
|
||||
* a second revoke is a no-op (the row is already invisible).
|
||||
*/
|
||||
async revoke(id: string, workspaceId: string): Promise<void> {
|
||||
await this.apiKeyRepo.softDelete(id, workspaceId);
|
||||
}
|
||||
|
||||
// Throttled best-effort last_used_at bump: skip if it was touched within the
|
||||
// window; otherwise fire-and-forget so a stamp write never fails or slows the
|
||||
// request (mirrors SessionActivityService.trackActivity).
|
||||
private touchLastUsed(row: ApiKey): void {
|
||||
const last = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0;
|
||||
if (Date.now() - last < LAST_USED_THROTTLE_MS) return;
|
||||
void this.apiKeyRepo.touchLastUsed(row.id).catch((err) => {
|
||||
this.logger.warn(
|
||||
`Failed to update api_key last_used_at for ${row.id}: ${
|
||||
(err as Error)?.message ?? err
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* `expiresAt` must be strictly in the future. Skips validation for `null`
|
||||
* (explicit "unlimited") and `undefined` (server applies the 1-year default),
|
||||
* so only an actually-supplied date is range-checked. Rejecting a PAST date at
|
||||
* the DTO layer means a caller cannot mint an already-dead key.
|
||||
*/
|
||||
function IsFutureDateString(options?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isFutureDateString',
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: {
|
||||
message: 'expiresAt must be a date in the future',
|
||||
...options,
|
||||
},
|
||||
validator: {
|
||||
validate(value: unknown) {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
const t = Date.parse(value);
|
||||
return !Number.isNaN(t) && t > Date.now();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
// undefined -> default 1 year (applied server-side); null -> unlimited
|
||||
// (explicit); an ISO date string -> that instant, which must be in the future.
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@IsFutureDateString()
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class RevokeApiKeyDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
@@ -20,3 +20,71 @@ describe('AuthController', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// The collab-token handler is the ARM-SEAM of the #501 anti-laundering defense:
|
||||
// it derives the api-key origin args ({ apiKeyId }) from the SIGNED-derived
|
||||
// `req.raw` fields (stamped by jwt.strategy) and threads them into the collab
|
||||
// token mint, forcing principal='api_key' so a later key revoke rejects NEW
|
||||
// collab connections. A regression here (reading `body`, dropping `apiKeyId`,
|
||||
// or inverting the ternary) would silently mint api-key requests as
|
||||
// principal='session' and reopen the laundering hole with a green suite. These
|
||||
// tests pin the EXACT args the controller passes to authService.getCollabToken.
|
||||
describe('AuthController.collabToken arms the api-key origin (#501)', () => {
|
||||
let controller: AuthController;
|
||||
let getCollabToken: jest.Mock;
|
||||
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
const workspace = { id: 'ws-1' } as any;
|
||||
|
||||
beforeEach(() => {
|
||||
getCollabToken = jest.fn().mockResolvedValue({ token: 'ct' });
|
||||
controller = new AuthController(
|
||||
{ getCollabToken } as any, // authService
|
||||
{} as any, // sessionService
|
||||
{} as any, // environmentService
|
||||
{} as any, // moduleRef
|
||||
{} as any, // auditService
|
||||
);
|
||||
});
|
||||
|
||||
it('threads { apiKeyId } when req.raw marks an api_key principal (ARMED)', async () => {
|
||||
const req = { raw: { authType: 'api_key', apiKeyId: 'k-1' } } as any;
|
||||
|
||||
await controller.collabToken(user, workspace, req);
|
||||
|
||||
expect(getCollabToken).toHaveBeenCalledTimes(1);
|
||||
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', {
|
||||
apiKeyId: 'k-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes undefined for a normal session request (NOT armed)', async () => {
|
||||
const req = { raw: {} } as any;
|
||||
|
||||
await controller.collabToken(user, workspace, req);
|
||||
|
||||
expect(getCollabToken).toHaveBeenCalledTimes(1);
|
||||
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
|
||||
});
|
||||
|
||||
it('does NOT arm when api_key authType lacks an apiKeyId', async () => {
|
||||
const req = { raw: { authType: 'api_key' } } as any;
|
||||
|
||||
await controller.collabToken(user, workspace, req);
|
||||
|
||||
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
|
||||
});
|
||||
|
||||
it('reads the SIGNED req.raw, never a spoofable client body', async () => {
|
||||
// A client-supplied body claiming api_key must be ignored: only the
|
||||
// jwt.strategy-stamped req.raw can arm the api-key origin.
|
||||
const req = {
|
||||
raw: {},
|
||||
body: { authType: 'api_key', apiKeyId: 'attacker' },
|
||||
} as any;
|
||||
|
||||
await controller.collabToken(user, workspace, req);
|
||||
|
||||
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,8 +207,20 @@ export class AuthController {
|
||||
async collabToken(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
return this.authService.getCollabToken(user, workspace.id);
|
||||
// Thread the api-key origin (#501): when the requester authenticated with an
|
||||
// api key (jwt.strategy stamped req.raw.authType/apiKeyId), the minted collab
|
||||
// token carries principal='api_key' + apiKeyId so a later revoke of the key
|
||||
// rejects NEW collab connections. A normal session request mints a
|
||||
// principal='session' token. Reading the SIGNED-derived req.raw fields (never
|
||||
// a client body) keeps it unspoofable.
|
||||
const raw = req.raw as { authType?: string; apiKeyId?: string };
|
||||
const apiKey =
|
||||
raw.authType === 'api_key' && raw.apiKeyId
|
||||
? { apiKeyId: raw.apiKeyId }
|
||||
: undefined;
|
||||
return this.authService.getCollabToken(user, workspace.id, apiKey);
|
||||
}
|
||||
|
||||
@SkipThrottle({ [AUTH_THROTTLER]: true })
|
||||
|
||||
@@ -5,9 +5,13 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||
import { SignupService } from './services/signup.service';
|
||||
import { TokenModule } from './token.module';
|
||||
import { ApiKeyModule } from '../api-key/api-key.module';
|
||||
|
||||
@Module({
|
||||
imports: [TokenModule, WorkspaceModule],
|
||||
// ApiKeyModule supplies ApiKeyService, injected into JwtStrategy so an
|
||||
// api_key Bearer/cookie token is validated directly (replacing the absent EE
|
||||
// `ee/api-key` dynamic require).
|
||||
imports: [TokenModule, WorkspaceModule, ApiKeyModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, SignupService, JwtStrategy],
|
||||
exports: [SignupService, AuthService],
|
||||
|
||||
@@ -33,6 +33,17 @@ export type JwtPayload = {
|
||||
aiChatId?: string | null;
|
||||
};
|
||||
|
||||
// The AUTH-PRINCIPAL kind behind a collab token — distinct from `actor`
|
||||
// (provenance). Stamped into EVERY newly-minted collab token (#501): 'session'
|
||||
// for a normal user/session (incl. the internal AI agent, which is session-
|
||||
// backed), 'api_key' when the token was minted by an api-key principal (an
|
||||
// external MCP agent). The discriminator keys on the token's ORIGIN, so the
|
||||
// collab seam can re-check a revoked api key on connect and reject a claimless
|
||||
// token after the rollout grace window. NOT keyed on `actor:'agent'` — the
|
||||
// internal agent is 'agent' but session-backed, so it must stay on the no-check
|
||||
// path.
|
||||
export type CollabPrincipal = 'session' | 'api_key';
|
||||
|
||||
export type JwtCollabPayload = {
|
||||
sub: string;
|
||||
workspaceId: string;
|
||||
@@ -44,6 +55,13 @@ export type JwtCollabPayload = {
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
aiChatId?: string | null;
|
||||
// Auth-principal discriminator (#501). Present on every post-rollout token;
|
||||
// its absence on a still-valid token past the grace window is treated as an
|
||||
// error, not trust (fail-closed).
|
||||
principal?: CollabPrincipal;
|
||||
// Only when principal === 'api_key': the minting key's id, so the collab seam
|
||||
// can row-check (and reject) a revoked key on connect.
|
||||
apiKeyId?: string;
|
||||
};
|
||||
|
||||
export type JwtExchangePayload = {
|
||||
|
||||
@@ -375,10 +375,20 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async getCollabToken(user: User, workspaceId: string) {
|
||||
async getCollabToken(
|
||||
user: User,
|
||||
workspaceId: string,
|
||||
// Origin of the request minting this collab token (#501). When the caller is
|
||||
// an api-key principal, its apiKeyId is threaded into the token so the collab
|
||||
// seam can re-check the key on connect (closing api-key -> long-lived-collab
|
||||
// laundering). Absent for a normal session/human request.
|
||||
apiKey?: { apiKeyId: string },
|
||||
) {
|
||||
const token = await this.tokenService.generateCollabToken(
|
||||
user,
|
||||
workspaceId,
|
||||
undefined,
|
||||
apiKey,
|
||||
);
|
||||
return { token };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { TokenService } from './token.service';
|
||||
import { JwtType } from '../dto/jwt-payload';
|
||||
|
||||
@@ -213,4 +214,175 @@ describe('TokenService.generateCollabToken', () => {
|
||||
aiChatId: 'chat-456',
|
||||
});
|
||||
});
|
||||
|
||||
// #501 fail-closed discriminator: EVERY collab token carries a principal.
|
||||
it("defaults principal to 'session' with NO apiKeyId (normal/internal-agent path)", async () => {
|
||||
const { service, jwtService } = makeTokenService();
|
||||
await service.generateCollabToken(makeUser() as never, 'ws-1');
|
||||
const [payload] = jwtService.sign.mock.calls[0];
|
||||
expect(payload.principal).toBe('session');
|
||||
expect(payload).not.toHaveProperty('apiKeyId');
|
||||
});
|
||||
|
||||
it("the internal agent (provenance, NO apiKey) still gets principal='session'", async () => {
|
||||
const { service, jwtService } = makeTokenService();
|
||||
await service.generateCollabToken(
|
||||
makeUser() as never,
|
||||
'ws-1',
|
||||
{ actor: 'agent', aiChatId: 'chat-1' },
|
||||
);
|
||||
const [payload] = jwtService.sign.mock.calls[0];
|
||||
// Keyed on api-key ORIGIN, not actor: an is_agent session token is 'session'.
|
||||
expect(payload.principal).toBe('session');
|
||||
expect(payload).not.toHaveProperty('apiKeyId');
|
||||
});
|
||||
|
||||
it("stamps principal='api_key' + apiKeyId when minted by an api-key principal", async () => {
|
||||
const { service, jwtService } = makeTokenService();
|
||||
await service.generateCollabToken(
|
||||
makeUser() as never,
|
||||
'ws-1',
|
||||
undefined,
|
||||
{ apiKeyId: 'key-9' },
|
||||
);
|
||||
const [payload] = jwtService.sign.mock.calls[0];
|
||||
expect(payload.principal).toBe('api_key');
|
||||
expect(payload.apiKeyId).toBe('key-9');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* API-key token minting MUST carry NO `exp` claim — the ONLY source of truth for
|
||||
* a key's lifetime/revocation is its `api_keys` row, checked on every request.
|
||||
*
|
||||
* This is a LIVE-bug regression: the shared JwtService is registered with a
|
||||
* global `signOptions.expiresIn` (default '90d') that merges into every sign(),
|
||||
* so an api-key minted through it silently gets exp=now+90d and an "unlimited"
|
||||
* key dies in 90 days. TokenService.generateApiToken mints through a dedicated
|
||||
* no-expiry signer instead. These tests construct the REAL signer (a real secret
|
||||
* via the stubbed EnvironmentService) and decode the produced JWT to assert the
|
||||
* observable property: no `exp`.
|
||||
*/
|
||||
describe('TokenService.generateApiToken (no exp claim ever)', () => {
|
||||
const APP_SECRET_LOCAL = 'apikey-secret';
|
||||
|
||||
function makeRealSignerService() {
|
||||
// Give the SHARED jwtService a global expiresIn so a regression (minting
|
||||
// through it) would show up as an exp claim — the exact live bug.
|
||||
const { JwtService } = require('@nestjs/jwt');
|
||||
const sharedJwt = new JwtService({
|
||||
secret: APP_SECRET_LOCAL,
|
||||
signOptions: { expiresIn: '90d', issuer: 'Docmost' },
|
||||
});
|
||||
const environmentService = {
|
||||
getAppSecret: () => APP_SECRET_LOCAL,
|
||||
};
|
||||
const service = new (TokenService as unknown as new (
|
||||
...args: unknown[]
|
||||
) => TokenService)(sharedJwt, environmentService);
|
||||
return { service };
|
||||
}
|
||||
|
||||
const user = makeUser({ id: 'svc-1', workspaceId: 'ws-1' });
|
||||
|
||||
it('mints an api-key JWT with NO exp claim and issuer Docmost', async () => {
|
||||
const { service } = makeRealSignerService();
|
||||
|
||||
const token = await service.generateApiToken({
|
||||
apiKeyId: 'key-1',
|
||||
user: user as never,
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
|
||||
const decoded = jwt.decode(token) as Record<string, unknown>;
|
||||
// The observable security property: no expiry lives in the JWT.
|
||||
expect(decoded.exp).toBeUndefined();
|
||||
expect(decoded).toMatchObject({
|
||||
sub: 'svc-1',
|
||||
apiKeyId: 'key-1',
|
||||
workspaceId: 'ws-1',
|
||||
type: JwtType.API_KEY,
|
||||
iss: 'Docmost',
|
||||
});
|
||||
});
|
||||
|
||||
it('demonstrates the live bug it guards: the SHARED signer WOULD add exp', () => {
|
||||
const { JwtService } = require('@nestjs/jwt');
|
||||
const sharedJwt = new JwtService({
|
||||
secret: APP_SECRET_LOCAL,
|
||||
signOptions: { expiresIn: '90d', issuer: 'Docmost' },
|
||||
});
|
||||
// Even with empty per-call options the global expiresIn merges in.
|
||||
const leaky = sharedJwt.sign({ sub: 'x', type: JwtType.API_KEY }, {});
|
||||
expect((jwt.decode(leaky) as Record<string, unknown>).exp).toBeDefined();
|
||||
});
|
||||
|
||||
it('refuses to mint for a disabled user', async () => {
|
||||
const { service } = makeRealSignerService();
|
||||
await expect(
|
||||
service.generateApiToken({
|
||||
apiKeyId: 'key-1',
|
||||
user: makeUser({ deactivatedAt: new Date() }) as never,
|
||||
workspaceId: 'ws-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* verifyJwtOneOf is the type-routing primitive: verify the signature once and
|
||||
* assert the token type is on an explicit allowlist. It must NOT degrade into a
|
||||
* "return whatever type" helper — a token whose type is off the allowlist is
|
||||
* rejected with the same generic error as a single-type mismatch.
|
||||
*/
|
||||
describe('TokenService.verifyJwtOneOf (allowlist type-routing)', () => {
|
||||
it('returns the payload when the type is on the allowlist', async () => {
|
||||
const verifyAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ type: JwtType.API_KEY, sub: 'u-1' });
|
||||
const { service } = makeTokenService({ verifyAsync });
|
||||
|
||||
const payload = await service.verifyJwtOneOf(token123(), [
|
||||
JwtType.ACCESS,
|
||||
JwtType.API_KEY,
|
||||
]);
|
||||
|
||||
expect(payload).toMatchObject({ type: JwtType.API_KEY, sub: 'u-1' });
|
||||
expect(verifyAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('accepts the OTHER allowed type too', async () => {
|
||||
const verifyAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ type: JwtType.ACCESS, sub: 'u-1' });
|
||||
const { service } = makeTokenService({ verifyAsync });
|
||||
|
||||
await expect(
|
||||
service.verifyJwtOneOf(token123(), [JwtType.ACCESS, JwtType.API_KEY]),
|
||||
).resolves.toMatchObject({ type: JwtType.ACCESS });
|
||||
});
|
||||
|
||||
it('rejects a token whose type is OFF the allowlist (confused-deputy guard)', async () => {
|
||||
const verifyAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ type: JwtType.COLLAB, sub: 'u-1' });
|
||||
const { service } = makeTokenService({ verifyAsync });
|
||||
|
||||
await expect(
|
||||
service.verifyJwtOneOf(token123(), [JwtType.ACCESS, JwtType.API_KEY]),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('verifies the signature exactly ONCE', async () => {
|
||||
const verifyAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ type: JwtType.ACCESS });
|
||||
const { service } = makeTokenService({ verifyAsync });
|
||||
await service.verifyJwtOneOf(token123(), [JwtType.ACCESS]);
|
||||
expect(verifyAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function token123(): string {
|
||||
return 'a.b.c';
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { StringValue } from 'ms';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import {
|
||||
JwtApiKeyPayload,
|
||||
@@ -62,6 +61,12 @@ export class TokenService {
|
||||
// token carries no actor/aiChatId and is treated as 'user' downstream.
|
||||
// aiChatId is nullable for an external agent with no internal ai_chats row.
|
||||
provenance?: { actor: 'agent'; aiChatId: string | null },
|
||||
// Optional api-key origin (#501). When the collab token is minted by an
|
||||
// api-key principal (an external MCP agent), the caller passes the key id so
|
||||
// the token carries principal='api_key' + apiKeyId and the collab seam can
|
||||
// re-check the key on connect. Absent -> principal='session' (a normal
|
||||
// user/session, including the internal session-backed AI agent).
|
||||
apiKey?: { apiKeyId: string },
|
||||
): Promise<string> {
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
@@ -71,6 +76,10 @@ export class TokenService {
|
||||
sub: user.id,
|
||||
workspaceId,
|
||||
type: JwtType.COLLAB,
|
||||
// Fail-closed discriminator on EVERY minted token: 'api_key' when minted by
|
||||
// an api-key principal, else 'session'.
|
||||
principal: apiKey ? 'api_key' : 'session',
|
||||
...(apiKey ? { apiKeyId: apiKey.apiKeyId } : {}),
|
||||
...(provenance
|
||||
? { actor: provenance.actor, aiChatId: provenance.aiChatId }
|
||||
: {}),
|
||||
@@ -123,9 +132,8 @@ export class TokenService {
|
||||
apiKeyId: string;
|
||||
user: User;
|
||||
workspaceId: string;
|
||||
expiresIn?: StringValue | number;
|
||||
}): Promise<string> {
|
||||
const { apiKeyId, user, workspaceId, expiresIn } = opts;
|
||||
const { apiKeyId, user, workspaceId } = opts;
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
@@ -137,7 +145,32 @@ export class TokenService {
|
||||
type: JwtType.API_KEY,
|
||||
};
|
||||
|
||||
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
|
||||
// API-key tokens carry NO `exp` claim EVER — the ONLY source of truth for a
|
||||
// key's lifetime and revocation is its `api_keys` row (checked on every
|
||||
// request), not the JWT. This CANNOT use `this.jwtService`: TokenModule
|
||||
// registers it with a global `signOptions.expiresIn` (JWT_TOKEN_EXPIRES_IN,
|
||||
// default '90d'), which merges into EVERY sign() call — even `sign(payload,
|
||||
// {})` — and `{ expiresIn: undefined }` THROWS rather than stripping it
|
||||
// (verified empirically). So an "unlimited" key minted through the shared
|
||||
// signer would silently get exp=now+90d and die in 90 days regardless of its
|
||||
// row. We mint through a dedicated no-expiry signer, re-stamping only
|
||||
// `issuer: 'Docmost'` for claim parity with the shared signer.
|
||||
return this.apiKeyJwtService().sign(payload);
|
||||
}
|
||||
|
||||
// Lazily-built JWT signer for API-key tokens: same APP_SECRET, same 'Docmost'
|
||||
// issuer, but WITHOUT the global `expiresIn` — so minted API-key tokens have no
|
||||
// `exp` claim. Built once and cached. Verification still goes through the
|
||||
// shared verifier (same secret); `verifyAsync` does not require an `exp`.
|
||||
private _apiKeyJwtService?: JwtService;
|
||||
private apiKeyJwtService(): JwtService {
|
||||
if (!this._apiKeyJwtService) {
|
||||
this._apiKeyJwtService = new JwtService({
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
signOptions: { issuer: 'Docmost' },
|
||||
});
|
||||
}
|
||||
return this._apiKeyJwtService;
|
||||
}
|
||||
|
||||
async generatePdfRenderToken(
|
||||
@@ -177,4 +210,31 @@ export class TokenService {
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a token's signature ONCE and assert its `type` is one of `allowed`.
|
||||
*
|
||||
* This is the type-routing primitive for surfaces that legitimately accept
|
||||
* more than one token type on the same Bearer slot (the /mcp Bearer path
|
||||
* accepts both an ACCESS and an API_KEY token). It is deliberately NOT a
|
||||
* "verify-and-return-whatever-type" helper — that would be a reusable
|
||||
* confused-deputy footgun (any caller could then feed an attachment/collab
|
||||
* token where an access token is expected). An explicit allowlist preserves
|
||||
* the type-pinning property of `verifyJwt`: a token whose `type` is not in the
|
||||
* allowlist is rejected with the SAME generic error as a type mismatch, and
|
||||
* the signature is verified exactly once (no double-verify).
|
||||
*/
|
||||
async verifyJwtOneOf(token: string, allowed: JwtType[]) {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
});
|
||||
|
||||
if (!allowed.includes(payload.type)) {
|
||||
throw new UnauthorizedException(
|
||||
'Invalid JWT token. Token type does not match.',
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ describe('JwtStrategy — provenance derivation', () => {
|
||||
const userSessionRepo: any = { findActiveById: jest.fn() };
|
||||
const sessionActivityService: any = { trackActivity: jest.fn() };
|
||||
const environmentService: any = { getAppSecret: () => 'test-secret' };
|
||||
const moduleRef: any = {};
|
||||
// ACCESS-path tests never touch the api-key seam; a bare stub suffices.
|
||||
const apiKeyService: any = { validate: jest.fn() };
|
||||
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
@@ -30,7 +31,7 @@ describe('JwtStrategy — provenance derivation', () => {
|
||||
userSessionRepo,
|
||||
sessionActivityService,
|
||||
environmentService,
|
||||
moduleRef,
|
||||
apiKeyService,
|
||||
);
|
||||
return { strategy, userRepo };
|
||||
}
|
||||
@@ -122,25 +123,29 @@ describe('JwtStrategy — provenance derivation', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486).
|
||||
* Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486
|
||||
* + #501).
|
||||
*
|
||||
* The access-token path stamped provenance; the API-key path returned early
|
||||
* WITHOUT it, so an is_agent API key's REST writes recorded no 'agent' marker.
|
||||
* The API-key payload carries no signed claim, so provenance is resolved from the
|
||||
* SERVER-SIDE user returned by ApiKeyService.validateApiKey: isAgent -> 'agent',
|
||||
* SERVER-SIDE user returned by ApiKeyService.validate: isAgent -> 'agent',
|
||||
* otherwise 'user'; aiChatId is always null (an API key has no ai_chats row).
|
||||
*
|
||||
* The enterprise ApiKeyService is not bundled in the OSS build, so the strategy
|
||||
* loads it through an overridable `resolveApiKeyService` seam that we stub here.
|
||||
* #501 wires the CORE ApiKeyService (the EE `ee/api-key` module is absent in the
|
||||
* fork) directly into the strategy — no dynamic require. The strategy also stamps
|
||||
* `req.raw.authType='api_key'` + `req.raw.apiKeyId` for the "a token cannot manage
|
||||
* tokens" guard on the /api-keys surface.
|
||||
*/
|
||||
describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
function makeApiKeyStrategy(validateApiKeyImpl: (p: any) => Promise<any>) {
|
||||
describe('JwtStrategy — API-key provenance derivation (#486/#501)', () => {
|
||||
function makeApiKeyStrategy(validateImpl: (p: any) => Promise<any>) {
|
||||
const userRepo: any = { findById: jest.fn() };
|
||||
const workspaceRepo: any = { findById: jest.fn() };
|
||||
const userSessionRepo: any = { findActiveById: jest.fn() };
|
||||
const sessionActivityService: any = { trackActivity: jest.fn() };
|
||||
const environmentService: any = { getAppSecret: () => 'test-secret' };
|
||||
const moduleRef: any = {};
|
||||
const validate = jest.fn(validateImpl);
|
||||
const apiKeyService: any = { validate };
|
||||
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
@@ -148,14 +153,9 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
userSessionRepo,
|
||||
sessionActivityService,
|
||||
environmentService,
|
||||
moduleRef,
|
||||
apiKeyService,
|
||||
);
|
||||
// Stub the EE ApiKeyService seam (the real module is not in the OSS build).
|
||||
const validateApiKey = jest.fn(validateApiKeyImpl);
|
||||
jest
|
||||
.spyOn(strategy as any, 'resolveApiKeyService')
|
||||
.mockReturnValue({ validateApiKey });
|
||||
return { strategy, validateApiKey };
|
||||
return { strategy, validate };
|
||||
}
|
||||
|
||||
const makeReq = () => ({ raw: {} as Record<string, any> });
|
||||
@@ -166,22 +166,23 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
type: JwtType.API_KEY,
|
||||
});
|
||||
|
||||
it("stamps actor='agent' for an is_agent API key (from the validated user)", async () => {
|
||||
it("stamps actor='agent' + authType/apiKeyId for an is_agent API key", async () => {
|
||||
const validated = {
|
||||
user: { id: 'svc-1', isAgent: true },
|
||||
workspace: { id: 'ws-1' },
|
||||
};
|
||||
const { strategy, validateApiKey } = makeApiKeyStrategy(
|
||||
async () => validated,
|
||||
);
|
||||
const { strategy, validate } = makeApiKeyStrategy(async () => validated);
|
||||
const req = makeReq();
|
||||
|
||||
const result = await strategy.validate(req, apiKeyPayload() as any);
|
||||
|
||||
expect(validateApiKey).toHaveBeenCalledTimes(1);
|
||||
expect(validate).toHaveBeenCalledTimes(1);
|
||||
expect(req.raw.actor).toBe('agent');
|
||||
// API keys carry no internal ai_chats row -> null.
|
||||
expect(req.raw.aiChatId).toBeNull();
|
||||
// Principal-kind markers for the management-surface guard.
|
||||
expect(req.raw.authType).toBe('api_key');
|
||||
expect(req.raw.apiKeyId).toBe('key-1');
|
||||
// The validated auth object is returned unchanged (req.user shape preserved).
|
||||
expect(result).toBe(validated);
|
||||
});
|
||||
@@ -197,25 +198,19 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
|
||||
expect(req.raw.actor).toBe('user');
|
||||
expect(req.raw.aiChatId).toBeNull();
|
||||
expect(req.raw.authType).toBe('api_key');
|
||||
});
|
||||
|
||||
it('throws Unauthorized (and stamps nothing) when the EE module is missing', async () => {
|
||||
const userRepo: any = { findById: jest.fn() };
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
{ findById: jest.fn() } as any,
|
||||
{ findActiveById: jest.fn() } as any,
|
||||
{ trackActivity: jest.fn() } as any,
|
||||
{ getAppSecret: () => 'test-secret' } as any,
|
||||
{} as any,
|
||||
);
|
||||
// EE not bundled: the seam returns null.
|
||||
jest.spyOn(strategy as any, 'resolveApiKeyService').mockReturnValue(null);
|
||||
it('propagates a validate() rejection and stamps nothing', async () => {
|
||||
const { strategy } = makeApiKeyStrategy(async () => {
|
||||
throw new UnauthorizedException();
|
||||
});
|
||||
const req = makeReq();
|
||||
|
||||
await expect(
|
||||
strategy.validate(req, apiKeyPayload() as any),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
expect(req.raw.actor).toBeUndefined();
|
||||
expect(req.raw.authType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
@@ -9,20 +9,18 @@ import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { SessionActivityService } from '../../session/session-activity.service';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { resolveProvenance } from '../../../common/decorators/auth-provenance.decorator';
|
||||
import { ApiKeyService } from '../../api-key/api-key.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private logger = new Logger('JwtStrategy');
|
||||
|
||||
constructor(
|
||||
private userRepo: UserRepo,
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
private sessionActivityService: SessionActivityService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: (req: FastifyRequest) => {
|
||||
@@ -102,12 +100,17 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
|
||||
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
|
||||
const apiKeyService = this.resolveApiKeyService();
|
||||
if (!apiKeyService) {
|
||||
throw new UnauthorizedException('Enterprise API Key module missing');
|
||||
}
|
||||
// The fork ships the core `ApiKeyService` (the EE `ee/api-key` module is
|
||||
// absent). `validate` throws a bare UnauthorizedException on any definite
|
||||
// deny (missing/revoked/expired row, disabled user, kill-switch off) and
|
||||
// propagates infra errors (→ 5xx) rather than masking them as a 401.
|
||||
const result = await this.apiKeyService.validate(payload);
|
||||
|
||||
const result = await apiKeyService.validateApiKey(payload);
|
||||
// Stamp the principal kind + key id so the /api-keys management surface can
|
||||
// enforce "a token cannot manage tokens". Done in this branch because it
|
||||
// returns before the shared ACCESS-path stamping below.
|
||||
req.raw.authType = 'api_key';
|
||||
req.raw.apiKeyId = payload.apiKeyId;
|
||||
|
||||
// Stamp the agent-edit provenance for the API-KEY path too (#486). Unlike the
|
||||
// access-token path above, it CANNOT be resolved before this point: the
|
||||
@@ -119,32 +122,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
// SERVER-SIDE user (never a client field), so an 'agent' badge is unspoofable
|
||||
// — mirroring the access-token path. Passing `null` for the claim means the
|
||||
// actor is decided solely by user.isAgent.
|
||||
const provenance = resolveProvenance((result as any)?.user, null);
|
||||
const provenance = resolveProvenance(result.user, null);
|
||||
req.raw.actor = provenance.actor;
|
||||
req.raw.aiChatId = provenance.aiChatId;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the enterprise ApiKeyService, or `null` when the EE module is not
|
||||
* bundled in this build (community build). Extracted as an overridable seam so
|
||||
* the API-key provenance stamping can be unit-tested without the EE package
|
||||
* present (docmost is OSS + a separate EE bundle; `require` of the EE path
|
||||
* throws here). Any load/resolve error is treated as "module missing".
|
||||
*/
|
||||
protected resolveApiKeyService(): {
|
||||
validateApiKey: (payload: JwtApiKeyPayload) => Promise<unknown>;
|
||||
} | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const ApiKeyModule = require('./../../../ee/api-key/api-key.service');
|
||||
return this.moduleRef.get(ApiKeyModule.ApiKeyService, { strict: false });
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'API Key module requested but enterprise module not bundled in this build',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ApiKeyModule } from './api-key/api-key.module';
|
||||
import { WorkspaceModule } from './workspace/workspace.module';
|
||||
import { PageModule } from './page/page.module';
|
||||
import { AttachmentModule } from './attachment/attachment.module';
|
||||
@@ -29,6 +30,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
imports: [
|
||||
UserModule,
|
||||
AuthModule,
|
||||
ApiKeyModule,
|
||||
WorkspaceModule,
|
||||
PageModule,
|
||||
AttachmentModule,
|
||||
|
||||
@@ -10,6 +10,12 @@ import { Transform } from 'class-transformer';
|
||||
|
||||
export type ContentFormat = 'json' | 'markdown' | 'html';
|
||||
|
||||
// READ-only rendering formats for `getPage` (#502). A superset of the writable
|
||||
// `ContentFormat` with `text` — a flat, deterministic, machine-diffable text
|
||||
// rendering — added. Kept SEPARATE from `ContentFormat` so the write path
|
||||
// (createPage/updatePage `parseProsemirrorContent`) can never be handed `text`.
|
||||
export type PageReadFormat = ContentFormat | 'text';
|
||||
|
||||
export class CreatePageDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
import { ContentFormat } from './create-page.dto';
|
||||
import { PageReadFormat } from './create-page.dto';
|
||||
import { IsPageIdOrSlugId } from './page-identity.validator';
|
||||
|
||||
export class PageIdDto {
|
||||
@@ -43,8 +44,19 @@ export class PageInfoDto extends PageIdDto {
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value?.toLowerCase())
|
||||
@IsIn(['json', 'markdown', 'html'])
|
||||
format?: ContentFormat;
|
||||
@IsIn(['json', 'markdown', 'html', 'text'])
|
||||
format?: PageReadFormat;
|
||||
}
|
||||
|
||||
export class PageWorkTimeDto extends PageIdDto {
|
||||
// Viewer IANA timezone for the per-day punch-card buckets (§6.3). Optional —
|
||||
// falls back to UTC server-side. Length-capped so a bogus value cannot bloat
|
||||
// the request; the value is only ever handed to Intl.DateTimeFormat, which
|
||||
// throws on an unknown zone (caught by the controller → 400).
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
tz?: string;
|
||||
}
|
||||
|
||||
export class DeletePageDto extends PageIdDto {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { PageController } from './page.controller';
|
||||
import { jsonToText, jsonToMarkdown } from '../../collaboration/collaboration.util';
|
||||
|
||||
// #502 READ: getPage `format:"text"` routes through the deterministic jsonToText
|
||||
// path (placeholders for non-text nodes, block-per-line), while `format:"json"`
|
||||
// (or none) returns the raw content and `format:"markdown"` still converts. This
|
||||
// pins the CONTROLLER wiring with lightweight mocks (no DB needed — the jsonToText
|
||||
// output contract itself is pinned in json-to-text-deterministic.spec.ts).
|
||||
|
||||
const CONTENT = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Config' }] },
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'key=', marks: [{ type: 'bold' }] },
|
||||
{ type: 'text', text: 'value' },
|
||||
],
|
||||
},
|
||||
{ type: 'image', attrs: { src: 's' } },
|
||||
],
|
||||
};
|
||||
|
||||
function makeController(page: any): PageController {
|
||||
const pageRepo = { findById: jest.fn().mockResolvedValue(page) } as any;
|
||||
const pageAccessService = {
|
||||
validateCanViewWithPermissions: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ canEdit: true, hasRestriction: false }),
|
||||
} as any;
|
||||
// Only pageRepo + pageAccessService are exercised by getPage; the rest are
|
||||
// never touched on this path, so undefined placeholders are fine.
|
||||
return new PageController(
|
||||
undefined as any, // pageService
|
||||
pageRepo,
|
||||
undefined as any, // pageHistoryService
|
||||
undefined as any, // spaceAbility
|
||||
pageAccessService,
|
||||
undefined as any, // backlinkService
|
||||
undefined as any, // labelService
|
||||
undefined as any, // auditService
|
||||
);
|
||||
}
|
||||
|
||||
const user = { id: 'u1' } as any;
|
||||
|
||||
describe('PageController.getPage — format:"text" (#502)', () => {
|
||||
it('returns deterministic flat text with placeholders for non-text nodes', async () => {
|
||||
const controller = makeController({ id: 'p1', content: CONTENT });
|
||||
const res: any = await controller.getPage({ pageId: 'p1', format: 'text' } as any, user);
|
||||
expect(res.content).toBe(jsonToText(CONTENT, { deterministic: true }));
|
||||
expect(res.content).toBe('Config\nkey=value\n[image]');
|
||||
expect(res.permissions).toEqual({ canEdit: true, hasRestriction: false });
|
||||
});
|
||||
|
||||
it('markdown format still converts to markdown (unchanged)', async () => {
|
||||
const controller = makeController({ id: 'p1', content: CONTENT });
|
||||
const res: any = await controller.getPage({ pageId: 'p1', format: 'markdown' } as any, user);
|
||||
expect(res.content).toBe(jsonToMarkdown(CONTENT));
|
||||
});
|
||||
|
||||
it('json / no format returns the raw ProseMirror content object', async () => {
|
||||
const controller = makeController({ id: 'p1', content: CONTENT });
|
||||
const res: any = await controller.getPage({ pageId: 'p1' } as any, user);
|
||||
expect(res.content).toEqual(CONTENT); // untouched object, not a string
|
||||
});
|
||||
|
||||
it('missing page -> NotFoundException', async () => {
|
||||
const controller = makeController(null);
|
||||
await expect(
|
||||
controller.getPage({ pageId: 'nope', format: 'text' } as any, user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PageController } from './page.controller';
|
||||
|
||||
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
||||
@@ -22,4 +27,88 @@ describe('PageController', () => {
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
// #395 — the work-time endpoint must be gated exactly like /history.
|
||||
describe('getPageWorkTime', () => {
|
||||
const user = { id: 'u1' } as any;
|
||||
|
||||
function build(overrides: {
|
||||
page?: any;
|
||||
validate?: jest.Mock;
|
||||
compute?: jest.Mock;
|
||||
}) {
|
||||
const pageRepo = { findById: jest.fn().mockResolvedValue(overrides.page) };
|
||||
const pageAccessService = {
|
||||
validateCanView: overrides.validate ?? jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const pageHistoryService = {
|
||||
computeWorkTime:
|
||||
overrides.compute ?? jest.fn().mockResolvedValue({ workMs: 0 }),
|
||||
};
|
||||
const c = new PageController(
|
||||
{} as any,
|
||||
pageRepo as any,
|
||||
pageHistoryService as any,
|
||||
{} as any,
|
||||
pageAccessService as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
return { c, pageRepo, pageAccessService, pageHistoryService };
|
||||
}
|
||||
|
||||
it('404s when the page does not exist', async () => {
|
||||
const { c } = build({ page: null });
|
||||
await expect(
|
||||
c.getPageWorkTime({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('enforces validateCanView before computing, then delegates with tz', async () => {
|
||||
const validate = jest.fn().mockResolvedValue(undefined);
|
||||
const compute = jest.fn().mockResolvedValue({ workMs: 42 });
|
||||
const { c } = build({ page: { id: 'pg' }, validate, compute });
|
||||
const out = await c.getPageWorkTime(
|
||||
{ pageId: 'pg', tz: 'Europe/Moscow' } as any,
|
||||
user,
|
||||
);
|
||||
expect(validate).toHaveBeenCalledWith({ id: 'pg' }, user);
|
||||
expect(compute).toHaveBeenCalledWith('pg', 'Europe/Moscow');
|
||||
expect(out).toEqual({ workMs: 42 });
|
||||
});
|
||||
|
||||
it('propagates a denied view gate and does NOT reach compute (security)', async () => {
|
||||
// If validateCanView is moved AFTER computeWorkTime, the timeline of a page
|
||||
// the caller may not see would be read/estimated before the gate — this
|
||||
// locks the order: a rejecting gate must short-circuit before any compute.
|
||||
const validate = jest.fn().mockRejectedValue(new ForbiddenException());
|
||||
const compute = jest.fn().mockResolvedValue({ workMs: 1 });
|
||||
const { c, pageHistoryService } = build({
|
||||
page: { id: 'pg' },
|
||||
validate,
|
||||
compute,
|
||||
});
|
||||
await expect(
|
||||
c.getPageWorkTime({ pageId: 'pg' } as any, user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(pageHistoryService.computeWorkTime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps an unknown-timezone RangeError to a 400', async () => {
|
||||
const compute = jest.fn().mockRejectedValue(new RangeError('bad tz'));
|
||||
const { c } = build({ page: { id: 'pg' }, compute });
|
||||
await expect(
|
||||
c.getPageWorkTime({ pageId: 'pg', tz: 'X/Y' } as any, user),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('does not swallow a non-RangeError from the service', async () => {
|
||||
const compute = jest.fn().mockRejectedValue(new Error('db down'));
|
||||
const { c } = build({ page: { id: 'pg' }, compute });
|
||||
await expect(
|
||||
c.getPageWorkTime({ pageId: 'pg' } as any, user),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
PageHistoryIdDto,
|
||||
PageIdDto,
|
||||
PageInfoDto,
|
||||
PageWorkTimeDto,
|
||||
} from './dto/page.dto';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
@@ -49,6 +50,7 @@ import { AddLabelsDto, RemoveLabelDto } from '../label/dto/label.dto';
|
||||
import {
|
||||
jsonToHtml,
|
||||
jsonToMarkdown,
|
||||
jsonToText,
|
||||
} from '../../collaboration/collaboration.util';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
@@ -93,10 +95,16 @@ export class PageController {
|
||||
const permissions = { canEdit, hasRestriction };
|
||||
|
||||
if (dto.format && dto.format !== 'json' && page.content) {
|
||||
const contentOutput =
|
||||
dto.format === 'markdown'
|
||||
? jsonToMarkdown(page.content)
|
||||
: jsonToHtml(page.content);
|
||||
let contentOutput: string;
|
||||
if (dto.format === 'markdown') {
|
||||
contentOutput = jsonToMarkdown(page.content);
|
||||
} else if (dto.format === 'text') {
|
||||
// #502: flat, deterministic, machine-diffable text (block-per-line,
|
||||
// inline marks/anchors dropped, non-text nodes -> stable placeholders).
|
||||
contentOutput = jsonToText(page.content, { deterministic: true });
|
||||
} else {
|
||||
contentOutput = jsonToHtml(page.content);
|
||||
}
|
||||
return {
|
||||
...page,
|
||||
content: contentOutput,
|
||||
@@ -524,6 +532,32 @@ export class PageController {
|
||||
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history/time')
|
||||
async getPageWorkTime(
|
||||
@Body() dto: PageWorkTimeDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// Same view gate as /history and /history/info.
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
try {
|
||||
return await this.pageHistoryService.computeWorkTime(page.id, dto.tz);
|
||||
} catch (e) {
|
||||
// Intl.DateTimeFormat throws RangeError on an unknown IANA zone; surface
|
||||
// it as a 400 rather than a 500.
|
||||
if (e instanceof RangeError) {
|
||||
throw new BadRequestException('Invalid timezone');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history/info')
|
||||
async getPageHistoryInfo(
|
||||
@@ -818,6 +852,11 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// Target-only validateCanView is intentional: getPageBreadCrumbs returns
|
||||
// the full ancestor chain WITHOUT per-ancestor permission filtering. Safe
|
||||
// because page restrictions inherit down the tree, so any ancestor the
|
||||
// caller could not view would already hide the target here — see the
|
||||
// getPageBreadCrumbs docstring / #471.
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.pageService.getPageBreadCrumbs(page.id);
|
||||
|
||||
@@ -3,6 +3,23 @@ import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import { PageHistory } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import {
|
||||
computeWorkTime,
|
||||
bucketByDay,
|
||||
DEFAULT_WORK_TIME_CONFIG,
|
||||
WorkTimeConfig,
|
||||
PerDay,
|
||||
} from '../work-time';
|
||||
|
||||
export interface PageWorkTime {
|
||||
workMs: number;
|
||||
agentOnlyMs: number;
|
||||
perDay: PerDay[];
|
||||
/** the config actually used, so the UI can show "≈" + the T_gap threshold. */
|
||||
config: WorkTimeConfig;
|
||||
/** the tz the per-day buckets were computed in (echoed back for the label). */
|
||||
tz: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PageHistoryService {
|
||||
@@ -23,4 +40,33 @@ export class PageHistoryService {
|
||||
paginationOptions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #395 — estimate time worked on a page (§5) and bucket it into the viewer's
|
||||
* calendar days for the punch-card (§6.3). Reads only the cheap history
|
||||
* projection (no `content`); the estimate itself is a pure, deterministic
|
||||
* function so it is unit-tested exhaustively without a DB.
|
||||
*
|
||||
* `tz` is the viewer's IANA zone (browser locale) — it moves which day a
|
||||
* session lands in and where its windows sit, but never the total (§10).
|
||||
*/
|
||||
async computeWorkTime(
|
||||
pageId: string,
|
||||
tz = 'UTC',
|
||||
config?: Partial<WorkTimeConfig>,
|
||||
): Promise<PageWorkTime> {
|
||||
const rows = await this.pageHistoryRepo.findTimelineByPageId(pageId);
|
||||
const result = computeWorkTime(rows, config);
|
||||
const usedConfig: WorkTimeConfig = { ...DEFAULT_WORK_TIME_CONFIG, ...config };
|
||||
// `bucketByDay` consumes the pure core's un-bucketed sessions here; the
|
||||
// full session list is NOT shipped on the response (no client reads it).
|
||||
const perDay = bucketByDay(result.sessions, tz);
|
||||
return {
|
||||
workMs: result.workMs,
|
||||
agentOnlyMs: result.agentOnlyMs,
|
||||
perDay,
|
||||
config: usedConfig,
|
||||
tz,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1071,6 +1071,29 @@ export class PageService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the ancestor chain of `childPageId` up to the space root, filtered
|
||||
* ONLY by `deletedAt` (+ MAX_PAGE_TREE_DEPTH) — WITHOUT per-ancestor
|
||||
* permission filtering. Callers that expose this to a user (the
|
||||
* `/breadcrumbs` endpoint) validate `validateCanView` on the TARGET page
|
||||
* only, then return the whole chain of ancestor titles (#471).
|
||||
*
|
||||
* This is safe — NOT a title leak — because page restrictions inherit DOWN
|
||||
* the tree: to view a page the caller must hold permission on EVERY
|
||||
* restricted ancestor (`validateCanView` -> `canUserAccessPage` checks the
|
||||
* full ancestor chain — see page-access.service.ts / page-permission.repo.ts
|
||||
* `canUserEditPage`). A restricted ancestor the caller may not see would
|
||||
* therefore already hide the TARGET page itself, so every ancestor reachable
|
||||
* here is one the caller is already entitled to view (content stays gated
|
||||
* regardless — getPage/getNode re-check permissions).
|
||||
*
|
||||
* Note the guarantee is the narrow "may view a descendant => may view its
|
||||
* ancestors", NOT "space membership sees every page" — restricted subtrees do
|
||||
* hide pages from members. Per-ancestor permission filtering here was
|
||||
* considered and declined as redundant given the inheritance invariant above
|
||||
* (#471). The same chain feeds the web-UI breadcrumb bar under identical CASL
|
||||
* scope.
|
||||
*/
|
||||
async getPageBreadCrumbs(childPageId: string, trx?: KyselyTransaction) {
|
||||
const ancestors = await dbOrTx(this.db, trx)
|
||||
.withRecursive('page_ancestors', (db) =>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { bucketByDay, zonedDayStart } from './bucket-by-day';
|
||||
import { computeWorkTime } from './compute-work-time';
|
||||
import { WorkSession, TimelineSample } from './work-time.types';
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
const HOUR = 60 * MIN;
|
||||
|
||||
function work(start: number, end: number): WorkSession {
|
||||
return { start, end, class: 'work' };
|
||||
}
|
||||
function agent(start: number, end: number): WorkSession {
|
||||
return { start, end, class: 'agent_only' };
|
||||
}
|
||||
|
||||
function sumActive(perDay: ReturnType<typeof bucketByDay>): number {
|
||||
return perDay.reduce((a, d) => a + d.activeMs, 0);
|
||||
}
|
||||
|
||||
describe('bucketByDay', () => {
|
||||
it('Σ activeMs == workMs — the §6.3 consistency invariant', () => {
|
||||
const rows: TimelineSample[] = [
|
||||
{ createdAt: '2026-07-04T03:40:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null },
|
||||
{ createdAt: '2026-07-04T03:49:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null },
|
||||
{ createdAt: '2026-07-04T18:11:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null },
|
||||
{ createdAt: '2026-07-06T15:34:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null },
|
||||
];
|
||||
const r = computeWorkTime(rows);
|
||||
const perDay = bucketByDay(r.sessions, 'UTC');
|
||||
expect(sumActive(perDay)).toBe(r.workMs);
|
||||
});
|
||||
|
||||
it('empty input → no days', () => {
|
||||
expect(bucketByDay([], 'UTC')).toEqual([]);
|
||||
});
|
||||
|
||||
it('midnight-crossing session splits across two days, sum preserved (§9#9)', () => {
|
||||
const start = Date.UTC(2026, 0, 10, 23, 14);
|
||||
const end = Date.UTC(2026, 0, 11, 0, 40);
|
||||
const perDay = bucketByDay([work(start, end)], 'UTC');
|
||||
expect(perDay).toHaveLength(2);
|
||||
expect(perDay[0].dayISO).toBe('2026-01-10');
|
||||
expect(perDay[1].dayISO).toBe('2026-01-11');
|
||||
expect(perDay[0].activeMs).toBe(46 * MIN); // 23:14 → 24:00
|
||||
expect(perDay[1].activeMs).toBe(40 * MIN); // 00:00 → 00:40
|
||||
expect(sumActive(perDay)).toBe(end - start);
|
||||
});
|
||||
|
||||
it('empty days between active days are emitted, not skipped (§9#12)', () => {
|
||||
const d1 = work(Date.UTC(2026, 0, 10, 10, 0), Date.UTC(2026, 0, 10, 11, 0));
|
||||
const d3 = work(Date.UTC(2026, 0, 12, 10, 0), Date.UTC(2026, 0, 12, 11, 0));
|
||||
const perDay = bucketByDay([d1, d3], 'UTC');
|
||||
expect(perDay.map((d) => d.dayISO)).toEqual([
|
||||
'2026-01-10',
|
||||
'2026-01-11',
|
||||
'2026-01-12',
|
||||
]);
|
||||
expect(perDay[1].activeMs).toBe(0);
|
||||
expect(perDay[1].windows).toEqual([]);
|
||||
});
|
||||
|
||||
it('agent_only windows are drawn but excluded from activeMs', () => {
|
||||
const w = work(Date.UTC(2026, 0, 10, 9, 0), Date.UTC(2026, 0, 10, 10, 0));
|
||||
const a = agent(Date.UTC(2026, 0, 10, 14, 0), Date.UTC(2026, 0, 10, 14, 30));
|
||||
const perDay = bucketByDay([w, a], 'UTC');
|
||||
expect(perDay).toHaveLength(1);
|
||||
expect(perDay[0].activeMs).toBe(1 * HOUR);
|
||||
expect(perDay[0].agentMs).toBe(30 * MIN);
|
||||
expect(perDay[0].windows.map((x) => x.class)).toEqual(['work', 'agent_only']);
|
||||
});
|
||||
|
||||
it('work and agent_only are unioned SEPARATELY (agent does not swallow work)', () => {
|
||||
// Overlapping work + agent windows on the same day.
|
||||
const w = work(Date.UTC(2026, 0, 10, 9, 0), Date.UTC(2026, 0, 10, 11, 0));
|
||||
const a = agent(Date.UTC(2026, 0, 10, 10, 0), Date.UTC(2026, 0, 10, 12, 0));
|
||||
const perDay = bucketByDay([w, a], 'UTC');
|
||||
expect(perDay[0].activeMs).toBe(2 * HOUR);
|
||||
expect(perDay[0].agentMs).toBe(2 * HOUR);
|
||||
});
|
||||
|
||||
it('overlapping same-class sessions are UNIONed, not summed (no double-count)', () => {
|
||||
// Two work sessions that overlap 10:00–10:30 on one day.
|
||||
const a = work(Date.UTC(2026, 0, 10, 9, 0), Date.UTC(2026, 0, 10, 10, 30));
|
||||
const b = work(Date.UTC(2026, 0, 10, 10, 0), Date.UTC(2026, 0, 10, 11, 0));
|
||||
const perDay = bucketByDay([a, b], 'UTC');
|
||||
expect(perDay).toHaveLength(1);
|
||||
// Union 09:00–11:00 = 2h, NOT 90m + 60m = 150m.
|
||||
expect(perDay[0].activeMs).toBe(2 * HOUR);
|
||||
// The drawn windows are also merged to one, so the punch-card cannot render
|
||||
// an overlapping double bar.
|
||||
expect(perDay[0].windows).toHaveLength(1);
|
||||
expect(perDay[0].windows[0].start).toBe(a.start);
|
||||
expect(perDay[0].windows[0].end).toBe(b.end);
|
||||
});
|
||||
|
||||
it('DST fall-back: a full 25-hour day still balances (§9#14)', () => {
|
||||
// America/New_York ends DST 2026-11-01 (25h day).
|
||||
const tz = 'America/New_York';
|
||||
const dayStart = zonedDayStart(Date.UTC(2026, 10, 1, 12, 0), tz);
|
||||
const nextStart = zonedDayStart(dayStart + 26 * HOUR, tz);
|
||||
expect(nextStart - dayStart).toBe(25 * HOUR);
|
||||
const perDay = bucketByDay([work(dayStart, nextStart)], tz);
|
||||
expect(perDay).toHaveLength(1);
|
||||
expect(perDay[0].dayISO).toBe('2026-11-01');
|
||||
expect(perDay[0].activeMs).toBe(25 * HOUR);
|
||||
expect(sumActive(perDay)).toBe(nextStart - dayStart);
|
||||
});
|
||||
|
||||
it('DST spring-forward: a full 23-hour day still balances (§9#14)', () => {
|
||||
// America/New_York starts DST 2026-03-08 (23h day).
|
||||
const tz = 'America/New_York';
|
||||
const dayStart = zonedDayStart(Date.UTC(2026, 2, 8, 12, 0), tz);
|
||||
const nextStart = zonedDayStart(dayStart + 26 * HOUR, tz);
|
||||
expect(nextStart - dayStart).toBe(23 * HOUR);
|
||||
const perDay = bucketByDay([work(dayStart, nextStart)], tz);
|
||||
expect(perDay).toHaveLength(1);
|
||||
expect(perDay[0].activeMs).toBe(23 * HOUR);
|
||||
expect(sumActive(perDay)).toBe(nextStart - dayStart);
|
||||
});
|
||||
|
||||
it('tz changes the day a session lands in but not the total', () => {
|
||||
const start = Date.UTC(2026, 0, 10, 2, 0); // 02:00 UTC
|
||||
const end = Date.UTC(2026, 0, 10, 3, 0);
|
||||
const utc = bucketByDay([work(start, end)], 'UTC');
|
||||
const ny = bucketByDay([work(start, end)], 'America/New_York'); // 21:00 prev day
|
||||
expect(utc[0].dayISO).toBe('2026-01-10');
|
||||
expect(ny[0].dayISO).toBe('2026-01-09');
|
||||
expect(sumActive(utc)).toBe(sumActive(ny));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { WorkSession, PerDay, DayWindow } from './work-time.types';
|
||||
|
||||
/**
|
||||
* Merge intervals into a disjoint, sorted union. Overlapping OR touching
|
||||
* intervals are joined. Empty input → [].
|
||||
*/
|
||||
function union(intervals: Array<[number, number]>): Array<[number, number]> {
|
||||
if (intervals.length === 0) return [];
|
||||
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
|
||||
const out: Array<[number, number]> = [];
|
||||
let [curStart, curEnd] = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const [s, e] = sorted[i];
|
||||
if (s <= curEnd) {
|
||||
if (e > curEnd) curEnd = e;
|
||||
} else {
|
||||
out.push([curStart, curEnd]);
|
||||
curStart = s;
|
||||
curEnd = e;
|
||||
}
|
||||
}
|
||||
out.push([curStart, curEnd]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Cache one Intl formatter per tz — constructing them is comparatively costly.
|
||||
const fmtCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function partsFmt(tz: string): Intl.DateTimeFormat {
|
||||
let fmt = fmtCache.get(tz);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
fmtCache.set(tz, fmt);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
interface WallParts {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
/** Wall-clock parts of an instant in `tz` (DST-correct, via Intl). */
|
||||
function wallParts(ms: number, tz: string): WallParts {
|
||||
const parts = partsFmt(tz).formatToParts(new Date(ms));
|
||||
const get = (type: string) =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? '0');
|
||||
let hour = get('hour');
|
||||
// Intl emits "24" for midnight under some engines/locales; normalize to 0.
|
||||
if (hour === 24) hour = 0;
|
||||
return {
|
||||
year: get('year'),
|
||||
month: get('month'),
|
||||
day: get('day'),
|
||||
hour,
|
||||
minute: get('minute'),
|
||||
second: get('second'),
|
||||
};
|
||||
}
|
||||
|
||||
/** tz offset (wall − real) at an instant, in ms. */
|
||||
function offset(ms: number, tz: string): number {
|
||||
const p = wallParts(ms, tz);
|
||||
const asUTC = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
||||
return asUTC - ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch-ms of the local-midnight day start of `ms` in `tz`. DST-correct: takes
|
||||
* the calendar day of the instant, its wall-midnight, then converts back with
|
||||
* the offset that actually applies AT that midnight (refined once). The rare
|
||||
* tz-with-a-DST-transition-exactly-at-midnight case is a documented edge (§9#14).
|
||||
*/
|
||||
export function zonedDayStart(ms: number, tz: string): number {
|
||||
const p = wallParts(ms, tz);
|
||||
const wallMidnightAsUTC = Date.UTC(p.year, p.month - 1, p.day, 0, 0, 0);
|
||||
let start = wallMidnightAsUTC - offset(ms, tz);
|
||||
// Refine with the offset at the computed midnight (DST may differ from `ms`).
|
||||
start = wallMidnightAsUTC - offset(start, tz);
|
||||
return start;
|
||||
}
|
||||
|
||||
/** The next local midnight after `dayStart` (handles 23/25h DST days). */
|
||||
function nextDayStart(dayStart: number, tz: string): number {
|
||||
// +26h always lands inside the NEXT calendar day (day length ∈ [23h,25h]),
|
||||
// never two days ahead; startOf('day') of it is the next midnight.
|
||||
return zonedDayStart(dayStart + 26 * 60 * 60 * 1000, tz);
|
||||
}
|
||||
|
||||
function isoDay(dayStart: number, tz: string): string {
|
||||
const p = wallParts(dayStart, tz);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
|
||||
}
|
||||
|
||||
/** Clip a union to [lo, hi) and emit windows of `class`. */
|
||||
function clip(
|
||||
merged: Array<[number, number]>,
|
||||
lo: number,
|
||||
hi: number,
|
||||
cls: DayWindow['class'],
|
||||
): DayWindow[] {
|
||||
const out: DayWindow[] = [];
|
||||
for (const [s, e] of merged) {
|
||||
const start = Math.max(s, lo);
|
||||
const end = Math.min(e, hi);
|
||||
if (end > start) out.push({ start, end, class: cls });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* #395 §6.3 — bucket sessions into calendar days of `tz` for the punch-card.
|
||||
* Pure and deterministic. `work` and `agent_only` are unioned SEPARATELY (else
|
||||
* agent windows would swallow work windows on overlap), then each union is split
|
||||
* at tz midnight boundaries (`startOf('day')` in tz, NOT "+24h" — DST-safe §9#14)
|
||||
* and clipped to each day.
|
||||
*
|
||||
* By construction Σ perDay.activeMs == workMs: the days are a partition of the
|
||||
* `work` union — no loss, no dup, even on 23/25h DST days. `agent_only` windows
|
||||
* are drawn but NOT in activeMs. Empty days between the first and last active day
|
||||
* are emitted (empty track + "—") so the rhythm/pauses stay visible.
|
||||
*/
|
||||
export function bucketByDay(sessions: WorkSession[], tz: string): PerDay[] {
|
||||
const uWork = union(
|
||||
sessions.filter((s) => s.class === 'work').map((s) => [s.start, s.end]),
|
||||
);
|
||||
const uAgent = union(
|
||||
sessions
|
||||
.filter((s) => s.class === 'agent_only')
|
||||
.map((s) => [s.start, s.end]),
|
||||
);
|
||||
|
||||
if (uWork.length === 0 && uAgent.length === 0) return [];
|
||||
|
||||
const minStart = Math.min(
|
||||
uWork.length ? uWork[0][0] : Infinity,
|
||||
uAgent.length ? uAgent[0][0] : Infinity,
|
||||
);
|
||||
const maxEnd = Math.max(
|
||||
uWork.length ? uWork[uWork.length - 1][1] : -Infinity,
|
||||
uAgent.length ? uAgent[uAgent.length - 1][1] : -Infinity,
|
||||
);
|
||||
|
||||
const perDay: PerDay[] = [];
|
||||
let dayStart = zonedDayStart(minStart, tz);
|
||||
// Guard against a pathological non-advancing boundary.
|
||||
let guard = 0;
|
||||
while (dayStart < maxEnd && guard < 100000) {
|
||||
guard++;
|
||||
const dayEnd = nextDayStart(dayStart, tz);
|
||||
const workWin = clip(uWork, dayStart, dayEnd, 'work');
|
||||
const agentWin = clip(uAgent, dayStart, dayEnd, 'agent_only');
|
||||
const activeMs = workWin.reduce((a, w) => a + (w.end - w.start), 0);
|
||||
const agentMs = agentWin.reduce((a, w) => a + (w.end - w.start), 0);
|
||||
const windows = [...workWin, ...agentWin].sort((a, b) => a.start - b.start);
|
||||
perDay.push({
|
||||
day: dayStart,
|
||||
dayISO: isoDay(dayStart, tz),
|
||||
activeMs,
|
||||
agentMs,
|
||||
windows,
|
||||
});
|
||||
dayStart = dayEnd;
|
||||
}
|
||||
return perDay;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { computeWorkTime } from './compute-work-time';
|
||||
import { bucketByDay } from './bucket-by-day';
|
||||
import { TimelineSample, WorkSession } from './work-time.types';
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
|
||||
/** Union wall-clock of a set of intervals (touching intervals merge). */
|
||||
function unionMs(intervals: Array<[number, number]>): number {
|
||||
if (intervals.length === 0) return 0;
|
||||
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
|
||||
let total = 0;
|
||||
let [cs, ce] = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const [s, e] = sorted[i];
|
||||
if (s <= ce) {
|
||||
if (e > ce) ce = e;
|
||||
} else {
|
||||
total += ce - cs;
|
||||
cs = s;
|
||||
ce = e;
|
||||
}
|
||||
}
|
||||
return total + (ce - cs);
|
||||
}
|
||||
|
||||
const ivsOf = (sessions: WorkSession[], cls?: string): Array<[number, number]> =>
|
||||
sessions
|
||||
.filter((x) => cls == null || x.class === cls)
|
||||
.map((x) => [x.start, x.end] as [number, number]);
|
||||
|
||||
function s(
|
||||
iso: string,
|
||||
opts: {
|
||||
source?: string | null;
|
||||
chat?: string | null;
|
||||
kind?: string | null;
|
||||
by?: string | null;
|
||||
} = {},
|
||||
): TimelineSample {
|
||||
return {
|
||||
createdAt: `${iso}Z`,
|
||||
lastUpdatedById: opts.by ?? 'human-1',
|
||||
lastUpdatedSource: opts.source === undefined ? 'user' : opts.source,
|
||||
lastUpdatedAiChatId: opts.chat ?? null,
|
||||
kind: opts.kind ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// §7 config: T_gap=30m, P_in+P_out=10m, P_single=2m.
|
||||
const S7 = { tGap: 30 * MIN, agentTGap: 30 * MIN, pIn: 5 * MIN, pOut: 5 * MIN, pSingle: 2 * MIN };
|
||||
|
||||
describe('computeWorkTime', () => {
|
||||
it('§7 fixture — sessionizes 20-ish samples to ≈1h32m, not the ≈60h naive span', () => {
|
||||
const rows: TimelineSample[] = [
|
||||
// S1: multi-sample morning session
|
||||
s('2026-07-04T03:40:00'),
|
||||
s('2026-07-04T03:45:00'),
|
||||
s('2026-07-04T03:49:00'),
|
||||
// S2: agent burst (one run) then human supervising → class work
|
||||
s('2026-07-04T15:43:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T15:47:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T15:50:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T16:13:00'),
|
||||
// S3: single
|
||||
s('2026-07-04T18:11:00'),
|
||||
// S4: multi-sample evening session
|
||||
s('2026-07-04T19:38:00'),
|
||||
s('2026-07-04T19:44:00'),
|
||||
s('2026-07-04T19:54:00'),
|
||||
// S5 / S6: two singles two days later, 44m apart → two sessions at T_gap=30
|
||||
s('2026-07-06T15:34:00'),
|
||||
s('2026-07-06T16:18:00'),
|
||||
];
|
||||
|
||||
const r = computeWorkTime(rows, S7);
|
||||
|
||||
// 19 + 40 + 2 + 26 + 2 + 2 = 91 minutes.
|
||||
expect(r.workMs).toBe(91 * MIN);
|
||||
expect(r.agentOnlyMs).toBe(0);
|
||||
expect(r.sessions).toHaveLength(6);
|
||||
expect(r.sessions.every((x) => x.class === 'work')).toBe(true);
|
||||
|
||||
const naiveSpan =
|
||||
new Date('2026-07-06T16:18:00Z').getTime() -
|
||||
new Date('2026-07-04T03:40:00Z').getTime();
|
||||
expect(naiveSpan).toBeGreaterThan(60 * 60 * MIN); // ≈60h
|
||||
expect(r.workMs).toBeLessThan(naiveSpan / 30); // dramatically smaller
|
||||
});
|
||||
|
||||
it('n=0 → zero, no sessions', () => {
|
||||
const r = computeWorkTime([]);
|
||||
expect(r).toEqual({ workMs: 0, agentOnlyMs: 0, sessions: [] });
|
||||
});
|
||||
|
||||
it('n=1 human → one P_single work session', () => {
|
||||
const r = computeWorkTime([s('2026-07-04T10:00:00')], S7);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].class).toBe('work');
|
||||
expect(r.workMs).toBe(2 * MIN);
|
||||
expect(r.agentOnlyMs).toBe(0);
|
||||
// pre-roll only: [t − P_single, t]
|
||||
expect(r.sessions[0].end).toBe(new Date('2026-07-04T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('n=1 agent → one P_single agent_only session, work=0 (§9#2)', () => {
|
||||
const r = computeWorkTime(
|
||||
[s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' })],
|
||||
S7,
|
||||
);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].class).toBe('agent_only');
|
||||
expect(r.workMs).toBe(0);
|
||||
expect(r.agentOnlyMs).toBe(2 * MIN);
|
||||
});
|
||||
|
||||
it('MUST close the last session — the newest session is not lost (§9#1)', () => {
|
||||
// Two singles a day apart: without the post-loop close, the 2nd is dropped.
|
||||
const rows = [s('2026-07-04T10:00:00'), s('2026-07-05T10:00:00')];
|
||||
const r = computeWorkTime(rows, S7);
|
||||
expect(r.sessions).toHaveLength(2);
|
||||
const lastStart = Math.max(...r.sessions.map((x) => x.start));
|
||||
expect(lastStart).toBe(
|
||||
new Date('2026-07-05T10:00:00Z').getTime() - 2 * MIN,
|
||||
);
|
||||
expect(r.workMs).toBe(4 * MIN);
|
||||
});
|
||||
|
||||
it('agent-burst collapse: density does not inflate — length = wall-clock', () => {
|
||||
const span = ['00', '01', '02', '03', '04', '05', '06'];
|
||||
const dense: TimelineSample[] = span.map((sec) =>
|
||||
s(`2026-07-04T10:00:${sec}`, { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
);
|
||||
const sparse: TimelineSample[] = [
|
||||
s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:00:06', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
];
|
||||
const rDense = computeWorkTime(dense, S7);
|
||||
const rSparse = computeWorkTime(sparse, S7);
|
||||
// Same 6-second wall-clock span → same estimate regardless of snapshot count.
|
||||
expect(rDense.agentOnlyMs).toBe(rSparse.agentOnlyMs);
|
||||
expect(rDense.sessions).toHaveLength(1);
|
||||
expect(rDense.sessions[0].class).toBe('agent_only');
|
||||
});
|
||||
|
||||
it('supervisory agent time inside a human session counts as work, not agent', () => {
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:05:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:12:00'), // human within T_gap
|
||||
];
|
||||
const r = computeWorkTime(rows, S7);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].class).toBe('work');
|
||||
expect(r.agentOnlyMs).toBe(0);
|
||||
expect(r.workMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('a DIFFERENT aiChatId breaks the burst — two agent runs, idle gap excluded', () => {
|
||||
// Run c1 ends 10:05, run c2 starts 10:20 (15m > agentTGap 7m) → two sessions.
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:05:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:20:00', { source: 'agent', chat: 'c2', kind: 'agent' }),
|
||||
s('2026-07-04T10:25:00', { source: 'agent', chat: 'c2', kind: 'agent' }),
|
||||
];
|
||||
const r = computeWorkTime(rows); // default agentTGap = 7m
|
||||
expect(r.sessions).toHaveLength(2);
|
||||
expect(r.sessions.every((x) => x.class === 'agent_only')).toBe(true);
|
||||
// The 15m idle gap between the two runs is NOT counted.
|
||||
const run1 = 5 * MIN + 5 * MIN + 5 * MIN; // pIn + span + pOut
|
||||
expect(r.agentOnlyMs).toBe(2 * run1);
|
||||
});
|
||||
|
||||
it('idle pulse (same/null run) is a full activity sample that continues a burst', () => {
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
// idle flush 4m later, null run id → continues the burst, not a new one
|
||||
s('2026-07-04T10:04:00', { source: 'agent', chat: null, kind: 'idle' }),
|
||||
s('2026-07-04T10:08:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
];
|
||||
const r = computeWorkTime(rows);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
// burst span 10:00→10:08 (+pIn/pOut) = 8 + 10 = 18m
|
||||
expect(r.agentOnlyMs).toBe(18 * MIN);
|
||||
});
|
||||
|
||||
it('a USER-sourced idle breaks an agent burst → session is work, not agent_only', () => {
|
||||
// A human supervision idle inherits source=user (aiChatId:null) and must NOT
|
||||
// be swallowed into the agent burst. Δ=3m is within the default agentTGap so
|
||||
// the two samples stay one session — but its class flips to `work`.
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:03:00', { source: 'user', chat: null, kind: 'idle' }),
|
||||
];
|
||||
const r = computeWorkTime(rows);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].class).toBe('work');
|
||||
expect(r.workMs).toBeGreaterThan(0);
|
||||
// The human idle is NOT captured as agent_only time.
|
||||
expect(r.agentOnlyMs).toBe(0);
|
||||
// Σ over `work` sessions == workMs and Σ over `agent_only` == agentOnlyMs.
|
||||
const sum = (cls: string) =>
|
||||
r.sessions
|
||||
.filter((x) => x.class === cls)
|
||||
.reduce((acc, x) => acc + (x.end - x.start), 0);
|
||||
expect(sum('work')).toBe(r.workMs);
|
||||
expect(sum('agent_only')).toBe(r.agentOnlyMs);
|
||||
});
|
||||
|
||||
it('idle pulse keeps a human writing session visible (not excluded)', () => {
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00'),
|
||||
s('2026-07-04T10:08:00', { kind: 'idle' }), // pulse within T_gap
|
||||
s('2026-07-04T10:15:00'),
|
||||
];
|
||||
const r = computeWorkTime(rows);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].class).toBe('work');
|
||||
// span 10:00→10:15 + pIn/pOut = 15 + 10 = 25m
|
||||
expect(r.workMs).toBe(25 * MIN);
|
||||
});
|
||||
|
||||
it('git-source samples are excluded (§10 excludeGit)', () => {
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00', { source: 'git', kind: 'boundary' }),
|
||||
s('2026-07-04T10:01:00', { source: 'git', kind: 'boundary' }),
|
||||
];
|
||||
expect(computeWorkTime(rows).workMs).toBe(0);
|
||||
// ...but honoured off:
|
||||
expect(
|
||||
computeWorkTime(rows, { excludeGit: false }).workMs,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects an invalid config (tGap < pIn + pOut)', () => {
|
||||
expect(() =>
|
||||
computeWorkTime([s('2026-07-04T10:00:00')], {
|
||||
tGap: 5 * MIN,
|
||||
pIn: 5 * MIN,
|
||||
pOut: 5 * MIN,
|
||||
}),
|
||||
).toThrow(/tGap/);
|
||||
});
|
||||
|
||||
it('rejects an invalid config (2·agentTGap < pIn + pOut)', () => {
|
||||
// tGap (default 15m) still ≥ pIn+pOut, so only the 2·agentTGap guard trips.
|
||||
// Without it a short session of one class between two of the other could
|
||||
// produce a NON-adjacent cross-class overlap the adjacent-only clip misses.
|
||||
expect(() =>
|
||||
computeWorkTime([s('2026-07-04T10:00:00')], {
|
||||
agentTGap: 2 * MIN,
|
||||
pIn: 5 * MIN,
|
||||
pOut: 5 * MIN,
|
||||
}),
|
||||
).toThrow(/agentTGap/);
|
||||
});
|
||||
|
||||
// F1 — cross-class double-count. On the DEFAULT config agentTGap (7m) < pIn+pOut
|
||||
// (10m), so a `work` session ending in an agent segment and a nearby separate
|
||||
// `agent_only` run (gap in (7m,10m]) used to produce OVERLAPPING padded
|
||||
// intervals — the same wall-clock counted into BOTH workMs and agentOnlyMs. The
|
||||
// cross-class padding clip must make the two per-class unions disjoint.
|
||||
it('does NOT double-count wall-clock across work/agent_only (§F1)', () => {
|
||||
// user@0s ; agent(chatX)@60s (breaks into a work session with the human) ;
|
||||
// agent(chatY)@560s,590s (a separate agent_only run). Raw gap between the work
|
||||
// session (ends 60s) and the agent run (starts 560s) is 500s ∈ (agentTGap,
|
||||
// pIn+pOut] once padded — the classic overlap window.
|
||||
const rows: TimelineSample[] = [
|
||||
s('2026-07-04T00:00:00'), // user @ 0s
|
||||
s('2026-07-04T00:01:00', { source: 'agent', chat: 'cX', kind: 'agent' }), // @ 60s
|
||||
s('2026-07-04T00:09:20', { source: 'agent', chat: 'cY', kind: 'agent' }), // @ 560s
|
||||
s('2026-07-04T00:09:50', { source: 'agent', chat: 'cY', kind: 'agent' }), // @ 590s
|
||||
];
|
||||
const r = computeWorkTime(rows); // DEFAULT config
|
||||
|
||||
// Both classes present.
|
||||
expect(r.workMs).toBeGreaterThan(0);
|
||||
expect(r.agentOnlyMs).toBeGreaterThan(0);
|
||||
|
||||
// Per-class metrics are exactly their own union (union, not Σ).
|
||||
expect(r.workMs).toBe(unionMs(ivsOf(r.sessions, 'work')));
|
||||
expect(r.agentOnlyMs).toBe(unionMs(ivsOf(r.sessions, 'agent_only')));
|
||||
|
||||
// The F1 invariant: work-union and agent-union are cross-class-disjoint, so
|
||||
// the union of ALL padded intervals equals workMs + agentOnlyMs (no overlap).
|
||||
// With the clip disabled this fails (union < sum by the 100s overlap).
|
||||
expect(unionMs(ivsOf(r.sessions))).toBe(r.workMs + r.agentOnlyMs);
|
||||
});
|
||||
|
||||
// F1 property/fuzz — random timelines across several timezones must uphold the
|
||||
// work-time invariants. Backs the (corrected) PR claim of a real fuzz test.
|
||||
it('property: random timelines uphold union & cross-class-disjoint invariants', () => {
|
||||
// Deterministic LCG (numerical-recipes constants) so a failure is reproducible.
|
||||
let seed = 0x9e3779b9 >>> 0;
|
||||
const rand = () => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return seed / 0x100000000;
|
||||
};
|
||||
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)];
|
||||
|
||||
const tzs = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'Europe/Moscow',
|
||||
'Australia/Lord_Howe', // 30-min DST offset — a nasty bucket stress
|
||||
];
|
||||
const base = Date.UTC(2026, 5, 1, 0, 0, 0); // 2026-06-01Z
|
||||
const chats = ['c1', 'c2', 'c3'];
|
||||
|
||||
for (let iter = 0; iter < 250; iter++) {
|
||||
const tz = pick(tzs);
|
||||
const n = 2 + Math.floor(rand() * 18); // 2..19 rows
|
||||
const rows: TimelineSample[] = [];
|
||||
// Walk time forward by a random inter-sample gap. The gap distribution is
|
||||
// centred on the DANGEROUS band — a bit under to a bit over pIn+pOut (10m)
|
||||
// AND straddling agentTGap (7m) — so adjacent samples routinely split into
|
||||
// separate sessions whose ±P padding would overlap if a class boundary sits
|
||||
// there. Mixing user/agent classes at these gaps reliably manufactures the
|
||||
// work-ending-in-agent → agent_only cross-class boundary F1 is about, plus
|
||||
// dense within-class runs (occasional 0–2m gaps) that exercise the union.
|
||||
let t = base + Math.floor(rand() * 60 * MIN);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const roll = rand();
|
||||
const gap =
|
||||
roll < 0.25
|
||||
? Math.floor(rand() * 2 * MIN) // dense burst (same-class union)
|
||||
: roll < 0.85
|
||||
? 5 * MIN + Math.floor(rand() * 8 * MIN) // 5–13m: the split band
|
||||
: 20 * MIN + Math.floor(rand() * 40 * MIN); // long idle → new day-ish
|
||||
t += gap;
|
||||
const iso = new Date(t).toISOString().slice(0, 19); // 'YYYY-MM-DDTHH:MM:SS'
|
||||
const isAgent = rand() < 0.5;
|
||||
rows.push(
|
||||
isAgent
|
||||
? s(iso, { source: 'agent', chat: pick(chats), kind: 'agent' })
|
||||
: s(iso, { source: 'user', kind: rand() < 0.3 ? 'idle' : 'manual' }),
|
||||
);
|
||||
}
|
||||
|
||||
const r = computeWorkTime(rows); // DEFAULT config
|
||||
|
||||
const workIvs = ivsOf(r.sessions, 'work');
|
||||
const agentIvs = ivsOf(r.sessions, 'agent_only');
|
||||
|
||||
// (1) each metric is exactly its per-class union (catches a union→Σ regress).
|
||||
expect(r.workMs).toBe(unionMs(workIvs));
|
||||
expect(r.agentOnlyMs).toBe(unionMs(agentIvs));
|
||||
|
||||
// (2) NO cross-class overlap: union(all) == workMs + agentOnlyMs (F1).
|
||||
expect(unionMs(ivsOf(r.sessions))).toBe(r.workMs + r.agentOnlyMs);
|
||||
|
||||
// (3) bucket invariant: Σ per-day activeMs == workMs (§6.3).
|
||||
const perDay = bucketByDay(r.sessions, tz);
|
||||
const sumActive = perDay.reduce((a, d) => a + d.activeMs, 0);
|
||||
expect(sumActive).toBe(r.workMs);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
TimelineSample,
|
||||
WorkSession,
|
||||
WorkTimeResult,
|
||||
} from './work-time.types';
|
||||
import { WorkTimeConfig, resolveWorkTimeConfig } from './work-time.config';
|
||||
|
||||
/** A normalized activity sample (one history row), createdAt as epoch-ms. */
|
||||
interface NormSample {
|
||||
t: number;
|
||||
isAgent: boolean;
|
||||
aiChatId: string | null;
|
||||
kind: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsed segment: either a scalar sample (t_start == t_end) or an
|
||||
* agent-burst spanning several agent samples of one run (§5.1). It participates
|
||||
* in sessionization as a single "sample".
|
||||
*/
|
||||
interface Segment {
|
||||
tStart: number;
|
||||
tEnd: number;
|
||||
isAgent: boolean;
|
||||
}
|
||||
|
||||
function toMs(v: Date | string | number): number {
|
||||
if (v instanceof Date) return v.getTime();
|
||||
if (typeof v === 'number') return v;
|
||||
return new Date(v).getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw rows → sorted, deduped activity samples. `git` is dropped when
|
||||
* configured; every other kind (incl. `idle` — the continuous-work pulse §3) is
|
||||
* a real activity sample. Sort is by createdAt ASC; samples whose timestamps
|
||||
* fall in the same `dedupRoundMs` bucket collapse to one (§9#7: a synchronous
|
||||
* boundary row + the immediate agent snapshot can share a createdAt). A merged
|
||||
* sample is human unless EVERY member is an agent, so supervision never gets
|
||||
* mis-attributed to the agent.
|
||||
*/
|
||||
function normalize(
|
||||
rows: TimelineSample[],
|
||||
config: WorkTimeConfig,
|
||||
): NormSample[] {
|
||||
const samples: NormSample[] = [];
|
||||
for (const row of rows) {
|
||||
const source = row.lastUpdatedSource;
|
||||
if (config.excludeGit && source === 'git') continue;
|
||||
samples.push({
|
||||
t: toMs(row.createdAt),
|
||||
isAgent: source === 'agent',
|
||||
aiChatId: row.lastUpdatedAiChatId ?? null,
|
||||
kind: row.kind ?? null,
|
||||
});
|
||||
}
|
||||
samples.sort((a, b) => a.t - b.t);
|
||||
|
||||
if (config.dedupRoundMs <= 0 || samples.length < 2) return samples;
|
||||
|
||||
const deduped: NormSample[] = [];
|
||||
for (const s of samples) {
|
||||
const prev = deduped[deduped.length - 1];
|
||||
if (prev && s.t - prev.t < config.dedupRoundMs) {
|
||||
// Merge into the previous sample. Human wins the class; keep the earliest
|
||||
// t; keep a non-null aiChatId if either has one (so a bare boundary row
|
||||
// does not erase the run id).
|
||||
prev.isAgent = prev.isAgent && s.isAgent;
|
||||
prev.aiChatId = prev.aiChatId ?? s.aiChatId;
|
||||
// Prefer the more specific kind (a real kind over a null/boundary) only
|
||||
// matters for burst continuation; keep prev.kind (earliest) as-is.
|
||||
continue;
|
||||
}
|
||||
deduped.push({ ...s });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse consecutive same-run agent samples into one burst segment (§5.1) so a
|
||||
* dense burst (8 snapshots in 7 minutes) contributes its wall-clock, not a count
|
||||
* × block. A burst is broken by any sample NOT continuing the same aiChatId
|
||||
* agent run: a non-agent sample, a `boundary` (actor transition), or a DIFFERENT
|
||||
* aiChatId. Only an AGENT-sourced `idle` pulse with the SAME or a null aiChatId
|
||||
* continues the burst (its label lags the real edit ≤ maxWait, well within
|
||||
* rounding); a user-sourced `idle` (a human supervision pulse) breaks it.
|
||||
*/
|
||||
function collapse(samples: NormSample[], config: WorkTimeConfig): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let burst: { chatId: string | null; tStart: number; tEnd: number } | null =
|
||||
null;
|
||||
|
||||
const flush = () => {
|
||||
if (!burst) return;
|
||||
let tEnd = burst.tEnd;
|
||||
if (config.burstCapMs != null && tEnd - burst.tStart > config.burstCapMs) {
|
||||
tEnd = burst.tStart + config.burstCapMs;
|
||||
}
|
||||
segments.push({ tStart: burst.tStart, tEnd, isAgent: true });
|
||||
burst = null;
|
||||
};
|
||||
|
||||
for (const s of samples) {
|
||||
// An agent-sourced idle pulse continues the current agent burst (same or
|
||||
// null run id). A user-sourced idle (human supervision) must NOT be swallowed
|
||||
// here — it falls through to the human branch so the session flips to `work`.
|
||||
if (
|
||||
burst &&
|
||||
s.kind === 'idle' &&
|
||||
s.isAgent &&
|
||||
(s.aiChatId === burst.chatId || s.aiChatId == null)
|
||||
) {
|
||||
burst.tEnd = s.t;
|
||||
continue;
|
||||
}
|
||||
if (s.isAgent && s.kind !== 'boundary') {
|
||||
if (burst && burst.chatId === s.aiChatId) {
|
||||
burst.tEnd = s.t;
|
||||
} else {
|
||||
flush();
|
||||
burst = { chatId: s.aiChatId, tStart: s.t, tEnd: s.t };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A human sample, a boundary, or an agent-boundary: breaks the burst and is
|
||||
// itself a zero-width segment (its class follows its own source).
|
||||
flush();
|
||||
segments.push({ tStart: s.t, tEnd: s.t, isAgent: s.isAgent });
|
||||
}
|
||||
flush();
|
||||
return segments;
|
||||
}
|
||||
|
||||
function gapThreshold(
|
||||
a: Segment,
|
||||
b: Segment,
|
||||
config: WorkTimeConfig,
|
||||
): number {
|
||||
return a.isAgent && b.isAgent ? config.agentTGap : config.tGap;
|
||||
}
|
||||
|
||||
/** Merge intervals; overlapping OR touching intervals are unioned. */
|
||||
function unionDuration(intervals: Array<[number, number]>): number {
|
||||
if (intervals.length === 0) return 0;
|
||||
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
|
||||
let total = 0;
|
||||
let [curStart, curEnd] = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const [s, e] = sorted[i];
|
||||
if (s <= curEnd) {
|
||||
if (e > curEnd) curEnd = e;
|
||||
} else {
|
||||
total += curEnd - curStart;
|
||||
curStart = s;
|
||||
curEnd = e;
|
||||
}
|
||||
}
|
||||
total += curEnd - curStart;
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* #395 core — estimate time worked on a page from its history timeline (§5).
|
||||
* Pure and deterministic: no DB, no clock, no I/O.
|
||||
*
|
||||
* Pipeline: normalize+dedup → collapse agent bursts → ONE sessionization pass
|
||||
* over all segments (threshold depends on the pair: both-agent → agentTGap, else
|
||||
* tGap; the last session is ALWAYS closed after the loop) → class per finished
|
||||
* session (all-agent → agent_only, else work) → pad each session (multi-sample
|
||||
* → [first−P_in, last+P_out]; lone scalar → [t−P_single, t]) → clip padding of
|
||||
* adjacent DIFFERENT-class sessions at the raw-gap midpoint (so work/agent_only
|
||||
* never overlap) → metrics are the union wall-clock within each class (union, not
|
||||
* Σ, so overlaps never double, and cross-class-disjoint by the clip above).
|
||||
*/
|
||||
export function computeWorkTime(
|
||||
rows: TimelineSample[],
|
||||
config?: Partial<WorkTimeConfig>,
|
||||
): WorkTimeResult {
|
||||
const cfg = resolveWorkTimeConfig(config);
|
||||
const samples = normalize(rows, cfg);
|
||||
const segments = collapse(samples, cfg);
|
||||
|
||||
// Sessionize — one pass over ALL segments.
|
||||
const rawSessions: Segment[][] = [];
|
||||
let cur: Segment[] | null = null;
|
||||
for (const seg of segments) {
|
||||
if (cur == null) {
|
||||
cur = [seg];
|
||||
} else {
|
||||
const last = cur[cur.length - 1];
|
||||
if (seg.tStart - last.tEnd <= gapThreshold(last, seg, cfg)) {
|
||||
cur.push(seg);
|
||||
} else {
|
||||
rawSessions.push(cur);
|
||||
cur = [seg];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cur != null) rawSessions.push(cur); // MUST close the last session (§5, §9#1)
|
||||
|
||||
// A finished session with BOTH its raw (unpadded) span and its padded bounds.
|
||||
// `rawSessions` are already in ascending time order, so `built` is too.
|
||||
interface BuiltSession {
|
||||
rawStart: number;
|
||||
rawEnd: number;
|
||||
padStart: number;
|
||||
padEnd: number;
|
||||
cls: WorkSession['class'];
|
||||
}
|
||||
const built: BuiltSession[] = [];
|
||||
|
||||
for (const segs of rawSessions) {
|
||||
const first = segs[0];
|
||||
const last = segs[segs.length - 1];
|
||||
const cls = segs.every((s) => s.isAgent) ? 'agent_only' : 'work';
|
||||
|
||||
let padStart: number;
|
||||
let padEnd: number;
|
||||
if (segs.length === 1 && first.tStart === first.tEnd) {
|
||||
// Lone single-instant session (one scalar, or a one-snapshot agent run):
|
||||
// pre-roll only, no invented "future" work (§5).
|
||||
padStart = first.tStart - cfg.pSingle;
|
||||
padEnd = first.tStart;
|
||||
} else {
|
||||
padStart = first.tStart - cfg.pIn;
|
||||
padEnd = last.tEnd + cfg.pOut;
|
||||
}
|
||||
|
||||
built.push({
|
||||
rawStart: first.tStart,
|
||||
rawEnd: last.tEnd,
|
||||
padStart,
|
||||
padEnd,
|
||||
cls,
|
||||
});
|
||||
}
|
||||
|
||||
// Clip cross-class padding so a `work` and an `agent_only` session that abut
|
||||
// never claim the same wall-clock. For each ADJACENT pair of DIFFERENT classes,
|
||||
// cap the earlier session's trailing pad and the later session's leading pad at
|
||||
// the MIDPOINT of the raw (unpadded) inactivity gap between them: the earlier
|
||||
// padded interval then ends ≤ midpoint and the later one starts ≥ midpoint, so
|
||||
// the two are disjoint (they touch at most at the midpoint). This makes the
|
||||
// per-class unions (workMs / agentOnlyMs) cross-class-disjoint BY CONSTRUCTION
|
||||
// — closing the double-count where a work session ending in an agent segment
|
||||
// and a nearby agent_only session (gap in (agentTGap, pIn+pOut]) overlapped and
|
||||
// were counted into both metrics (§5, §9). Within-class adjacency is left
|
||||
// untouched: `unionDuration` already dedups it, and clipping there could perturb
|
||||
// the per-class metric value.
|
||||
for (let i = 1; i < built.length; i++) {
|
||||
const a = built[i - 1];
|
||||
const b = built[i];
|
||||
if (a.cls === b.cls) continue;
|
||||
const midpoint = (a.rawEnd + b.rawStart) / 2;
|
||||
if (a.padEnd > midpoint) a.padEnd = midpoint;
|
||||
if (b.padStart < midpoint) b.padStart = midpoint;
|
||||
}
|
||||
|
||||
const sessions: WorkSession[] = [];
|
||||
const workIvs: Array<[number, number]> = [];
|
||||
const agentIvs: Array<[number, number]> = [];
|
||||
for (const s of built) {
|
||||
sessions.push({ start: s.padStart, end: s.padEnd, class: s.cls });
|
||||
(s.cls === 'work' ? workIvs : agentIvs).push([s.padStart, s.padEnd]);
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => a.start - b.start);
|
||||
|
||||
return {
|
||||
workMs: unionDuration(workIvs),
|
||||
agentOnlyMs: unionDuration(agentIvs),
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { computeWorkTime } from './compute-work-time';
|
||||
export { bucketByDay, zonedDayStart } from './bucket-by-day';
|
||||
export {
|
||||
DEFAULT_WORK_TIME_CONFIG,
|
||||
resolveWorkTimeConfig,
|
||||
} from './work-time.config';
|
||||
export type { WorkTimeConfig } from './work-time.config';
|
||||
export type {
|
||||
TimelineSample,
|
||||
WorkSession,
|
||||
WorkTimeResult,
|
||||
SessionClass,
|
||||
DayWindow,
|
||||
PerDay,
|
||||
} from './work-time.types';
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
IDLE_MAX_WAIT_USER,
|
||||
IDLE_MAX_WAIT_AGENT,
|
||||
} from '../../../collaboration/constants';
|
||||
|
||||
/**
|
||||
* #395 — tunables for the work-time estimate (§10). Defaults are calibrated off
|
||||
* #374's idle-pulse ceilings: after #374 a continuous editing session leaves a
|
||||
* history row at least every ~IDLE_MAX_WAIT (10m user / 5m agent), so a gap
|
||||
* WIDER than that ceiling contains un-pulsed idle time = (partial) inactivity.
|
||||
* `tGap` therefore sits a little above the user ceiling, `agentTGap` a little
|
||||
* above the agent ceiling — a gap within the threshold is pulse-backed and
|
||||
* counts as work.
|
||||
*/
|
||||
export interface WorkTimeConfig {
|
||||
/** user inactivity timeout: gap ≤ tGap between samples = continuous work. */
|
||||
tGap: number;
|
||||
/** timeout for a pair of consecutive agent samples (tighter than tGap). */
|
||||
agentTGap: number;
|
||||
/** pre-roll padding for a multi-sample session (work began before sample 1). */
|
||||
pIn: number;
|
||||
/** post-roll padding for a multi-sample session (work continued after last). */
|
||||
pOut: number;
|
||||
/** block for a lone single-sample session (pre-roll only, no invented future). */
|
||||
pSingle: number;
|
||||
/** drop `git`-source samples (they are not human/agent article work). */
|
||||
excludeGit: boolean;
|
||||
/** optional cap on one collapsed agent-burst segment's wall-clock (§9#3). */
|
||||
burstCapMs?: number;
|
||||
/** samples whose createdAt round to the same bucket dedup to one (§9#7). */
|
||||
dedupRoundMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WORK_TIME_CONFIG: WorkTimeConfig = {
|
||||
// ~15m: IDLE_MAX_WAIT_USER (10m) + headroom. Empirically backcast on a real
|
||||
// 307-snapshot article (≈24h at 15m matched the owner's estimate; 30/45m
|
||||
// over-counted). See #395 §10.
|
||||
tGap: 15 * 60 * 1000,
|
||||
// ~7m: IDLE_MAX_WAIT_AGENT (5m) + headroom.
|
||||
agentTGap: 7 * 60 * 1000,
|
||||
pIn: 5 * 60 * 1000,
|
||||
pOut: 5 * 60 * 1000,
|
||||
pSingle: 2 * 60 * 1000,
|
||||
excludeGit: true,
|
||||
burstCapMs: undefined,
|
||||
dedupRoundMs: 1000,
|
||||
};
|
||||
|
||||
// Compile-time cross-check that the defaults really are pulse-anchored — if a
|
||||
// future edit moves the #374 ceilings, this reminds us to re-calibrate.
|
||||
void IDLE_MAX_WAIT_USER;
|
||||
void IDLE_MAX_WAIT_AGENT;
|
||||
|
||||
/**
|
||||
* Fill a partial config with defaults and validate it. Cross-class metric
|
||||
* disjointness is guaranteed jointly by `computeWorkTime`'s adjacent-pair padding
|
||||
* clip (it caps the padding of adjacent DIFFERENT-class sessions at the raw-gap
|
||||
* midpoint) AND the two bounds enforced below (§5):
|
||||
* - `tGap ≥ pIn + pOut`: a session's own padding never exceeds its inactivity
|
||||
* window.
|
||||
* - `2·agentTGap ≥ pIn + pOut`: makes the adjacent-only clip provably COMPLETE.
|
||||
* A NON-adjacent (i, i+2) cross-class overlap could only arise from two
|
||||
* same-class sessions separated by a full intervening session of the other
|
||||
* class; that separation spans at least two inter-session gaps, each strictly
|
||||
* `> agentTGap`, so it is `> 2·agentTGap`. Requiring `2·agentTGap ≥ pIn + pOut`
|
||||
* means even the widest padded reach (pIn + pOut) cannot bridge it — so the
|
||||
* only cross-class overlaps possible are between ADJACENT sessions, which the
|
||||
* clip handles. `workMs`/`agentOnlyMs` are therefore disjoint by construction.
|
||||
*/
|
||||
export function resolveWorkTimeConfig(
|
||||
partial?: Partial<WorkTimeConfig>,
|
||||
): WorkTimeConfig {
|
||||
const config = { ...DEFAULT_WORK_TIME_CONFIG, ...(partial ?? {}) };
|
||||
|
||||
for (const key of [
|
||||
'tGap',
|
||||
'agentTGap',
|
||||
'pIn',
|
||||
'pOut',
|
||||
'pSingle',
|
||||
'dedupRoundMs',
|
||||
] as const) {
|
||||
const value = config[key];
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`work-time config: ${key} must be a non-negative number`);
|
||||
}
|
||||
}
|
||||
if (config.burstCapMs != null && config.burstCapMs <= 0) {
|
||||
throw new Error('work-time config: burstCapMs must be > 0 when set');
|
||||
}
|
||||
if (config.tGap < config.pIn + config.pOut) {
|
||||
throw new Error(
|
||||
"work-time config: tGap must be ≥ pIn + pOut (a session's padding may not exceed its inactivity window)",
|
||||
);
|
||||
}
|
||||
if (2 * config.agentTGap < config.pIn + config.pOut) {
|
||||
throw new Error(
|
||||
'work-time config: 2·agentTGap must be ≥ pIn + pOut (so non-adjacent cross-class padding cannot overlap)',
|
||||
);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* #395 — "time worked on an article" domain types.
|
||||
*
|
||||
* The estimate is built by sessionizing a page's `page_history` timeline on
|
||||
* inactivity gaps (WakaTime-style), NOT by taking the span between the first and
|
||||
* last edit (which over-counts sleep / lunch / idle days). See the design doc in
|
||||
* issue #395 §5–§6.3 for the normative algorithm.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A single `page_history` row projected for the work-time computation — the
|
||||
* cheap columns only (no `content`). Produced by
|
||||
* `PageHistoryRepo.findTimelineByPageId`. `createdAt` is whatever the DB driver
|
||||
* hands back (Date); the pure core normalizes it to epoch-ms itself so it stays
|
||||
* deterministic and DB-free.
|
||||
*/
|
||||
export interface TimelineSample {
|
||||
createdAt: Date | string | number;
|
||||
lastUpdatedById: string | null;
|
||||
/** 'user' | 'agent' | 'git' | null (legacy autosave = human). */
|
||||
lastUpdatedSource: string | null;
|
||||
lastUpdatedAiChatId: string | null;
|
||||
/** #370 tier: 'manual' | 'agent' | 'idle' | 'boundary' | null (legacy). */
|
||||
kind: string | null;
|
||||
}
|
||||
|
||||
/** A finished session's class (§5.1). */
|
||||
export type SessionClass = 'work' | 'agent_only';
|
||||
|
||||
/**
|
||||
* A finished session: absolute wall-clock bounds already padded with P_in/P_out
|
||||
* (multi-sample) or P_single (single scalar), plus its class. This is enough for
|
||||
* both the metrics and the per-day punch-card colouring.
|
||||
*/
|
||||
export interface WorkSession {
|
||||
/** epoch-ms, inclusive lower bound (already P-padded). */
|
||||
start: number;
|
||||
/** epoch-ms, exclusive upper bound (already P-padded). */
|
||||
end: number;
|
||||
class: SessionClass;
|
||||
}
|
||||
|
||||
/** Output of {@link computeWorkTime}. */
|
||||
export interface WorkTimeResult {
|
||||
/** union wall-clock of `work` sessions, ms (the headline metric). */
|
||||
workMs: number;
|
||||
/** union wall-clock of `agent_only` sessions, ms (secondary). */
|
||||
agentOnlyMs: number;
|
||||
sessions: WorkSession[];
|
||||
}
|
||||
|
||||
/** One activity window inside a calendar day (already clipped to the day). */
|
||||
export interface DayWindow {
|
||||
/** epoch-ms. */
|
||||
start: number;
|
||||
/** epoch-ms. */
|
||||
end: number;
|
||||
class: SessionClass;
|
||||
}
|
||||
|
||||
/** One calendar day of the punch-card (§6.3). */
|
||||
export interface PerDay {
|
||||
/** epoch-ms of the local-midnight day start in the requested tz. */
|
||||
day: number;
|
||||
/** 'YYYY-MM-DD' in the requested tz — stable, tz-independent label. */
|
||||
dayISO: string;
|
||||
/** Σ of `work` windows this day, ms. Σ over days == workMs (invariant §6.3). */
|
||||
activeMs: number;
|
||||
/** Σ of `agent_only` windows this day, ms (drawn, NOT in activeMs). */
|
||||
agentMs: number;
|
||||
/** both classes, clipped to the day, sorted by start (for drawing). */
|
||||
windows: DayWindow[];
|
||||
}
|
||||
@@ -1,33 +1,56 @@
|
||||
import { Space } from '@docmost/db/types/entity.types';
|
||||
|
||||
export class SearchResponseDto {
|
||||
// #529 A7 — the single per-hit SUPERSET returned by the unified search engine.
|
||||
// The web-UI reads id/highlight/icon/space/title/…; the MCP agent maps id→pageId
|
||||
// and reads snippet/score/path. `rank`/`highlight` are null for substring-only
|
||||
// hits (the web already falls back). Nothing the legacy web response carried is
|
||||
// dropped.
|
||||
export class SearchResultDto {
|
||||
id: string;
|
||||
title: string;
|
||||
// Alias of `id` for the MCP layer (it addresses pages by pageId).
|
||||
pageId: string;
|
||||
slugId: string;
|
||||
icon: string;
|
||||
parentPageId: string;
|
||||
title: string;
|
||||
space?: Partial<Space>;
|
||||
creatorId: string;
|
||||
rank: number;
|
||||
highlight: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
space: Partial<Space>;
|
||||
// ts_rank_cd of the FTS branch; null for substring-only hits.
|
||||
rank: number | null;
|
||||
// ts_headline marked HTML; null for substring-only hits.
|
||||
highlight: string | null;
|
||||
// Plain windowed snippet around the match (empty for titleOnly).
|
||||
snippet: string;
|
||||
// Ancestor titles root → direct parent ([] for a root page).
|
||||
path: string[];
|
||||
// Per-response ordering proxy (falls back to rank).
|
||||
score: number;
|
||||
// Which fields matched: 'title' and/or 'text'.
|
||||
matchedFields: string[];
|
||||
// Which parsed positive/required terms this hit matched.
|
||||
matchedTerms: string[];
|
||||
}
|
||||
|
||||
// Response shape for the opt-in agent-lookup mode (#443, `substring: true`).
|
||||
// Additive to the FTS response: carries the location (`path`), a windowed
|
||||
// `snippet` around the first match and a per-response sort `score`. The MCP
|
||||
// layer maps `id → pageId`; `slugId` is never exposed.
|
||||
export class SearchLookupResponseDto {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
parentPageId: string | null;
|
||||
// Ancestor titles from the space root down to the direct parent; [] for a
|
||||
// root page.
|
||||
path: string[];
|
||||
// ~300–500 chars around the first match (or a leading text window / extended
|
||||
// ts_headline fallback).
|
||||
snippet: string;
|
||||
// 0..1 float, meaningful ONLY for sorting within one response.
|
||||
score: number;
|
||||
// The paginated envelope (A5). `total` is the EXACT permission-filtered count of
|
||||
// pages matching the positive lexical query (fail-closed). `hasMore` is true when
|
||||
// more results exist WITHIN the fusion window; `truncatedAtCap` signals the match
|
||||
// set exceeded CANDIDATE_CAP and the tail is unreachable by pagination.
|
||||
export class SearchResponseDto {
|
||||
items: SearchResultDto[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
truncatedAtCap: boolean;
|
||||
offset: number;
|
||||
query: {
|
||||
raw: string;
|
||||
parsed: {
|
||||
positive: string[];
|
||||
required: string[];
|
||||
excluded: string[];
|
||||
reason?: string;
|
||||
};
|
||||
mode: 'or' | 'and';
|
||||
match: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class SearchDTO {
|
||||
// Defense-in-depth cap on the raw query length. The real stack-depth bound is
|
||||
// the parser's MAX_PARSED_TERMS term cap (see search-query-parser.ts); this
|
||||
// just rejects absurd payloads early. 10k chars still comfortably holds any
|
||||
// legitimate query.
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(10000)
|
||||
query: string;
|
||||
|
||||
// #529 A3 — match mode. `auto` (default) routes identifier-like terms
|
||||
// (10.31.41, esp32, WB-MGE-30D86B) to the substring/trigram branch and words
|
||||
// to full-text; `word`/`prefix`/`substring` are explicit overrides.
|
||||
@IsOptional()
|
||||
@IsIn(['auto', 'word', 'prefix', 'substring'])
|
||||
match?: 'auto' | 'word' | 'prefix' | 'substring';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
@@ -33,15 +47,19 @@ export class SearchDTO {
|
||||
|
||||
// --- Opt-in agent-lookup mode (#443). ------------------------------------
|
||||
// These fields are ADDITIVE and default-off: a web client that sends none of
|
||||
// them gets byte-identical FTS behaviour and result shape. They are only read
|
||||
// by the substring/path/snippet code path in SearchService.searchPage.
|
||||
// them gets byte-identical FTS behaviour and result shape. In the unified #529
|
||||
// engine, `parentPageId` and `titleOnly` are read by SearchService.searchPage
|
||||
// (subtree scoping and title-only matching, respectively). `substring` is NOT
|
||||
// read by the native driver — it is accepted-but-ignored, kept only for
|
||||
// back-compat with the upstream lookup request shape.
|
||||
//
|
||||
// NOTE (standalone stdio vs stock upstream): stock upstream validates this DTO
|
||||
// with `whitelist: true`, so an older server silently strips these unknown
|
||||
// fields and the request degrades gracefully to the plain FTS behaviour.
|
||||
|
||||
// Enables the hybrid substring branch (title + text_content LIKE) merged with
|
||||
// the existing FTS branch, plus tiered ranking, path and windowed snippet.
|
||||
// Accepted-but-ignored by the #529 native driver (kept for upstream lookup
|
||||
// back-compat). The unified engine ALWAYS runs the hybrid FTS + substring/
|
||||
// trigram branches with tiered ranking, so this flag no longer toggles anything.
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
substring?: boolean;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
parseSearchQuery,
|
||||
hasPositiveRecall,
|
||||
MAX_PARSED_TERMS,
|
||||
} from './search-query-parser';
|
||||
|
||||
describe('parseSearchQuery — tokenization & operators (A2)', () => {
|
||||
it('splits on whitespace into positive terms (OR recall)', () => {
|
||||
const p = parseSearchQuery('стамбул роснефть');
|
||||
expect(p.positive.map((t) => t.text)).toEqual(['стамбул', 'роснефть']);
|
||||
expect(p.required).toEqual([]);
|
||||
expect(p.excluded).toEqual([]);
|
||||
expect(p.mode).toBe('or');
|
||||
});
|
||||
|
||||
it('treats +term as required and -term as excluded', () => {
|
||||
const p = parseSearchQuery('+кофейня -архив');
|
||||
expect(p.required.map((t) => t.text)).toEqual(['кофейня']);
|
||||
expect(p.excluded.map((t) => t.text)).toEqual(['архив']);
|
||||
expect(p.positive).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a leading-operator-free hyphen/dot/colon token as ONE literal term', () => {
|
||||
// WB-MGE-30D86B, 10.0.12.5, a:b — internal -,.,: are literal, one term each.
|
||||
expect(parseSearchQuery('WB-MGE-30D86B').positive[0].text).toBe(
|
||||
'WB-MGE-30D86B',
|
||||
);
|
||||
expect(parseSearchQuery('10.0.12.5').positive[0].text).toBe('10.0.12.5');
|
||||
expect(parseSearchQuery('host:8080').positive[0].text).toBe('host:8080');
|
||||
});
|
||||
|
||||
it('only a LEADING +/- is an operator; -архив excludes архив', () => {
|
||||
const p = parseSearchQuery('-архив');
|
||||
expect(p.excluded.map((t) => t.text)).toEqual(['архив']);
|
||||
expect(p.positive).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a bare "-" / "+" and an all-operator remainder', () => {
|
||||
const p = parseSearchQuery('- + foo -- ++');
|
||||
expect(p.positive.map((t) => t.text)).toEqual(['foo']);
|
||||
expect(p.required).toEqual([]);
|
||||
expect(p.excluded).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses a quoted phrase as one adjacency term', () => {
|
||||
const p = parseSearchQuery('"воздушный шар" кофе');
|
||||
expect(p.positive[0]).toMatchObject({ text: 'воздушный шар', branch: 'phrase' });
|
||||
expect(p.positive[1].text).toBe('кофе');
|
||||
});
|
||||
|
||||
it('applies +/- to a phrase', () => {
|
||||
const req = parseSearchQuery('+"воздушный шар"');
|
||||
expect(req.required[0]).toMatchObject({ text: 'воздушный шар', branch: 'phrase' });
|
||||
const exc = parseSearchQuery('-"воздушный шар"');
|
||||
expect(exc.excluded[0]).toMatchObject({ text: 'воздушный шар', branch: 'phrase' });
|
||||
});
|
||||
|
||||
it('drops an unbalanced quote token', () => {
|
||||
const p = parseSearchQuery('kafka "unclosed here');
|
||||
expect(p.positive.map((t) => t.text)).toEqual(['kafka']);
|
||||
});
|
||||
|
||||
it('strips tsquery metacharacters from a bare FTS term (no 500)', () => {
|
||||
const p = parseSearchQuery('foo|bar');
|
||||
// `|` is not an identifier signal → FTS branch; the metachar is stripped so
|
||||
// the term becomes the two words that survive.
|
||||
expect(p.positive[0].branch === 'fts' || p.positive[0].branch === 'ftsPrefix').toBe(true);
|
||||
expect(p.positive[0].text).toBe('foo bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSearchQuery — match=auto classification (A3)', () => {
|
||||
it('routes identifier-like terms to the substring branch', () => {
|
||||
expect(parseSearchQuery('10.31.41').positive[0].branch).toBe('substring');
|
||||
expect(parseSearchQuery('esp32').positive[0].branch).toBe('substring');
|
||||
expect(parseSearchQuery('WB-MGE-30D86B').positive[0].branch).toBe('substring');
|
||||
});
|
||||
|
||||
it('routes purely-alphabetic words to the FTS (prefix) branch', () => {
|
||||
expect(parseSearchQuery('печат').positive[0].branch).toBe('ftsPrefix');
|
||||
expect(parseSearchQuery('ресторан').positive[0].branch).toBe('ftsPrefix');
|
||||
});
|
||||
|
||||
it('explicit match overrides: word / prefix / substring', () => {
|
||||
expect(parseSearchQuery('печат', { match: 'word' }).positive[0].branch).toBe('fts');
|
||||
expect(parseSearchQuery('печат', { match: 'prefix' }).positive[0].branch).toBe(
|
||||
'ftsPrefix',
|
||||
);
|
||||
expect(parseSearchQuery('печат', { match: 'substring' }).positive[0].branch).toBe(
|
||||
'substring',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSearchQuery — reasons & recall', () => {
|
||||
it('only-negation yields reason only-negation and no positive recall', () => {
|
||||
const p = parseSearchQuery('-архив');
|
||||
expect(p.reason).toBe('only-negation');
|
||||
expect(hasPositiveRecall(p)).toBe(false);
|
||||
});
|
||||
|
||||
it('empty / whitespace / garbage yields reason empty', () => {
|
||||
expect(parseSearchQuery('').reason).toBe('empty');
|
||||
expect(parseSearchQuery(' ').reason).toBe('empty');
|
||||
// A bare operator drops to nothing → empty (no exclusion survived).
|
||||
expect(parseSearchQuery('+ -').reason).toBe('empty');
|
||||
});
|
||||
|
||||
it('a required term alone IS positive recall (no reason)', () => {
|
||||
const p = parseSearchQuery('+кофейня');
|
||||
expect(p.reason).toBeUndefined();
|
||||
expect(hasPositiveRecall(p)).toBe(true);
|
||||
});
|
||||
|
||||
it('mode flag flows through', () => {
|
||||
expect(parseSearchQuery('a b', { mode: 'and' }).mode).toBe('and');
|
||||
expect(parseSearchQuery('a b').mode).toBe('or');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSearchQuery — term cap (stack-depth guard)', () => {
|
||||
it('caps the total parsed terms at MAX_PARSED_TERMS without throwing', () => {
|
||||
// A pasted text block: far more words than the cap. The parser must bound the
|
||||
// SQL tsquery nesting depth (else Postgres blows its stack → HTTP 500).
|
||||
const words = Array.from({ length: 5000 }, (_, i) => `w${i}`);
|
||||
let p!: ReturnType<typeof parseSearchQuery>;
|
||||
expect(() => {
|
||||
p = parseSearchQuery(words.join(' '));
|
||||
}).not.toThrow();
|
||||
const total = p.positive.length + p.required.length + p.excluded.length;
|
||||
expect(total).toBe(MAX_PARSED_TERMS);
|
||||
// Stable order: the FIRST cap terms are kept.
|
||||
expect(p.positive.slice(0, 3).map((t) => t.text)).toEqual(['w0', 'w1', 'w2']);
|
||||
expect(p.positive[MAX_PARSED_TERMS - 1].text).toBe(`w${MAX_PARSED_TERMS - 1}`);
|
||||
});
|
||||
|
||||
it('counts positive + required + excluded together toward the cap', () => {
|
||||
// Interleave operators so overflow can fall on any bucket. Total must still
|
||||
// never exceed the cap, and the first cap terms (in stable order) win.
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const op = i % 3 === 0 ? '+' : i % 3 === 1 ? '-' : '';
|
||||
tokens.push(`${op}t${i}`);
|
||||
}
|
||||
const p = parseSearchQuery(tokens.join(' '));
|
||||
const total = p.positive.length + p.required.length + p.excluded.length;
|
||||
expect(total).toBe(MAX_PARSED_TERMS);
|
||||
// t0 (+, required) and t1 (-, excluded) and t2 (bare, positive) are all within
|
||||
// the first cap tokens → each bucket got its leading terms.
|
||||
expect(p.required[0].text).toBe('t0');
|
||||
expect(p.excluded[0].text).toBe('t1');
|
||||
expect(p.positive[0].text).toBe('t2');
|
||||
});
|
||||
|
||||
it('leaves a normal (<= cap) query completely unchanged', () => {
|
||||
const p = parseSearchQuery('+кофейня -архив "воздушный шар" ресторан 10.31.41');
|
||||
expect(p.required.map((t) => t.text)).toEqual(['кофейня']);
|
||||
expect(p.excluded.map((t) => t.text)).toEqual(['архив']);
|
||||
expect(p.positive.map((t) => t.text)).toEqual([
|
||||
'воздушный шар',
|
||||
'ресторан',
|
||||
'10.31.41',
|
||||
]);
|
||||
// Phrase / substring branch handling survives under the cap.
|
||||
expect(p.positive[0].branch).toBe('phrase');
|
||||
expect(p.positive[2].branch).toBe('substring');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
// #529 Phase A — server-side query parser (the SINGLE source of query semantics).
|
||||
//
|
||||
// Clients (web-UI, MCP agent, public share) send a RAW query string plus flags
|
||||
// (`match`, `mode`); ALL query interpretation happens HERE, so every consumer
|
||||
// gets identical operator/phrase/morphology behaviour. The parser is PURE (no
|
||||
// SQL, no DB) so it is exhaustively unit-testable; SearchService turns the parsed
|
||||
// AST into a parameterized tsquery/predicate tree (never string-concatenated SQL).
|
||||
//
|
||||
// Grammar (A2):
|
||||
// - Whitespace splits tokens, but a double-quoted run is ONE token ("a b" is a
|
||||
// phrase). An unbalanced quote is dropped.
|
||||
// - A token is an OPERATOR token only when it STARTS with `+` or `-`, the
|
||||
// remainder is non-empty, and the remainder is not itself only operators.
|
||||
// `+`/`-` inside a token (`WB-MGE-30D86B`, `10.0.12.5`, `a:b`) is a LITERAL —
|
||||
// the token is a single term. A bare `-`/`+` is dropped.
|
||||
// - `"phrase"` → phrase term; `+"phrase"`/`-"phrase"` apply the operator to it.
|
||||
// - Positive terms (bare + phrase, no operator) form the OR recall set.
|
||||
// `+term` is a REQUIRED predicate, `-term` an EXCLUDED predicate (A2): both
|
||||
// are applied in SQL WHERE against the whole candidate set, not folded into
|
||||
// the positive tsquery.
|
||||
// - Only-negation (no positive term) short-circuits to an empty result with
|
||||
// reason `only-negation` (never runs a costly NOT-scan).
|
||||
|
||||
export type SearchMatchMode = 'auto' | 'word' | 'prefix' | 'substring';
|
||||
export type SearchBooleanMode = 'or' | 'and';
|
||||
|
||||
// Hard cap on the total number of parsed terms (positive + required + excluded).
|
||||
// SearchService folds each FTS term into a LEFT-NESTED SQL tsquery expression
|
||||
// `(((t1)||(t2))||(t3))…`, embedded several times per query — so nesting depth
|
||||
// grows with the term count. A pasted text block (thousands of words) would nest
|
||||
// deep enough to blow Postgres' `stack depth limit` → an ERROR → HTTP 500 for the
|
||||
// caller. Capping in the parser bounds that depth for EVERY consumer (web / MCP /
|
||||
// share), since they all route through here. 64 comfortably covers any real query
|
||||
// while keeping the SQL nesting shallow. Overflow terms (beyond the first 64, in
|
||||
// stable order) are dropped rather than throwing.
|
||||
export const MAX_PARSED_TERMS = 64;
|
||||
|
||||
// How a single term is matched against the index.
|
||||
// - 'fts' : full-text lexeme, exact (no trailing prefix).
|
||||
// - 'ftsPrefix' : full-text lexeme with a `:*` prefix match.
|
||||
// - 'phrase' : an adjacency phrase (phraseto_tsquery).
|
||||
// - 'substring' : a literal LOWER(f_unaccent(col)) LIKE '%needle%' branch
|
||||
// (identifiers the tokenizer mangles: IPs, hostnames, IDs).
|
||||
export type SearchTermBranch = 'fts' | 'ftsPrefix' | 'phrase' | 'substring';
|
||||
|
||||
export interface ParsedTerm {
|
||||
// The user-visible term text, operator stripped, quotes removed. This is what
|
||||
// `matchedTerms` echoes back per hit.
|
||||
text: string;
|
||||
branch: SearchTermBranch;
|
||||
}
|
||||
|
||||
export interface ParsedQuery {
|
||||
raw: string;
|
||||
// OR-recall set (bare + phrase terms with no operator).
|
||||
positive: ParsedTerm[];
|
||||
// AND predicates (`+term`) — the candidate MUST match each of these.
|
||||
required: ParsedTerm[];
|
||||
// NOT predicates (`-term`) — the candidate must match NONE of these.
|
||||
excluded: ParsedTerm[];
|
||||
mode: SearchBooleanMode;
|
||||
// Set only when the query yields no positive recall: 'empty' (nothing usable)
|
||||
// or 'only-negation' (there were exclusions but no positive term).
|
||||
reason?: 'empty' | 'only-negation';
|
||||
}
|
||||
|
||||
interface RawToken {
|
||||
op: '' | '+' | '-';
|
||||
kind: 'word' | 'phrase';
|
||||
text: string;
|
||||
}
|
||||
|
||||
// tsquery metacharacters that must never reach to_tsquery from a bare term — they
|
||||
// are what turned adversarial input into a 500 before (#139). Stripped for the FTS
|
||||
// branch; the substring branch keeps them (they are literal there).
|
||||
const TSQUERY_META = /[:&|!()*<>\\]+/g;
|
||||
|
||||
// A term is "identifier-like" when it carries a digit or one of . _ : / - AND is
|
||||
// not purely alphabetic (letters only). Such tokens (10.31.41, esp32,
|
||||
// WB-MGE-30D86B) are mangled by the FTS tokenizer, so `match: auto` routes them
|
||||
// to the substring branch. A purely-alphabetic word (печат, ресторан) stays FTS.
|
||||
const IDENTIFIER_SIGNAL = /[0-9._:/\\-]/;
|
||||
const PURELY_ALPHA = /^\p{L}+$/u;
|
||||
|
||||
function isIdentifierLike(text: string): boolean {
|
||||
return IDENTIFIER_SIGNAL.test(text) && !PURELY_ALPHA.test(text);
|
||||
}
|
||||
|
||||
// Clean a bare term for the FTS branch: NFC-normalize, drop tsquery metacharacters
|
||||
// and collapse whitespace. Returns '' when nothing usable remains.
|
||||
export function cleanFtsLexeme(raw: string): string {
|
||||
return (raw ?? '')
|
||||
.normalize('NFC')
|
||||
.replace(TSQUERY_META, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Split a raw query into tokens, honouring double quotes and a single leading
|
||||
// +/- operator. Unbalanced quotes and bare operators are dropped.
|
||||
function tokenize(raw: string): RawToken[] {
|
||||
const tokens: RawToken[] = [];
|
||||
const s = raw ?? '';
|
||||
let i = 0;
|
||||
const n = s.length;
|
||||
|
||||
const isSpace = (c: string) => /\s/.test(c);
|
||||
|
||||
while (i < n) {
|
||||
// Skip leading whitespace.
|
||||
while (i < n && isSpace(s[i])) i++;
|
||||
if (i >= n) break;
|
||||
|
||||
let op: '' | '+' | '-' = '';
|
||||
// A single leading +/- is a tentative operator. Only ONE leading operator is
|
||||
// consumed; a second (`--x`) leaves `-x` as the remainder (a literal dash).
|
||||
if (s[i] === '+' || s[i] === '-') {
|
||||
op = s[i] as '+' | '-';
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i < n && s[i] === '"') {
|
||||
// Quoted phrase: read until the closing quote.
|
||||
const close = s.indexOf('"', i + 1);
|
||||
if (close === -1) {
|
||||
// Unbalanced quote → drop this token and everything the open quote would
|
||||
// have consumed (the rest of the string).
|
||||
break;
|
||||
}
|
||||
const phrase = s.slice(i + 1, close);
|
||||
i = close + 1;
|
||||
if (phrase.trim().length > 0) {
|
||||
tokens.push({ op, kind: 'phrase', text: phrase.trim() });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bare word: read until the next whitespace.
|
||||
let j = i;
|
||||
while (j < n && !isSpace(s[j])) j++;
|
||||
const word = s.slice(i, j);
|
||||
i = j;
|
||||
|
||||
// A bare operator (`-`/`+` with no remainder) or an all-operator remainder is
|
||||
// dropped.
|
||||
if (word.length === 0) continue;
|
||||
if (op && /^[+-]+$/.test(word)) continue;
|
||||
|
||||
tokens.push({ op, kind: 'word', text: word });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Resolve the match branch for a single term given the global match mode.
|
||||
function branchForTerm(
|
||||
text: string,
|
||||
kind: 'word' | 'phrase',
|
||||
mode: SearchMatchMode,
|
||||
): SearchTermBranch {
|
||||
if (kind === 'phrase') return 'phrase';
|
||||
switch (mode) {
|
||||
case 'word':
|
||||
return 'fts';
|
||||
case 'prefix':
|
||||
return 'ftsPrefix';
|
||||
case 'substring':
|
||||
return 'substring';
|
||||
case 'auto':
|
||||
default:
|
||||
// Identifiers the tokenizer mangles go to substring; words get a prefix
|
||||
// FTS match (so `печат` still finds `печатать`, but `печат` no longer drags
|
||||
// in `впечатления` because the russian stemmer anchors the stem).
|
||||
return isIdentifierLike(text) ? 'substring' : 'ftsPrefix';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw user query + flags into a structured, SQL-agnostic AST.
|
||||
* Pure and total: never throws, always returns a ParsedQuery.
|
||||
*/
|
||||
export function parseSearchQuery(
|
||||
raw: string,
|
||||
opts: { match?: SearchMatchMode; mode?: SearchBooleanMode } = {},
|
||||
): ParsedQuery {
|
||||
const match: SearchMatchMode = opts.match ?? 'auto';
|
||||
const mode: SearchBooleanMode = opts.mode ?? 'or';
|
||||
|
||||
const positive: ParsedTerm[] = [];
|
||||
const required: ParsedTerm[] = [];
|
||||
const excluded: ParsedTerm[] = [];
|
||||
|
||||
for (const tok of tokenize(raw)) {
|
||||
// For an FTS branch, the token must survive metacharacter cleaning; for the
|
||||
// substring/phrase branch the literal text is used. A term that cleans to
|
||||
// nothing AND is not usable as a substring is dropped.
|
||||
const branch = branchForTerm(tok.text, tok.kind, match);
|
||||
|
||||
let usableText: string;
|
||||
if (branch === 'fts' || branch === 'ftsPrefix') {
|
||||
usableText = cleanFtsLexeme(tok.text);
|
||||
} else {
|
||||
// phrase / substring keep the literal (trimmed) text.
|
||||
usableText = tok.text.trim();
|
||||
}
|
||||
if (!usableText) continue;
|
||||
|
||||
// Stack-depth guard: stop after MAX_PARSED_TERMS surviving terms (positive +
|
||||
// required + excluded, combined) so the SQL tsquery nesting stays shallow.
|
||||
// The first 64 terms are kept in stable order; the rest are dropped.
|
||||
if (positive.length + required.length + excluded.length >= MAX_PARSED_TERMS) {
|
||||
break;
|
||||
}
|
||||
|
||||
const term: ParsedTerm = { text: usableText, branch };
|
||||
|
||||
if (tok.op === '+') required.push(term);
|
||||
else if (tok.op === '-') excluded.push(term);
|
||||
else positive.push(term);
|
||||
}
|
||||
|
||||
const parsed: ParsedQuery = { raw: raw ?? '', positive, required, excluded, mode };
|
||||
|
||||
if (positive.length === 0) {
|
||||
// Required terms with no positive recall still form a valid positive set (the
|
||||
// required predicates ARE the recall). Only when there is neither a positive
|
||||
// nor a required term is the query empty / only-negation.
|
||||
if (required.length === 0) {
|
||||
parsed.reason = excluded.length > 0 ? 'only-negation' : 'empty';
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this parsed query have any positive recall to run? False means we must
|
||||
* short-circuit to an empty result (with `reason`), never a costly NOT-only scan.
|
||||
*/
|
||||
export function hasPositiveRecall(parsed: ParsedQuery): boolean {
|
||||
return parsed.positive.length > 0 || parsed.required.length > 0;
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
import {
|
||||
computeLookupScore,
|
||||
escapeLikePattern,
|
||||
SearchLookupTier,
|
||||
} from './search.service';
|
||||
import { escapeLikePattern } from './search.service';
|
||||
|
||||
/**
|
||||
* Pure-function coverage for the #443 agent-lookup helpers:
|
||||
* - escapeLikePattern: LIKE-metacharacter escaping so `%`/`_`/`\` are literals
|
||||
* (the acceptance-table requirement that a query of `%` or `_` does NOT match
|
||||
* everything);
|
||||
* - computeLookupScore: the tiered 0..1 ranking score, where a stronger tier
|
||||
* always outranks a weaker one regardless of the in-tier secondary signal.
|
||||
* Pure-function coverage for `escapeLikePattern` — LIKE-metacharacter escaping so
|
||||
* `%`/`_`/`\` are matched literally (the acceptance requirement that a query of
|
||||
* `%` or `_` does NOT match everything, #529 acceptance #10). The substring
|
||||
* branch's DB behaviour is covered by the integration spec.
|
||||
*
|
||||
* The DB-touching branch (substring UNION FTS, path CTE, snippet window) is
|
||||
* covered by the integration spec against the real schema.
|
||||
* NOTE (#529): the old tiered `computeLookupScore` was replaced by RRF rank
|
||||
* fusion in the unified engine, so its unit coverage moved to the integration
|
||||
* ordering tests; only the escaping helper remains a pure unit here.
|
||||
*/
|
||||
describe('escapeLikePattern', () => {
|
||||
it('escapes the LIKE metacharacters % _ and \\', () => {
|
||||
@@ -43,53 +38,3 @@ describe('escapeLikePattern', () => {
|
||||
expect(escapeLikePattern(null as any)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeLookupScore', () => {
|
||||
it('keeps every score within (0, 1]', () => {
|
||||
for (const tier of [
|
||||
SearchLookupTier.TITLE_EXACT,
|
||||
SearchLookupTier.TITLE_SUBSTRING,
|
||||
SearchLookupTier.TEXT,
|
||||
]) {
|
||||
for (const secondary of [0, 0.001, 1, 100, 1e6]) {
|
||||
const s = computeLookupScore({ tier, secondary });
|
||||
expect(s).toBeGreaterThan(0);
|
||||
expect(s).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('a stronger tier ALWAYS outranks a weaker tier, whatever the secondary', () => {
|
||||
// Weak tier with a huge secondary must still lose to a strong tier with a
|
||||
// tiny secondary — tiers dominate.
|
||||
const strongLowSecondary = computeLookupScore({
|
||||
tier: SearchLookupTier.TITLE_EXACT,
|
||||
secondary: 0,
|
||||
});
|
||||
const weakHighSecondary = computeLookupScore({
|
||||
tier: SearchLookupTier.TEXT,
|
||||
secondary: 1e9,
|
||||
});
|
||||
expect(strongLowSecondary).toBeGreaterThan(weakHighSecondary);
|
||||
});
|
||||
|
||||
it('within a tier a larger secondary sorts higher', () => {
|
||||
const lo = computeLookupScore({
|
||||
tier: SearchLookupTier.TEXT,
|
||||
secondary: 0.1,
|
||||
});
|
||||
const hi = computeLookupScore({
|
||||
tier: SearchLookupTier.TEXT,
|
||||
secondary: 5,
|
||||
});
|
||||
expect(hi).toBeGreaterThan(lo);
|
||||
});
|
||||
|
||||
it('treats a negative/absent secondary as 0', () => {
|
||||
const zero = computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: 0 });
|
||||
expect(computeLookupScore({ tier: SearchLookupTier.TEXT })).toBe(zero);
|
||||
expect(
|
||||
computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: -5 }),
|
||||
).toBe(zero);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,74 +1,45 @@
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
/**
|
||||
* Coverage for SearchService.searchPage query-mode selection (search.service.ts
|
||||
* @25). searchPage chooses HOW the result set is scoped — by explicit space, by
|
||||
* the authenticated user's member spaces, or by a share — and must return an
|
||||
* empty set (without leaking data) for every disallowed combination.
|
||||
* Unit coverage for SearchService.searchPage SCOPE-SECURITY early returns — the
|
||||
* branches that must yield an empty result WITHOUT ever touching the DB, so they
|
||||
* can leak nothing. The happy-path scope SQL (explicit space / member spaces /
|
||||
* share id set) is covered against the real schema in the integration spec.
|
||||
*
|
||||
* The kysely query builder is mocked with the same chainable pattern as the
|
||||
* existing search.service.spec.ts: every builder method returns the same builder
|
||||
* and `.execute()` resolves the supplied rows. Each `.where(...)` call is
|
||||
* recorded so we can assert exactly which scope clause was applied — that is the
|
||||
* mutation-resistant signal that distinguishes one query mode from another.
|
||||
*
|
||||
* These specs catch cross-space / cross-workspace search leakage and
|
||||
* share-scope bypass (data exposure).
|
||||
* Every case here returns BEFORE the raw-SQL candidate query runs, so a bare `db`
|
||||
* stub (never called) is enough — a call to it would itself be a failure signal.
|
||||
*/
|
||||
describe('SearchService.searchPage — query-mode selection', () => {
|
||||
// Build a chainable selectFrom('pages') builder that records its calls. The
|
||||
// builder is returned from `db.selectFrom` and is the single object every
|
||||
// chained call mutates/returns, mirroring the existing spec's pattern.
|
||||
function makeBuilder(rows: Array<{ id: string; highlight?: string }>) {
|
||||
const builder: any = {};
|
||||
builder.select = jest.fn(() => builder);
|
||||
builder.where = jest.fn(() => builder);
|
||||
builder.$if = jest.fn(() => builder);
|
||||
builder.orderBy = jest.fn(() => builder);
|
||||
builder.limit = jest.fn(() => builder);
|
||||
builder.offset = jest.fn(() => builder);
|
||||
builder.execute = jest.fn(async () => rows);
|
||||
return builder;
|
||||
}
|
||||
|
||||
describe('SearchService.searchPage — scope-security early returns', () => {
|
||||
function makeService(opts?: {
|
||||
rows?: Array<{ id: string; highlight?: string }>;
|
||||
share?: any;
|
||||
isRestricted?: boolean;
|
||||
descendants?: Array<{ id: string }>;
|
||||
memberSpaceIds?: string[];
|
||||
}) {
|
||||
const builder = makeBuilder(opts?.rows ?? []);
|
||||
|
||||
const db: any = {
|
||||
selectFrom: jest.fn(() => builder),
|
||||
};
|
||||
|
||||
// `getUserSpaceIdsQuery` returns a sub-query object that searchPage passes
|
||||
// straight into `.where('spaceId', 'in', <subquery>)`. A sentinel is enough
|
||||
// to assert the user-scoped branch was taken.
|
||||
const userSpaceIdsQuery = { __userSpaceIdsQuery: true };
|
||||
// A db that THROWS if touched — these branches must not reach SQL.
|
||||
const db: any = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('db must not be touched on an empty-scope branch');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const pageRepo = {
|
||||
// `.select((eb) => this.pageRepo.withSpace(eb))` — value ignored by stub.
|
||||
withSpace: jest.fn(() => ({ __withSpace: true })),
|
||||
getPageAndDescendantsExcludingRestricted: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts?.descendants ?? []),
|
||||
getPageAndDescendantsExcludingRestricted: jest.fn(),
|
||||
getPageAndDescendants: jest.fn(),
|
||||
};
|
||||
const shareRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts?.share ?? null),
|
||||
};
|
||||
const spaceMemberRepo = {
|
||||
getUserSpaceIdsQuery: jest.fn(() => userSpaceIdsQuery),
|
||||
getUserSpaceIds: jest.fn().mockResolvedValue(opts?.memberSpaceIds ?? []),
|
||||
};
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts?.isRestricted ?? false),
|
||||
// Let everything through page-level permission filtering by default.
|
||||
filterAccessiblePageIds: jest
|
||||
.fn()
|
||||
.mockImplementation(async ({ pageIds }: { pageIds: string[] }) => pageIds),
|
||||
filterAccessiblePageIds: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new SearchService(
|
||||
@@ -78,145 +49,81 @@ describe('SearchService.searchPage — query-mode selection', () => {
|
||||
spaceMemberRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
db,
|
||||
builder,
|
||||
pageRepo,
|
||||
shareRepo,
|
||||
spaceMemberRepo,
|
||||
pagePermissionRepo,
|
||||
userSpaceIdsQuery,
|
||||
};
|
||||
return { service, pageRepo, shareRepo, spaceMemberRepo, pagePermissionRepo };
|
||||
}
|
||||
|
||||
const whereCallFor = (builder: any, column: any) =>
|
||||
builder.where.mock.calls.find((c: any[]) => c[0] === column);
|
||||
|
||||
it('returns {items:[]} for a blank query WITHOUT touching the DB', async () => {
|
||||
const { service, db } = makeService();
|
||||
|
||||
it('returns total:0 for a blank query WITHOUT touching the DB or any repo', async () => {
|
||||
const { service, shareRepo, spaceMemberRepo } = makeService();
|
||||
const result = await service.searchPage(
|
||||
{ query: '' } as any,
|
||||
{ userId: 'user-1', workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ items: [] });
|
||||
// Blank query is rejected before any query builder is constructed.
|
||||
expect(db.selectFrom).not.toHaveBeenCalled();
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
expect(shareRepo.findById).not.toHaveBeenCalled();
|
||||
expect(spaceMemberRepo.getUserSpaceIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scopes to the explicit spaceId branch', async () => {
|
||||
const { service, builder, db, spaceMemberRepo, shareRepo } = makeService({
|
||||
rows: [{ id: 'p-1' }],
|
||||
});
|
||||
|
||||
it('only-negation short-circuits with reason "only-negation", never scanning', async () => {
|
||||
const { service, spaceMemberRepo } = makeService();
|
||||
const result = await service.searchPage(
|
||||
{ query: 'plan', spaceId: 'space-42' } as any,
|
||||
{ query: '-архив' } as any,
|
||||
{ userId: 'user-1', workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
expect(db.selectFrom).toHaveBeenCalledWith('pages');
|
||||
// The explicit-space branch adds exactly `.where('spaceId', '=', 'space-42')`.
|
||||
expect(whereCallFor(builder, 'spaceId')).toEqual([
|
||||
'spaceId',
|
||||
'=',
|
||||
'space-42',
|
||||
]);
|
||||
// It must NOT fall through to the user-member-spaces or share branch.
|
||||
expect(spaceMemberRepo.getUserSpaceIdsQuery).not.toHaveBeenCalled();
|
||||
expect(shareRepo.findById).not.toHaveBeenCalled();
|
||||
expect(result.items.map((i: any) => i.id)).toEqual(['p-1']);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.query.parsed.reason).toBe('only-negation');
|
||||
// Never resolves scope (returns before) — no expensive NOT-only scan.
|
||||
expect(spaceMemberRepo.getUserSpaceIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scopes an authenticated user WITHOUT spaceId to their member spaces', async () => {
|
||||
const { service, builder, spaceMemberRepo, userSpaceIdsQuery, shareRepo } =
|
||||
makeService({ rows: [{ id: 'p-9' }] });
|
||||
|
||||
await service.searchPage(
|
||||
{ query: 'plan' } as any,
|
||||
{ userId: 'user-7', workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
// The user-scoped branch resolves the member-spaces sub-query for that user
|
||||
// and restricts both spaceId (to that sub-query) and workspaceId.
|
||||
expect(spaceMemberRepo.getUserSpaceIdsQuery).toHaveBeenCalledWith('user-7');
|
||||
expect(whereCallFor(builder, 'spaceId')).toEqual([
|
||||
'spaceId',
|
||||
'in',
|
||||
userSpaceIdsQuery,
|
||||
]);
|
||||
expect(whereCallFor(builder, 'workspaceId')).toEqual([
|
||||
'workspaceId',
|
||||
'=',
|
||||
'ws-1',
|
||||
]);
|
||||
// Authenticated user path must not consult shares.
|
||||
expect(shareRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns {items:[]} when the share belongs to a DIFFERENT workspace', async () => {
|
||||
const { service, builder, shareRepo, pagePermissionRepo } = makeService({
|
||||
share: {
|
||||
id: 'share-1',
|
||||
pageId: 'page-1',
|
||||
workspaceId: 'OTHER-ws',
|
||||
includeSubPages: false,
|
||||
},
|
||||
it('returns empty when the share belongs to a DIFFERENT workspace (no leak)', async () => {
|
||||
const { service, shareRepo, pagePermissionRepo } = makeService({
|
||||
share: { id: 's1', pageId: 'p1', workspaceId: 'OTHER', includeSubPages: false },
|
||||
});
|
||||
|
||||
const result = await service.searchPage(
|
||||
{ query: 'plan', shareId: 'share-1' } as any,
|
||||
{ query: 'plan', shareId: 's1' } as any,
|
||||
{ workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
expect(shareRepo.findById).toHaveBeenCalledWith('share-1');
|
||||
expect(result).toEqual({ items: [] });
|
||||
// Workspace mismatch short-circuits before any restricted-ancestor / id
|
||||
// scoping or DB execution: no leak across workspaces.
|
||||
expect(shareRepo.findById).toHaveBeenCalledWith('s1');
|
||||
expect(result.items).toEqual([]);
|
||||
// Workspace mismatch short-circuits before restricted-ancestor / enumeration.
|
||||
expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled();
|
||||
expect(builder.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns {items:[]} when the shared page has a restricted ancestor', async () => {
|
||||
const { service, builder, pagePermissionRepo, pageRepo } = makeService({
|
||||
share: {
|
||||
id: 'share-1',
|
||||
pageId: 'page-1',
|
||||
workspaceId: 'ws-1',
|
||||
includeSubPages: true,
|
||||
},
|
||||
it('returns empty when the shared page has a restricted ancestor', async () => {
|
||||
const { service, pagePermissionRepo, pageRepo } = makeService({
|
||||
share: { id: 's1', pageId: 'p1', workspaceId: 'ws-1', includeSubPages: true },
|
||||
isRestricted: true,
|
||||
});
|
||||
|
||||
const result = await service.searchPage(
|
||||
{ query: 'plan', shareId: 'share-1' } as any,
|
||||
{ query: 'plan', shareId: 's1' } as any,
|
||||
{ workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
);
|
||||
expect(result).toEqual({ items: [] });
|
||||
// Restricted ancestor must block before page enumeration and DB execution.
|
||||
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith('p1');
|
||||
expect(result.items).toEqual([]);
|
||||
expect(
|
||||
pageRepo.getPageAndDescendantsExcludingRestricted,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(builder.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns {items:[]} with no userId, no spaceId and no shareId', async () => {
|
||||
const { service, builder, shareRepo } = makeService();
|
||||
|
||||
it('returns empty with no userId, no spaceId and no shareId', async () => {
|
||||
const { service, shareRepo } = makeService();
|
||||
const result = await service.searchPage(
|
||||
{ query: 'plan' } as any,
|
||||
{ workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ items: [] });
|
||||
// The catch-all else returns empty without scoping/executing or hitting shares.
|
||||
expect(result.items).toEqual([]);
|
||||
expect(shareRepo.findById).not.toHaveBeenCalled();
|
||||
expect(builder.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an authenticated user with NO member spaces gets an empty result', async () => {
|
||||
const { service, spaceMemberRepo } = makeService({ memberSpaceIds: [] });
|
||||
const result = await service.searchPage(
|
||||
{ query: 'plan' } as any,
|
||||
{ userId: 'user-1', workspaceId: 'ws-1' },
|
||||
);
|
||||
expect(spaceMemberRepo.getUserSpaceIds).toHaveBeenCalledWith('user-1');
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SearchService, buildTsQuery } from './search.service';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
describe('SearchService', () => {
|
||||
it('should be defined', () => {
|
||||
@@ -99,59 +99,3 @@ describe('SearchService.searchSuggestions — onlyTemplates filter', () => {
|
||||
expect(isTemplateWhereCall(pageBuilder)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Unit tests for `buildTsQuery` (extracted from search.service.ts). It turns a raw
|
||||
// user query into a prefix tsquery string fed to `to_tsquery('english', ...)`.
|
||||
//
|
||||
// REAL BUG (Gitea #139, item 10): the previous inline `tsquery(query.trim() + '*')`
|
||||
// let to_tsquery operator characters through, so adversarial inputs could produce a
|
||||
// fragment that to_tsquery rejects -> 500. The extraction sanitizes the input
|
||||
// (strip everything but letters/numbers/whitespace) so these inputs degrade to a
|
||||
// safe, neutral query with NO throw, while normal queries keep working.
|
||||
describe('buildTsQuery', () => {
|
||||
it('builds a prefix query for a normal single word', () => {
|
||||
expect(buildTsQuery('hello')).toBe('hello:*');
|
||||
});
|
||||
|
||||
it('joins multiple words with AND and a trailing prefix match', () => {
|
||||
expect(buildTsQuery('foo bar')).toBe('foo&bar:*');
|
||||
});
|
||||
|
||||
it('preserves accented and non-Latin words', () => {
|
||||
expect(buildTsQuery('héllo café')).toBe('héllo&café:*');
|
||||
expect(buildTsQuery('日本語')).toBe('日本語:*');
|
||||
});
|
||||
|
||||
it('neutralizes to_tsquery operator inputs without throwing', () => {
|
||||
// Each of these previously risked an invalid to_tsquery -> 500. They must now
|
||||
// produce a safe (here empty) query and never throw.
|
||||
for (const input of ['&', '!', '*', '<->', '\\']) {
|
||||
expect(() => buildTsQuery(input)).not.toThrow();
|
||||
expect(buildTsQuery(input)).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('handles stopword-only input safely', () => {
|
||||
// pg-tsquery still tokenizes stopwords; to_tsquery reduces them to nothing.
|
||||
// The important contract is: no throw, and a deterministic string.
|
||||
expect(() => buildTsQuery('the a of')).not.toThrow();
|
||||
expect(buildTsQuery('the a of')).toBe('the&a&of:*');
|
||||
});
|
||||
|
||||
it('returns empty string for empty / whitespace-only / null-ish input', () => {
|
||||
expect(buildTsQuery('')).toBe('');
|
||||
expect(buildTsQuery(' ')).toBe('');
|
||||
expect(buildTsQuery(undefined as unknown as string)).toBe('');
|
||||
});
|
||||
|
||||
it('handles a very long input without throwing', () => {
|
||||
const long = 'a'.repeat(10000);
|
||||
expect(() => buildTsQuery(long)).not.toThrow();
|
||||
expect(buildTsQuery(long)).toBe(`${long}:*`);
|
||||
});
|
||||
|
||||
it('strips punctuation embedded in otherwise valid words', () => {
|
||||
expect(buildTsQuery('c++ code')).toBe('c&code:*');
|
||||
expect(buildTsQuery('a-b-c')).toBe('a&b&c:*');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,11 +32,13 @@ import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
|
||||
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
|
||||
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
|
||||
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
@@ -124,11 +126,13 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
AiChatRepo,
|
||||
AiChatMessageRepo,
|
||||
AiChatRunRepo,
|
||||
AiChatRunStepRepo,
|
||||
AiChatPageSnapshotRepo,
|
||||
AiProviderCredentialsRepo,
|
||||
AiMcpServerRepo,
|
||||
AiAgentRoleRepo,
|
||||
PageEmbeddingRepo,
|
||||
ApiKeyRepo,
|
||||
PageListener,
|
||||
],
|
||||
exports: [
|
||||
@@ -159,11 +163,13 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
AiChatRepo,
|
||||
AiChatMessageRepo,
|
||||
AiChatRunRepo,
|
||||
AiChatRunStepRepo,
|
||||
AiChatPageSnapshotRepo,
|
||||
AiProviderCredentialsRepo,
|
||||
AiMcpServerRepo,
|
||||
AiAgentRoleRepo,
|
||||
PageEmbeddingRepo,
|
||||
ApiKeyRepo,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule implements OnApplicationBootstrap {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* #529 Phase A1 — the `ru_en` text-search configuration + the config swap.
|
||||
*
|
||||
* WHY: the search stack was pinned to the `english` FTS config, which stems only
|
||||
* Latin words. On a Russian-language wiki that is a morphology black hole:
|
||||
* «ресторанов москвы» never matched a page titled «ресторан в москве». `ru_en`
|
||||
* layers the russian_stem over the Cyrillic token classes and english_stem over
|
||||
* the ascii ones, so BOTH languages get proper morphology from one config.
|
||||
*
|
||||
* CREATE TEXT SEARCH CONFIGURATION ru_en (COPY = simple);
|
||||
* ALTER ... asciiword/asciihword/hword_asciipart WITH english_stem;
|
||||
* ALTER ... word/hword/hword_part WITH russian_stem;
|
||||
*
|
||||
* `to_tsvector('ru_en', …)` with the LITERAL config name is IMMUTABLE, so it is
|
||||
* valid inside a trigger, a generated column and an index expression.
|
||||
*
|
||||
* THE INVARIANT (acceptance #13): the config of the STORED column and the config
|
||||
* of the QUERY must change together. This migration flips BOTH stored sides
|
||||
* (pages.tsv via its trigger + a reindex; page_embeddings.fts, the RAG lexical
|
||||
* leg, via its generated expression); the matching QUERY-side flips
|
||||
* (search.service.ts and page-embedding.repo.ts `hybridSearch`) ship in the SAME
|
||||
* commit. Trigram indexes are LOWER(f_unaccent(...)) and do NOT depend on the FTS
|
||||
* config, so they are untouched.
|
||||
*
|
||||
* REINDEX / LOCK MODEL (deploy-critical). Kysely runs EACH migration in its OWN
|
||||
* transaction (see sibling 20260706T120000 "Kysely runs each migration in a
|
||||
* transaction"; migrate.ts / migration.service.ts set no `disableTransactions`,
|
||||
* and PostgresJSDialect has transactional DDL). A single migration file therefore
|
||||
* cannot commit between batches, so the issue's "procedural batch job OUTSIDE the
|
||||
* transaction + dual-config read window + migration_complete gate" is not
|
||||
* expressible in-migration here. That machinery bridges a reindex spread over
|
||||
* MANY committed batches, during which some rows are still `english` while others
|
||||
* are already `ru_en`. Our pages.tsv reindex is a SINGLE `UPDATE pages SET tsv`,
|
||||
* ATOMIC within THIS migration's own transaction: at COMMIT every row is `ru_en`
|
||||
* at once, so no morphology-desync window exists and no dual-config read path is
|
||||
* required — the query config flips to `ru_en` in the very same release. This is
|
||||
* the deliberate, correct adaptation to this framework (see the PR notes).
|
||||
*
|
||||
* - pages.tsv: swapping the trigger is a cheap catalog change (no table lock),
|
||||
* and the reindex is a single `UPDATE pages SET tsv = <ru_en expr>` — a
|
||||
* ROW-level-lock (RowExclusiveLock) backfill, NOT an ACCESS EXCLUSIVE rewrite
|
||||
* (mirrors the existing space_id backfill in 20250725T052004). On a LARGE
|
||||
* tenant this still writes every row + its WAL and leaves dead tuples, so it
|
||||
* can take MINUTES and blocks the startup migrator for that time — but it
|
||||
* never blocks concurrent reads. It stays inline unconditionally.
|
||||
*
|
||||
* - page_embeddings.fts is a GENERATED STORED column; Postgres cannot change a
|
||||
* generated expression without DROP+ADD, which is a full-table ACCESS
|
||||
* EXCLUSIVE REWRITE of page_embeddings — it blocks ALL reads AND writes on
|
||||
* that table (including the RAG agent) for the rewrite's duration. That inline
|
||||
* rewrite is appropriate for small/typical tenants (this fork's target) and
|
||||
* is the DEFAULT.
|
||||
*
|
||||
* LARGE TENANTS have two documented escape hatches, either of which makes the
|
||||
* migration genuinely no-op the rewrite (it is NOT a blind DROP+ADD):
|
||||
* (a) Set `SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE=false`. The migration then
|
||||
* SKIPS the embeddings rewrite entirely and logs a WARNING. The operator
|
||||
* MUST perform the ru_en fts swap out-of-band; until they do, the RAG
|
||||
* lexical leg stays on `english` while the query config is `ru_en` — a
|
||||
* documented, operator-owned desync window. (pages.tsv still swaps
|
||||
* inline — the gate is ONLY the embeddings rewrite.)
|
||||
* (b) Perform the swap out-of-band BEFORE deploy — add a plain column →
|
||||
* batched backfill → brief-lock swap → CREATE INDEX CONCURRENTLY — so
|
||||
* the `fts` column's generated expression already references the TARGET
|
||||
* config when the migration runs. The migration detects this (it reads
|
||||
* the column's actual generation expression from pg_catalog) and does a
|
||||
* TRUE no-op — no DROP, no ADD, no rewrite. This is real idempotency,
|
||||
* not the old (false) "IF-EXISTS guards no-op" claim: `DROP COLUMN IF
|
||||
* EXISTS` guards against ABSENCE, not presence, so it would have dropped
|
||||
* and recreated an existing `fts` regardless. The at-target check is the
|
||||
* only honest no-op path.
|
||||
*
|
||||
* Same documented trade-off family as the #443 trgm GIN migration (20260706T120000).
|
||||
*/
|
||||
|
||||
// pages.tsv trigger body for a given FTS config — mirrors the latest form
|
||||
// (20250729T213756): f_unaccent + a 1MB text cap on text_content, weights A/B.
|
||||
function pagesTriggerSql(config: 'ru_en' | 'english') {
|
||||
return sql`
|
||||
CREATE OR REPLACE FUNCTION pages_tsvector_trigger() RETURNS trigger AS $$
|
||||
begin
|
||||
new.tsv :=
|
||||
setweight(to_tsvector('${sql.raw(config)}', f_unaccent(coalesce(new.title, ''))), 'A') ||
|
||||
setweight(to_tsvector('${sql.raw(config)}', f_unaccent(substring(coalesce(new.text_content, ''), 1, 1000000))), 'B');
|
||||
return new;
|
||||
end;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`;
|
||||
}
|
||||
|
||||
async function swapPagesConfig(db: Kysely<any>, config: 'ru_en' | 'english') {
|
||||
// 1. Point the trigger at the target config (new/edited rows use it going
|
||||
// forward). CREATE OR REPLACE FUNCTION takes only a brief catalog lock.
|
||||
await pagesTriggerSql(config).execute(db);
|
||||
|
||||
// 2. Reindex existing rows: recompute tsv directly with the target config. A
|
||||
// plain UPDATE — row locks, no ACCESS EXCLUSIVE. Equivalent to firing the
|
||||
// trigger but cheaper (no self-update round trip).
|
||||
await sql`
|
||||
UPDATE pages
|
||||
SET tsv =
|
||||
setweight(to_tsvector('${sql.raw(config)}', f_unaccent(coalesce(title, ''))), 'A') ||
|
||||
setweight(to_tsvector('${sql.raw(config)}', f_unaccent(substring(coalesce(text_content, ''), 1, 1000000))), 'B')
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
// The default in-migration ACCESS EXCLUSIVE rewrite of page_embeddings.fts is
|
||||
// ON unless the operator explicitly opts out with the env flag. Parsed strictly
|
||||
// (mirrors CLIENT_TELEMETRY_ENABLED / DEBUG_MODE in common/): only a literal
|
||||
// (case-insensitive) 'false' disables it; anything else — unset included —
|
||||
// keeps the default true.
|
||||
function inlineEmbeddingsRewriteEnabled(): boolean {
|
||||
return (
|
||||
(process.env.SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE ?? 'true').toLowerCase() !==
|
||||
'false'
|
||||
);
|
||||
}
|
||||
|
||||
// Read page_embeddings.fts's ACTUAL generated-column expression from pg_catalog
|
||||
// (the generation expression is stored as a column default marked generated).
|
||||
// Returns '' when the column is absent.
|
||||
async function embeddingsFtsExpr(db: Kysely<any>): Promise<string> {
|
||||
const r = await sql<{ def: string }>`
|
||||
SELECT pg_get_expr(d.adbin, d.adrelid) AS def
|
||||
FROM pg_attrdef d
|
||||
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
||||
WHERE a.attname = 'fts'
|
||||
AND a.attrelid = 'page_embeddings'::regclass
|
||||
AND NOT a.attisdropped
|
||||
`.execute(db);
|
||||
return r.rows[0]?.def ?? '';
|
||||
}
|
||||
|
||||
async function swapEmbeddingsFtsConfig(
|
||||
db: Kysely<any>,
|
||||
config: 'ru_en' | 'english',
|
||||
) {
|
||||
// 1. TRUE no-op path (real out-of-band escape hatch): if the column's current
|
||||
// generation expression already references the TARGET config, there is
|
||||
// nothing to do. An operator who pre-swapped the column out-of-band lands
|
||||
// here and the migration does NOT rewrite the table. ('ru_en' and 'english'
|
||||
// are disjoint tokens, neither a substring of the other or of the rest of
|
||||
// the expression, so a plain contains-check is unambiguous.)
|
||||
const currentExpr = await embeddingsFtsExpr(db);
|
||||
if (currentExpr.includes(config)) return;
|
||||
|
||||
// 2. Env-gated opt-out: large tenants set SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE
|
||||
// =false to skip the ACCESS EXCLUSIVE rewrite in-migration and own the swap
|
||||
// out-of-band. Warn loudly so the desync window is not silent.
|
||||
if (!inlineEmbeddingsRewriteEnabled()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[migration 20260707T130000] SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE=false: ` +
|
||||
`SKIPPING the page_embeddings.fts rewrite to '${config}'. The operator MUST ` +
|
||||
`perform this fts swap out-of-band. Until then the RAG lexical leg stays on ` +
|
||||
`its current config while the query config is '${config}' (documented, ` +
|
||||
`operator-owned desync window).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Default inline path: the generated `fts` expression can only change via
|
||||
// DROP+ADD (a full-table ACCESS EXCLUSIVE rewrite; see the lock note in the
|
||||
// header). The GIN index depends on the column, so it is dropped with it and
|
||||
// recreated.
|
||||
await sql`DROP INDEX IF EXISTS idx_page_embeddings_fts`.execute(db);
|
||||
await sql`ALTER TABLE page_embeddings DROP COLUMN IF EXISTS fts`.execute(db);
|
||||
await sql`
|
||||
ALTER TABLE page_embeddings
|
||||
ADD COLUMN fts tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('${sql.raw(config)}', f_unaccent(content))) STORED
|
||||
`.execute(db);
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_page_embeddings_fts
|
||||
ON page_embeddings USING gin(fts)
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
async function ruEnConfigExists(db: Kysely<any>): Promise<boolean> {
|
||||
const r = await sql<{ n: number }>`
|
||||
SELECT count(*)::int AS n FROM pg_ts_config WHERE cfgname = 'ru_en'
|
||||
`.execute(db);
|
||||
return (r.rows[0]?.n ?? 0) > 0;
|
||||
}
|
||||
|
||||
async function ensureRuEnConfig(db: Kysely<any>): Promise<void> {
|
||||
// Idempotent by EXISTENCE, not by drop-recreate. The old `DROP ... IF EXISTS;
|
||||
// CREATE` was safe only on a first run: on a re-run the page_embeddings.fts
|
||||
// generated column already has a hard dependency on ru_en, so dropping the
|
||||
// config would fail. Create only when it is genuinely missing.
|
||||
if (await ruEnConfigExists(db)) return;
|
||||
await sql`CREATE TEXT SEARCH CONFIGURATION ru_en (COPY = simple)`.execute(db);
|
||||
// Latin token classes → english_stem.
|
||||
await sql`
|
||||
ALTER TEXT SEARCH CONFIGURATION ru_en
|
||||
ALTER MAPPING FOR asciiword, asciihword, hword_asciipart
|
||||
WITH english_stem
|
||||
`.execute(db);
|
||||
// Cyrillic / non-ascii token classes → russian_stem.
|
||||
await sql`
|
||||
ALTER TEXT SEARCH CONFIGURATION ru_en
|
||||
ALTER MAPPING FOR word, hword, hword_part
|
||||
WITH russian_stem
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await ensureRuEnConfig(db);
|
||||
|
||||
// Flip both stored sides to ru_en (query-side flips in the same commit).
|
||||
// swapEmbeddingsFtsConfig no-ops when fts already references ru_en, so a
|
||||
// re-run of up() is idempotent and does NOT re-rewrite the embeddings table.
|
||||
await swapPagesConfig(db, 'ru_en');
|
||||
await swapEmbeddingsFtsConfig(db, 'ru_en');
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
// Reverse ORDER matters: the trigger and the generated column reference the
|
||||
// `ru_en` config by name, so they must be moved back to `english` BEFORE the
|
||||
// config can be dropped (a generated column that still depends on `ru_en` would
|
||||
// block the DROP with a dependency error).
|
||||
await swapEmbeddingsFtsConfig(db, 'english');
|
||||
await swapPagesConfig(db, 'english');
|
||||
|
||||
// Drop the config ONLY if nothing still references it. When the embeddings
|
||||
// rewrite was gated off (SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE=false), the fts
|
||||
// column can still reference ru_en — dropping the config would then fail with a
|
||||
// dependency error. Skip + warn so down() stays non-fatal; the operator drops
|
||||
// ru_en after completing the out-of-band english swap.
|
||||
if ((await embeddingsFtsExpr(db)).includes('ru_en')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[migration 20260707T130000] down(): page_embeddings.fts still references ` +
|
||||
`ru_en (inline rewrite was gated off) — leaving the ru_en text-search ` +
|
||||
`configuration in place. Drop it out-of-band once fts is back on 'english'.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await sql`DROP TEXT SEARCH CONFIGURATION IF EXISTS ru_en`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* `ai_chat_run_steps` — append-only per-step persistence for an assistant turn
|
||||
* (#492 wave C). Each finished agent step's UI `parts` (its text part + a part
|
||||
* per tool call, WITH the tool output) is INSERTed as its own lightweight row the
|
||||
* moment the step ends, instead of REWRITING the whole assistant row's growing
|
||||
* `metadata.parts` jsonb on every `onStepFinish`.
|
||||
*
|
||||
* WHY a separate table + INSERT (not a jsonb `||` append on the message row): a
|
||||
* Postgres jsonb UPDATE rewrites the ENTIRE TOASTed row version under MVCC, so
|
||||
* re-persisting a growing `metadata.parts` on every step is O(n²) write volume
|
||||
* (a 50-step run with ~100 KB tool outputs wrote hundreds of MB of WAL / dead
|
||||
* tuples per turn, hammering autovacuum). `||` would only shave the network
|
||||
* payload — the WAL/TOAST rewrite harm remains. An INSERT into a per-step table
|
||||
* writes ONLY that step's bytes, so the per-turn write volume is O(Σ steps).
|
||||
*
|
||||
* The full `metadata.parts` on the message row is assembled ONCE at finalize (the
|
||||
* terminal completed/error/aborted write). Mid-run, a resuming client's seed is
|
||||
* reconstructed by concatenating these step rows in `step_index` order — which
|
||||
* reproduces exactly what the old per-step full-row rewrite persisted. Records
|
||||
* written the OLD way (full `metadata.parts` on the row, no step rows) still
|
||||
* reconstruct from the row unchanged; the two eras are distinguished by whether
|
||||
* the row already carries non-empty `metadata.parts` (see reconstructRunParts /
|
||||
* assembleStepParts in ai-chat.service.ts).
|
||||
*
|
||||
* ON DELETE CASCADE on `message_id`: the step rows are a derived projection of the
|
||||
* assistant message; they must vanish with it (or with its workspace).
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('ai_chat_run_steps')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
// The assistant message row this step belongs to (the #183 projection). The
|
||||
// step rows are a derived, per-step slice of that message, so they cascade.
|
||||
.addColumn('message_id', 'uuid', (col) =>
|
||||
col.references('ai_chat_messages.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
// 0-based index of the finished step within the turn. Ordering key for
|
||||
// reconstruction; unique per message (idempotent step re-persist).
|
||||
.addColumn('step_index', 'integer', (col) => col.notNull())
|
||||
// The step's UI parts (text part + a `tool-*` part per call, WITH output).
|
||||
// Concatenated in step order to rebuild the turn's `metadata.parts`.
|
||||
.addColumn('parts', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// Idempotent per-step persist: a retried INSERT of the same (message, step)
|
||||
// is a no-op (the service uses ON CONFLICT DO NOTHING). This also serves the
|
||||
// reconstruction read (WHERE message_id ORDER BY step_index).
|
||||
await db.schema
|
||||
.createIndex('ai_chat_run_steps_message_step_uidx')
|
||||
.ifNotExists()
|
||||
.on('ai_chat_run_steps')
|
||||
.columns(['message_id', 'step_index'])
|
||||
.unique()
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('ai_chat_run_steps').ifExists().execute();
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import { AiChatRunStep } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Append-only per-step persistence for an assistant turn (#492). Each finished
|
||||
* agent step's UI `parts` (its text part + a `tool-*` part per call, WITH the
|
||||
* tool output) is INSERTed as its own lightweight row the moment the step ends —
|
||||
* instead of REWRITING the assistant row's growing `metadata.parts` jsonb on every
|
||||
* `onStepFinish` (a Postgres jsonb UPDATE rewrites the whole TOASTed row version
|
||||
* under MVCC, so that was O(n²) WAL/dead-tuple churn per turn).
|
||||
*
|
||||
* The full `metadata.parts` on the message row is assembled ONCE at finalize;
|
||||
* mid-run, a resuming client's seed is rebuilt from these rows in `stepIndex`
|
||||
* order (see `assembleStepParts` / the reconstruct seam in ai-chat.service.ts).
|
||||
* Every method is workspace-scoped as defense-in-depth.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiChatRunStepRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
/**
|
||||
* Append one finished step's parts. Idempotent: a retried persist of the SAME
|
||||
* (message, stepIndex) is a no-op via ON CONFLICT DO NOTHING — the per-step
|
||||
* writes are fired fire-and-forget + serialized, and a duplicate must never
|
||||
* throw into the stream or double the parts. Returns whether a NEW row landed
|
||||
* (false = the step was already persisted).
|
||||
*/
|
||||
async insertStep(
|
||||
messageId: string,
|
||||
workspaceId: string,
|
||||
stepIndex: number,
|
||||
parts: unknown,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<boolean> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const inserted = await db
|
||||
.insertInto('aiChatRunSteps')
|
||||
.values({
|
||||
messageId,
|
||||
workspaceId,
|
||||
stepIndex,
|
||||
// jsonb column: cast through never (same pattern as the message repo).
|
||||
parts: parts as never,
|
||||
})
|
||||
.onConflict((oc) => oc.columns(['messageId', 'stepIndex']).doNothing())
|
||||
.returning('id')
|
||||
.executeTakeFirst();
|
||||
return inserted !== undefined;
|
||||
}
|
||||
|
||||
/** All persisted steps for ONE assistant message, in step order. */
|
||||
async findByMessage(
|
||||
messageId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AiChatRunStep[]> {
|
||||
return this.db
|
||||
.selectFrom('aiChatRunSteps')
|
||||
.selectAll('aiChatRunSteps')
|
||||
.where('messageId', '=', messageId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.orderBy('stepIndex', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* All persisted steps for a SET of assistant messages, grouped by messageId
|
||||
* (each group in step order). One query for the batch — the hydration seam
|
||||
* (getMessages / delta / export) calls this only for the rows that actually
|
||||
* need reconstruction (an active new-style row whose `metadata.parts` is still
|
||||
* empty), which is usually none, so this is skipped on the common path.
|
||||
*/
|
||||
async findByMessageIds(
|
||||
messageIds: string[],
|
||||
workspaceId: string,
|
||||
): Promise<Map<string, AiChatRunStep[]>> {
|
||||
const byMessage = new Map<string, AiChatRunStep[]>();
|
||||
if (messageIds.length === 0) return byMessage;
|
||||
const rows = await this.db
|
||||
.selectFrom('aiChatRunSteps')
|
||||
.selectAll('aiChatRunSteps')
|
||||
.where('messageId', 'in', messageIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.orderBy('stepIndex', 'asc')
|
||||
.execute();
|
||||
for (const row of rows) {
|
||||
const list = byMessage.get(row.messageId);
|
||||
if (list) list.push(row);
|
||||
else byMessage.set(row.messageId, [row]);
|
||||
}
|
||||
return byMessage;
|
||||
}
|
||||
}
|
||||
@@ -200,8 +200,11 @@ export class PageEmbeddingRepo {
|
||||
*
|
||||
* The `model_dimensions = $dim` filter applies ONLY on the semantic side
|
||||
* (cosine compares same-dimension vectors; pgvector errors otherwise). The
|
||||
* lexical side (`fts`) is dimension-independent. If `websearch_to_tsquery`
|
||||
* yields an EMPTY query (e.g. the text is all stopwords) the `@@` matches
|
||||
* lexical side (`fts`) is dimension-independent. Its query config is `ru_en`,
|
||||
* matched IN LOCKSTEP with the `page_embeddings.fts` generated column's config
|
||||
* (#529 acceptance #13): a mismatch silently breaks Cyrillic RAG retrieval. If
|
||||
* `websearch_to_tsquery` yields an EMPTY query (e.g. the text is all stopwords)
|
||||
* the `@@` matches
|
||||
* nothing and the lexical CTE is empty, so results degrade to pure-semantic —
|
||||
* which is correct behaviour, not an error.
|
||||
*
|
||||
@@ -249,7 +252,7 @@ export class PageEmbeddingRepo {
|
||||
row_number() OVER (ORDER BY ts_rank(pe.fts, q.query) DESC) AS rank_ix
|
||||
FROM page_embeddings pe
|
||||
JOIN pages p ON p.id = pe.page_id,
|
||||
websearch_to_tsquery('english', f_unaccent(${queryText})) AS q(query)
|
||||
websearch_to_tsquery('ru_en', f_unaccent(${queryText})) AS q(query)
|
||||
WHERE pe.workspace_id = ${workspaceId}
|
||||
AND pe.space_id IN (${spaceList})
|
||||
AND p.deleted_at IS NULL
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { DB, Users } from '@docmost/db/types/db';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { ApiKey, InsertableApiKey } from '@docmost/db/types/entity.types';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { ExpressionBuilder } from 'kysely';
|
||||
|
||||
// Public-facing shape: the api_keys row plus a compact creator attribution used
|
||||
// by the admin list (the workspace-wide view attributes each key to its author).
|
||||
export type ApiKeyWithCreator = ApiKey & {
|
||||
creator: Pick<Users, 'id' | 'name' | 'email' | 'avatarUrl'> | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
// Compact creator attribution embedded in the admin list rows. A nested
|
||||
// subquery (jsonObjectFrom) keeps it one round-trip; the token material is
|
||||
// never stored, so nothing sensitive is joined in.
|
||||
private withCreator(eb: ExpressionBuilder<DB, 'apiKeys'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.email', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'apiKeys.creatorId'),
|
||||
).as('creator');
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ApiKey | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('apiKeys')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
// Convention (soft-delete): a revoked key is invisible to reads.
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByCreator(
|
||||
creatorId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ApiKeyWithCreator[]> {
|
||||
return this.db
|
||||
.selectFrom('apiKeys')
|
||||
.selectAll('apiKeys')
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.where('creatorId', '=', creatorId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute() as unknown as Promise<ApiKeyWithCreator[]>;
|
||||
}
|
||||
|
||||
async findAllInWorkspace(
|
||||
workspaceId: string,
|
||||
): Promise<ApiKeyWithCreator[]> {
|
||||
return this.db
|
||||
.selectFrom('apiKeys')
|
||||
.selectAll('apiKeys')
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute() as unknown as Promise<ApiKeyWithCreator[]>;
|
||||
}
|
||||
|
||||
async insert(
|
||||
insertable: InsertableApiKey,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ApiKey> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('apiKeys')
|
||||
.values(insertable)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
// Soft-delete = revoke. Terminal: the row stays for forensics but is invisible
|
||||
// to every read above, so validate() denies it immediately (no grace window).
|
||||
async softDelete(id: string, workspaceId: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('apiKeys')
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Throttled best-effort forensics stamp. Not on the critical path: callers
|
||||
// fire-and-forget and swallow errors, so it never fails a request.
|
||||
async touchLastUsed(id: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('apiKeys')
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,44 @@ export class PageHistoryRepo {
|
||||
return { ...result, items: result.items.map(attachPageHistoryAgent) };
|
||||
}
|
||||
|
||||
/**
|
||||
* #395 — cheap projection of a page's FULL history timeline for the work-time
|
||||
* estimate: only the columns the sessionizer needs, no heavy `content`, sorted
|
||||
* oldest→newest. The secondary `id` tie-break keeps rows sharing a `createdAt`
|
||||
* (e.g. a synchronous pre-agent boundary row + the immediate agent snapshot)
|
||||
* in a deterministic order.
|
||||
*/
|
||||
async findTimelineByPageId(
|
||||
pageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<
|
||||
Array<
|
||||
Pick<
|
||||
PageHistory,
|
||||
| 'createdAt'
|
||||
| 'lastUpdatedById'
|
||||
| 'lastUpdatedSource'
|
||||
| 'lastUpdatedAiChatId'
|
||||
| 'kind'
|
||||
>
|
||||
>
|
||||
> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('pageHistory')
|
||||
.select([
|
||||
'createdAt',
|
||||
'lastUpdatedById',
|
||||
'lastUpdatedSource',
|
||||
'lastUpdatedAiChatId',
|
||||
'kind',
|
||||
])
|
||||
.where('pageId', '=', pageId)
|
||||
.orderBy('createdAt', 'asc')
|
||||
.orderBy('id', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findPageLastHistory(
|
||||
pageId: string,
|
||||
opts?: {
|
||||
|
||||
+17
@@ -692,6 +692,22 @@ export interface AiChatRuns {
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
// Append-only per-step persistence for an assistant turn (#492). Mirrors
|
||||
// migration 20260708T120000-ai-chat-run-steps.ts. Each finished agent step's UI
|
||||
// `parts` are INSERTed as their own row (instead of rewriting the message row's
|
||||
// growing `metadata.parts` jsonb every step — an O(n²) WAL/TOAST churn). The full
|
||||
// `metadata.parts` is assembled once at finalize; mid-run a resuming client's seed
|
||||
// is rebuilt by concatenating these rows in `stepIndex` order. Cascades with the
|
||||
// assistant message row it projects.
|
||||
export interface AiChatRunSteps {
|
||||
id: Generated<string>;
|
||||
messageId: string;
|
||||
workspaceId: string;
|
||||
stepIndex: number;
|
||||
parts: Json;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
// Per-(chat,page) snapshot of the open page's Markdown at the END of the agent's
|
||||
// previous turn (#274). Mirrors migration 20260702T120000-ai-chat-page-snapshot.ts.
|
||||
// The next turn diffs the CURRENT Markdown against `contentMd` to surface edits a
|
||||
@@ -729,6 +745,7 @@ export interface DB {
|
||||
aiChats: AiChats;
|
||||
aiChatMessages: AiChatMessages;
|
||||
aiChatRuns: AiChatRuns;
|
||||
aiChatRunSteps: AiChatRunSteps;
|
||||
aiChatPageSnapshots: AiChatPageSnapshots;
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user