From 88e9255e8b0c32dea012dacdbcd9adf6affb4e0f Mon Sep 17 00:00:00 2001 From: Salihu <91833785+salihudickson@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:52:47 +0100 Subject: [PATCH 01/18] feat: Page presentation mode (#2484) * page presentation init * fix minor issues * minor fixes * improve slides display * fix: presentation min width * fixes:fullscreen support, Only change slides with controls and keyboard - Refresh controls ui - Interactive slide content (tabs, toggles, links, code copy, checkboxes, media, text selection) works without side effects - Scrolling no longer stalls at fractional zoom or display scaling - Keys pressed inside pop-ups opened from slide content no longer change slides - Subpages blocks list their children and heading links copy the real anchor - Renaming the page no longer closes the presentation - Whitespace, non-breaking space, hard break and page break blocks no longer produce blank slides - Title slide wraps long words and keeps the direction of RTL titles - The dialog has an accessible name, slide changes are announced and slide content is labelled - Other fixes * fix(subpages): show a sidebar-style dot for subpages without children * fix(base): share one realtime subscription per base across mounted views - Embedded bases keep receiving realtime updates after a second view of the same base (presentation, history) unmounts - Row and schema events are no longer applied twice while two views of a base are mounted --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com> --- .../public/locales/en-US/translation.json | 7 + .../src/ee/base/hooks/use-base-socket.test.ts | 117 ++++ .../src/ee/base/hooks/use-base-socket.ts | 458 ++++++++-------- .../presentation-modal.module.css | 134 +++++ .../presentation/presentation-modal.tsx | 501 ++++++++++++++++++ .../presentation/presentation.utils.test.ts | 423 +++++++++++++++ .../presentation/presentation.utils.ts | 270 ++++++++++ .../components/subpages/subpage-item.tsx | 10 +- .../components/subpages/subpages.module.css | 7 +- .../components/header/page-header-menu.tsx | 48 +- 10 files changed, 1759 insertions(+), 216 deletions(-) create mode 100644 apps/client/src/ee/base/hooks/use-base-socket.test.ts create mode 100644 apps/client/src/features/editor/components/presentation/presentation-modal.module.css create mode 100644 apps/client/src/features/editor/components/presentation/presentation-modal.tsx create mode 100644 apps/client/src/features/editor/components/presentation/presentation.utils.test.ts create mode 100644 apps/client/src/features/editor/components/presentation/presentation.utils.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 5fad7e0d..159a7a61 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -161,6 +161,13 @@ "Pending": "Pending", "Please confirm your action": "Please confirm your action", "Preferences": "Preferences", + "Presentation mode": "Presentation mode", + "Previous slide": "Previous slide", + "Next slide": "Next slide", + "Exit presentation mode": "Exit presentation mode", + "Enter fullscreen": "Enter fullscreen", + "Exit fullscreen": "Exit fullscreen", + "Slide {{current}} of {{total}}": "Slide {{current}} of {{total}}", "Print PDF": "Print PDF", "Profile": "Profile", "Recently updated": "Recently updated", diff --git a/apps/client/src/ee/base/hooks/use-base-socket.test.ts b/apps/client/src/ee/base/hooks/use-base-socket.test.ts new file mode 100644 index 00000000..7c70155d --- /dev/null +++ b/apps/client/src/ee/base/hooks/use-base-socket.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; +import { InfiniteData, QueryClient } from "@tanstack/react-query"; +import type { Socket } from "socket.io-client"; +import type { IPagination } from "@/lib/types"; +import type { IBaseRow } from "@/ee/base/types/base.types"; + +vi.mock("@/main.tsx", async () => ({ + queryClient: new (await import("@tanstack/react-query")).QueryClient(), +})); + +const { retainBaseSubscription } = await import("./use-base-socket"); + +function createFakeSocket() { + const handlers = new Set<(raw: unknown) => void>(); + const emitted: unknown[] = []; + const socket = { + on: vi.fn((_event: string, handler: (raw: unknown) => void) => { + handlers.add(handler); + return socket; + }), + off: vi.fn((_event: string, handler: (raw: unknown) => void) => { + handlers.delete(handler); + return socket; + }), + emit: vi.fn((_event: string, payload: unknown) => { + emitted.push(payload); + return socket; + }), + }; + return { + socket: socket as unknown as Socket, + handlers, + emitted, + receive: (raw: unknown) => handlers.forEach((handler) => handler(raw)), + }; +} + +function seedRows(queryClient: QueryClient, pageId: string) { + queryClient.setQueryData>>( + ["base-rows", pageId, "view-1"], + { pages: [{ items: [], meta: {} } as IPagination], pageParams: [null] }, + ); +} + +describe("retainBaseSubscription", () => { + it("joins the base room once for several views and leaves after the last one", () => { + const { socket, emitted, handlers } = createFakeSocket(); + const queryClient = new QueryClient(); + + const releaseFirst = retainBaseSubscription(socket, "page-1", queryClient); + const releaseSecond = retainBaseSubscription(socket, "page-1", queryClient); + expect(emitted).toEqual([{ operation: "base:subscribe", pageId: "page-1" }]); + expect(handlers.size).toBe(1); + + releaseFirst(); + expect(emitted).toHaveLength(1); + expect(handlers.size).toBe(1); + + releaseSecond(); + expect(emitted).toEqual([ + { operation: "base:subscribe", pageId: "page-1" }, + { operation: "base:unsubscribe", pageId: "page-1" }, + ]); + expect(handlers.size).toBe(0); + }); + + it("applies a created row once while two views of the base are mounted", () => { + const { socket, receive } = createFakeSocket(); + const queryClient = new QueryClient(); + seedRows(queryClient, "page-1"); + + const releaseFirst = retainBaseSubscription(socket, "page-1", queryClient); + const releaseSecond = retainBaseSubscription(socket, "page-1", queryClient); + receive({ operation: "base:row:created", pageId: "page-1", row: { id: "row-1" } }); + + const rows = queryClient.getQueryData>>([ + "base-rows", + "page-1", + "view-1", + ]); + expect(rows.pages[0].items.map((row) => row.id)).toEqual(["row-1"]); + + releaseFirst(); + releaseSecond(); + }); + + it("subscribes again when a view mounts after the last one left", () => { + const { socket, emitted } = createFakeSocket(); + const queryClient = new QueryClient(); + + retainBaseSubscription(socket, "page-1", queryClient)(); + const release = retainBaseSubscription(socket, "page-1", queryClient); + + expect(emitted).toEqual([ + { operation: "base:subscribe", pageId: "page-1" }, + { operation: "base:unsubscribe", pageId: "page-1" }, + { operation: "base:subscribe", pageId: "page-1" }, + ]); + release(); + }); + + it("keeps separate subscriptions per base", () => { + const { socket, emitted } = createFakeSocket(); + const queryClient = new QueryClient(); + + const releaseFirst = retainBaseSubscription(socket, "page-1", queryClient); + const releaseSecond = retainBaseSubscription(socket, "page-2", queryClient); + releaseFirst(); + + expect(emitted).toEqual([ + { operation: "base:subscribe", pageId: "page-1" }, + { operation: "base:subscribe", pageId: "page-2" }, + { operation: "base:unsubscribe", pageId: "page-1" }, + ]); + releaseSecond(); + }); +}); diff --git a/apps/client/src/ee/base/hooks/use-base-socket.ts b/apps/client/src/ee/base/hooks/use-base-socket.ts index 5f57263f..cdfb9dd6 100644 --- a/apps/client/src/ee/base/hooks/use-base-socket.ts +++ b/apps/client/src/ee/base/hooks/use-base-socket.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useAtomValue, getDefaultStore } from "jotai"; -import { useQueryClient, InfiniteData } from "@tanstack/react-query"; +import { useQueryClient, InfiniteData, QueryClient } from "@tanstack/react-query"; +import type { Socket } from "socket.io-client"; import { socketAtom } from "@/features/websocket/atoms/socket-atom"; import { IBase, @@ -137,86 +138,54 @@ export function markRequestIdOutbound(requestId: string): void { } } -// Realtime bridge for a single base. Joins the base-{pageId} room on mount, -// leaves on unmount, and reconciles React Query caches on inbound events. -export function useBaseSocket(pageId: string | undefined): void { - const socket = useAtomValue(socketAtom); - const queryClient = useQueryClient(); +function createBaseEventHandler( + pageId: string, + queryClient: QueryClient, +): (raw: unknown) => void { + return (raw: unknown) => { + if (!raw || typeof raw !== "object") return; + const event = raw as BaseInboundEvent; + if (event.pageId !== pageId) return; - useEffect(() => { - if (!socket || !pageId) return; + const requestId = (event as any).requestId as string | undefined; + if (requestId && outboundRequestIds.has(requestId)) { + outboundRequestIds.delete(requestId); + return; + } - socket.emit("message", { operation: "base:subscribe", pageId }); - - const handler = (raw: unknown) => { - if (!raw || typeof raw !== "object") return; - const event = raw as BaseInboundEvent; - if (event.pageId !== pageId) return; - - const requestId = (event as any).requestId as string | undefined; - if (requestId && outboundRequestIds.has(requestId)) { - outboundRequestIds.delete(requestId); - return; + switch (event.operation) { + case "base:row:created": { + const e = event as BaseRowCreated; + const baseForCreate = queryClient.getQueryData(["bases", pageId]); + const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban"); + if (hasKanbanForCreate) { + invalidateBaseRows(pageId); + } else { + queryClient.setQueriesData>>( + { queryKey: ["base-rows", pageId] }, + (old) => { + if (!old) return old; + const lastPageIndex = old.pages.length - 1; + return { + ...old, + pages: old.pages.map((page, index) => + index === lastPageIndex + ? { ...page, items: [...page.items, e.row] } + : page, + ), + }; + }, + ); + } + break; } - - switch (event.operation) { - case "base:row:created": { - const e = event as BaseRowCreated; - const baseForCreate = queryClient.getQueryData(["bases", pageId]); - const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban"); - if (hasKanbanForCreate) { - invalidateBaseRows(pageId); - } else { - queryClient.setQueriesData>>( - { queryKey: ["base-rows", pageId] }, - (old) => { - if (!old) return old; - const lastPageIndex = old.pages.length - 1; - return { - ...old, - pages: old.pages.map((page, index) => - index === lastPageIndex - ? { ...page, items: [...page.items, e.row] } - : page, - ), - }; - }, - ); - } - break; - } - case "base:row:updated": { - const e = event as BaseRowUpdated; - const baseForUpdate = queryClient.getQueryData(["bases", pageId]); - const hasKanbanForUpdate = (baseForUpdate?.views ?? []).some((v) => v.type === "kanban"); - if (hasKanbanForUpdate) { - invalidateBaseRows(pageId); - } else { - queryClient.setQueriesData>>( - { queryKey: ["base-rows", pageId] }, - (old) => - !old - ? old - : { - ...old, - pages: old.pages.map((page) => ({ - ...page, - items: page.items.map((row) => - row.id === e.rowId - ? { - ...row, - cells: { ...row.cells, ...e.updatedCells }, - } - : row, - ), - })), - }, - ); - } - break; - } - case "base:row:deleted": { - const e = event as BaseRowDeleted; + case "base:row:updated": { + const e = event as BaseRowUpdated; + const baseForUpdate = queryClient.getQueryData(["bases", pageId]); + const hasKanbanForUpdate = (baseForUpdate?.views ?? []).some((v) => v.type === "kanban"); + if (hasKanbanForUpdate) { + invalidateBaseRows(pageId); + } else { queryClient.setQueriesData>>( { queryKey: ["base-rows", pageId] }, (old) => @@ -226,157 +195,224 @@ export function useBaseSocket(pageId: string | undefined): void { ...old, pages: old.pages.map((page) => ({ ...page, - items: page.items.filter((row) => row.id !== e.rowId), + items: page.items.map((row) => + row.id === e.rowId + ? { + ...row, + cells: { ...row.cells, ...e.updatedCells }, + } + : row, + ), })), }, ); - const store = getDefaultStore(); - const selectedIdsAtom = selectedRowIdsAtomFamily(pageId); - const current = store.get(selectedIdsAtom); - if (current.has(e.rowId)) { - const next = new Set(current); - next.delete(e.rowId); - store.set(selectedIdsAtom, next); - } - break; } - case "base:rows:deleted": { - const e = event as BaseRowsDeleted; - const removeSet = new Set(e.rowIds); + break; + } + case "base:row:deleted": { + const e = event as BaseRowDeleted; + queryClient.setQueriesData>>( + { queryKey: ["base-rows", pageId] }, + (old) => + !old + ? old + : { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.filter((row) => row.id !== e.rowId), + })), + }, + ); + const store = getDefaultStore(); + const selectedIdsAtom = selectedRowIdsAtomFamily(pageId); + const current = store.get(selectedIdsAtom); + if (current.has(e.rowId)) { + const next = new Set(current); + next.delete(e.rowId); + store.set(selectedIdsAtom, next); + } + break; + } + case "base:rows:deleted": { + const e = event as BaseRowsDeleted; + const removeSet = new Set(e.rowIds); + queryClient.setQueriesData>>( + { queryKey: ["base-rows", pageId] }, + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.filter((row) => !removeSet.has(row.id)), + })), + }; + }, + ); + const store = getDefaultStore(); + const selectedIdsAtom = selectedRowIdsAtomFamily(pageId); + const current = store.get(selectedIdsAtom); + if (current.size > 0) { + let changed = false; + const next = new Set(current); + for (const id of e.rowIds) { + if (next.delete(id)) changed = true; + } + if (changed) store.set(selectedIdsAtom, next); + } + break; + } + case "base:row:reordered": { + const e = event as BaseRowReordered; + const baseForReorder = queryClient.getQueryData(["bases", pageId]); + const hasKanbanForReorder = (baseForReorder?.views ?? []).some((v) => v.type === "kanban"); + if (hasKanbanForReorder) { + invalidateBaseRows(pageId); + } else { queryClient.setQueriesData>>( { queryKey: ["base-rows", pageId] }, - (old) => { - if (!old) return old; - return { - ...old, - pages: old.pages.map((page) => ({ - ...page, - items: page.items.filter((row) => !removeSet.has(row.id)), - })), - }; - }, + (old) => + !old + ? old + : { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((row) => + row.id === e.rowId + ? { ...row, position: e.position } + : row, + ), + })), + }, ); - const store = getDefaultStore(); - const selectedIdsAtom = selectedRowIdsAtomFamily(pageId); - const current = store.get(selectedIdsAtom); - if (current.size > 0) { - let changed = false; - const next = new Set(current); - for (const id of e.rowIds) { - if (next.delete(id)) changed = true; - } - if (changed) store.set(selectedIdsAtom, next); - } - break; } - case "base:row:reordered": { - const e = event as BaseRowReordered; - const baseForReorder = queryClient.getQueryData(["bases", pageId]); - const hasKanbanForReorder = (baseForReorder?.views ?? []).some((v) => v.type === "kanban"); - if (hasKanbanForReorder) { - invalidateBaseRows(pageId); - } else { - queryClient.setQueriesData>>( - { queryKey: ["base-rows", pageId] }, - (old) => - !old - ? old - : { - ...old, - pages: old.pages.map((page) => ({ - ...page, - items: page.items.map((row) => - row.id === e.rowId - ? { ...row, position: e.position } - : row, - ), - })), - }, - ); - } - break; - } - case "base:rows:updated": { - const e = event as BaseRowsUpdated; - // Only refetch if the batch touches rows currently in cache; formula - // backfills emit one event per 500 rows so this avoids redundant fetches. - const updatedIds = new Set(e.rowIds); - const caches = queryClient.getQueriesData< - InfiniteData> - >({ queryKey: ["base-rows", pageId] }); - let touchesCache = false; - outer: for (const [, data] of caches) { - if (!data) continue; - for (const page of data.pages) { - for (const row of page.items) { - if (updatedIds.has(row.id)) { - touchesCache = true; - break outer; - } + break; + } + case "base:rows:updated": { + const e = event as BaseRowsUpdated; + // Only refetch if the batch touches rows currently in cache; formula + // backfills emit one event per 500 rows so this avoids redundant fetches. + const updatedIds = new Set(e.rowIds); + const caches = queryClient.getQueriesData< + InfiniteData> + >({ queryKey: ["base-rows", pageId] }); + let touchesCache = false; + outer: for (const [, data] of caches) { + if (!data) continue; + for (const page of data.pages) { + for (const row of page.items) { + if (updatedIds.has(row.id)) { + touchesCache = true; + break outer; } } } - if (touchesCache) { - queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] }); - } - break; } - case "base:schema:bumped": { - // Worker committed a type conversion or cell GC; re-fetch under the new schema. + if (touchesCache) { + queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] }); + } + break; + } + case "base:schema:bumped": { + // Worker committed a type conversion or cell GC; re-fetch under the new schema. + queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] }); + queryClient.invalidateQueries({ queryKey: ["bases", pageId] }); + break; + } + case "base:subscribed": { + const e = event as BaseSubscribed; + const cached = queryClient.getQueryData(["bases", pageId]); + if (cached && cached.baseSchemaVersion !== e.schemaVersion) { queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] }); queryClient.invalidateQueries({ queryKey: ["bases", pageId] }); - break; } - case "base:subscribed": { - const e = event as BaseSubscribed; - const cached = queryClient.getQueryData(["bases", pageId]); - if (cached && cached.baseSchemaVersion !== e.schemaVersion) { - queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] }); - queryClient.invalidateQueries({ queryKey: ["bases", pageId] }); - } - break; - } - case "base:formula:recompute:started": { - const e = event as BaseFormulaRecomputeStarted; - const store = getDefaultStore(); - store.set(formulaRecomputeAtom, { - ...store.get(formulaRecomputeAtom), - [e.jobId]: e.propertyIds, - }); - break; - } - case "base:formula:recompute:completed": { - const e = event as BaseFormulaRecomputeCompleted; - const store = getDefaultStore(); - const current = store.get(formulaRecomputeAtom); - if (e.jobId in current) { - const next = { ...current }; - delete next[e.jobId]; - store.set(formulaRecomputeAtom, next); - } - break; - } - case "base:property:created": - case "base:property:updated": - case "base:property:deleted": - case "base:property:reordered": - case "base:view:created": - case "base:view:updated": - case "base:view:deleted": { - // Schema/metadata events only affect properties/views, not cell data. - queryClient.invalidateQueries({ queryKey: ["bases", pageId] }); - break; - } - default: - break; + break; } - }; + case "base:formula:recompute:started": { + const e = event as BaseFormulaRecomputeStarted; + const store = getDefaultStore(); + store.set(formulaRecomputeAtom, { + ...store.get(formulaRecomputeAtom), + [e.jobId]: e.propertyIds, + }); + break; + } + case "base:formula:recompute:completed": { + const e = event as BaseFormulaRecomputeCompleted; + const store = getDefaultStore(); + const current = store.get(formulaRecomputeAtom); + if (e.jobId in current) { + const next = { ...current }; + delete next[e.jobId]; + store.set(formulaRecomputeAtom, next); + } + break; + } + case "base:property:created": + case "base:property:updated": + case "base:property:deleted": + case "base:property:reordered": + case "base:view:created": + case "base:view:updated": + case "base:view:deleted": { + // Schema/metadata events only affect properties/views, not cell data. + queryClient.invalidateQueries({ queryKey: ["bases", pageId] }); + break; + } + default: + break; + } + }; +} - socket.on("message", handler); +type BaseSubscription = { + count: number; + handler: (raw: unknown) => void; +}; - return () => { - socket.off("message", handler); - socket.emit("message", { operation: "base:unsubscribe", pageId }); +const baseSubscriptions = new WeakMap>(); + +// One room subscription and cache handler per base, shared by every mounted view. +export function retainBaseSubscription( + socket: Socket, + pageId: string, + queryClient: QueryClient, +): () => void { + const subscriptions = + baseSubscriptions.get(socket) ?? new Map(); + baseSubscriptions.set(socket, subscriptions); + + let subscription = subscriptions.get(pageId); + if (!subscription) { + subscription = { + count: 0, + handler: createBaseEventHandler(pageId, queryClient), }; + subscriptions.set(pageId, subscription); + socket.on("message", subscription.handler); + socket.emit("message", { operation: "base:subscribe", pageId }); + } + subscription.count += 1; + + const retained = subscription; + return () => { + retained.count -= 1; + if (retained.count > 0) return; + subscriptions.delete(pageId); + socket.off("message", retained.handler); + socket.emit("message", { operation: "base:unsubscribe", pageId }); + }; +} + +// Realtime bridge for a base: reconciles React Query caches on inbound events. +export function useBaseSocket(pageId: string | undefined): void { + const socket = useAtomValue(socketAtom); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!socket || !pageId) return; + return retainBaseSubscription(socket, pageId, queryClient); }, [socket, pageId, queryClient]); } diff --git a/apps/client/src/features/editor/components/presentation/presentation-modal.module.css b/apps/client/src/features/editor/components/presentation/presentation-modal.module.css new file mode 100644 index 00000000..fc079cc3 --- /dev/null +++ b/apps/client/src/features/editor/components/presentation/presentation-modal.module.css @@ -0,0 +1,134 @@ +.content { + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); + overflow: hidden; +} + +.body { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + padding: 0; +} + +.closeButton { + position: absolute; + top: 16px; + right: 16px; + z-index: 10; +} + +.stage { + position: relative; + flex: 1; + min-height: 0; +} + +.slideArea, +.measurementArea { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 6vh 5vw 10vh; + scrollbar-gutter: stable both-edges; +} + +.slideArea { + overflow-y: auto; + overflow-x: hidden; + outline: none; +} + +.scrollable { + zoom: 0.85; +} + +.measurementArea { + overflow: hidden; + visibility: hidden; + pointer-events: none; +} + +.editorContainer { + width: 100%; + max-width: 900px; + margin-block: auto; +} + +.stage :global(.ProseMirror) { + font-size: 1.3rem; + line-height: 1.6; +} + +.stage :global(.ProseMirror) h1 { + font-size: 3rem; +} + +.stage :global(.ProseMirror) h2 { + font-size: 2.25rem; +} + +.stage :global(.ProseMirror) h3 { + font-size: 1.75rem; +} + +.titleSlide { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 900px; + margin-block: auto; + text-align: center; +} + +.titleSlideText { + font-size: 3.5rem; + font-weight: 700; + overflow-wrap: anywhere; +} + +.controls { + display: flex; + justify-content: center; + padding: 8px 0 24px; +} + +.controlBar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px; + border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); + border-radius: 999px; + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); + box-shadow: var(--mantine-shadow-md); +} + +.controlButton:disabled { + background-color: transparent; +} + +.slideCounter { + min-width: max(72px, calc((var(--total-digits, 1) * 2 + 3) * 1ch + 16px)); + padding-inline: 8px; + text-align: center; + font-size: var(--mantine-font-size-md); + font-variant-numeric: tabular-nums; + user-select: none; +} + +.slideTotal { + color: light-dark( + var(--mantine-color-dimmed), + color-mix(in srgb, var(--mantine-color-dark-1), var(--mantine-color-dark-2)) + ); +} + +.controlBar .controlDivider { + align-self: center; + height: 24px; + margin-inline: 4px; +} diff --git a/apps/client/src/features/editor/components/presentation/presentation-modal.tsx b/apps/client/src/features/editor/components/presentation/presentation-modal.tsx new file mode 100644 index 00000000..ec64947b --- /dev/null +++ b/apps/client/src/features/editor/components/presentation/presentation-modal.tsx @@ -0,0 +1,501 @@ +import "@/features/editor/styles/index.css"; +import { + type CSSProperties, + type MouseEvent as ReactMouseEvent, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ActionIcon, + Box, + Divider, + Modal, + Title, + Tooltip, + VisuallyHidden, +} from "@mantine/core"; +import { + IconChevronLeft, + IconChevronRight, + IconMaximize, + IconMinimize, + IconX, +} from "@tabler/icons-react"; +import { useFullscreenDocument, useWindowEvent } from "@mantine/hooks"; +import { + type Content, + type Editor, + EditorProvider, + type JSONContent, + useCurrentEditor, +} from "@tiptap/react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { UniqueID } from "@docmost/editor-ext"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; +import clsx from "clsx"; +import { mainExtensions } from "@/features/editor/extensions/extensions"; +import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; +import { extractPageSlugId } from "@/lib"; +import { + findSlideIndexForNode, + getContentBoxHeight, + getDeckKeyAction, + measureBlocks, + paginateBlocks, + sameSlideLayouts, + setEditorPageId, + shouldReturnFocusToSlide, + type SlideLayout, +} from "./presentation.utils"; +import classes from "./presentation-modal.module.css"; + +type PresentationModalProps = { + pageId: string; + title: string; + content: Content; + opened: boolean; + onClose: () => void; +}; + +type PresentationDeckProps = Omit; + +type DeckState = { + slides: SlideLayout[] | null; + nodes: JSONContent[]; + index: number; +}; + +type NodesCache = { + doc: ProseMirrorNode; + nodes: JSONContent[]; +}; + +const excludedExtensionNames = new Set([ + "uniqueID", + "trailingNode", + "tableHeaderPin", + "tableReadonlySort", +]); + +const presentationExtensions = [ + ...mainExtensions.filter((ext) => !excludedExtensionNames.has(ext.name)), + UniqueID.configure({ + types: ["heading", "paragraph"], + updateDocument: false, + }), +]; + +export default function PresentationModal({ + pageId, + title, + content, + opened, + onClose, +}: PresentationModalProps) { + const { t } = useTranslation(); + const { pageSlug } = useParams(); + const pageSlugId = extractPageSlugId(pageSlug); + + useEffect(() => { + if (opened) { + onClose(); + } + }, [pageSlugId]); + + useEffect(() => { + if (!opened) return; + // keeps focus, clicks and find-in-page out of the live page behind the deck + const root = document.getElementById("root"); + root?.setAttribute("inert", ""); + return () => root?.removeAttribute("inert"); + }, [opened]); + + return ( + + + + + + + + + ); +} + +function PresentationDeck({ + pageId, + title, + content, + onClose, +}: PresentationDeckProps) { + const { t } = useTranslation(); + const [deck, setDeck] = useState({ + slides: null, + nodes: [], + index: 0, + }); + const stageRef = useRef(null); + const measurementAreaRef = useRef(null); + const slideAreaRef = useRef(null); + const lastLayoutRef = useRef(null); + const nodesCacheRef = useRef(null); + + const { toggle: toggleFullscreen, fullscreen } = useFullscreenDocument(); + const canFullscreen = document.fullscreenEnabled; + + useEffect( + () => () => { + // closing the presentation must not leave the page in fullscreen + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => undefined); + } + }, + [], + ); + + const handleToggleFullscreen = () => { + toggleFullscreen().catch(() => undefined); + }; + + const slideCount = (deck.slides?.length ?? 0) + 1; + const contentSlide = + deck.index > 0 ? deck.slides?.[deck.index - 1] : undefined; + const slideKey = contentSlide ? contentSlide.nodeIndexes.join("-") : "title"; + + const slideDoc = useMemo( + () => + contentSlide + ? { + type: "doc", + content: contentSlide.nodeIndexes.map((index) => deck.nodes[index]), + } + : null, + [contentSlide, deck.nodes], + ); + + const paginate = useCallback((editor: Editor) => { + const area = measurementAreaRef.current; + if (!area || editor.isDestroyed) return; + + const slides = paginateBlocks( + measureBlocks(editor), + getContentBoxHeight(area), + ); + if (sameSlideLayouts(lastLayoutRef.current, slides)) return; + lastLayoutRef.current = slides; + + const doc = editor.state.doc; + if (nodesCacheRef.current?.doc !== doc) { + nodesCacheRef.current = { doc, nodes: doc.toJSON().content ?? [] }; + } + const { nodes } = nodesCacheRef.current; + setDeck((current) => { + const anchor = current.slides?.[current.index - 1]?.nodeIndexes[0]; + const index = + anchor === undefined ? 0 : findSlideIndexForNode(slides, anchor) + 1; + return { slides, nodes, index }; + }); + }, []); + + const showSlide = (pick: (index: number, lastIndex: number) => number) => { + setDeck((current) => { + const lastIndex = current.slides?.length ?? 0; + const index = Math.min( + Math.max(pick(current.index, lastIndex), 0), + lastIndex, + ); + return index === current.index ? current : { ...current, index }; + }); + }; + + const goToNext = () => showSlide((index) => index + 1); + const goToPrevious = () => showSlide((index) => index - 1); + + const scrollSlide = (direction: 1 | -1, page: boolean) => { + const area = slideAreaRef.current; + if (!area) return false; + + const previousTop = area.scrollTop; + // scrollBy is instant here, so scrollTop has already moved when it returns + area.scrollBy({ top: direction * area.clientHeight * (page ? 0.9 : 0.15) }); + return area.scrollTop !== previousTop; + }; + + useWindowEvent("keydown", (event) => { + const action = getDeckKeyAction(event, stageRef.current); + if (!action) return; + event.preventDefault(); + + switch (action) { + case "next": + goToNext(); + break; + case "previous": + goToPrevious(); + break; + case "first": + showSlide(() => 0); + break; + case "last": + showSlide((_, lastIndex) => lastIndex); + break; + case "lineDown": + if (!scrollSlide(1, false)) goToNext(); + break; + case "lineUp": + if (!scrollSlide(-1, false)) goToPrevious(); + break; + case "pageDown": + if (!scrollSlide(1, true)) goToNext(); + break; + case "pageUp": + if (!scrollSlide(-1, true)) goToPrevious(); + break; + } + }); + + useLayoutEffect(() => { + const area = slideAreaRef.current; + area?.scrollTo({ top: 0 }); + + const active = document.activeElement; + if ( + !active || + active === document.body || + (active as HTMLButtonElement).disabled + ) { + area?.focus({ preventScroll: true }); + } + }, [slideKey]); + + useEffect(() => { + const stage = stageRef.current; + if (!stage) return; + + // comment marks dispatch this to open the page's comment panel behind the modal + const stopCommentActivation = (event: Event) => event.stopPropagation(); + stage.addEventListener("ACTIVE_COMMENT_EVENT", stopCommentActivation); + return () => + stage.removeEventListener("ACTIVE_COMMENT_EVENT", stopCommentActivation); + }, []); + + const returnFocusAfterClick = (event: ReactMouseEvent) => { + const area = slideAreaRef.current; + // detail is 0 when a key activated the control, which should keep focus + if (!area || event.detail === 0) return; + if (shouldReturnFocusToSlide(document.activeElement, area)) { + area.focus({ preventScroll: true }); + } + }; + + const handleSlideMouseOut = (event: ReactMouseEvent) => { + // keys typed while an embed's iframe has focus never reach the deck + if ( + event.target instanceof HTMLIFrameElement && + document.activeElement === event.target + ) { + slideAreaRef.current?.focus({ preventScroll: true }); + } + }; + + return ( + + + + + + + +
+ + + + {slideDoc ? ( + setEditorPageId(editor, pageId)} + /> + ) : ( +
+ + {title || t("Untitled")} + +
+ )} +
+
+ +
+
+ + + + + + + + + + + {canFullscreen && ( + <> + + + + {fullscreen ? ( + + ) : ( + + )} + + + + )} +
+ + + {t("Slide {{current}} of {{total}}", { + current: deck.index + 1, + total: slideCount, + })} + +
+
+ ); +} + +function MeasurementObserver({ + areaRef, + onMeasure, +}: { + areaRef: RefObject; + onMeasure: (editor: Editor) => void; +}) { + const { editor } = useCurrentEditor(); + + useEffect(() => { + const area = areaRef.current; + if (!editor || !area) return; + + const observer = new ResizeObserver(() => onMeasure(editor)); + observer.observe(area); + observer.observe(editor.view.dom); + return () => observer.disconnect(); + }, [editor, areaRef, onMeasure]); + + return null; +} diff --git a/apps/client/src/features/editor/components/presentation/presentation.utils.test.ts b/apps/client/src/features/editor/components/presentation/presentation.utils.test.ts new file mode 100644 index 00000000..a9bebab7 --- /dev/null +++ b/apps/client/src/features/editor/components/presentation/presentation.utils.test.ts @@ -0,0 +1,423 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import { + findSlideIndexForNode, + getContentBoxHeight, + getDeckKeyAction, + isBlankBlock, + type MeasuredBlock, + paginateBlocks, + sameSlideLayouts, + shouldReturnFocusToSlide, +} from "./presentation.utils"; + +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] }, + image: { group: "block", atom: true, toDOM: () => ["img"] }, + text: { group: "inline" }, + hardBreak: { group: "inline", inline: true, toDOM: () => ["br"] }, + mention: { group: "inline", inline: true, atom: true, toDOM: () => ["span"] }, + }, +}); + +let nextIndex = 0; +function block(height: number, overrides: Partial = {}): MeasuredBlock { + const index = overrides.index ?? nextIndex++; + const top = overrides.top ?? index * 1000; + return { + index, + type: "paragraph", + blank: false, + top, + bottom: top + height, + marginTop: 0, + marginBottom: 0, + ...overrides, + }; +} + +function stacked(heights: number[], gap = 0, type = "paragraph"): MeasuredBlock[] { + let top = 0; + return heights.map((height, index) => { + const result = block(height, { index, top, type }); + top += height + gap; + return result; + }); +} + +type BlockSpec = [type: string, height: number, headingLevel?: number]; + +function sequence(...specs: BlockSpec[]): MeasuredBlock[] { + return stacked(specs.map(([, height]) => height)).map((measured, index) => ({ + ...measured, + type: specs[index][0], + headingLevel: specs[index][2], + })); +} + +const indexes = (slides: { nodeIndexes: number[] }[]) => + slides.map((slide) => slide.nodeIndexes); + +describe("isBlankBlock", () => { + const paragraph = (...content: ReturnType[]) => + schema.nodes.paragraph.create(null, content); + + it("treats empty, whitespace, nbsp and hardBreak-only paragraphs as blank", () => { + expect(isBlankBlock(paragraph())).toBe(true); + expect(isBlankBlock(paragraph(schema.text(" ")))).toBe(true); + expect(isBlankBlock(paragraph(schema.text("\u00a0")))).toBe(true); + expect(isBlankBlock(paragraph(schema.nodes.hardBreak.create()))).toBe(true); + }); + + it("keeps paragraphs with text or inline atoms", () => { + expect(isBlankBlock(paragraph(schema.text("Hi")))).toBe(false); + expect(isBlankBlock(paragraph(schema.nodes.mention.create()))).toBe(false); + }); + + it("never treats non-textblocks as blank", () => { + expect(isBlankBlock(schema.nodes.image.create())).toBe(false); + }); +}); + +describe("getContentBoxHeight", () => { + const area = (clientHeight: number, padding: { top?: string; bottom?: string } = {}) => { + const element = document.createElement("div"); + element.style.paddingTop = padding.top ?? ""; + element.style.paddingBottom = padding.bottom ?? ""; + Object.defineProperty(element, "clientHeight", { value: clientHeight }); + document.body.appendChild(element); + return element; + }; + + it("subtracts the vertical padding from the client height", () => { + expect(getContentBoxHeight(area(600, { top: "20px", bottom: "10px" }))).toBe(570); + }); + + it("treats padding that does not resolve to a number as zero", () => { + expect(getContentBoxHeight(area(600))).toBe(600); + }); +}); + +describe("paginateBlocks", () => { + it("keeps blocks that fit on one slide", () => { + const slides = paginateBlocks(stacked([100, 100, 100], 10), 500); + expect(indexes(slides)).toEqual([[0, 1, 2]]); + expect(slides[0].scrollable).toBe(false); + }); + + it("starts a new slide when the next block would overflow", () => { + expect(indexes(paginateBlocks(stacked([300, 300, 300], 0), 650))).toEqual([[0, 1], [2]]); + }); + + it("counts the first block's top margin and the last block's bottom margin", () => { + const blocks = stacked([300, 300], 0).map((b) => ({ ...b, marginTop: 30, marginBottom: 30 })); + expect(indexes(paginateBlocks(blocks, 640))).toEqual([[0], [1]]); + expect(indexes(paginateBlocks(blocks, 660))).toEqual([[0, 1]]); + }); + + it("counts the gap between adjacent blocks", () => { + const blocks = stacked([300, 300, 300], 20); + expect(indexes(paginateBlocks(blocks, 930))).toEqual([[0, 1], [2]]); + expect(indexes(paginateBlocks(blocks, 940))).toEqual([[0, 1, 2]]); + }); + + it("uses the larger margin as the gap across a skipped blank block", () => { + for (const [marginBottom, marginTop] of [[10, 30], [30, 10]]) { + const blocks = [ + block(200, { index: 0, top: 0, marginBottom }), + block(400, { index: 1, top: 210, blank: true }), + block(200, { index: 2, top: 640, marginTop }), + ]; + expect(indexes(paginateBlocks(blocks, 425))).toEqual([[0], [2]]); + expect(indexes(paginateBlocks(blocks, 435))).toEqual([[0, 2]]); + } + }); + + it("splits on dividers and drops them", () => { + const blocks = stacked([100, 10, 100], 0); + blocks[1].type = "horizontalRule"; + expect(indexes(paginateBlocks(blocks, 1000))).toEqual([[0], [2]]); + }); + + it("starts a new slide at every H1", () => { + const blocks = stacked([50, 50, 50], 0); + blocks[1] = { ...blocks[1], type: "heading", headingLevel: 1 }; + blocks[2] = { ...blocks[2], type: "heading", headingLevel: 2 }; + expect(indexes(paginateBlocks(blocks, 1000))).toEqual([[0], [1, 2]]); + }); + + it("ends the slide after a table or base", () => { + const blocks = stacked([50, 50, 50, 50], 0); + blocks[1].type = "table"; + blocks[2].type = "base"; + expect(indexes(paginateBlocks(blocks, 1000))).toEqual([[0, 1], [2], [3]]); + }); + + it("skips blank blocks and never emits an empty slide", () => { + const blocks = stacked([10, 50, 50, 10], 0); + blocks[0].type = "horizontalRule"; + blocks[1].blank = true; + blocks[2] = { ...blocks[2], type: "heading", headingLevel: 1 }; + blocks[3].type = "horizontalRule"; + expect(indexes(paginateBlocks(blocks, 1000))).toEqual([[2]]); + }); + + it("does not count the height of skipped blank blocks", () => { + const blocks = stacked([200, 400, 200], 0); + blocks[1].blank = true; + expect(indexes(paginateBlocks(blocks, 450))).toEqual([[0, 2]]); + }); + + describe("zero-height blocks", () => { + const divider: BlockSpec = ["horizontalRule", 10]; + const pageBreak: BlockSpec = ["pageBreak", 0]; + const heading: BlockSpec = ["heading", 50, 1]; + const paragraph: BlockSpec = ["paragraph", 50]; + const table: BlockSpec = ["table", 50]; + const cases: [string, BlockSpec[], number[][]][] = [ + ["divider, page break, H1", [divider, pageBreak, heading], [[2]]], + ["divider, page break, H1, paragraph", [divider, pageBreak, heading, paragraph], [[2, 3]]], + ["table, page break, H1, paragraph", [table, pageBreak, heading, paragraph], [[0], [2, 3]]], + ["page break, H1, paragraph", [pageBreak, heading, paragraph], [[1, 2]]], + ["paragraph, table, page break", [paragraph, table, pageBreak], [[0, 1]]], + ]; + + it.each(cases)("never gives a page break a slide of its own: %s", (_label, specs, expected) => { + expect(indexes(paginateBlocks(sequence(...specs), 1000))).toEqual(expected); + }); + + it("still splits on a zero-height divider", () => { + const slides = paginateBlocks(sequence(paragraph, ["horizontalRule", 0], paragraph), 1000); + expect(indexes(slides)).toEqual([[0], [2]]); + }); + }); + + it("marks a block taller than the area as a scrollable slide of its own", () => { + const slides = paginateBlocks(stacked([100, 900, 100], 0), 500); + expect(indexes(slides)).toEqual([[0], [1], [2]]); + expect(slides.map((slide) => slide.scrollable)).toEqual([false, true, false]); + }); + + it("returns no slides for an empty or blank-only document", () => { + expect(paginateBlocks([], 500)).toEqual([]); + expect(paginateBlocks(stacked([0, 0]).map((b) => ({ ...b, blank: true })), 500)).toEqual([]); + }); +}); + +describe("findSlideIndexForNode", () => { + const slides = [ + { nodeIndexes: [0, 1], scrollable: false }, + { nodeIndexes: [3, 4], scrollable: false }, + { nodeIndexes: [6], scrollable: false }, + ]; + + it("finds the slide containing the node", () => { + expect(findSlideIndexForNode(slides, 4)).toBe(1); + }); + + it("falls forward to the next slide for a skipped node", () => { + expect(findSlideIndexForNode(slides, 2)).toBe(1); + }); + + it("clamps to the last slide", () => { + expect(findSlideIndexForNode(slides, 9)).toBe(2); + }); + + it("returns -1 when there are no slides", () => { + expect(findSlideIndexForNode([], 3)).toBe(-1); + }); +}); + +describe("sameSlideLayouts", () => { + it("compares node indexes and scrollable flags", () => { + const a = [{ nodeIndexes: [0, 1], scrollable: false }]; + expect(sameSlideLayouts(a, [{ nodeIndexes: [0, 1], scrollable: false }])).toBe(true); + expect(sameSlideLayouts(a, [{ nodeIndexes: [0, 1], scrollable: true }])).toBe(false); + expect(sameSlideLayouts(a, [{ nodeIndexes: [0], scrollable: false }])).toBe(false); + expect(sameSlideLayouts(null, a)).toBe(false); + }); +}); + +function keyEvent(key: string, target: Element = document.body, extra: Partial = {}) { + return { key, target, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, defaultPrevented: false, ...extra }; +} + +function element(html: string, selector: string): Element { + const host = document.createElement("div"); + host.innerHTML = html; + document.body.appendChild(host); + return host.querySelector(selector); +} + +describe("getDeckKeyAction", () => { + it("maps navigation keys", () => { + expect(getDeckKeyAction(keyEvent("ArrowRight"), null)).toBe("next"); + expect(getDeckKeyAction(keyEvent("Enter"), null)).toBe("next"); + expect(getDeckKeyAction(keyEvent("ArrowLeft"), null)).toBe("previous"); + expect(getDeckKeyAction(keyEvent("ArrowDown"), null)).toBe("lineDown"); + expect(getDeckKeyAction(keyEvent("ArrowUp"), null)).toBe("lineUp"); + expect(getDeckKeyAction(keyEvent("PageDown"), null)).toBe("pageDown"); + expect(getDeckKeyAction(keyEvent(" "), null)).toBe("pageDown"); + expect(getDeckKeyAction(keyEvent("PageUp"), null)).toBe("pageUp"); + expect(getDeckKeyAction(keyEvent("Home"), null)).toBe("first"); + expect(getDeckKeyAction(keyEvent("End"), null)).toBe("last"); + expect(getDeckKeyAction(keyEvent("Escape"), null)).toBeNull(); + }); + + it("ignores modified and already handled keys", () => { + expect(getDeckKeyAction(keyEvent("ArrowRight", document.body, { metaKey: true }), null)).toBeNull(); + expect(getDeckKeyAction(keyEvent("f", document.body, { ctrlKey: true }), null)).toBeNull(); + expect(getDeckKeyAction(keyEvent(" ", document.body, { shiftKey: true }), null)).toBeNull(); + expect(getDeckKeyAction(keyEvent("ArrowRight", document.body, { defaultPrevented: true }), null)).toBeNull(); + }); + + it("leaves typing targets alone", () => { + expect(getDeckKeyAction(keyEvent("ArrowRight", element("", "input")), null)).toBeNull(); + expect(getDeckKeyAction(keyEvent(" ", element('
x
', "div")), null)).toBeNull(); + }); + + it("lets Space and Enter activate focused buttons", () => { + const button = element("", "button"); + expect(getDeckKeyAction(keyEvent(" ", button), null)).toBeNull(); + expect(getDeckKeyAction(keyEvent("Enter", button), null)).toBeNull(); + expect(getDeckKeyAction(keyEvent("ArrowRight", button), null)).toBe("next"); + }); + + it("lets Enter activate a focused link while Space still moves the slide", () => { + const link = element('x', "a"); + expect(getDeckKeyAction(keyEvent("Enter", link), null)).toBeNull(); + expect(getDeckKeyAction(keyEvent(" ", link), null)).toBe("pageDown"); + }); + + it("keeps Enter reserved for every interactive target", () => { + const cases: [string, string][] = [ + ['
Docs
', "div"], + ["", "label"], + ['
cell
', ".node-base > div"], + ]; + for (const [html, selector] of cases) { + expect(getDeckKeyAction(keyEvent("Enter", element(html, selector)), null)).toBeNull(); + } + }); + + it("exempts Space only for targets that Space activates", () => { + const activated: [string, string][] = [ + ["
More
", "summary"], + ['
Copy
', "div"], + ['
', '[role="tab"]'], + ['
Done
', "div"], + ['
Rename
', "div"], + ["", "video"], + ["", "audio"], + ]; + for (const [html, selector] of activated) { + expect(getDeckKeyAction(keyEvent(" ", element(html, selector)), null)).toBeNull(); + } + + const notActivated: [string, string][] = [ + ['
Docs
', "div"], + ["", "label"], + ]; + for (const [html, selector] of notActivated) { + expect(getDeckKeyAction(keyEvent(" ", element(html, selector)), null)).toBe("pageDown"); + } + }); + + it("leaves horizontal arrows to a focused tab list", () => { + const tab = element('
', '[role="tab"]'); + expect(getDeckKeyAction(keyEvent("ArrowRight", tab), null)).toBeNull(); + expect(getDeckKeyAction(keyEvent("PageDown", tab), null)).toBe("pageDown"); + }); + + describe("overlays opened from slide content", () => { + const deckElement = () => element("
", "div"); + + it("ignores keys inside a dialog or menu that does not hold the deck", () => { + const dialog = element('
Filter
', "span"); + const menu = element('
Sort
', "span"); + expect(getDeckKeyAction(keyEvent("ArrowRight", dialog), deckElement())).toBeNull(); + expect(getDeckKeyAction(keyEvent(" ", dialog), deckElement())).toBeNull(); + expect(getDeckKeyAction(keyEvent("ArrowRight", menu), deckElement())).toBeNull(); + }); + + it("still navigates from inside the dialog that holds the deck", () => { + const slide = element('
Slide
', "span"); + expect(getDeckKeyAction(keyEvent("ArrowRight", slide), slide.closest(".stage"))).toBe("next"); + }); + + it("navigates from a target outside any overlay when the deck element is known", () => { + expect(getDeckKeyAction(keyEvent("ArrowRight"), deckElement())).toBe("next"); + }); + + it("ignores keys inside an overlay nested in the deck's own dialog", () => { + const html = + '
Filter
'; + const nested = element(html, 'div[role="dialog"] div[role="dialog"] span'); + const stage = nested.parentElement.parentElement.querySelector(".stage"); + expect(getDeckKeyAction(keyEvent("ArrowRight", nested), stage)).toBeNull(); + }); + + it("skips the overlay check without a deck element", () => { + const dialog = element('
Filter
', "span"); + expect(getDeckKeyAction(keyEvent("ArrowRight", dialog), null)).toBe("next"); + }); + }); +}); + +describe("shouldReturnFocusToSlide", () => { + const slide = (html: string, selector: string) => { + const host = document.createElement("div"); + host.innerHTML = `
${html}
`; + document.body.appendChild(host); + return { area: host.firstElementChild, active: host.querySelector(selector) }; + }; + + it("returns focus from a control clicked inside the slide", () => { + const cases: [string, string][] = [ + ["", "button"], + ['
', "button"], + ]; + for (const [html, selector] of cases) { + const { area, active } = slide(html, selector); + expect(shouldReturnFocusToSlide(active, area)).toBe(true); + } + }); + + it("leaves text entry inside the slide alone", () => { + const cases: [string, string][] = [ + ["", "input"], + ["", "textarea"], + ['
x
', "span"], + ]; + for (const [html, selector] of cases) { + const { area, active } = slide(html, selector); + expect(shouldReturnFocusToSlide(active, area)).toBe(false); + } + }); + + it("leaves a focused video or audio control alone", () => { + const cases: [string, string][] = [ + ["", "video"], + ["", "audio"], + ]; + for (const [html, selector] of cases) { + const { area, active } = slide(html, selector); + expect(shouldReturnFocusToSlide(active, area)).toBe(false); + } + }); + + it("leaves focus inside an embedded base alone", () => { + const { area, active } = slide('
', "button"); + expect(shouldReturnFocusToSlide(active, area)).toBe(false); + }); + + it("ignores focus outside the slide, on the slide itself, or nowhere", () => { + const { area } = slide("

Text

", "p"); + const outside = element("", "button"); + expect(shouldReturnFocusToSlide(outside, area)).toBe(false); + expect(shouldReturnFocusToSlide(area, area)).toBe(false); + expect(shouldReturnFocusToSlide(null, area)).toBe(false); + }); +}); diff --git a/apps/client/src/features/editor/components/presentation/presentation.utils.ts b/apps/client/src/features/editor/components/presentation/presentation.utils.ts new file mode 100644 index 00000000..721e8f9b --- /dev/null +++ b/apps/client/src/features/editor/components/presentation/presentation.utils.ts @@ -0,0 +1,270 @@ +import type { Editor } from "@tiptap/react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +export type MeasuredBlock = { + index: number; + type: string; + headingLevel?: number; + blank: boolean; + top: number; + bottom: number; + marginTop: number; + marginBottom: number; +}; + +export type SlideLayout = { + nodeIndexes: number[]; + scrollable: boolean; +}; + +export type DeckKeyAction = + | "next" + | "previous" + | "first" + | "last" + | "lineDown" + | "lineUp" + | "pageDown" + | "pageUp"; + +type DeckKeyEvent = Pick< + KeyboardEvent, + | "key" + | "altKey" + | "ctrlKey" + | "metaKey" + | "shiftKey" + | "target" + | "defaultPrevented" +>; + +const deckKeyActions: Record = { + ArrowRight: "next", + Enter: "next", + ArrowLeft: "previous", + Home: "first", + End: "last", + ArrowDown: "lineDown", + ArrowUp: "lineUp", + PageDown: "pageDown", + " ": "pageDown", + PageUp: "pageUp", +}; + +const interactiveSelector = [ + "a[href]", + "button", + "input", + "select", + "textarea", + "label", + "summary", + "video", + "audio", + "iframe", + '[role="button"]', + '[role="tab"]', + '[role="link"]', + '[role="checkbox"]', + '[role="menuitem"]', + '[contenteditable="true"]', + ".node-base", +].join(", "); + +const spaceActivatedSelector = [ + "button", + "summary", + 'input[type="checkbox"]', + 'input[type="radio"]', + "video", + "audio", + '[role="button"]', + '[role="tab"]', + '[role="checkbox"]', + '[role="menuitem"]', +].join(", "); + +const textEntrySelector = 'input, textarea, select, [contenteditable="true"]'; + +const overlaySelector = '[role="dialog"], [role="menu"]'; + +const isDivider = (block: MeasuredBlock) => block.type === "horizontalRule"; + +const isSectionHeading = (block: MeasuredBlock) => + block.type === "heading" && block.headingLevel === 1; + +const endsSection = (block: MeasuredBlock) => + block.type === "table" || block.type === "base"; + +function closestFrom(target: EventTarget | null, selector: string) { + return target instanceof Element ? target.closest(selector) : null; +} + +function gapBetween(previous: MeasuredBlock, next: MeasuredBlock) { + if (next.index === previous.index + 1) return next.top - previous.bottom; + return Math.max(previous.marginBottom, next.marginTop); +} + +export function isBlankBlock(node: ProseMirrorNode): boolean { + if (!node.isTextblock) return false; + + let blank = true; + node.forEach((child) => { + if (child.isText ? child.text?.trim() : child.type.name !== "hardBreak") { + blank = false; + } + }); + return blank; +} + +export function measureBlocks(editor: Editor): MeasuredBlock[] { + const blocks: MeasuredBlock[] = []; + + editor.state.doc.forEach((node, offset, index) => { + const dom = editor.view.nodeDOM(offset); + const block: MeasuredBlock = { + index, + type: node.type.name, + headingLevel: node.type.name === "heading" ? node.attrs.level : undefined, + blank: true, + top: 0, + bottom: 0, + marginTop: 0, + marginBottom: 0, + }; + + if (dom instanceof HTMLElement && dom.offsetParent !== null) { + const style = window.getComputedStyle(dom); + block.blank = isBlankBlock(node); + block.top = dom.offsetTop; + block.bottom = dom.offsetTop + dom.offsetHeight; + block.marginTop = parseFloat(style.marginTop) || 0; + block.marginBottom = parseFloat(style.marginBottom) || 0; + } + + blocks.push(block); + }); + + return blocks; +} + +export function getContentBoxHeight(element: HTMLElement): number { + const style = window.getComputedStyle(element); + return ( + element.clientHeight - + (parseFloat(style.paddingTop) || 0) - + (parseFloat(style.paddingBottom) || 0) + ); +} + +export function paginateBlocks( + blocks: MeasuredBlock[], + availableHeight: number, +): SlideLayout[] { + const slides: SlideLayout[] = []; + let current: MeasuredBlock[] = []; + let currentHeight = 0; + + const closeSlide = () => { + if (!current.length) return; + slides.push({ + nodeIndexes: current.map((block) => block.index), + scrollable: currentHeight > availableHeight, + }); + current = []; + currentHeight = 0; + }; + + for (const block of blocks) { + if (block.blank) continue; + if (isDivider(block)) { + closeSlide(); + continue; + } + // page breaks lay out with zero height and must never form a slide + if (block.bottom <= block.top) continue; + + const blockHeight = block.bottom - block.top; + const previous = current[current.length - 1]; + const extendedHeight = previous + ? currentHeight - + previous.marginBottom + + gapBetween(previous, block) + + blockHeight + + block.marginBottom + : 0; + + if (previous && !isSectionHeading(block) && extendedHeight <= availableHeight) { + current.push(block); + currentHeight = extendedHeight; + } else { + closeSlide(); + current.push(block); + currentHeight = block.marginTop + blockHeight + block.marginBottom; + } + + if (endsSection(block)) closeSlide(); + } + + closeSlide(); + return slides; +} + +export function findSlideIndexForNode( + slides: SlideLayout[], + nodeIndex: number, +): number { + const position = slides.findIndex( + (slide) => slide.nodeIndexes[slide.nodeIndexes.length - 1] >= nodeIndex, + ); + return position === -1 ? slides.length - 1 : position; +} + +export function sameSlideLayouts( + previous: SlideLayout[] | null, + next: SlideLayout[], +): boolean { + return ( + previous !== null && + previous.length === next.length && + previous.every( + (slide, index) => + slide.scrollable === next[index].scrollable && + slide.nodeIndexes.join() === next[index].nodeIndexes.join(), + ) + ); +} + +export function getDeckKeyAction( + event: DeckKeyEvent, + deckElement: Element | null, +): DeckKeyAction | null { + if (event.defaultPrevented) return null; + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return null; + + const action = deckKeyActions[event.key]; + if (!action || closestFrom(event.target, textEntrySelector)) return null; + + if (event.key === "Enter" && closestFrom(event.target, interactiveSelector)) return null; + if (event.key === " " && closestFrom(event.target, spaceActivatedSelector)) return null; + + const movesBetweenTabs = event.key === "ArrowLeft" || event.key === "ArrowRight"; + if (movesBetweenTabs && closestFrom(event.target, '[role="tablist"]')) return null; + + const overlay = closestFrom(event.target, overlaySelector); + if (overlay && deckElement && !overlay.contains(deckElement)) return null; + + return action; +} + +export function shouldReturnFocusToSlide( + active: Element | null, + slideArea: Element, +): boolean { + if (!active || active === slideArea || !slideArea.contains(active)) return false; + return !active.closest(`${textEntrySelector}, video, audio, .node-base`); +} + +export function setEditorPageId(editor: Editor, pageId: string): void { + (editor.storage as unknown as { pageId?: string }).pageId = pageId; +} diff --git a/apps/client/src/features/editor/components/subpages/subpage-item.tsx b/apps/client/src/features/editor/components/subpages/subpage-item.tsx index d5353689..e6047208 100644 --- a/apps/client/src/features/editor/components/subpages/subpage-item.tsx +++ b/apps/client/src/features/editor/components/subpages/subpage-item.tsx @@ -1,5 +1,9 @@ import { ActionIcon, Collapse } from "@mantine/core"; -import { IconChevronRight, IconFileDescription } from "@tabler/icons-react"; +import { + IconChevronRight, + IconFileDescription, + IconPointFilled, +} from "@tabler/icons-react"; import { useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -86,7 +90,9 @@ export default function SubpageItem({ /> ) : ( - + + + )} diff --git a/apps/client/src/features/editor/components/subpages/subpages.module.css b/apps/client/src/features/editor/components/subpages/subpages.module.css index f3e4f4f8..88c97440 100644 --- a/apps/client/src/features/editor/components/subpages/subpages.module.css +++ b/apps/client/src/features/editor/components/subpages/subpages.module.css @@ -100,9 +100,14 @@ flex: none; } -.togglePlaceholder { +.leafDot { flex: none; + display: inline-flex; + align-items: center; + justify-content: center; width: rem(22px); + height: rem(22px); + color: var(--subpages-icon); } .chevron { diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 9b02a459..ff04943f 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -12,13 +12,15 @@ import { IconMarkdown, IconMessage, IconPaperclip, + IconPresentation, IconPrinter, IconStar, IconStarFilled, IconTrash, IconWifiOff, } from "@tabler/icons-react"; -import React, { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { Content } from "@tiptap/react"; import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx"; import { useAtom, useAtomValue } from "jotai"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; @@ -39,11 +41,13 @@ import { htmlToMarkdown } from "@docmost/editor-ext"; import { pageEditorAtom, yjsConnectionStatusAtom, + yjsSyncedAtom, } from "@/features/editor/atoms/editor-atoms.ts"; 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 PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx"; +import PresentationModal from "@/features/editor/components/presentation/presentation-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { PageShareModal } from "@/ee/page-permission"; import { @@ -78,10 +82,17 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { [ [ "mod+F", - () => { + (keyboardEvent) => { + // keepMounted modals (page import) stay in the DOM while closed + const modalOpen = Array.from( + document.querySelectorAll('[aria-modal="true"]'), + ).some((modal) => modal.getClientRects().length > 0); + if (modalOpen) return; + keyboardEvent.preventDefault(); const event = new CustomEvent("openFindDialogFromEditor", {}); document.dispatchEvent(event); }, + { preventDefault: false }, ], [ "Escape", @@ -163,6 +174,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { attachmentsOpened, { open: openAttachmentsModal, close: closeAttachmentsModal }, ] = useDisclosure(false); + const [ + presentationOpened, + { open: openPresentationModal, close: closePresentationModal }, + ] = useDisclosure(false); const [pageEditor] = useAtom(pageEditorAtom); const pageUpdatedAt = useTimeAgo(page?.updatedAt); const favoriteIds = useFavoriteIds("page", page?.spaceId); @@ -204,6 +219,18 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { openDeleteModal({ onConfirm: () => handleDelete(page.id) }); }; + const yjsSynced = useAtomValue(yjsSyncedAtom); + const [presentationContent, setPresentationContent] = useState(null); + + const handleOpenPresentation = () => { + const liveContent = + yjsSynced && pageEditor && !pageEditor.isDestroyed + ? pageEditor.getJSON() + : null; + setPresentationContent(liveContent ?? page?.content ?? null); + openPresentationModal(); + }; + const handleToggleFavorite = () => { if (!page?.id) return; const params = { type: "page" as const, pageId: page.id }; @@ -308,6 +335,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { )} + {!page?.isBase && ( + } + onClick={handleOpenPresentation} + > + {t("Presentation mode")} + + )} + {!readOnly && !page?.isBase && ( + + ); } From d35e8f0cf93d4d5e40173175d3a3964a91e54176 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:47:18 +0100 Subject: [PATCH 02/18] feat(presentation): laser pointer and max width (#2561) * feat: add laser pointer * feat: increase slide max width to 1440px * fix: show presentation errors inside the modal --- .../public/locales/en-US/translation.json | 2 + .../presentation/laser-pointer.test.tsx | 90 ++++++++++++++ .../components/presentation/laser-pointer.tsx | 36 ++++++ .../presentation-modal.module.css | 24 +++- .../presentation/presentation-modal.tsx | 113 ++++++++++++------ 5 files changed, 229 insertions(+), 36 deletions(-) create mode 100644 apps/client/src/features/editor/components/presentation/laser-pointer.test.tsx create mode 100644 apps/client/src/features/editor/components/presentation/laser-pointer.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 159a7a61..edd6458f 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "Failed to fetch recent pages", "Failed to import pages": "Failed to import pages", "Failed to load page. An error occurred.": "Failed to load page. An error occurred.", + "Failed to load presentation. An error occurred.": "Failed to load presentation. An error occurred.", "Failed to update data": "Failed to update data", "Failed to create base": "Failed to create base", "Failed to update base": "Failed to update base", @@ -167,6 +168,7 @@ "Exit presentation mode": "Exit presentation mode", "Enter fullscreen": "Enter fullscreen", "Exit fullscreen": "Exit fullscreen", + "Laser pointer": "Laser pointer", "Slide {{current}} of {{total}}": "Slide {{current}} of {{total}}", "Print PDF": "Print PDF", "Profile": "Profile", diff --git a/apps/client/src/features/editor/components/presentation/laser-pointer.test.tsx b/apps/client/src/features/editor/components/presentation/laser-pointer.test.tsx new file mode 100644 index 00000000..d4c59658 --- /dev/null +++ b/apps/client/src/features/editor/components/presentation/laser-pointer.test.tsx @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import LaserPointer from "./laser-pointer"; + +type PointerInit = MouseEventInit & { pointerType?: string }; + +function dispatchPointer( + target: EventTarget, + type: "pointermove" | "pointerout", + init: PointerInit = {}, +) { + // jsdom has no PointerEvent + const event = new MouseEvent(type, { bubbles: true, ...init }); + Object.defineProperty(event, "pointerType", { + value: init.pointerType ?? "mouse", + }); + target.dispatchEvent(event); +} + +function renderLaserPointer(enabled = true) { + const view = render(); + return { ...view, dot: view.container.firstElementChild as HTMLElement }; +} + +describe("LaserPointer", () => { + it("follows the pointer", () => { + const { dot } = renderLaserPointer(); + + dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 }); + + expect(dot.style.transform).toBe("translate(120px, 80px)"); + expect(dot.style.visibility).toBe("visible"); + }); + + it("stays hidden while disabled", () => { + const { dot } = renderLaserPointer(false); + + dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 }); + + expect(dot.hidden).toBe(true); + }); + + it("shows at the last pointer position once enabled", () => { + const { dot, rerender } = renderLaserPointer(false); + dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 }); + + rerender(); + + expect(dot.hidden).toBe(false); + expect(dot.style.transform).toBe("translate(120px, 80px)"); + }); + + it("hides for touch input", () => { + const { dot } = renderLaserPointer(); + dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 }); + + dispatchPointer(window, "pointermove", { pointerType: "touch" }); + + expect(dot.style.visibility).toBe("hidden"); + }); + + it("hides when the pointer leaves the window", () => { + const { dot, container } = renderLaserPointer(); + dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 }); + + dispatchPointer(container, "pointerout", { relatedTarget: null }); + + expect(dot.style.visibility).toBe("hidden"); + }); + + it("hides when the pointer moves over an embed", () => { + const { dot, container } = renderLaserPointer(); + const embed = container.appendChild(document.createElement("iframe")); + dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 }); + + dispatchPointer(container, "pointerout", { relatedTarget: embed }); + + expect(dot.style.visibility).toBe("hidden"); + }); + + it("stays visible when the pointer moves onto another page element", () => { + const { dot, container } = renderLaserPointer(); + const paragraph = container.appendChild(document.createElement("p")); + dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 }); + + dispatchPointer(container, "pointerout", { relatedTarget: paragraph }); + + expect(dot.style.visibility).toBe("visible"); + }); +}); diff --git a/apps/client/src/features/editor/components/presentation/laser-pointer.tsx b/apps/client/src/features/editor/components/presentation/laser-pointer.tsx new file mode 100644 index 00000000..b35c5bc3 --- /dev/null +++ b/apps/client/src/features/editor/components/presentation/laser-pointer.tsx @@ -0,0 +1,36 @@ +import { useRef } from "react"; +import { useWindowEvent } from "@mantine/hooks"; +import classes from "./presentation-modal.module.css"; + +type LaserPointerProps = { + enabled: boolean; +}; + +export default function LaserPointer({ enabled }: LaserPointerProps) { + const dotRef = useRef(null); + + useWindowEvent("pointermove", (event) => { + const dot = dotRef.current; + if (!dot) return; + dot.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`; + dot.style.visibility = event.pointerType === "touch" ? "hidden" : "visible"; + }); + + useWindowEvent("pointerout", (event) => { + // the page gets no pointer events outside the window or over an embed + const leftPage = + !event.relatedTarget || event.relatedTarget instanceof HTMLIFrameElement; + if (leftPage && dotRef.current) { + dotRef.current.style.visibility = "hidden"; + } + }); + + return ( + @@ -474,6 +517,8 @@ function PresentationDeck({ })} + + ); } From 59b6142ecdea537d3991eb22c98f1ce230bfba5e Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:04:19 +0100 Subject: [PATCH 03/18] fix: hide comment marks on presentation slides (#2562) --- .../components/presentation/presentation-modal.module.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/client/src/features/editor/components/presentation/presentation-modal.module.css b/apps/client/src/features/editor/components/presentation/presentation-modal.module.css index 58c2c3e3..5e4a21c1 100644 --- a/apps/client/src/features/editor/components/presentation/presentation-modal.module.css +++ b/apps/client/src/features/editor/components/presentation/presentation-modal.module.css @@ -74,6 +74,11 @@ font-size: 1.75rem; } +.stage :global(.ProseMirror .comment-mark) { + background: none; + border-bottom: none; +} + .titleSlide { display: flex; align-items: center; From 6a06d7fd7d299d7ebc5cefba18ae10607e1fd791 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:07:26 +0100 Subject: [PATCH 04/18] fix: give docx export tables real column widths and cell padding (#2564) - Add Word's default 108 twip left and right cell margins --- apps/server/src/ee | 2 +- .../src/lib/prosemirror-docx/serializer.ts | 44 ++++++++++++--- .../src/lib/prosemirror-docx/utils.ts | 55 +++++++++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index 71625f8e..662a0042 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 71625f8e246a7680991302fede87ba999c9db9da +Subproject commit 662a0042eb6c56a01f8958447780fe2354467b14 diff --git a/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts b/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts index 739f6f51..8745bd69 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts @@ -26,10 +26,18 @@ import { ITableOptions, ITableRowOptions, IPropertiesOptions, + WidthType, } from 'docx'; import { imageDimensionsFromData } from 'image-dimensions'; import { createNumbering, NumberingStyles } from './numbering'; -import { buildDoc, createShortId } from './utils'; +import { + buildDoc, + CELL_MARGIN, + createShortId, + getTableCellWidths, + getTableColumnWidths, + TEXT_WIDTH, +} from './utils'; import { IFootnotes, INumbering, Mutable, SectionConfig, SerializationState } from './types'; // This is duplicated from @curvenote/schema @@ -337,7 +345,9 @@ export class DocxSerializerState { const { getCellOptions, getRowOptions, tableOptions } = opts; const actualChildren = this.children; const rows: TableRow[] = []; - node.content.forEach((row) => { + const columnWidths = getTableColumnWidths(node); + const cellWidths = getTableCellWidths(node, columnWidths); + node.content.forEach((row, _rowOffset, rowIndex) => { const cells: TableCell[] = []; // Check if all cells are headers in this row let tableHeader = true; @@ -348,10 +358,13 @@ export class DocxSerializerState { }); // This scales images inside of tables this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount; - row.content.forEach((cell) => { + row.content.forEach((cell, _cellOffset, cellIndex) => { this.children = []; this.renderContent(cell); - const tableCellOpts: Mutable = { children: this.children }; + const tableCellOpts: Mutable = { + children: this.children, + width: { size: cellWidths[rowIndex][cellIndex], type: WidthType.DXA }, + }; const colspan = cell.attrs.colspan ?? 1; const rowspan = cell.attrs.rowspan ?? 1; if (colspan > 1) tableCellOpts.columnSpan = colspan; @@ -366,7 +379,13 @@ export class DocxSerializerState { rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader })); }); this.maxImageWidth = MAX_IMAGE_WIDTH; - const table = new Table({ ...tableOptions, rows }); + const table = new Table({ + width: { size: TEXT_WIDTH, type: WidthType.DXA }, + columnWidths, + margins: { left: CELL_MARGIN, right: CELL_MARGIN }, + ...tableOptions, + rows, + }); actualChildren.push(table); // If there are multiple tables, this seperates them actualChildren.push(new Paragraph('')); @@ -763,6 +782,8 @@ export class DocxSerializerStateAsync { const { getCellOptions, getRowOptions, tableOptions } = opts; const actualChildren = this.children; const rows: TableRow[] = []; + const columnWidths = getTableColumnWidths(node); + const cellWidths = getTableCellWidths(node, columnWidths); for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) { const row = node.content.child(rowIndex); @@ -786,7 +807,10 @@ export class DocxSerializerStateAsync { this.children = []; // eslint-disable-next-line no-await-in-loop await this.renderContent(cell); // Ensure order - const tableCellOpts: Mutable = { children: this.children }; + const tableCellOpts: Mutable = { + children: this.children, + width: { size: cellWidths[rowIndex][cellIndex], type: WidthType.DXA }, + }; const colspan = cell.attrs.colspan ?? 1; const rowspan = cell.attrs.rowspan ?? 1; if (colspan > 1) tableCellOpts.columnSpan = colspan; @@ -803,7 +827,13 @@ export class DocxSerializerStateAsync { } this.maxImageWidth = MAX_IMAGE_WIDTH; - const table = new Table({ ...tableOptions, rows }); + const table = new Table({ + width: { size: TEXT_WIDTH, type: WidthType.DXA }, + columnWidths, + margins: { left: CELL_MARGIN, right: CELL_MARGIN }, + ...tableOptions, + rows, + }); actualChildren.push(table); // If there are multiple tables, this separates them actualChildren.push(new Paragraph('')); diff --git a/packages/editor-ext/src/lib/prosemirror-docx/utils.ts b/packages/editor-ext/src/lib/prosemirror-docx/utils.ts index 60378b10..c8ef9e3e 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/utils.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/utils.ts @@ -5,14 +5,69 @@ import { ISectionOptions, Packer, SectionType, + sectionMarginDefaults, + sectionPageSizeDefaults, } from 'docx'; import { Node as ProsemirrorNode } from 'prosemirror-model'; +import { TableMap } from '@tiptap/pm/tables'; import { IFootnotes, SerializationState } from './types'; +// twips between the margins of docx's default page +export const TEXT_WIDTH = + sectionPageSizeDefaults.WIDTH - sectionMarginDefaults.LEFT - sectionMarginDefaults.RIGHT; + +// twips of left and right cell padding Word gives tables by default +export const CELL_MARGIN = 108; + +// colwidth is in editor px; these are the editor's default content width and cellMinWidth +const EDITOR_WIDTH = 772; +const CELL_MIN_WIDTH = 49; + export function createShortId() { return Math.random().toString(36).slice(2, 11); } +function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0); +} + +// Spreads TEXT_WIDTH over the columns in their editor proportions. Columns never resized +// have no colwidth and share what the resized ones leave of the editor width. +export function getTableColumnWidths(table: ProsemirrorNode): number[] { + const colwidths: number[] = []; + table.firstChild?.forEach((cell) => { + for (let i = 0; i < cell.attrs.colspan; i += 1) { + colwidths.push(cell.attrs.colwidth?.[i] ?? 0); + } + }); + + const columnCount = TableMap.get(table).width; + const resized = colwidths.filter((width) => width > 0); + const unresizedCount = columnCount - resized.length; + const share = unresizedCount ? (EDITOR_WIDTH - sum(resized)) / unresizedCount : 0; + const widths = Array.from({ length: columnCount }, (_, i) => + Math.max(colwidths[i] || share, CELL_MIN_WIDTH), + ); + + const total = sum(widths); + return widths.map((width) => Math.round((width / total) * TEXT_WIDTH)); +} + +// Each cell, by row and cell index, is as wide as the columns it spans. +export function getTableCellWidths(table: ProsemirrorNode, columnWidths: number[]): number[][] { + const map = TableMap.get(table); + const cellWidths: number[][] = []; + table.forEach((row, rowOffset) => { + const rowWidths: number[] = []; + row.forEach((_cell, cellOffset) => { + const { left, right } = map.findCell(rowOffset + 1 + cellOffset); + rowWidths.push(sum(columnWidths.slice(left, right))); + }); + cellWidths.push(rowWidths); + }); + return cellWidths; +} + export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document { let sections = state?.sections?.length ? state.sections.map((section) => ({ From 297cc29bba634483298e1209e50ca1ca2aa75024 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:02:33 +0100 Subject: [PATCH 05/18] feat: add Template option to empty page get-started row (#2565) --- .../public/locales/en-US/translation.json | 3 + .../components/template-picker-modal.tsx | 13 ++ .../src/ee/template/queries/template-query.ts | 17 +- apps/client/src/ee/template/template.utils.ts | 16 ++ .../empty-page/empty-page-get-started.tsx | 156 ++++++++++++++---- .../src/features/editor/full-editor.tsx | 8 +- apps/client/src/pages/page/page.tsx | 1 + apps/server/src/ee | 2 +- 8 files changed, 173 insertions(+), 43 deletions(-) create mode 100644 apps/client/src/ee/template/template.utils.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index edd6458f..08426c25 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1004,6 +1004,9 @@ "Use template": "Use template", "Preview template: {{title}}": "Preview template: {{title}}", "Use a template": "Use a template", + "Template": "Template", + "Failed to load template": "Failed to load template", + "This page is no longer empty": "This page is no longer empty", "Search templates...": "Search templates...", "Search spaces...": "Search spaces...", "No templates found": "No templates found", diff --git a/apps/client/src/ee/template/components/template-picker-modal.tsx b/apps/client/src/ee/template/components/template-picker-modal.tsx index 609862fe..76c55295 100644 --- a/apps/client/src/ee/template/components/template-picker-modal.tsx +++ b/apps/client/src/ee/template/components/template-picker-modal.tsx @@ -34,6 +34,8 @@ type TemplatePickerModalProps = { onClose: () => void; /** Pre-select this space in the destination picker after a template is chosen. */ initialSpaceId?: string; + /** Receives the chosen template instead of creating a page from it. */ + onUse?: (template: ITemplate) => void; }; type ScopeFilter = "current" | "all"; @@ -42,6 +44,7 @@ export default function TemplatePickerModal({ opened, onClose, initialSpaceId, + onUse, }: TemplatePickerModalProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -101,6 +104,11 @@ export default function TemplatePickerModal({ }; const handleQuickUse = (tpl: ITemplate) => { + if (onUse) { + onUse(tpl); + handleClose(); + return; + } if (initialSpaceId) { createInInitialSpace(tpl); return; @@ -114,6 +122,11 @@ export default function TemplatePickerModal({ }; const handlePreviewUse = () => { + if (onUse && previewTemplate) { + onUse(previewTemplate); + handleClose(); + return; + } if (initialSpaceId && previewTemplate) { createInInitialSpace(previewTemplate); return; diff --git a/apps/client/src/ee/template/queries/template-query.ts b/apps/client/src/ee/template/queries/template-query.ts index 3157daa0..f3a28d00 100644 --- a/apps/client/src/ee/template/queries/template-query.ts +++ b/apps/client/src/ee/template/queries/template-query.ts @@ -23,6 +23,10 @@ import { useTranslation } from "react-i18next"; import { invalidateOnCreatePage } from "@/features/page/queries/page-query.ts"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; +import { + spaceRoots, + updateSpaceRoots, +} from "@/features/page/tree/utils/utils.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { IPage } from "@/features/page/types/page.types.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; @@ -185,15 +189,14 @@ export function useUseTemplateMutation() { children: [], }; - // Only mutate the tree atom and broadcast if it currently represents - // this space. Cross-space template-use (e.g., from the gallery picking + // Only mutate the tree atom and broadcast if this space's tree is + // loaded. Cross-space template-use (e.g., from the gallery picking // a different space) lets the target space's clients pick up the new // page on their next React Query refetch (focus, navigation, etc.). // Without this guard we'd both pollute the local tree and send a wrong // `index` to remote clients in the target space. - const current = store.get(treeDataAtom); - const treeIsForThisSpace = current[0]?.spaceId === page.spaceId; - if (!treeIsForThisSpace) return; + const current = spaceRoots(store.get(treeDataAtom), page.spaceId); + if (current.length === 0) return; const lastIndex = parentId === null @@ -201,7 +204,9 @@ export function useUseTemplateMutation() { : (treeModel.find(current, parentId)?.children?.length ?? 0); setTreeData((prev) => - treeModel.insert(prev, parentId, newNode, lastIndex), + updateSpaceRoots(prev, page.spaceId, (roots) => + treeModel.insert(roots, parentId, newNode, lastIndex), + ), ); setTimeout(() => { diff --git a/apps/client/src/ee/template/template.utils.ts b/apps/client/src/ee/template/template.utils.ts new file mode 100644 index 00000000..14698bef --- /dev/null +++ b/apps/client/src/ee/template/template.utils.ts @@ -0,0 +1,16 @@ +import { Editor } from "@tiptap/core"; +import { ITemplate } from "@/ee/template/types/template.types"; + +export function applyTemplateToPage( + editor: Editor, + titleEditor: Editor, + template: ITemplate, +): boolean { + if (!editor.isEmpty) return false; + + if (titleEditor.isEmpty && template.title) { + titleEditor.commands.setContent(template.title, { emitUpdate: true }); + } + editor.commands.setContent(template.content); + return true; +} diff --git a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx index 5b180402..acc0ce88 100644 --- a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx +++ b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx @@ -1,31 +1,54 @@ -import { useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { Button } from "@mantine/core"; -import { IconTable, IconLayoutKanban } from "@tabler/icons-react"; +import { useDisclosure } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { + IconTable, + IconLayoutKanban, + IconTemplate, +} from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useAtomValue } from "jotai"; +import { ErrorBoundary } from "react-error-boundary"; import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query"; import { pageEditorAtom, + titleEditorAtom, yjsSyncedAtom, } from "@/features/editor/atoms/editor-atoms"; import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; +import { ITemplate } from "@/ee/template/types/template.types"; +import { getTemplateById } from "@/ee/template/services/template-service.ts"; +import { applyTemplateToPage } from "@/ee/template/template.utils"; import classes from "./empty-page-get-started.module.css"; +const TemplatePickerModal = lazy( + () => import("@/ee/template/components/template-picker-modal"), +); + type EmptyPageGetStartedProps = { pageId: string; + spaceId: string; editable: boolean; }; export function EmptyPageGetStarted({ pageId, + spaceId, editable, }: EmptyPageGetStartedProps) { const { t } = useTranslation(); const editor = useAtomValue(pageEditorAtom); + const titleEditor = useAtomValue(titleEditorAtom); const isSynced = useAtomValue(yjsSyncedAtom); const hasBases = useHasFeature(Feature.BASES); + const hasTemplates = useHasFeature(Feature.TEMPLATES); const convertMutation = useConvertPageToBaseMutation(); + const [ + templatePickerOpened, + { open: openTemplatePicker, close: closeTemplatePicker }, + ] = useDisclosure(false); const [isEmpty, setIsEmpty] = useState(false); useEffect(() => { @@ -40,43 +63,106 @@ export function EmptyPageGetStarted({ }; }, [editor]); - if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null; + const handleUseTemplate = async (selected: ITemplate) => { + let template: ITemplate; + try { + template = await getTemplateById(selected.id); + } catch { + notifications.show({ + message: t("Failed to load template"), + color: "red", + }); + return; + } + + if ( + !editor || + editor.isDestroyed || + !titleEditor || + titleEditor.isDestroyed + ) { + return; + } + + if (!applyTemplateToPage(editor, titleEditor, template)) { + notifications.show({ + message: t("This page is no longer empty"), + color: "red", + }); + } + }; const chips = [ - { - key: "base", - label: t("Base"), - icon: IconTable, - onClick: () => convertMutation.mutate({ pageId }), - disabled: convertMutation.isPending, - }, - { - key: "kanban", - label: t("Kanban"), - icon: IconLayoutKanban, - onClick: () => convertMutation.mutate({ pageId, template: "kanban" }), - disabled: convertMutation.isPending, - }, + ...(hasBases + ? [ + { + key: "base", + label: t("Base"), + icon: IconTable, + onClick: () => convertMutation.mutate({ pageId }), + disabled: convertMutation.isPending, + }, + { + key: "kanban", + label: t("Kanban"), + icon: IconLayoutKanban, + onClick: () => + convertMutation.mutate({ pageId, template: "kanban" }), + disabled: convertMutation.isPending, + }, + ] + : []), + ...(hasTemplates + ? [ + { + key: "template", + label: t("Template"), + icon: IconTemplate, + onClick: openTemplatePicker, + disabled: false, + }, + ] + : []), ]; + const showChips = + editable && !!editor && isSynced && isEmpty && chips.length > 0; + return ( -
- {t("Get started with")} -
- {chips.map((chip) => ( - - ))} -
-
+ <> + {showChips && ( +
+ {t("Get started with")} +
+ {chips.map((chip) => ( + + ))} +
+
+ )} + + {hasTemplates && templatePickerOpened && ( + null}> + + + + + )} + ); } diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index f41fe0f7..b37ab541 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -48,6 +48,7 @@ export interface FullEditorProps { slugId: string; title: string; content: string; + spaceId: string; spaceSlug: string; editable: boolean; creator?: PageUser; @@ -60,6 +61,7 @@ export function FullEditor({ title, slugId, content, + spaceId, spaceSlug, editable, creator, @@ -115,7 +117,11 @@ export function FullEditor({ content={content} canComment={canComment} /> - + ); } diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index b809c9b0..b04ad568 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -184,6 +184,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) { title={page.title} content={page.content} slugId={page.slugId} + spaceId={page.spaceId} spaceSlug={page?.space?.slug} editable={canEdit} creator={page.creator} diff --git a/apps/server/src/ee b/apps/server/src/ee index 662a0042..e90378d6 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 662a0042eb6c56a01f8958447780fe2354467b14 +Subproject commit e90378d63dd5f75e771d2bed05a550fad6e0d5ef From 211d557a2e639cd9231263430dc6c15c91fe3581 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:05:47 +0100 Subject: [PATCH 06/18] fix(ai-chat): bind chat input submit to the current editor instance (#2566) --- .../src/ee/ai-chat/components/chat-input.tsx | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/client/src/ee/ai-chat/components/chat-input.tsx b/apps/client/src/ee/ai-chat/components/chat-input.tsx index 98286351..3d1fa310 100644 --- a/apps/client/src/ee/ai-chat/components/chat-input.tsx +++ b/apps/client/src/ee/ai-chat/components/chat-input.tsx @@ -188,22 +188,7 @@ const ChatInput = forwardRef(function ChatInput({ setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); }, []); - const handleSubmit = useCallback(() => { - if (!editor || editor.isDestroyed || isStreaming) return; - const json = editor.getJSON(); - const text = editorJsonToText(json).trim(); - const readyAttachments = pendingAttachments.filter((a) => !a.uploading); - if (!text && readyAttachments.length === 0) return; - - const mentions = extractMentions(json); - onSendRef.current(text, mentions, readyAttachments); - editor.commands.clearContent(); - editor.commands.focus(); - setPendingAttachments([]); - }, [isStreaming, pendingAttachments]); - - const handleSubmitRef = useRef(handleSubmit); - handleSubmitRef.current = handleSubmit; + const handleSubmitRef = useRef<() => void>(() => {}); const editor = useEditor({ extensions: [ @@ -276,6 +261,22 @@ const ChatInput = forwardRef(function ChatInput({ }, }); + const handleSubmit = useCallback(() => { + if (!editor || editor.isDestroyed || isStreaming) return; + const json = editor.getJSON(); + const text = editorJsonToText(json).trim(); + const readyAttachments = pendingAttachments.filter((a) => !a.uploading); + if (!text && readyAttachments.length === 0) return; + + const mentions = extractMentions(json); + onSendRef.current(text, mentions, readyAttachments); + editor.commands.clearContent(); + editor.commands.focus(); + setPendingAttachments([]); + }, [editor, isStreaming, pendingAttachments]); + + handleSubmitRef.current = handleSubmit; + useEffect(() => { if (editor && !editor.isDestroyed && autofocus) { editor.commands.focus(); From 2dafbf0ea7a0a5dfd612081071151238d0088bfd Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:08:50 +0100 Subject: [PATCH 07/18] fix(deps): update packages with security advisories (#2567) - server: @nestjs/* 11.2.7, @modelcontextprotocol/sdk 1.32.1, @fastify/multipart 10.1.2, pino-pretty 13.2.0, socket.io 4.8.4 - client: axios 1.20.0, katex 0.19.0, i18next-http-backend 4.0.2 - root: nx 23.3.0, concurrently 10.0.6, dompurify 3.4.16 - overrides: fastify 5.12.5, fast-uri 3.1.8, ip-address 10.7.3, brace-expansion@^5 5.0.12, smol-toml 1.9.0, katex 0.19.0 - re-resolve handlebars, proxy-addr, engine.io, @fastify/busboy, source-map-js, fast-copy, brace-expansion@1 to patched versions --- apps/client/package.json | 6 +- apps/server/package.json | 18 +- package.json | 8 +- pnpm-lock.yaml | 1613 +++++++++++++++++--------------------- pnpm-workspace.yaml | 15 +- 5 files changed, 727 insertions(+), 933 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index dd7dba95..80836fa8 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -37,18 +37,18 @@ "@tanstack/react-table": "8.21.3", "@tanstack/react-virtual": "3.14.3", "alfaaz": "1.1.0", - "axios": "1.18.1", + "axios": "1.20.0", "blueimp-load-image": "5.16.0", "clsx": "2.1.1", "file-saver": "2.0.5", "highlightjs-sap-abap": "0.3.0", "i18next": "25.10.1", - "i18next-http-backend": "3.0.6", + "i18next-http-backend": "4.0.2", "jotai": "2.20.1", "jotai-optics": "0.4.0", "js-cookie": "3.0.7", "jwt-decode": "4.0.0", - "katex": "0.16.40", + "katex": "0.19.0", "lowlight": "3.3.0", "mantine-form-zod-resolver": "1.3.0", "mermaid": "11.16.1", diff --git a/apps/server/package.json b/apps/server/package.json index 3c19de8a..e8c9c83b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -41,30 +41,30 @@ "@docmost/base-formula": "workspace:*", "@docmost/pdf-inspector": "1.9.6", "@fastify/cookie": "11.1.2", - "@fastify/multipart": "10.1.1", + "@fastify/multipart": "10.1.2", "@fastify/static": "10.1.3", "@jmondi/oauth2-server": "^4.3.7", "@keyv/redis": "5.1.6", "@langchain/core": "1.1.46", "@langchain/textsplitters": "1.0.1", - "@modelcontextprotocol/sdk": "1.30.0", + "@modelcontextprotocol/sdk": "1.32.1", "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs-labs/nestjs-ioredis": "11.0.4", "@nestjs/bullmq": "11.0.5", "@nestjs/cache-manager": "3.1.3", - "@nestjs/common": "11.2.1", + "@nestjs/common": "11.2.7", "@nestjs/config": "4.0.4", - "@nestjs/core": "11.2.1", + "@nestjs/core": "11.2.7", "@nestjs/event-emitter": "3.1.0", "@nestjs/jwt": "11.0.2", "@nestjs/mapped-types": "2.1.1", "@nestjs/passport": "11.0.5", - "@nestjs/platform-fastify": "11.2.1", - "@nestjs/platform-socket.io": "11.2.1", + "@nestjs/platform-fastify": "11.2.7", + "@nestjs/platform-socket.io": "11.2.7", "@nestjs/schedule": "6.1.3", "@nestjs/terminus": "11.1.1", "@nestjs/throttler": "6.5.0", - "@nestjs/websockets": "11.2.1", + "@nestjs/websockets": "11.2.7", "@node-saml/passport-saml": "5.1.0", "@socket.io/redis-adapter": "8.3.0", "@turbopuffer/turbopuffer": "^2.8.0", @@ -106,7 +106,7 @@ "pg-tsquery": "8.4.2", "pgvector": "0.2.1", "pino-http": "11.0.0", - "pino-pretty": "13.1.3", + "pino-pretty": "13.2.0", "postgres": "3.4.8", "postmark": "4.0.7", "react": "19.2.7", @@ -115,7 +115,7 @@ "rxjs": "^7.8.2", "sanitize-filename": "1.6.3", "scimmy": "1.3.5", - "socket.io": "4.8.3", + "socket.io": "4.8.4", "stripe": "^17.7.0", "tlds": "1.261.0", "tmp-promise": "3.0.3", diff --git a/package.json b/package.json index 4333fc27..34adafb5 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "date-fns": "4.1.0", "diff": "8.0.3", "docx": "9.7.1", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "fractional-indexing-jittered": "1.0.0", "highlight.js": "11.11.1", "image-dimensions": "2.5.0", @@ -81,12 +81,12 @@ "yjs": "^13.6.30" }, "devDependencies": { - "@nx/js": "23.1.1", + "@nx/js": "23.3.0", "@types/bytes": "3.1.5", "@types/qrcode": "1.5.6", "@types/turndown": "5.0.6", - "concurrently": "10.0.4", - "nx": "23.1.1", + "concurrently": "10.0.6", + "nx": "23.3.0", "tsx": "^4.21.0" }, "workspaces": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 337fdc35..cc5df57c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,8 +8,9 @@ overrides: prosemirror-changeset: 2.4.0 glob: 13.0.6 ws: 8.21.3 - dompurify: 3.4.13 + dompurify: 3.4.16 mermaid: 11.16.1 + katex: 0.19.0 undici: 7.29.1 tmp: 0.2.7 nanoid@^3: 3.3.18 @@ -17,19 +18,19 @@ overrides: express-rate-limit: 8.2.2 flatted: 3.4.2 find-my-way: 9.7.0 - fastify: 5.12.3 + fastify: 5.12.5 yaml@>=2.0.0 <2.8.3: 2.8.3 - brace-expansion@^5: 5.0.9 - axios: 1.18.1 - ip-address: 10.5.1 - fast-uri: 3.1.7 + brace-expansion@^5: 5.0.12 + axios: 1.20.0 + ip-address: 10.7.3 + fast-uri: 3.1.8 form-data@>=4.0.0 <4.0.6: 4.0.6 nanoid@>=4.0.0 <5.1.16: 5.1.16 esbuild@>=0.27.3 <0.28.1: 0.28.1 '@opentelemetry/core@>=2.0.0 <2.8.0': 2.9.0 js-yaml@>=3.0.0 <3.15.2: 3.15.2 js-yaml@>=4.0.0 <4.3.2: 4.3.2 - smol-toml@<1.7.1: 1.7.1 + smol-toml@<1.9.0: 1.9.0 patchedDependencies: scimmy@1.3.5: 775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673 @@ -67,7 +68,7 @@ importers: version: 4.5.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) '@joplin/turndown': specifier: 4.0.82 - version: 4.0.82(supports-color@7.2.0) + version: 4.0.82(supports-color@10.2.2) '@joplin/turndown-plugin-gfm': specifier: 1.0.64 version: 1.0.64 @@ -177,8 +178,8 @@ importers: specifier: 9.7.1 version: 9.7.1 dompurify: - specifier: 3.4.13 - version: 3.4.13 + specifier: 3.4.16 + version: 3.4.16 fractional-indexing-jittered: specifier: 1.0.0 version: 1.0.0 @@ -220,8 +221,8 @@ importers: version: 13.6.30 devDependencies: '@nx/js': - specifier: 23.1.1 - version: 23.1.1(@babel/traverse@7.29.7(supports-color@7.2.0))(nx@23.1.1)(supports-color@7.2.0) + specifier: 23.3.0 + version: 23.3.0(@babel/traverse@7.29.7(supports-color@10.2.2))(nx@23.3.0)(supports-color@10.2.2) '@types/bytes': specifier: 3.1.5 version: 3.1.5 @@ -232,11 +233,11 @@ importers: specifier: 5.0.6 version: 5.0.6 concurrently: - specifier: 10.0.4 - version: 10.0.4 + specifier: 10.0.6 + version: 10.0.6 nx: - specifier: 23.1.1 - version: 23.1.1 + specifier: 23.3.0 + version: 23.3.0 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -319,8 +320,8 @@ importers: specifier: 1.1.0 version: 1.1.0 axios: - specifier: 1.18.1 - version: 1.18.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + specifier: 1.20.0 + version: 1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) blueimp-load-image: specifier: 5.16.0 version: 5.16.0 @@ -337,8 +338,8 @@ importers: specifier: 25.10.1 version: 25.10.1(typescript@5.9.3) i18next-http-backend: - specifier: 3.0.6 - version: 3.0.6 + specifier: 4.0.2 + version: 4.0.2 jotai: specifier: 2.20.1 version: 2.20.1(@babel/core@7.29.7(supports-color@10.2.2))(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) @@ -352,8 +353,8 @@ importers: specifier: 4.0.0 version: 4.0.0 katex: - specifier: 0.16.40 - version: 0.16.40 + specifier: 0.19.0 + version: 0.19.0 lowlight: specifier: 3.3.0 version: 3.3.0 @@ -527,8 +528,8 @@ importers: specifier: 11.1.2 version: 11.1.2 '@fastify/multipart': - specifier: 10.1.1 - version: 10.1.1 + specifier: 10.1.2 + version: 10.1.2 '@fastify/static': specifier: 10.1.3 version: 10.1.3 @@ -545,59 +546,59 @@ importers: specifier: 1.0.1 version: 1.0.1(@langchain/core@1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3)) '@modelcontextprotocol/sdk': - specifier: 1.30.0 - version: 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.3.6) + specifier: 1.32.1 + version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.3.6) '@nest-lab/throttler-storage-redis': specifier: 1.2.0 - version: 1.2.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(@nestjs/throttler@6.5.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2))(ioredis@5.10.1(supports-color@10.2.2))(reflect-metadata@0.2.2) + version: 1.2.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(@nestjs/throttler@6.5.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2))(ioredis@5.10.1(supports-color@10.2.2))(reflect-metadata@0.2.2) '@nestjs-labs/nestjs-ioredis': specifier: 11.0.4 - version: 11.0.4(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(ioredis@5.10.1(supports-color@10.2.2)) + version: 11.0.4(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(ioredis@5.10.1(supports-color@10.2.2)) '@nestjs/bullmq': specifier: 11.0.5 - version: 11.0.5(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(bullmq@5.79.0(supports-color@10.2.2)) + version: 11.0.5(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(bullmq@5.79.0(supports-color@10.2.2)) '@nestjs/cache-manager': specifier: 3.1.3 - version: 3.1.3(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) + version: 3.1.3(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) '@nestjs/common': - specifier: 11.2.1 - version: 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + specifier: 11.2.7 + version: 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) '@nestjs/config': specifier: 4.0.4 - version: 4.0.4(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(rxjs@7.8.2) + version: 4.0.4(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(rxjs@7.8.2) '@nestjs/core': - specifier: 11.2.1 - version: 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + specifier: 11.2.7 + version: 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter': specifier: 3.1.0 - version: 3.1.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1) + version: 3.1.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7) '@nestjs/jwt': specifier: 11.0.2 - version: 11.0.2(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2)) + version: 11.0.2(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2)) '@nestjs/mapped-types': specifier: 2.1.1 - version: 2.1.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + version: 2.1.1(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) '@nestjs/passport': specifier: 11.0.5 - version: 11.0.5(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(passport@0.7.0) + version: 11.0.5(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(passport@0.7.0) '@nestjs/platform-fastify': - specifier: 11.2.1 - version: 11.2.1(@fastify/static@10.1.3)(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1) + specifier: 11.2.7 + version: 11.2.7(@fastify/static@10.1.3)(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7) '@nestjs/platform-socket.io': - specifier: 11.2.1 - version: 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(rxjs@7.8.2)(supports-color@10.2.2) + specifier: 11.2.7 + version: 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(rxjs@7.8.2)(supports-color@10.2.2) '@nestjs/schedule': specifier: 6.1.3 - version: 6.1.3(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1) + version: 6.1.3(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7) '@nestjs/terminus': specifier: 11.1.1 - version: 11.1.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.1(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/throttler': specifier: 6.5.0 - version: 6.5.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2) + version: 6.5.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2) '@nestjs/websockets': - specifier: 11.2.1 - version: 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(@nestjs/platform-socket.io@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + specifier: 11.2.7 + version: 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(@nestjs/platform-socket.io@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@node-saml/passport-saml': specifier: 5.1.0 version: 5.1.0(supports-color@10.2.2) @@ -687,13 +688,13 @@ importers: version: 5.1.16 nestjs-cls: specifier: 6.2.0 - version: 6.2.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 6.2.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: 3.1.2 - version: 3.1.2(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(kysely@0.28.17)(reflect-metadata@0.2.2) + version: 3.1.2(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(kysely@0.28.17)(reflect-metadata@0.2.2) nestjs-pino: specifier: 4.6.1 - version: 4.6.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2) + version: 4.6.1(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2) nodemailer: specifier: 10.0.10 version: 10.0.10 @@ -722,14 +723,14 @@ importers: specifier: 11.0.0 version: 11.0.0 pino-pretty: - specifier: 13.1.3 - version: 13.1.3 + specifier: 13.2.0 + version: 13.2.0 postgres: specifier: 3.4.8 version: 3.4.8 postmark: specifier: 4.0.7 - version: 4.0.7(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2) + version: 4.0.7(supports-color@10.2.2) react: specifier: 19.2.7 version: 19.2.7 @@ -749,8 +750,8 @@ importers: specifier: 1.3.5 version: 1.3.5(patch_hash=775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673) socket.io: - specifier: 4.8.3 - version: 4.8.3(supports-color@10.2.2) + specifier: 4.8.4 + version: 4.8.4(supports-color@10.2.2) stripe: specifier: ^17.7.0 version: 17.7.0 @@ -762,7 +763,7 @@ importers: version: 3.0.3 typesense: specifier: 3.0.5 - version: 3.0.5(@babel/runtime@7.29.2)(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2) + version: 3.0.5(@babel/runtime@7.29.2)(supports-color@10.2.2) undici: specifier: 7.29.1 version: 7.29.1 @@ -787,7 +788,7 @@ importers: version: 11.0.10(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.1.19 - version: 11.1.19(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1) + version: 11.1.19(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7) '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 @@ -1854,6 +1855,14 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + '@clickhouse/client-common@1.18.2': resolution: {integrity: sha512-J0SG6q9V31ydxonglpj9xhNRsUxCsF71iEZ784yldqMYwsHixj/9xHFDgBDX3DuMiDx/kPDfXnf+pimp08wIBA==} deprecated: 'Deprecated: import from @clickhouse/client or @clickhouse/client-web instead.' @@ -2164,8 +2173,8 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} - '@fastify/busboy@3.1.1': - resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==} + '@fastify/busboy@3.2.2': + resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==} '@fastify/cookie@11.1.2': resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} @@ -2191,8 +2200,11 @@ packages: '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} - '@fastify/multipart@10.1.1': - resolution: {integrity: sha512-jyRHgnFVdchZRKjJRf6kGPEiDp3Bg4MLo4d6/krt8Z4RutLrqL5IYWihx8a4tnv7Tu7JfuHcyC+dU9zPzTxiSg==} + '@fastify/middie@9.3.4': + resolution: {integrity: sha512-dd8phTnVWzx3iKPBKSvtRUT8+Igvy/MP+P5HcY2tjejEyeVbyjkD4HgzKessUObue5RRM5J6PZdxtAqgbn84yw==} + + '@fastify/multipart@10.1.2': + resolution: {integrity: sha512-WHsqW5ffDXCbXXtdBQDryxu58fQvFPVGh4E4MlCO6hwCLNm0lYTCS6GKXUw2P64REDoECUNpMVvtMMstdIyBjQ==} '@fastify/proxy-addr@5.0.0': resolution: {integrity: sha512-37qVVA1qZ5sgH7KpHkkC4z9SK6StIsIcOmpjvMPXNb3vx2GQxhZocogVYbr2PbbeLCQxYIPDok307xEvRZOzGA==} @@ -2656,8 +2668,8 @@ packages: '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} - '@modelcontextprotocol/sdk@1.30.0': - resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -2760,8 +2772,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.2.1': - resolution: {integrity: sha512-SEgtP+M9DqNhQkgJIlJ3oTp3gemo/8owySovzMGmJj2kcfIH1G6QP45AAb8dE4a3IpVicpUvdDAy7Syk7ebjBw==} + '@nestjs/common@11.2.7': + resolution: {integrity: sha512-CGOa0I/5Ac43ydfpjztozOrvgDWYBdY9ktkO0OrRqRo1d8n5cphtrTGYHOwKLMIel0BpSYZoQymJ9Dywv/BzLw==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -2779,8 +2791,8 @@ packages: '@nestjs/common': ^10.0.0 || ^11.0.0 rxjs: ^7.1.0 - '@nestjs/core@11.2.1': - resolution: {integrity: sha512-M5PWFU8NdRTgX9Po49d7TQKg7f5t8GAVUa/Esy4tmaMWcKugTzg3ZzpJfD3LEPMuRHjfs6+8pQYgtuP2uz3rDw==} + '@nestjs/core@11.2.7': + resolution: {integrity: sha512-ynMkVOAEykJMtls1X+MQV3TNY6hrnMkyHowU8XY8R6UqM3tdqT4WNVtemQOmFrqHw3f85lbe765fcpZyjBOcqA==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -2827,8 +2839,8 @@ packages: '@nestjs/common': ^10.0.0 || ^11.0.0 passport: ^0.5.0 || ^0.6.0 || ^0.7.0 - '@nestjs/platform-fastify@11.2.1': - resolution: {integrity: sha512-dX00kr+yAA7Eh8u/LsYxaxdUizmhvaeUT4pbpNTixNDLFBP73XVuatSrgBdmq7HVAsk0mozI/zL/g7R0MSVUrA==} + '@nestjs/platform-fastify@11.2.7': + resolution: {integrity: sha512-MdkxncTAQiuw5F9KJKUI1qgBRRGl+WAC+66NlAR63iFXVwH6OzVMlcTXdAB4luelUscpoEPMhB6djrAJ7Jc+Sw==} peerDependencies: '@fastify/static': ^10.1.2 '@fastify/view': ^10.0.0 || ^11.0.0 || ^12.0.0 @@ -2840,8 +2852,8 @@ packages: '@fastify/view': optional: true - '@nestjs/platform-socket.io@11.2.1': - resolution: {integrity: sha512-BZuk8PpZCN5Dp1nXRuzRIMP9GwOH7YkP10nXQDtKnJfJnMhEfi5ANTjHrk/j0qHB/dhxy6WgFzib7ncSg7wMDw==} + '@nestjs/platform-socket.io@11.2.7': + resolution: {integrity: sha512-lEXZA+fEeQemLaDb3eAwghR0BYHJgb7KZ/SVmw6LQ7DPo9suSu51bjQtr/JSjpvagLqJQREIrM1X8jQvd4uZZQ==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/websockets': ^11.0.0 @@ -2926,8 +2938,8 @@ packages: '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 reflect-metadata: ^0.1.13 || ^0.2.0 - '@nestjs/websockets@11.2.1': - resolution: {integrity: sha512-HWBSJl4kciGJW+PfSgVUKa2tsrM9IqmjsovGQOla44x6VnE/N57y/dJmEJISaoHUJm+bDm4whO5ggXxQHhLwMw==} + '@nestjs/websockets@11.2.7': + resolution: {integrity: sha512-iKZoAL7ZX/s1I0io6ECAGHnf6dxdF8UCCNjYYGLgB4tZd3XUoC/y3PU+dKP2w8Ksy6e/6GJm4qEf3twvVselpg==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -2957,13 +2969,13 @@ packages: resolution: {integrity: sha512-pBm+iFjv9eihcgeJuSUs4c0AuX1QEFdHwP8w1iaWCfDzXdeWZxUBU5HT2bY2S4dvNutcy+A9hYsH7ZLBGtgwDg==} engines: {node: '>= 18'} - '@nx/devkit@23.1.1': - resolution: {integrity: sha512-FmBfS1xUkWYvDYH/ysO7gAqGlaRzugLac8SIC+X/p76WBmhM6tJhfRW/BQ8mxOFZoVLmwhIiR8X0dRPKdasFxw==} + '@nx/devkit@23.3.0': + resolution: {integrity: sha512-xGSV4gCwQsVdt49VRcfxd2L31dAO/9t9Isv2bM5O89vyEzwd/tkW/IQwUzWn3OdhCnHayd37S1tDyJ5DilCv7w==} peerDependencies: nx: '>= 22 <= 24 || ^23.0.0-0' - '@nx/js@23.1.1': - resolution: {integrity: sha512-8YnhKAnSE7lTiiUEoUyO5LBmZiRIqsc/v2qFMAUfbJKr2bAKAB4h+6Lz9ZfbMvRzJH3fcDZVVWVcvU5GXmQhzg==} + '@nx/js@23.3.0': + resolution: {integrity: sha512-J0XWLwFK405uusy4SPDFyHN2e9LpiQmxqItaqaHSQzcXNUs7fWW/pEeoJ6ZQkwn84yWS+hIzT317T98JaoCZhA==} peerDependencies: '@swc/cli': '>=0.6.0 <0.9.0' verdaccio: ^6.0.5 @@ -2973,62 +2985,62 @@ packages: verdaccio: optional: true - '@nx/nx-darwin-arm64@23.1.1': - resolution: {integrity: sha512-Rq/RXLX5uIvJQfb6kuUgEirquT5ARaAgQyNaMZVnOPAL5wuxaDvQag8WX/WUCIgbUKZuGkclJwM7Vlnvtn3bdg==} + '@nx/nx-darwin-arm64@23.3.0': + resolution: {integrity: sha512-g06RId2Tn99ICwDFisZfDZ2vpM2bXvoCZeNtTWgwLm5mpJ4A7+akI7iXCe50CbzgE4waVRQCOAYpD1zPsKoUVQ==} cpu: [arm64] os: [darwin] - '@nx/nx-darwin-x64@23.1.1': - resolution: {integrity: sha512-doWaPLPd6yUas3FhQJqMAScupCsToeTedK4RRWm700VhHoVdBTN4ejIBRBfoiT/SPAwY6EOHb8uFDJhGo7geMg==} + '@nx/nx-darwin-x64@23.3.0': + resolution: {integrity: sha512-6t9P5ZQ3LPuCf+HBDMTGtpKHjVvINL9/blpY4PucKjdC+vnNSL7uTZwng1ePenlNF0WFtLHqxUx/H1XgohRq0w==} cpu: [x64] os: [darwin] - '@nx/nx-freebsd-x64@23.1.1': - resolution: {integrity: sha512-9rDZKBPGuX8mid11RimJ2ENqDYZpPZhrqTlI9q/VnqcPLq0Bw/8AhqCKhBVlfNqJjbi4OsRQYDK7UkqIlcfThg==} + '@nx/nx-freebsd-x64@23.3.0': + resolution: {integrity: sha512-B8Xuh36ZdMJ2SxpFfUITUvCZQTKoGOFHpSactxCx3ymFBUc24Z0B/hQRIYPv43icugcnp71dIwu6IvI66/aqtQ==} cpu: [x64] os: [freebsd] - '@nx/nx-linux-arm-gnueabihf@23.1.1': - resolution: {integrity: sha512-NDR5X2HiD6WU3JEaDJmOLteIGIFqjrjkzoFWrQke2Y1oCRYu+UyFdPeaMVUyAs5OyUx4U+SD+eBQPTCNbWmazA==} + '@nx/nx-linux-arm-gnueabihf@23.3.0': + resolution: {integrity: sha512-piMlCEI0tbHt1+QSgL8J1szlqBSGKV27nax8uvhTxleR2Evl3Kxa80BDIEObrUU3lz+AS4oKrcUHUeyJGPdCsQ==} cpu: [arm] os: [linux] - '@nx/nx-linux-arm64-gnu@23.1.1': - resolution: {integrity: sha512-tWDHJII8+aHweTzHelf5dGM6qGNmHbAPhCc3jrtrM0uE+UD/wt2Dpq7H3086Iyia9M9jGM9sYpuD/6W6WAFAMA==} + '@nx/nx-linux-arm64-gnu@23.3.0': + resolution: {integrity: sha512-SljVcZYwD4qpwseAUsEQmrXxE0vcPdDMEtvKlpkTProofAMeQFNEh4imw458TylOq7YeFVnNQdZcBnTO+1FeIw==} cpu: [arm64] os: [linux] libc: [glibc] - '@nx/nx-linux-arm64-musl@23.1.1': - resolution: {integrity: sha512-t7iVMZ7Cj3LmPcfaYt9KOkojpuC5XRmtZ/0G+NBMA2GuRhCVbzpOgUCob0nQMV2TrTwn5oAWJeDc4xLni+oteg==} + '@nx/nx-linux-arm64-musl@23.3.0': + resolution: {integrity: sha512-UwzWPtcWxkAE8IOZl4M1eiqsa1yci+zlZ7Vv5ogozhm9jD9EHZVsgeWLrftW22OtU2u+ude/fzBeacuqUDOMtg==} cpu: [arm64] os: [linux] libc: [musl] - '@nx/nx-linux-x64-gnu@23.1.1': - resolution: {integrity: sha512-stuCayctOt/4AFvxKYgGTPluUc0HW7DcyTz9yeTMl/zj0+FENEr8RCTjInD0Q5qVGzYphma6SssVSh432w5agw==} + '@nx/nx-linux-x64-gnu@23.3.0': + resolution: {integrity: sha512-PJerI1Ag/wkPh8XMBKO4Cx6Ao01QJsSk/ycFNmM7vzW19Db3OvsTSKqiql/z96zINI69MqLDZH2iwHMZXbJupQ==} cpu: [x64] os: [linux] libc: [glibc] - '@nx/nx-linux-x64-musl@23.1.1': - resolution: {integrity: sha512-cZSUqGV+iHda39W91BNKSysSu6OFfR6M4ViQBcMtbwq3ce19gDr2f23MqGZEQZtaD/eSQ9UNEhx09nhrdYxWDg==} + '@nx/nx-linux-x64-musl@23.3.0': + resolution: {integrity: sha512-CGete35FhqkdlBZM/Oq6Ddu+yQ7UHMRV/ZYCoKeNU4/YIDIWze8VblF7PGKLU4k5JN4bRY1MmDmryHqVIMXMBA==} cpu: [x64] os: [linux] libc: [musl] - '@nx/nx-win32-arm64-msvc@23.1.1': - resolution: {integrity: sha512-iDlYbFHgTYV5lg1ypEt+LAj86o/uyy6vR0ha5pcUs4FqXzQgy14lOeyRod/EZs9Er3DIyJlEi/rpEvaP99/Hag==} + '@nx/nx-win32-arm64-msvc@23.3.0': + resolution: {integrity: sha512-T/ChrK9uzt9/29TQzc8FGkRGDJC130ltM77eKI3Z67OjyD4SKHhsiTu5GameH1BU72jcF4QEKFf74PoI9JzbuA==} cpu: [arm64] os: [win32] - '@nx/nx-win32-x64-msvc@23.1.1': - resolution: {integrity: sha512-UD21AHWJ2PEA+PuANPCt+lj3kYpAzYNq+bm4VCbrt3KZd7zDPas0ns85UIhfrhsNiSmYzjWz2/JDk2S3cA6lGw==} + '@nx/nx-win32-x64-msvc@23.3.0': + resolution: {integrity: sha512-ermx8Qy8qf0ZIEDiGPqIXxQ0OPEL8VVUOyvAsxDec8aft3pCFjX8LrFhlfnAfM6MBFs5Bnc9Xl9D8TMzFBgo9w==} cpu: [x64] os: [win32] - '@nx/workspace@23.1.1': - resolution: {integrity: sha512-woBDOW9bNcp+I2UEKhV62zc+1/gPCKPkTHZgwCdgoXSlXC3N9soGpnLG5QFy8NLodFC1f+5TSQJqN1tBW5rfIA==} + '@nx/workspace@23.3.0': + resolution: {integrity: sha512-tAGzqqjk+qtCeJTp+bBEANR9aGkmzo4WxWUBQCZHE4A7quWlCDRkpcAg8OG/cDMX8Kwa6/yw+NSYeH1JuE0eCg==} '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} @@ -5074,19 +5086,9 @@ packages: ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} @@ -5224,8 +5226,8 @@ packages: avvio@9.1.0: resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} - axios@1.18.1: - resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} babel-jest@30.3.0: resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} @@ -5292,10 +5294,6 @@ packages: resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} engines: {node: 20 || >=22} - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -5346,11 +5344,11 @@ packages: resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} engines: {node: '>=10'} - brace-expansion@1.1.18: - resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} braces@3.0.3: @@ -5592,6 +5590,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -5603,10 +5605,6 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} - commander@8.3.0: - resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} - engines: {node: '>= 12'} - comment-json@4.6.2: resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} engines: {node: '>= 6'} @@ -5617,8 +5615,8 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.6: + resolution: {integrity: sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==} engines: {node: '>=22'} hasBin: true @@ -5734,9 +5732,6 @@ packages: engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} hasBin: true - cross-fetch@4.1.0: - resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6112,8 +6107,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.13: - resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -6182,18 +6177,14 @@ packages: resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==} engines: {node: '>=10.0.0'} - engine.io@6.6.9: - resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} + engine.io@6.6.11: + resolution: {integrity: sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==} engines: {node: '>=10.2.0'} enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} - enquirer@2.3.6: - resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} - engines: {node: '>=8.6'} - entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -6430,8 +6421,8 @@ packages: peerDependencies: typescript: ^5.6.3 - fast-copy@4.0.2: - resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + fast-copy@4.1.2: + resolution: {integrity: sha512-DUHggaYcouhy5I1LacDJ+JDSqGlumVJP2EAi5Nwt1pWPeLpSvIjkrY+x4fsoI1/+GlTyq+DM9zdgWCFSfqNODA==} fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -6461,8 +6452,17 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.7: - resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -6481,8 +6481,8 @@ packages: fastify-plugin@6.0.0: resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} - fastify@5.12.3: - resolution: {integrity: sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==} + fastify@5.12.5: + resolution: {integrity: sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==} fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -6541,10 +6541,6 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flat@5.0.2: - resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} - hasBin: true - flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} @@ -6711,8 +6707,8 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + handlebars@4.7.10: + resolution: {integrity: sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==} engines: {node: '>=0.4.7'} hasBin: true @@ -6819,8 +6815,9 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - i18next-http-backend@3.0.6: - resolution: {integrity: sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==} + i18next-http-backend@4.0.2: + resolution: {integrity: sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==} + engines: {node: '>=18'} i18next@25.10.1: resolution: {integrity: sha512-d7MZx1UDamSmjbaqFg00w+EXUTqIB8x8cmYRGsAzQqXFVyrNFprGqPItANtlF6V1tuBFyZyp+4/q2MFqODWerg==} @@ -6849,6 +6846,10 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + image-blob-reduce@3.0.1: resolution: {integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==} @@ -6901,8 +6902,8 @@ packages: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} - ip-address@10.5.1: - resolution: {integrity: sha512-EXujUp9jyOI/chPgtqk6uy7fDq8AeCB/WlfEuPg9LN0fN9lzKAKfuDYi60SMhHwgUiEhZvVYsbGZN+RUU1INiA==} + ip-address@10.7.3: + resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -7396,12 +7397,8 @@ packages: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} - katex@0.16.40: - resolution: {integrity: sha512-1DJcK/L05k1Y9Gf7wMcyuqFOL6BiY3vY0CFcAM/LPRN04NALxcl6u7lOWNsp3f/bCHWxigzQl6FbR95XJ4R84Q==} - hasBin: true - - katex@0.16.47: - resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + katex@0.19.0: + resolution: {integrity: sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==} hasBin: true keyv@4.5.4: @@ -7955,15 +7952,6 @@ packages: node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true @@ -8000,8 +7988,8 @@ packages: nwsapi@2.2.16: resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==} - nx@23.1.1: - resolution: {integrity: sha512-oDdW2JgVllgfyyN6OqlRzeABw0QrlXdxyl9rtOUMMXQzlkpYA1RTs8jinJCe6QSo7aEn0dZ+Ar7dd09hMudBsg==} + nx@23.3.0: + resolution: {integrity: sha512-R7Kl+hURSGiG9uEjc19Cg3o+vGCDHqCVp6mioiBMEsOSol/LgzP+K8TVF06TnnSC8uzxMeB0ymu+LGMJGXnbbw==} hasBin: true peerDependencies: '@swc-node/register': ^1.11.1 @@ -8327,8 +8315,8 @@ packages: pino-http@11.0.0: resolution: {integrity: sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==} - pino-pretty@13.1.3: - resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + pino-pretty@13.2.0: + resolution: {integrity: sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg==} hasBin: true pino-std-serializers@7.0.0: @@ -8531,8 +8519,8 @@ packages: prosemirror-view@1.42.3: resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} proxy-from-env@2.1.0: @@ -8947,6 +8935,9 @@ packages: secure-json-parse@4.0.0: resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -8964,8 +8955,8 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -9012,8 +9003,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.9.0: - resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + shell-quote@1.12.0: + resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} engines: {node: '>= 0.4'} side-channel-list@1.0.1: @@ -9053,8 +9044,8 @@ packages: resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==} deprecated: Unsupported - smol-toml@1.7.1: - resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} engines: {node: '>= 18'} socket.io-adapter@2.5.4: @@ -9072,11 +9063,18 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} + socket.io@4.8.4: + resolution: {integrity: sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==} + engines: {node: '>=10.2.0'} + sonic-boom@4.0.1: resolution: {integrity: sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + sonic-boom@5.0.2: + resolution: {integrity: sha512-/JEruzo6yhNVhsLblTRchgTQHIt7SsdJP0YyrfWaNKjIOQDfrdZv9gwYraYCAsmKV39/LAdV31FLL5NCDpoL/A==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map-support@0.5.13: @@ -9372,9 +9370,6 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -9794,9 +9789,6 @@ packages: web-vitals@5.3.0: resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==} - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -9842,9 +9834,6 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} @@ -10168,7 +10157,7 @@ snapshots: '@angular-devkit/core@19.2.23(chokidar@4.0.3)': dependencies: ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + ajv-formats: 3.0.1 jsonc-parser: 3.3.1 picomatch: 4.0.4 rxjs: 7.8.1 @@ -10179,7 +10168,7 @@ snapshots: '@angular-devkit/core@19.2.24(chokidar@4.0.3)': dependencies: ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + ajv-formats: 3.0.1 jsonc-parser: 3.3.1 picomatch: 4.0.4 rxjs: 7.8.1 @@ -10755,26 +10744,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/core@7.29.7(supports-color@7.2.0)': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@7.2.0) - '@babel/types': 7.29.7 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@7.2.0) - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -10799,32 +10768,32 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.23.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/helper-create-class-features-plugin@7.23.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.22.5 '@babel/helper-environment-visitor': 7.24.6 '@babel/helper-function-name': 7.24.6 '@babel/helper-member-expression-to-functions': 7.23.0 '@babel/helper-optimise-call-expression': 7.22.5 - '@babel/helper-replace-supers': 7.22.20(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-replace-supers': 7.22.20(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 '@babel/helper-split-export-declaration': 7.24.6 semver: 6.3.1 - '@babel/helper-create-regexp-features-plugin@7.22.15(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/helper-create-regexp-features-plugin@7.22.15(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.22.5 regexpu-core: 5.3.2 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.4.4(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-define-polyfill-provider@0.4.4(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -10850,13 +10819,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': - dependencies: - '@babel/traverse': 7.29.7(supports-color@7.2.0) - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -10866,31 +10828,22 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - '@babel/helper-optimise-call-expression@7.22.5': dependencies: '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-remap-async-to-generator@7.22.20(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/helper-remap-async-to-generator@7.22.20(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.22.5 '@babel/helper-environment-visitor': 7.24.6 '@babel/helper-wrap-function': 7.22.20 - '@babel/helper-replace-supers@7.22.20(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/helper-replace-supers@7.22.20(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-environment-visitor': 7.24.6 '@babel/helper-member-expression-to-functions': 7.23.0 '@babel/helper-optimise-call-expression': 7.22.5 @@ -10932,43 +10885,38 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 - '@babel/plugin-transform-optional-chaining': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.23.7(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-environment-visitor': 7.24.6 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-proposal-decorators@7.23.7(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-decorators': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7(supports-color@10.2.2))': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/plugin-transform-optional-chaining': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.23.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-environment-visitor': 7.24.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-proposal-decorators@7.23.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': @@ -10981,39 +10929,29 @@ snapshots: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-decorators@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-decorators@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-import-assertions@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-syntax-import-assertions@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7(supports-color@10.2.2))': @@ -11021,560 +10959,495 @@ snapshots: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-arrow-functions@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-arrow-functions@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-async-generator-functions@7.23.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-async-generator-functions@7.23.7(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-environment-visitor': 7.24.6 '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-async-to-generator@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-async-to-generator@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.29.7(supports-color@10.2.2)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-block-scoped-functions@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-block-scoping@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-block-scoping@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-class-properties@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-class-properties@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-class-static-block@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-class-static-block@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-classes@7.23.8(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-classes@7.23.8(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.22.5 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-environment-visitor': 7.24.6 '@babel/helper-function-name': 7.24.6 '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-replace-supers': 7.22.20(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-replace-supers': 7.22.20(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-split-export-declaration': 7.24.6 globals: 11.12.0 - '@babel/plugin-transform-computed-properties@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-computed-properties@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/template': 7.29.7 - '@babel/plugin-transform-destructuring@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-destructuring@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-dotall-regex@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-dotall-regex@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-duplicate-keys@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-duplicate-keys@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-dynamic-import@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-dynamic-import@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-exponentiation-operator@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-exponentiation-operator@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-builder-binary-assignment-operator-visitor': 7.22.15 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-export-namespace-from@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-export-namespace-from@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-for-of@7.23.6(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-for-of@7.23.6(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 - '@babel/plugin-transform-function-name@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-function-name@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-function-name': 7.24.6 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-json-strings@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-json-strings@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-literals@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-literals@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-logical-assignment-operators@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-logical-assignment-operators@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-member-expression-literals@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-member-expression-literals@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-modules-amd@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-amd@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-commonjs@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-simple-access': 7.24.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-umd@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-named-capturing-groups-regex@7.22.5(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-named-capturing-groups-regex@7.22.5(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-new-target@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-new-target@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-nullish-coalescing-operator@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-nullish-coalescing-operator@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-numeric-separator@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-numeric-separator@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-object-rest-spread@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-object-rest-spread@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/compat-data': 7.29.7 - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-object-super@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-object-super@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-replace-supers': 7.22.20(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-replace-supers': 7.22.20(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-optional-catch-binding@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-optional-catch-binding@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-optional-chaining@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-optional-chaining@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-parameters@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-parameters@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-private-methods@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-private-methods@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-private-property-in-object@7.23.4(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-private-property-in-object@7.23.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-property-literals@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-property-literals@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-regenerator@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-regenerator@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 regenerator-transform: 0.15.2 - '@babel/plugin-transform-reserved-words@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-reserved-words@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-runtime@7.23.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-runtime@7.23.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - babel-plugin-polyfill-corejs2: 0.4.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - babel-plugin-polyfill-corejs3: 0.8.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - babel-plugin-polyfill-regenerator: 0.5.4(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + babel-plugin-polyfill-corejs2: 0.4.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-corejs3: 0.8.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-regenerator: 0.5.4(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-shorthand-properties@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-shorthand-properties@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-spread@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-spread@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 - '@babel/plugin-transform-sticky-regex@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-sticky-regex@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-template-literals@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-template-literals@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-typeof-symbol@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-typeof-symbol@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-typescript@7.23.6(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-typescript@7.23.6(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-create-class-features-plugin': 7.23.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-unicode-escapes@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-unicode-escapes@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-property-regex@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-unicode-property-regex@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-regex@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-unicode-regex@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-sets-regex@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-unicode-sets-regex@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/preset-env@7.23.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/preset-env@7.23.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/compat-data': 7.29.7 - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.23.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-import-assertions': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-arrow-functions': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-async-generator-functions': 7.23.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-async-to-generator': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-block-scoped-functions': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-block-scoping': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-class-properties': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-class-static-block': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-classes': 7.23.8(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-computed-properties': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-destructuring': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-dotall-regex': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-duplicate-keys': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-dynamic-import': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-exponentiation-operator': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-export-namespace-from': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-for-of': 7.23.6(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-function-name': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-json-strings': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-literals': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-logical-assignment-operators': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-member-expression-literals': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-modules-amd': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-modules-umd': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-named-capturing-groups-regex': 7.22.5(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-new-target': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-nullish-coalescing-operator': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-numeric-separator': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-object-rest-spread': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-object-super': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-optional-catch-binding': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-optional-chaining': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-private-methods': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-private-property-in-object': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-property-literals': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-regenerator': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-reserved-words': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-shorthand-properties': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-spread': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-sticky-regex': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-template-literals': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-typeof-symbol': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-unicode-escapes': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-unicode-property-regex': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-unicode-regex': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-unicode-sets-regex': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@7.2.0)) - babel-plugin-polyfill-corejs2: 0.4.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - babel-plugin-polyfill-corejs3: 0.8.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - babel-plugin-polyfill-regenerator: 0.5.4(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.23.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-assertions': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-arrow-functions': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-async-generator-functions': 7.23.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-async-to-generator': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-block-scoped-functions': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-block-scoping': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-class-properties': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-class-static-block': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-classes': 7.23.8(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-computed-properties': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-destructuring': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-dotall-regex': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-duplicate-keys': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-dynamic-import': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-exponentiation-operator': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-export-namespace-from': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-for-of': 7.23.6(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-function-name': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-json-strings': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-literals': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-logical-assignment-operators': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-member-expression-literals': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-modules-amd': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-modules-umd': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-named-capturing-groups-regex': 7.22.5(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-new-target': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-numeric-separator': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-object-rest-spread': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-object-super': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-optional-catch-binding': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-optional-chaining': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-private-methods': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-private-property-in-object': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-property-literals': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-regenerator': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-reserved-words': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-shorthand-properties': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-spread': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-sticky-regex': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-template-literals': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-typeof-symbol': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-escapes': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-property-regex': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-regex': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-sets-regex': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@10.2.2)) + babel-plugin-polyfill-corejs2: 0.4.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-corejs3: 0.8.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-regenerator: 0.5.4(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) core-js-compat: 3.35.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/types': 7.29.7 esutils: 2.0.3 - '@babel/preset-typescript@7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/preset-typescript@7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-typescript': 7.23.6(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-typescript': 7.23.6(@babel/core@7.29.7(supports-color@10.2.2)) transitivePeerDependencies: - supports-color @@ -11612,18 +11485,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.7(supports-color@7.2.0)': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -11672,6 +11533,18 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + '@clickhouse/client-common@1.18.2': {} '@clickhouse/client@1.18.2': @@ -11942,10 +11815,10 @@ snapshots: '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.7 + ajv-formats: 3.0.1 + fast-uri: 3.1.8 - '@fastify/busboy@3.1.1': {} + '@fastify/busboy@3.2.2': {} '@fastify/cookie@11.1.2': dependencies: @@ -11976,9 +11849,17 @@ snapshots: dependencies: dequal: 2.0.3 - '@fastify/multipart@10.1.1': + '@fastify/middie@9.3.4': dependencies: - '@fastify/busboy': 3.1.1 + '@fastify/error': 4.0.0 + fastify-plugin: 6.0.0 + find-my-way: 9.7.0 + path-to-regexp: 8.4.2 + reusify: 1.1.0 + + '@fastify/multipart@10.1.2': + dependencies: + '@fastify/busboy': 3.2.2 '@fastify/deepmerge': 3.1.0 '@fastify/error': 4.0.0 fastify-plugin: 6.0.0 @@ -12444,11 +12325,11 @@ snapshots: '@joplin/turndown-plugin-gfm@1.0.64': {} - '@joplin/turndown@4.0.82(supports-color@7.2.0)': + '@joplin/turndown@4.0.82(supports-color@10.2.2)': dependencies: '@adobe/css-tools': 4.4.3 html-entities: 1.4.0 - jsdom: 26.1.0(supports-color@7.2.0) + jsdom: 26.1.0(supports-color@10.2.2) transitivePeerDependencies: - bufferutil - canvas @@ -12591,16 +12472,16 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.3.6)': dependencies: '@hono/node-server': 2.0.12(hono@4.13.8) ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + ajv-formats: 3.0.1 content-type: 1.0.5 cors: 2.8.5 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.1 express: 5.2.1(supports-color@10.2.2) express-rate-limit: 8.2.2(express@5.2.1(supports-color@10.2.2)) hono: 4.13.8 @@ -12653,40 +12534,40 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@nest-lab/throttler-storage-redis@1.2.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(@nestjs/throttler@6.5.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2))(ioredis@5.10.1(supports-color@10.2.2))(reflect-metadata@0.2.2)': + '@nest-lab/throttler-storage-redis@1.2.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(@nestjs/throttler@6.5.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2))(ioredis@5.10.1(supports-color@10.2.2))(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/throttler': 6.5.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2) ioredis: 5.10.1(supports-color@10.2.2) reflect-metadata: 0.2.2 tslib: 2.8.1 - '@nestjs-labs/nestjs-ioredis@11.0.4(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(ioredis@5.10.1(supports-color@10.2.2))': + '@nestjs-labs/nestjs-ioredis@11.0.4(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(ioredis@5.10.1(supports-color@10.2.2))': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) ioredis: 5.10.1(supports-color@10.2.2) tslib: 2.8.1 - '@nestjs/bull-shared@11.0.5(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)': + '@nestjs/bull-shared@11.0.5(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.5(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(bullmq@5.79.0(supports-color@10.2.2))': + '@nestjs/bullmq@11.0.5(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(bullmq@5.79.0(supports-color@10.2.2))': dependencies: - '@nestjs/bull-shared': 11.0.5(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1) - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/bull-shared': 11.0.5(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.79.0(supports-color@10.2.2) tslib: 2.8.1 - '@nestjs/cache-manager@3.1.3(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': + '@nestjs/cache-manager@3.1.3(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) cache-manager: 7.2.8 keyv: 5.6.0 rxjs: 7.8.2 @@ -12719,7 +12600,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2)': + '@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2)': dependencies: file-type: 21.3.4(supports-color@10.2.2) iterare: 1.2.1 @@ -12734,17 +12615,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/config@4.0.4(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(rxjs@7.8.2)': + '@nestjs/config@4.0.4(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) dotenv: 17.4.1 dotenv-expand: 12.0.3 lodash: 4.18.1 rxjs: 7.8.2 - '@nestjs/core@11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) fast-safe-stringify: 2.1.1 iterare: 1.2.1 path-to-regexp: 8.4.2 @@ -12753,54 +12634,53 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/websockets': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(@nestjs/platform-socket.io@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(@nestjs/platform-socket.io@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/event-emitter@3.1.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)': + '@nestjs/event-emitter@3.1.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) eventemitter2: 6.4.9 - '@nestjs/jwt@11.0.2(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))': + '@nestjs/jwt@11.0.2(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) '@types/jsonwebtoken': 9.0.10 jsonwebtoken: 9.0.3 - '@nestjs/mapped-types@2.1.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.15.1 - '@nestjs/passport@11.0.5(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(passport@0.7.0)': + '@nestjs/passport@11.0.5(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(passport@0.7.0)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) passport: 0.7.0 - '@nestjs/platform-fastify@11.2.1(@fastify/static@10.1.3)(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)': + '@nestjs/platform-fastify@11.2.7(@fastify/static@10.1.3)(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)': dependencies: '@fastify/cors': 11.3.0 '@fastify/formbody': 8.0.2 - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@fastify/middie': 9.3.4 + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) fast-querystring: 1.1.2 - fastify: 5.12.3 - fastify-plugin: 6.0.0 + fastify: 5.12.5 find-my-way: 9.7.0 light-my-request: 6.6.0 path-to-regexp: 8.4.2 - reusify: 1.1.0 tslib: 2.8.1 optionalDependencies: '@fastify/static': 10.1.3 - '@nestjs/platform-socket.io@11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(rxjs@7.8.2)(supports-color@10.2.2)': + '@nestjs/platform-socket.io@11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(rxjs@7.8.2)(supports-color@10.2.2)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/websockets': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(@nestjs/platform-socket.io@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/websockets': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(@nestjs/platform-socket.io@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) rxjs: 7.8.2 socket.io: 4.8.3(supports-color@10.2.2) tslib: 2.8.1 @@ -12809,10 +12689,10 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schedule@6.1.3(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)': + '@nestjs/schedule@6.1.3(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.4.0 '@nestjs/schematics@11.0.10(chokidar@4.0.3)(typescript@5.9.3)': @@ -12826,38 +12706,38 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/terminus@11.1.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/terminus@11.1.1(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) boxen: 5.1.2 check-disk-space: 3.4.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 - '@nestjs/testing@11.1.19(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)': + '@nestjs/testing@11.1.19(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/throttler@6.5.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2)': + '@nestjs/throttler@6.5.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 - '@nestjs/websockets@11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(@nestjs/platform-socket.io@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/websockets@11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(@nestjs/platform-socket.io@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-socket.io': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/platform-socket.io': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(rxjs@7.8.2)(supports-color@10.2.2) '@noble/hashes@1.8.0': {} @@ -12893,34 +12773,33 @@ snapshots: transitivePeerDependencies: - supports-color - '@nx/devkit@23.1.1(nx@23.1.1)': + '@nx/devkit@23.3.0(nx@23.3.0)': dependencies: ejs: 5.0.1 - enquirer: 2.3.6 minimatch: 10.2.5 - nx: 23.1.1 + nx: 23.3.0 semver: 7.7.4 tslib: 2.8.1 yargs-parser: 21.1.1 - '@nx/js@23.1.1(@babel/traverse@7.29.7(supports-color@7.2.0))(nx@23.1.1)(supports-color@7.2.0)': + '@nx/js@23.3.0(@babel/traverse@7.29.7(supports-color@10.2.2))(nx@23.3.0)(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/plugin-proposal-decorators': 7.23.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-class-properties': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-class-static-block': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-private-methods': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-private-property-in-object': 7.23.4(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-runtime': 7.23.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/preset-env': 7.23.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/preset-typescript': 7.23.3(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-proposal-decorators': 7.23.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-class-properties': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-class-static-block': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-private-methods': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-private-property-in-object': 7.23.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-runtime': 7.23.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-env': 7.23.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-typescript': 7.23.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/runtime': 7.29.2 - '@nx/devkit': 23.1.1(nx@23.1.1) - '@nx/workspace': 23.1.1 + '@nx/devkit': 23.3.0(nx@23.3.0) + '@nx/workspace': 23.3.0 '@zkochan/js-yaml': 0.0.7 - babel-plugin-const-enum: 1.2.0(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + babel-plugin-const-enum: 1.2.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) babel-plugin-macros: 3.1.0 - babel-plugin-transform-typescript-metadata: 0.3.2(@babel/core@7.29.7(supports-color@7.2.0))(@babel/traverse@7.29.7(supports-color@7.2.0)) + babel-plugin-transform-typescript-metadata: 0.3.2(@babel/core@7.29.7(supports-color@10.2.2))(@babel/traverse@7.29.7(supports-color@10.2.2)) chalk: 4.1.2 columnify: 1.6.0 detect-port: 2.1.0 @@ -12941,43 +12820,42 @@ snapshots: - nx - supports-color - '@nx/nx-darwin-arm64@23.1.1': + '@nx/nx-darwin-arm64@23.3.0': optional: true - '@nx/nx-darwin-x64@23.1.1': + '@nx/nx-darwin-x64@23.3.0': optional: true - '@nx/nx-freebsd-x64@23.1.1': + '@nx/nx-freebsd-x64@23.3.0': optional: true - '@nx/nx-linux-arm-gnueabihf@23.1.1': + '@nx/nx-linux-arm-gnueabihf@23.3.0': optional: true - '@nx/nx-linux-arm64-gnu@23.1.1': + '@nx/nx-linux-arm64-gnu@23.3.0': optional: true - '@nx/nx-linux-arm64-musl@23.1.1': + '@nx/nx-linux-arm64-musl@23.3.0': optional: true - '@nx/nx-linux-x64-gnu@23.1.1': + '@nx/nx-linux-x64-gnu@23.3.0': optional: true - '@nx/nx-linux-x64-musl@23.1.1': + '@nx/nx-linux-x64-musl@23.3.0': optional: true - '@nx/nx-win32-arm64-msvc@23.1.1': + '@nx/nx-win32-arm64-msvc@23.3.0': optional: true - '@nx/nx-win32-x64-msvc@23.1.1': + '@nx/nx-win32-x64-msvc@23.3.0': optional: true - '@nx/workspace@23.1.1': + '@nx/workspace@23.3.0': dependencies: - '@nx/devkit': 23.1.1(nx@23.1.1) + '@nx/devkit': 23.3.0(nx@23.3.0) '@zkochan/js-yaml': 0.0.7 chalk: 4.1.2 - enquirer: 2.3.6 - nx: 23.1.1 + nx: 23.3.0 picomatch: 4.0.4 semver: 7.7.4 tslib: 2.8.1 @@ -15093,12 +14971,6 @@ snapshots: transitivePeerDependencies: - supports-color - agent-base@6.0.2(supports-color@7.2.0): - dependencies: - debug: 4.4.3(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - agent-base@7.1.4: {} ai-sdk-ollama@3.8.1(ai@6.0.285(zod@4.3.6))(zod@4.3.6): @@ -15119,12 +14991,12 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 4.3.6 - ajv-formats@2.1.1(ajv@8.18.0): - optionalDependencies: + ajv-formats@2.1.1: + dependencies: ajv: 8.18.0 - ajv-formats@3.0.1(ajv@8.18.0): - optionalDependencies: + ajv-formats@3.0.1: + dependencies: ajv: 8.18.0 ajv-keywords@3.5.2(ajv@6.14.0): @@ -15146,7 +15018,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.7 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -15282,7 +15154,7 @@ snapshots: '@fastify/error': 4.0.0 fastq: 1.17.1 - axios@1.18.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): + axios@1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2)) form-data: 4.0.6 @@ -15292,26 +15164,6 @@ snapshots: - debug - supports-color - axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2): - dependencies: - follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) - form-data: 4.0.6 - https-proxy-agent: 5.0.1(supports-color@10.2.2) - proxy-from-env: 2.1.0 - transitivePeerDependencies: - - debug - - supports-color - - axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): - dependencies: - follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) - form-data: 4.0.6 - https-proxy-agent: 5.0.1(supports-color@7.2.0) - proxy-from-env: 2.1.0 - transitivePeerDependencies: - - debug - - supports-color - babel-jest@30.3.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -15325,12 +15177,12 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-const-enum@1.2.0(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + babel-plugin-const-enum@1.2.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -15354,36 +15206,36 @@ snapshots: cosmiconfig: 7.1.0 resolve: 1.22.8 - babel-plugin-polyfill-corejs2@0.4.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + babel-plugin-polyfill-corejs2@0.4.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@babel/compat-data': 7.29.7 - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-define-polyfill-provider': 0.4.4(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.4.4(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) semver: 6.3.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.8.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + babel-plugin-polyfill-corejs3@0.8.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-define-polyfill-provider': 0.4.4(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.4.4(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) core-js-compat: 3.35.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.5.4(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + babel-plugin-polyfill-regenerator@0.5.4(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-define-polyfill-provider': 0.4.4(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.4.4(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - babel-plugin-transform-typescript-metadata@0.3.2(@babel/core@7.29.7(supports-color@7.2.0))(@babel/traverse@7.29.7(supports-color@7.2.0)): + babel-plugin-transform-typescript-metadata@0.3.2(@babel/core@7.29.7(supports-color@10.2.2))(@babel/traverse@7.29.7(supports-color@10.2.2)): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 optionalDependencies: - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@10.2.2) babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7(supports-color@10.2.2)): dependencies: @@ -15414,8 +15266,6 @@ snapshots: balanced-match@4.0.3: {} - balanced-match@4.0.4: {} - base64-js@1.5.1: {} base64id@2.0.0: {} @@ -15472,14 +15322,14 @@ snapshots: widest-line: 3.1.0 wrap-ansi: 7.0.0 - brace-expansion@1.1.18: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: - balanced-match: 4.0.4 + balanced-match: 4.0.3 braces@3.0.3: dependencies: @@ -15726,14 +15576,14 @@ snapshots: commander@13.1.0: {} + commander@15.0.0: {} + commander@2.20.3: {} commander@4.1.1: {} commander@7.2.0: {} - commander@8.3.0: {} - comment-json@4.6.2: dependencies: array-timsort: 1.0.3 @@ -15743,11 +15593,11 @@ snapshots: concat-map@0.0.1: {} - concurrently@10.0.4: + concurrently@10.0.6: dependencies: chalk: 5.6.2 rxjs: 7.8.2 - shell-quote: 1.9.0 + shell-quote: 1.12.0 supports-color: 10.2.2 tree-kill: 1.2.2 yargs: 18.0.0 @@ -15755,7 +15605,7 @@ snapshots: conf@15.1.0: dependencies: ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + ajv-formats: 3.0.1 atomically: 2.1.1 debounce-fn: 6.0.0 dot-prop: 10.1.0 @@ -15855,12 +15705,6 @@ snapshots: dependencies: cross-spawn: 7.0.6 - cross-fetch@4.1.0: - dependencies: - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -15880,7 +15724,7 @@ snapshots: css-tree@3.2.1: dependencies: mdn-data: 2.27.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-what@6.1.0: {} @@ -16128,12 +15972,6 @@ snapshots: optionalDependencies: supports-color: 10.2.2 - debug@4.4.3(supports-color@7.2.0): - dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 - decamelize@1.2.0: {} decimal.js@10.6.0: {} @@ -16244,7 +16082,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.13: + dompurify@3.4.16: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -16317,13 +16155,12 @@ snapshots: engine.io-parser@5.2.2: {} - engine.io@6.6.9(supports-color@10.2.2): + engine.io@6.6.11(supports-color@10.2.2): dependencies: '@types/cors': 2.8.17 '@types/node': 25.5.0 '@types/ws': 8.18.1 accepts: 1.3.8 - base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.5 debug: 4.4.3(supports-color@10.2.2) @@ -16339,10 +16176,6 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 - enquirer@2.3.6: - dependencies: - ansi-colors: 4.1.3 - entities@4.5.0: {} entities@6.0.1: {} @@ -16675,7 +16508,7 @@ snapshots: express-rate-limit@8.2.2(express@5.2.1(supports-color@10.2.2)): dependencies: express: 5.2.1(supports-color@10.2.2) - ip-address: 10.5.1 + ip-address: 10.7.3 express@5.2.1(supports-color@10.2.2): dependencies: @@ -16698,7 +16531,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.16.0 range-parser: 1.2.1 router: 2.2.0(supports-color@10.2.2) @@ -16715,7 +16548,7 @@ snapshots: porter2: 1.1.0 typescript: 5.9.3 - fast-copy@4.0.2: {} + fast-copy@4.1.2: {} fast-decode-uri-component@1.0.1: {} @@ -16729,8 +16562,8 @@ snapshots: dependencies: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.7 + ajv-formats: 3.0.1 + fast-uri: 3.1.8 json-schema-ref-resolver: 2.0.1 rfdc: 1.3.1 @@ -16738,8 +16571,8 @@ snapshots: dependencies: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.7 + ajv-formats: 3.0.1 + fast-uri: 3.1.8 json-schema-ref-resolver: 3.0.0 rfdc: 1.3.1 @@ -16751,7 +16584,17 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.7: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.8: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 fast-xml-builder@1.2.0: dependencies: @@ -16773,7 +16616,7 @@ snapshots: fastify-plugin@6.0.0: {} - fastify@5.12.3: + fastify@5.12.5: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.0.0 @@ -16860,18 +16703,12 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 - flat@5.0.2: {} - flatted@3.4.2: {} follow-redirects@1.16.0(debug@4.4.3(supports-color@10.2.2)): optionalDependencies: debug: 4.4.3(supports-color@10.2.2) - follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)): - optionalDependencies: - debug: 4.4.3(supports-color@7.2.0) - for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -17026,7 +16863,7 @@ snapshots: hachure-fill@0.5.2: {} - handlebars@4.7.9: + handlebars@4.7.10: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -17143,13 +16980,6 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy-agent@7.0.2(supports-color@7.2.0): - dependencies: - agent-base: 7.1.4 - debug: 4.4.3(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - https-proxy-agent@5.0.1(supports-color@10.2.2): dependencies: agent-base: 6.0.2(supports-color@10.2.2) @@ -17157,13 +16987,6 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@5.0.1(supports-color@7.2.0): - dependencies: - agent-base: 6.0.2(supports-color@7.2.0) - debug: 4.4.3(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 @@ -17171,20 +16994,9 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6(supports-color@7.2.0): - dependencies: - agent-base: 7.1.4 - debug: 4.4.3(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - human-signals@2.1.0: {} - i18next-http-backend@3.0.6: - dependencies: - cross-fetch: 4.1.0 - transitivePeerDependencies: - - encoding + i18next-http-backend@4.0.2: {} i18next@25.10.1(typescript@5.9.3): dependencies: @@ -17206,6 +17018,8 @@ snapshots: ignore@7.0.5: {} + ignore@7.0.6: {} + image-blob-reduce@3.0.1: dependencies: pica: 7.1.1 @@ -17260,7 +17074,7 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.5.1: {} + ip-address@10.7.3: {} ipaddr.js@1.9.1: {} @@ -17838,14 +17652,14 @@ snapshots: - supports-color - utf-8-validate - jsdom@26.1.0(supports-color@7.2.0): + jsdom@26.1.0(supports-color@10.2.2): dependencies: cssstyle: 4.2.1 data-urls: 5.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2(supports-color@7.2.0) - https-proxy-agent: 7.0.6(supports-color@7.2.0) + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.16 parse5: 7.3.0 @@ -17943,13 +17757,9 @@ snapshots: jwt-decode@4.0.0: {} - katex@0.16.40: + katex@0.19.0: dependencies: - commander: 8.3.0 - - katex@0.16.47: - dependencies: - commander: 8.3.0 + commander: 15.0.0 keyv@4.5.4: dependencies: @@ -18256,9 +18066,9 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.21 - dompurify: 3.4.13 + dompurify: 3.4.16 es-toolkit: 1.46.1 - katex: 0.16.47 + katex: 0.19.0 khroma: 2.1.0 marked: 16.4.2 roughjs: 4.6.6 @@ -18299,11 +18109,11 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.18 + brace-expansion: 1.1.21 minimist@1.2.8: {} @@ -18365,23 +18175,23 @@ snapshots: neo-async@2.6.2: {} - nestjs-cls@6.2.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2): + nestjs-cls@6.2.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - nestjs-kysely@3.1.2(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.1)(kysely@0.28.17)(reflect-metadata@0.2.2): + nestjs-kysely@3.1.2(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(kysely@0.28.17)(reflect-metadata@0.2.2): dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) - '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) kysely: 0.28.17 reflect-metadata: 0.2.2 - nestjs-pino@4.6.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2): + nestjs-pino@4.6.1(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) + '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) pino: 10.1.0 pino-http: 11.0.0 rxjs: 7.8.2 @@ -18394,10 +18204,6 @@ snapshots: dependencies: lodash: 4.18.1 - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 @@ -18425,8 +18231,10 @@ snapshots: nwsapi@2.2.16: {} - nx@23.1.1: + nx@23.3.0: dependencies: + '@clack/core': 1.4.3 + '@clack/prompts': 1.7.0 '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.4.5 '@emnapi/wasi-threads': 1.0.4 @@ -18435,20 +18243,15 @@ snapshots: '@tybys/wasm-util': 0.9.0 '@yarnpkg/lockfile': 1.1.0 '@zkochan/js-yaml': 0.0.7 - agent-base: 6.0.2(supports-color@7.2.0) - ansi-colors: 4.1.3 ansi-regex: 5.0.1 ansi-styles: 4.3.0 argparse: 2.0.1 - asynckit: 0.4.0 - axios: 1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) balanced-match: 4.0.3 base64-js: 1.5.1 bl: 4.1.0 - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 buffer: 5.7.1 bundle-name: 4.1.0 - call-bind-apply-helpers: 1.0.2 chalk: 4.1.2 cli-cursor: 3.1.0 cli-spinners: 2.6.1 @@ -18456,43 +18259,26 @@ snapshots: clone: 1.0.4 color-convert: 2.0.1 color-name: 1.1.4 - combined-stream: 1.0.8 - debug: 4.4.3(supports-color@7.2.0) default-browser: 5.2.1 default-browser-id: 5.0.0 defaults: 1.0.4 define-lazy-prop: 3.0.0 - delayed-stream: 1.0.0 dotenv: 16.4.7 dotenv-expand: 12.0.3 - dunder-proto: 1.0.1 ejs: 5.0.1 emoji-regex: 8.0.0 end-of-stream: 1.4.5 - enquirer: 2.3.6 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 escalade: 3.2.0 escape-string-regexp: 1.0.5 + fast-string-truncated-width: 3.0.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 figures: 3.2.0 - flat: 5.0.2 - follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) - form-data: 4.0.6 fs-constants: 1.0.0 - function-bind: 1.1.2 get-caller-file: 2.0.5 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 has-flag: 4.0.0 - has-symbols: 1.1.0 - has-tostringtag: 1.0.2 - hasown: 2.0.4 - https-proxy-agent: 5.0.1(supports-color@7.2.0) ieee754: 1.2.1 - ignore: 7.0.5 + ignore: 7.0.6 inherits: 2.0.4 is-docker: 3.0.0 is-fullwidth-code-point: 3.0.0 @@ -18505,13 +18291,9 @@ snapshots: jsonc-parser: 3.3.1 lines-and-columns: 2.0.3 log-symbols: 4.1.0 - math-intrinsics: 1.1.0 - mime-db: 1.52.0 - mime-types: 2.1.35 mimic-fn: 2.1.0 minimatch: 10.2.5 minimist: 1.2.8 - ms: 2.1.3 npm-run-path: 4.0.1 once: 1.4.0 onetime: 5.1.2 @@ -18519,16 +18301,16 @@ snapshots: ora: 5.4.1 path-key: 3.1.1 picocolors: 1.1.1 - proxy-from-env: 2.1.0 readable-stream: 3.6.2 require-directory: 2.1.1 resolve.exports: 2.0.3 restore-cursor: 3.1.0 run-applescript: 7.0.0 safe-buffer: 5.2.1 - semver: 7.8.4 + semver: 7.8.5 signal-exit: 3.0.7 - smol-toml: 1.7.1 + sisteransi: 1.0.5 + smol-toml: 1.9.0 string-width: 4.2.3 string_decoder: 1.3.0 strip-ansi: 6.0.1 @@ -18538,6 +18320,7 @@ snapshots: tmp: 0.2.7 tsconfig-paths: 4.2.0 tslib: 2.8.1 + undici: 7.29.1 util-deprecate: 1.0.2 wcwidth: 1.0.1 which: 3.0.1 @@ -18548,16 +18331,16 @@ snapshots: yargs: 17.7.2 yargs-parser: 21.1.1 optionalDependencies: - '@nx/nx-darwin-arm64': 23.1.1 - '@nx/nx-darwin-x64': 23.1.1 - '@nx/nx-freebsd-x64': 23.1.1 - '@nx/nx-linux-arm-gnueabihf': 23.1.1 - '@nx/nx-linux-arm64-gnu': 23.1.1 - '@nx/nx-linux-arm64-musl': 23.1.1 - '@nx/nx-linux-x64-gnu': 23.1.1 - '@nx/nx-linux-x64-musl': 23.1.1 - '@nx/nx-win32-arm64-msvc': 23.1.1 - '@nx/nx-win32-x64-msvc': 23.1.1 + '@nx/nx-darwin-arm64': 23.3.0 + '@nx/nx-darwin-x64': 23.3.0 + '@nx/nx-freebsd-x64': 23.3.0 + '@nx/nx-linux-arm-gnueabihf': 23.3.0 + '@nx/nx-linux-arm64-gnu': 23.3.0 + '@nx/nx-linux-arm64-musl': 23.3.0 + '@nx/nx-linux-x64-gnu': 23.3.0 + '@nx/nx-linux-x64-musl': 23.3.0 + '@nx/nx-win32-arm64-msvc': 23.3.0 + '@nx/nx-win32-x64-msvc': 23.3.0 nypm@0.6.6: dependencies: @@ -18887,11 +18670,11 @@ snapshots: pino-std-serializers: 7.0.0 process-warning: 5.0.0 - pino-pretty@13.1.3: + pino-pretty@13.2.0: dependencies: colorette: 2.0.20 dateformat: 4.6.3 - fast-copy: 4.0.2 + fast-copy: 4.1.2 fast-safe-stringify: 2.1.1 help-me: 5.0.0 joycon: 3.1.1 @@ -18899,8 +18682,8 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 pump: 3.0.3 - secure-json-parse: 4.0.0 - sonic-boom: 4.0.1 + secure-json-parse: 4.1.0 + sonic-boom: 5.0.2 strip-json-comments: 5.0.3 pino-std-serializers@7.0.0: {} @@ -18998,7 +18781,7 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postgres-array@2.0.0: optional: true @@ -19021,15 +18804,15 @@ snapshots: '@posthog/core': 1.35.3 '@posthog/types': 1.390.2 core-js: 3.43.0 - dompurify: 3.4.13 + dompurify: 3.4.16 fflate: 0.4.9 preact: 10.29.2 query-selector-shadow-dom: 1.0.1 web-vitals: 5.3.0 - postmark@4.0.7(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2): + postmark@4.0.7(supports-color@10.2.2): dependencies: - axios: 1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2) + axios: 1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - debug - supports-color @@ -19147,7 +18930,7 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -19299,7 +19082,7 @@ snapshots: prompts: 2.4.2 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - socket.io: 4.8.3(supports-color@10.2.2) + socket.io: 4.8.4(supports-color@10.2.2) tailwindcss: 4.1.18 tsconfig-paths: 4.2.0 transitivePeerDependencies: @@ -19623,7 +19406,7 @@ snapshots: dependencies: chokidar: 3.6.0 immutable: 4.3.9 - source-map-js: 1.2.1 + source-map-js: 1.2.2 sax@1.6.0: {} @@ -19643,13 +19426,15 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 ajv: 8.18.0 - ajv-formats: 2.1.1(ajv@8.18.0) + ajv-formats: 2.1.1 ajv-keywords: 5.1.0(ajv@8.18.0) scimmy@1.3.5(patch_hash=775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673): {} secure-json-parse@4.0.0: {} + secure-json-parse@4.1.0: {} + selderee@0.11.0: dependencies: parseley: 0.12.1 @@ -19660,7 +19445,7 @@ snapshots: semver@7.8.1: {} - semver@7.8.4: {} + semver@7.8.5: {} send@1.2.1(supports-color@10.2.2): dependencies: @@ -19725,7 +19510,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.9.0: {} + shell-quote@1.12.0: {} side-channel-list@1.0.1: dependencies: @@ -19767,7 +19552,7 @@ snapshots: sliced@1.0.1: {} - smol-toml@1.7.1: {} + smol-toml@1.9.0: {} socket.io-adapter@2.5.4(supports-color@10.2.2): dependencies: @@ -19802,7 +19587,20 @@ snapshots: base64id: 2.0.0 cors: 2.8.5 debug: 4.4.3(supports-color@10.2.2) - engine.io: 6.6.9(supports-color@10.2.2) + engine.io: 6.6.11(supports-color@10.2.2) + socket.io-adapter: 2.5.4(supports-color@10.2.2) + socket.io-parser: 4.2.7(supports-color@10.2.2) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io@4.8.4(supports-color@10.2.2): + dependencies: + accepts: 1.3.8 + cors: 2.8.5 + debug: 4.4.3(supports-color@10.2.2) + engine.io: 6.6.11(supports-color@10.2.2) socket.io-adapter: 2.5.4(supports-color@10.2.2) socket.io-parser: 4.2.7(supports-color@10.2.2) transitivePeerDependencies: @@ -19814,7 +19612,11 @@ snapshots: dependencies: atomic-sleep: 1.0.0 - source-map-js@1.2.1: {} + sonic-boom@5.0.2: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.2: {} source-map-support@0.5.13: dependencies: @@ -20120,8 +19922,6 @@ snapshots: dependencies: tldts: 6.1.72 - tr46@0.0.3: {} - tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -20142,7 +19942,7 @@ snapshots: dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 + handlebars: 4.7.10 jest: 30.3.0(@types/node@25.5.0)(babel-plugin-macros@3.1.0)(supports-color@10.2.2)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.0)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 @@ -20293,10 +20093,10 @@ snapshots: typescript@5.9.3: {} - typesense@3.0.5(@babel/runtime@7.29.2)(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2): + typesense@3.0.5(@babel/runtime@7.29.2)(supports-color@10.2.2): dependencies: '@babel/runtime': 7.29.2 - axios: 1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2) + axios: 1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) loglevel: 1.9.2 tslib: 2.8.1 transitivePeerDependencies: @@ -20515,8 +20315,6 @@ snapshots: web-vitals@5.3.0: {} - webidl-conversions@3.0.1: {} - webidl-conversions@7.0.0: {} webpack-node-externals@3.0.0: {} @@ -20572,11 +20370,6 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - when-exit@2.1.5: {} which-boxed-primitive@1.1.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e3b58a88..aa2e0e7e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,8 +7,9 @@ overrides: prosemirror-changeset: 2.4.0 glob: 13.0.6 ws: 8.21.3 - dompurify: 3.4.13 + dompurify: 3.4.16 mermaid: 11.16.1 + katex: 0.19.0 undici: 7.29.1 tmp: 0.2.7 nanoid@^3: 3.3.18 @@ -16,19 +17,19 @@ overrides: express-rate-limit: 8.2.2 flatted: 3.4.2 find-my-way: 9.7.0 - fastify: 5.12.3 + fastify: 5.12.5 yaml@>=2.0.0 <2.8.3: 2.8.3 - brace-expansion@^5: 5.0.9 - axios: 1.18.1 - ip-address: 10.5.1 - fast-uri: 3.1.7 + brace-expansion@^5: 5.0.12 + axios: 1.20.0 + ip-address: 10.7.3 + fast-uri: 3.1.8 form-data@>=4.0.0 <4.0.6: 4.0.6 nanoid@>=4.0.0 <5.1.16: 5.1.16 esbuild@>=0.27.3 <0.28.1: 0.28.1 '@opentelemetry/core@>=2.0.0 <2.8.0': 2.9.0 js-yaml@>=3.0.0 <3.15.2: 3.15.2 js-yaml@>=4.0.0 <4.3.2: 4.3.2 - smol-toml@<1.7.1: 1.7.1 + smol-toml@<1.9.0: 1.9.0 shamefullyHoist: true minimumReleaseAge: 4320 allowBuilds: From c1305f002e47ece4d40f0aebba75d8d0f762d8ea Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:42:27 +0100 Subject: [PATCH 08/18] chore(mcp): migrate to @modelcontextprotocol/server v2 (#2568) --- apps/server/package.json | 2 +- apps/server/src/ee | 2 +- pnpm-lock.yaml | 381 ++------------------------------------- pnpm-workspace.yaml | 2 - 4 files changed, 18 insertions(+), 369 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index e8c9c83b..4b0b5443 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -47,7 +47,7 @@ "@keyv/redis": "5.1.6", "@langchain/core": "1.1.46", "@langchain/textsplitters": "1.0.1", - "@modelcontextprotocol/sdk": "1.32.1", + "@modelcontextprotocol/server": "2.3.1", "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs-labs/nestjs-ioredis": "11.0.4", "@nestjs/bullmq": "11.0.5", diff --git a/apps/server/src/ee b/apps/server/src/ee index e90378d6..35b9514c 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit e90378d63dd5f75e771d2bed05a550fad6e0d5ef +Subproject commit 35b9514c059b4d01e06fde09e6f7aaf3b48f7d87 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc5df57c..7933e4c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,14 +15,12 @@ overrides: tmp: 0.2.7 nanoid@^3: 3.3.18 lodash-es: 4.18.1 - express-rate-limit: 8.2.2 flatted: 3.4.2 find-my-way: 9.7.0 fastify: 5.12.5 yaml@>=2.0.0 <2.8.3: 2.8.3 brace-expansion@^5: 5.0.12 axios: 1.20.0 - ip-address: 10.7.3 fast-uri: 3.1.8 form-data@>=4.0.0 <4.0.6: 4.0.6 nanoid@>=4.0.0 <5.1.16: 5.1.16 @@ -545,9 +543,9 @@ importers: '@langchain/textsplitters': specifier: 1.0.1 version: 1.0.1(@langchain/core@1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3)) - '@modelcontextprotocol/sdk': - specifier: 1.32.1 - version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.3.6) + '@modelcontextprotocol/server': + specifier: 2.3.1 + version: 2.3.1 '@nest-lab/throttler-storage-redis': specifier: 1.2.0 version: 1.2.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(@nestjs/throttler@6.5.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2))(ioredis@5.10.1(supports-color@10.2.2))(reflect-metadata@0.2.2) @@ -2273,12 +2271,6 @@ packages: y-prosemirror: ^1.2.1 yjs: ^13.6.8 - '@hono/node-server@2.0.12': - resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} - engines: {node: '>=20'} - peerDependencies: - hono: ^4 - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -2668,15 +2660,13 @@ packages: '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} - '@modelcontextprotocol/sdk@1.32.1': - resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true + '@modelcontextprotocol/core@2.3.1': + resolution: {integrity: sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.3.1': + resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==} + engines: {node: '>=20'} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} @@ -5036,10 +5026,6 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -5330,10 +5316,6 @@ packages: blueimp-load-image@5.16.0: resolution: {integrity: sha512-3DUSVdOtlfNRk7moRZuTwDmA3NnG8KIJuLcq3c0J7/BIr6X3Vb/EpX3kUH1joxUhmoVF4uCpDfz7wHkz8pQajA==} - body-parser@2.3.0: - resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} - engines: {node: '>=18'} - boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -5627,22 +5609,10 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} - engines: {node: '>=18'} - content-disposition@2.0.1: resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} engines: {node: '>=18'} - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} - engines: {node: '>=18'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -6139,9 +6109,6 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - ejs@5.0.1: resolution: {integrity: sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==} engines: {node: '>=0.12.18'} @@ -6160,10 +6127,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -6363,10 +6326,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} @@ -6377,18 +6336,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.1.1: resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -6405,16 +6356,6 @@ packages: resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - express-rate-limit@8.2.2: - resolution: {integrity: sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - fast-bm25@0.0.5: resolution: {integrity: sha512-6HTiLmPkgeqcPJHccN0pXdqnA7OzhaEQZTFzWnfjIyPoX5sGVKUUpfRc2K2o6zMwK+g009miRhADYn/f2Ax0Mg==} engines: {node: '>=14.0.0'} @@ -6521,10 +6462,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - find-my-way@9.7.0: resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} engines: {node: '>=20'} @@ -6572,10 +6509,6 @@ packages: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - fractional-indexing-jittered@1.0.0: resolution: {integrity: sha512-0tLU0FOedVY7lrvN4LK0DVj6FTuYM0pWDpN97/8UTZE2lx1+OwX8+2uL7IOWc2PmktYTHQjMT6FvZZ3SGCdZdg==} @@ -6583,10 +6516,6 @@ packages: resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} engines: {node: ^14.13.1 || >=16.0.0} - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -6765,10 +6694,6 @@ packages: highlightjs-sap-abap@0.3.0: resolution: {integrity: sha512-nSiUvEOCycjtFA3pHaTowrbAAk5+lciBHyoVkDsd6FTRBtW9sT2dt42o2jAKbXjZVUidtacdk+j0Y2xnd233Mw==} - hono@4.13.8: - resolution: {integrity: sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==} - engines: {node: '>=16.9.0'} - hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -6902,14 +6827,6 @@ packages: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} - ip-address@10.7.3: - resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} - engines: {node: '>= 12'} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - ipaddr.js@2.2.0: resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} engines: {node: '>= 10'} @@ -7009,9 +6926,6 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -7772,18 +7686,10 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -7909,10 +7815,6 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -8053,10 +7955,6 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -8175,10 +8073,6 @@ packages: parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - passport-google-oauth20@2.0.0: resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} engines: {node: '>= 0.4.0'} @@ -8330,10 +8224,6 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -8519,10 +8409,6 @@ packages: prosemirror-view@1.42.3: resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} - proxy-addr@2.0.8: - resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} - engines: {node: '>= 0.10'} - proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -8577,14 +8463,6 @@ packages: raf-schd@4.0.3: resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - react-clear-modal@2.0.18: resolution: {integrity: sha512-Aiv8Bw5NVm19tlUt3RLV2a1I/ya+UlyEZjREosn5G887nnusnefT+ls4AXkuP8XLn1KOah6DrM5MemV7cXgwWg==} peerDependencies: @@ -8850,10 +8728,6 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} @@ -8960,14 +8834,6 @@ packages: engines: {node: '>=10'} hasBin: true - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -9482,14 +9348,6 @@ packages: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - - type-is@2.1.0: - resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} - engines: {node: '>= 18'} - typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -9588,10 +9446,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -10082,11 +9936,6 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} - peerDependencies: - zod: ^3.25 || ^4 - zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -11958,10 +11807,6 @@ snapshots: y-prosemirror: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) yjs: 13.6.30 - '@hono/node-server@2.0.12(hono@4.13.8)': - dependencies: - hono: 4.13.8 - '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -12472,29 +12317,14 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.3.6)': + '@modelcontextprotocol/core@2.3.1': dependencies: - '@hono/node-server': 2.0.12(hono@4.13.8) - ajv: 8.18.0 - ajv-formats: 3.0.1 - content-type: 1.0.5 - cors: 2.8.5 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.1 - express: 5.2.1(supports-color@10.2.2) - express-rate-limit: 8.2.2(express@5.2.1(supports-color@10.2.2)) - hono: 4.13.8 - jose: 6.1.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) - optionalDependencies: - '@cfworker/json-schema': 4.1.1 - transitivePeerDependencies: - - supports-color + + '@modelcontextprotocol/server@2.3.1': + dependencies: + '@modelcontextprotocol/core': 2.3.1 + zod: 4.3.6 '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -14946,11 +14776,6 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.0.0 - acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -15293,20 +15118,6 @@ snapshots: blueimp-load-image@5.16.0: {} - body-parser@2.3.0(supports-color@10.2.2): - dependencies: - bytes: 3.1.2 - content-type: 2.0.0 - debug: 4.4.3(supports-color@10.2.2) - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.16.0 - raw-body: 3.0.2 - type-is: 2.1.0 - transitivePeerDependencies: - - supports-color - boolbase@1.0.0: {} bowser@2.14.1: {} @@ -15616,14 +15427,8 @@ snapshots: confbox@0.1.8: {} - content-disposition@1.0.1: {} - content-disposition@2.0.1: {} - content-type@1.0.5: {} - - content-type@2.0.0: {} - convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -16118,8 +15923,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - ee-first@1.1.1: {} - ejs@5.0.1: {} electron-to-chromium@1.5.420: {} @@ -16130,8 +15933,6 @@ snapshots: emoji-regex@8.0.0: {} - encodeurl@2.0.0: {} - encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -16464,22 +16265,14 @@ snapshots: esutils@2.0.3: {} - etag@1.8.1: {} - eventemitter2@6.4.9: {} eventemitter3@4.0.7: {} events@3.3.0: {} - eventsource-parser@3.0.6: {} - eventsource-parser@3.1.1: {} - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.0.6 - execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -16505,44 +16298,6 @@ snapshots: jest-mock: 30.3.0 jest-util: 30.3.0 - express-rate-limit@8.2.2(express@5.2.1(supports-color@10.2.2)): - dependencies: - express: 5.2.1(supports-color@10.2.2) - ip-address: 10.7.3 - - express@5.2.1(supports-color@10.2.2): - dependencies: - accepts: 2.0.0 - body-parser: 2.3.0(supports-color@10.2.2) - content-disposition: 1.0.1 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@10.2.2) - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1(supports-color@10.2.2) - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.8 - qs: 6.16.0 - range-parser: 1.2.1 - router: 2.2.0(supports-color@10.2.2) - send: 1.2.1(supports-color@10.2.2) - serve-static: 2.2.1(supports-color@10.2.2) - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - fast-bm25@0.0.5(typescript@5.9.3): dependencies: porter2: 1.1.0 @@ -16671,17 +16426,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1(supports-color@10.2.2): - dependencies: - debug: 4.4.3(supports-color@10.2.2) - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - find-my-way@9.7.0: dependencies: fast-deep-equal: 3.1.3 @@ -16744,14 +16488,10 @@ snapshots: dezalgo: 1.0.4 once: 1.4.0 - forwarded@0.2.0: {} - fractional-indexing-jittered@1.0.0: {} fractional-indexing@3.2.0: {} - fresh@2.0.0: {} - fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -16927,8 +16667,6 @@ snapshots: highlightjs-sap-abap@0.3.0: {} - hono@4.13.8: {} - hookified@1.15.1: {} html-encoding-sniffer@4.0.0: @@ -17074,10 +16812,6 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.7.3: {} - - ipaddr.js@1.9.1: {} - ipaddr.js@2.2.0: {} is-array-buffer@3.0.5: @@ -17161,8 +16895,6 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - is-promise@4.0.0: {} - is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -18042,14 +17774,10 @@ snapshots: mdn-data@2.27.1: {} - media-typer@1.1.0: {} - memfs@3.5.3: dependencies: fs-monkey: 1.0.5 - merge-descriptors@2.0.0: {} - merge-stream@2.0.0: {} mermaid@11.16.1: @@ -18171,8 +17899,6 @@ snapshots: negotiator@0.6.3: {} - negotiator@1.0.0: {} - neo-async@2.6.2: {} nestjs-cls@6.2.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2): @@ -18398,10 +18124,6 @@ snapshots: on-exit-leak-free@2.1.2: {} - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - once@1.4.0: dependencies: wrappy: 1.0.2 @@ -18535,8 +18257,6 @@ snapshots: leac: 0.6.0 peberminta: 0.9.0 - parseurl@1.3.3: {} - passport-google-oauth20@2.0.0: dependencies: passport-oauth2: 1.8.0 @@ -18704,8 +18424,6 @@ snapshots: pirates@4.0.7: {} - pkce-challenge@5.0.1: {} - pkg-dir@4.2.0: dependencies: find-up: 4.1.0 @@ -18930,11 +18648,6 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - proxy-addr@2.0.8: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - proxy-from-env@2.1.0: {} psl@1.15.0: @@ -19034,15 +18747,6 @@ snapshots: raf-schd@4.0.3: {} - range-parser@1.2.1: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - react-clear-modal@2.0.18(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 @@ -19341,16 +19045,6 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - router@2.2.0(supports-color@10.2.2): - dependencies: - debug: 4.4.3(supports-color@10.2.2) - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color - rrweb-cssom@0.7.1: {} rrweb-cssom@0.8.0: {} @@ -19447,31 +19141,6 @@ snapshots: semver@7.8.5: {} - send@1.2.1(supports-color@10.2.2): - dependencies: - debug: 4.4.3(supports-color@10.2.2) - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1(supports-color@10.2.2): - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1(supports-color@10.2.2) - transitivePeerDependencies: - - supports-color - set-blocking@2.0.0: {} set-cookie-parser@2.6.0: {} @@ -20035,18 +19704,6 @@ snapshots: dependencies: tagged-tag: 1.0.0 - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - - type-is@2.1.0: - dependencies: - content-type: 2.0.0 - media-typer: 1.1.0 - mime-types: 3.0.2 - typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -20148,8 +19805,6 @@ snapshots: universalify@2.0.1: {} - unpipe@1.0.0: {} - unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -20607,10 +20262,6 @@ snapshots: yoctocolors@2.1.2: {} - zod-to-json-schema@3.25.1(zod@4.3.6): - dependencies: - zod: 4.3.6 - zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aa2e0e7e..2b0fa25a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,14 +14,12 @@ overrides: tmp: 0.2.7 nanoid@^3: 3.3.18 lodash-es: 4.18.1 - express-rate-limit: 8.2.2 flatted: 3.4.2 find-my-way: 9.7.0 fastify: 5.12.5 yaml@>=2.0.0 <2.8.3: 2.8.3 brace-expansion@^5: 5.0.12 axios: 1.20.0 - ip-address: 10.7.3 fast-uri: 3.1.8 form-data@>=4.0.0 <4.0.6: 4.0.6 nanoid@>=4.0.0 <5.1.16: 5.1.16 From 3934cb911f3e574e72b2bb91f3e8c51eb6abbfd3 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:25:07 +0100 Subject: [PATCH 09/18] fix: WebSocket improvements (#2573) --- .../src/ee/oauth/pages/oauth-consent.tsx | 2 + .../src/features/auth/hooks/use-auth.ts | 2 + .../src/features/editor/full-editor.tsx | 10 +- .../src/features/editor/page-editor.tsx | 25 ++ apps/client/src/lib/local-page-data.test.ts | 35 +++ apps/client/src/lib/local-page-data.ts | 25 ++ apps/server/src/app.module.ts | 2 + .../src/collaboration/collab-access.module.ts | 9 + apps/server/src/collaboration/constants.ts | 1 + .../authentication.extension.spec.ts | 221 ++++++++++++++++++ .../extensions/authentication.extension.ts | 185 +++++++++++---- .../extensions/persistence.extension.ts | 11 +- .../services/collab-access.service.spec.ts | 29 +++ .../services/collab-access.service.ts | 32 +++ .../src/common/helpers/with-cache.spec.ts | 29 +++ apps/server/src/common/helpers/with-cache.ts | 13 +- .../src/core/auth/services/auth.service.ts | 5 + .../casl/abilities/space-ability.factory.ts | 4 +- .../core/group/services/group-user.service.ts | 84 +++++-- .../src/core/group/services/group.service.ts | 31 +-- .../page/page-access/page-access.service.ts | 14 +- .../src/core/page/services/page.service.ts | 28 ++- .../src/core/session/session.service.spec.ts | 49 ++++ .../src/core/session/session.service.ts | 12 +- .../space/services/space-member.service.ts | 26 ++- .../workspace/services/workspace.service.ts | 10 + .../database/repos/group/group-user.repo.ts | 7 +- .../repos/page/page-permission.repo.ts | 35 ++- .../repos/session/user-session.repo.ts | 8 +- .../database/repos/space/space-member.repo.ts | 15 +- apps/server/src/ee | 2 +- apps/server/src/ws/base-realtime.bridge.ts | 11 + apps/server/src/ws/ws.gateway.ts | 34 ++- apps/server/src/ws/ws.service.spec.ts | 67 ++++++ apps/server/src/ws/ws.service.ts | 126 ++++++++-- apps/server/src/ws/ws.utils.ts | 4 + 36 files changed, 1062 insertions(+), 141 deletions(-) create mode 100644 apps/client/src/lib/local-page-data.test.ts create mode 100644 apps/client/src/lib/local-page-data.ts create mode 100644 apps/server/src/collaboration/collab-access.module.ts create mode 100644 apps/server/src/collaboration/extensions/authentication.extension.spec.ts create mode 100644 apps/server/src/collaboration/services/collab-access.service.spec.ts create mode 100644 apps/server/src/collaboration/services/collab-access.service.ts create mode 100644 apps/server/src/common/helpers/with-cache.spec.ts create mode 100644 apps/server/src/core/session/session.service.spec.ts create mode 100644 apps/server/src/ws/ws.service.spec.ts diff --git a/apps/client/src/ee/oauth/pages/oauth-consent.tsx b/apps/client/src/ee/oauth/pages/oauth-consent.tsx index a19b6082..b0c7ed74 100644 --- a/apps/client/src/ee/oauth/pages/oauth-consent.tsx +++ b/apps/client/src/ee/oauth/pages/oauth-consent.tsx @@ -36,6 +36,7 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { logout } from "@/features/auth/services/auth-service"; import { ICurrentUser } from "@/features/user/types/user.types"; import APP_ROUTE from "@/lib/app-route.ts"; +import { deleteLocalPageData } from "@/lib/local-page-data.ts"; import { approveOAuthAuthorization, getOAuthAuthorizeInfo, @@ -226,6 +227,7 @@ function ConsentCard({ info, currentUser, params }: ConsentCardProps) { } setCurrentUser(RESET); try { + await deleteLocalPageData(); await logout(); } finally { window.location.replace( diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 970a8589..0f133212 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -27,6 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import { RESET } from "jotai/utils"; import { useTranslation } from "react-i18next"; import { isCloud } from "@/lib/config.ts"; +import { deleteLocalPageData } from "@/lib/local-page-data.ts"; import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts"; export default function useAuth() { @@ -165,6 +166,7 @@ export default function useAuth() { const handleLogout = async () => { setCurrentUser(RESET); + await deleteLocalPageData(); await logout(); window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`); }; diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index b37ab541..d44df08c 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -69,24 +69,24 @@ export function FullEditor({ canComment, }: FullEditorProps) { const [user] = useAtom(userAtom); - const fullPageWidth = user.settings?.preferences?.fullPageWidth; + const fullPageWidth = user?.settings?.preferences?.fullPageWidth; const editorToolbarEnabled = - user.settings?.preferences?.editorToolbar ?? false; + user?.settings?.preferences?.editorToolbar ?? false; const [currentPageEditMode, setCurrentPageEditMode] = useAtom( currentPageEditModeAtom, ); const userPageEditMode = - user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; + user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; const isEditMode = currentPageEditMode === PageEditMode.Edit; // Apply the user's saved preference only once on initial load, not on every // page navigation — so the mode sticks across navigations within a session. useEffect(() => { - if (!defaultEditModeApplied) { + if (user && !defaultEditModeApplied) { setCurrentPageEditMode(userPageEditMode as PageEditMode); defaultEditModeApplied = true; } - }, [userPageEditMode, setCurrentPageEditMode]); + }, [user, userPageEditMode, setCurrentPageEditMode]); return ( { try { const message = JSON.parse(payload); + if (message?.type === "page.permissions") { + // applied locally, a refetch can still read the old access from the server cache + for (const key of [slugId, pageId]) { + queryClient.setQueryData( + ["pages", key], + (page) => + page && { + ...page, + permissions: { ...page.permissions, canEdit: false }, + }, + ); + } + if (message.access === "none") { + queryClient + .getQueryCache() + .find({ queryKey: ["pages", slugId], exact: true }) + ?.setState({ + status: "error", + fetchStatus: "idle", + data: undefined, + error: Object.assign(new Error("Forbidden"), { status: 403 }), + }); + } + return; + } if (message?.type !== "page.updated" || !message.updatedAt) return; const pageData = queryClient.getQueryData(["pages", slugId]); if (pageData) { diff --git a/apps/client/src/lib/local-page-data.test.ts b/apps/client/src/lib/local-page-data.test.ts new file mode 100644 index 00000000..30245dcd --- /dev/null +++ b/apps/client/src/lib/local-page-data.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { deleteLocalPageData } from "./local-page-data"; + +function stubIndexedDB(names: string[]) { + const deleteDatabase = vi.fn(() => { + const request: Record void> = {}; + setTimeout(() => request.onsuccess?.()); + return request; + }); + vi.stubGlobal("indexedDB", { + databases: async () => names.map((name) => ({ name, version: 1 })), + deleteDatabase, + }); + return deleteDatabase; +} + +describe("deleteLocalPageData", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("deletes only the page databases", async () => { + const deleteDatabase = stubIndexedDB(["page.a", "page.b", "other-db"]); + + await deleteLocalPageData(); + + expect(deleteDatabase.mock.calls).toEqual([["page.a"], ["page.b"]]); + }); + + it("does nothing when the browser cannot list databases", async () => { + vi.stubGlobal("indexedDB", { deleteDatabase: vi.fn() }); + + await expect(deleteLocalPageData()).resolves.toBeUndefined(); + }); +}); diff --git a/apps/client/src/lib/local-page-data.ts b/apps/client/src/lib/local-page-data.ts new file mode 100644 index 00000000..c5c4ffce --- /dev/null +++ b/apps/client/src/lib/local-page-data.ts @@ -0,0 +1,25 @@ +// y-indexeddb names each database after the collab document, page. +const PAGE_DATABASE_PREFIX = "page."; + +export async function deleteLocalPageData(): Promise { + try { + const databases = (await indexedDB.databases?.()) ?? []; + const pageDatabaseNames = databases + .map((database) => database.name) + .filter((name) => name?.startsWith(PAGE_DATABASE_PREFIX)); + + await Promise.all( + pageDatabaseNames.map( + (name) => + new Promise((resolve) => { + const request = indexedDB.deleteDatabase(name); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + request.onblocked = () => resolve(); + }), + ), + ); + } catch { + // local cleanup must never block logout + } +} diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 5002ca8a..28ef64e5 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -7,6 +7,7 @@ import { AuditActorInterceptor } from './common/interceptors/audit-actor.interce import { CoreModule } from './core/core.module'; import { EnvironmentModule } from './integrations/environment/environment.module'; import { CollaborationModule } from './collaboration/collaboration.module'; +import { CollabAccessModule } from './collaboration/collab-access.module'; import { WsModule } from './ws/ws.module'; import { DatabaseModule } from '@docmost/db/database.module'; import { StorageModule } from './integrations/storage/storage.module'; @@ -83,6 +84,7 @@ try { inject: [EnvironmentService], }), CollaborationModule, + CollabAccessModule, WsModule, QueueModule, StaticModule, diff --git a/apps/server/src/collaboration/collab-access.module.ts b/apps/server/src/collaboration/collab-access.module.ts new file mode 100644 index 00000000..1f18303c --- /dev/null +++ b/apps/server/src/collaboration/collab-access.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { CollabAccessService } from './services/collab-access.service'; + +@Global() +@Module({ + providers: [CollabAccessService], + exports: [CollabAccessService], +}) +export class CollabAccessModule {} diff --git a/apps/server/src/collaboration/constants.ts b/apps/server/src/collaboration/constants.ts index 8ce8c825..24deb058 100644 --- a/apps/server/src/collaboration/constants.ts +++ b/apps/server/src/collaboration/constants.ts @@ -1,3 +1,4 @@ export const HISTORY_INTERVAL = 5 * 60 * 1000; export const HISTORY_FAST_INTERVAL = 60 * 1000; export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000; +export const COLLAB_ACCESS_RECHECK_CHANNEL = 'collab:access-recheck'; diff --git a/apps/server/src/collaboration/extensions/authentication.extension.spec.ts b/apps/server/src/collaboration/extensions/authentication.extension.spec.ts new file mode 100644 index 00000000..9397c93c --- /dev/null +++ b/apps/server/src/collaboration/extensions/authentication.extension.spec.ts @@ -0,0 +1,221 @@ +import { Forbidden } from '@hocuspocus/common'; +import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { AuthenticationExtension } from './authentication.extension'; +import { COLLAB_ACCESS_RECHECK_CHANNEL } from '../constants'; + +const WRITER = [{ userId: 'any', role: 'writer' }]; +const READER = [{ userId: 'any', role: 'reader' }]; +const NO_ACCESS = JSON.stringify({ type: 'page.permissions', access: 'none' }); +const READ_ACCESS = JSON.stringify({ type: 'page.permissions', access: 'read' }); + +function makeConnection(userId: string) { + return { + context: { user: { id: userId, workspaceId: 'ws_1' } }, + readOnly: false, + close: jest.fn(), + sendStateless: jest.fn(), + }; +} + +async function makeExtension( + spaceRoles: Record, + documents: Record[]> = {}, +) { + const subscriber = { + on: jest.fn(), + subscribe: jest.fn(async () => undefined), + disconnect: jest.fn(), + }; + const spaceMemberRepo = { + getUserSpaceRoles: jest.fn(async (userId: string) => spaceRoles[userId]), + }; + const pagePermissionRepo = { + canUserEditPage: jest.fn(async () => ({ + hasAnyRestriction: false, + canAccess: true, + canEdit: true, + })), + }; + const extension = new AuthenticationExtension( + { verifyJwt: jest.fn(async (token: string) => ({ sub: token, workspaceId: 'ws_1' })) } as any, + { + findById: jest.fn(async (id: string) => { + if (id === 'user_broken') throw new Error('db down'); + return { id, workspaceId: 'ws_1' }; + }), + } as any, + { + findById: jest.fn(async (id: string) => ({ + id, + spaceId: 'space_1', + workspaceId: id === 'page_other_ws' ? 'ws_2' : 'ws_1', + deletedAt: null, + })), + getPageAndDescendants: jest.fn(async (id: string) => [{ id }, { id: 'child_page' }]), + } as any, + spaceMemberRepo as any, + pagePermissionRepo as any, + { getOrThrow: () => ({ duplicate: () => subscriber }) } as any, + ); + const instance = { + documents: new Map( + Object.entries(documents).map(([name, connections]) => [ + name, + { name, getConnections: () => connections }, + ]), + ), + }; + await extension.onConfigure({ instance } as any); + return { extension, subscriber, spaceMemberRepo, pagePermissionRepo }; +} + +describe('AuthenticationExtension access recheck', () => { + it('closes connections of users who lost access and leaves others alone', async () => { + const removed = makeConnection('user_removed'); + const other = makeConnection('user_other'); + const { extension } = await makeExtension( + { user_removed: undefined, user_other: WRITER }, + { 'page.page_1': [removed, other] }, + ); + + await extension.recheckAccess({ userIds: ['user_removed'] }); + + expect(removed.sendStateless).toHaveBeenCalledWith(NO_ACCESS); + expect(removed.readOnly).toBe(true); + expect(removed.close).toHaveBeenCalledWith(Forbidden); + expect(other.sendStateless).not.toHaveBeenCalled(); + expect(other.close).not.toHaveBeenCalled(); + }); + + it('makes downgraded editors read-only without closing them', async () => { + const downgraded = makeConnection('user_reader'); + const { extension } = await makeExtension( + { user_reader: READER }, + { 'page.page_1': [downgraded] }, + ); + + await extension.recheckAccess({ userIds: ['user_reader'] }); + + expect(downgraded.readOnly).toBe(true); + expect(downgraded.sendStateless).toHaveBeenCalledWith(READ_ACCESS); + expect(downgraded.close).not.toHaveBeenCalled(); + }); + + it('rechecks connections on pages under a changed page only', async () => { + const underChangedPage = makeConnection('user_1'); + const elsewhere = makeConnection('user_1'); + const { extension, spaceMemberRepo } = await makeExtension( + { user_1: undefined }, + { 'page.child_page': [underChangedPage], 'page.other_page': [elsewhere] }, + ); + + await extension.recheckAccess({ pageIds: ['page_1'] }); + + expect(underChangedPage.close).toHaveBeenCalledWith(Forbidden); + expect(elsewhere.close).not.toHaveBeenCalled(); + expect(spaceMemberRepo.getUserSpaceRoles).toHaveBeenCalledWith( + 'user_1', + 'space_1', + { skipCache: true }, + ); + }); + + it('rechecks when an access message arrives on the channel', async () => { + const removed = makeConnection('user_removed'); + const { subscriber } = await makeExtension( + { user_removed: undefined }, + { 'page.page_1': [removed] }, + ); + const handler = (name: string) => + subscriber.on.mock.calls.find(([event]) => event === name)[1]; + + expect(subscriber.on).toHaveBeenCalledWith('error', expect.any(Function)); + handler('ready')(); + handler('ready')(); + expect(subscriber.subscribe).toHaveBeenCalledTimes(2); + expect(subscriber.subscribe).toHaveBeenCalledWith( + COLLAB_ACCESS_RECHECK_CHANNEL, + ); + const onMessage = handler('message'); + await onMessage( + COLLAB_ACCESS_RECHECK_CHANNEL, + JSON.stringify({ userIds: ['user_removed'] }), + ); + + expect(removed.close).toHaveBeenCalledWith(Forbidden); + }); + + it('keeps rechecking after one connection fails', async () => { + const broken = makeConnection('user_broken'); + const removed = makeConnection('user_removed'); + const { extension } = await makeExtension( + { user_removed: undefined }, + { 'page.page_1': [broken, removed] }, + ); + + await extension.recheckAccess({ userIds: ['user_broken', 'user_removed'] }); + + expect(broken.close).not.toHaveBeenCalled(); + expect(removed.close).toHaveBeenCalledWith(Forbidden); + }); +}); + +describe('AuthenticationExtension onAuthenticate', () => { + it('checks permissions without the cache', async () => { + const { extension, spaceMemberRepo, pagePermissionRepo } = + await makeExtension({ user_reader: READER }); + + await extension.onAuthenticate({ + documentName: 'page.page_1', + token: 'user_reader', + connectionConfig: { readOnly: false }, + } as any); + + expect(spaceMemberRepo.getUserSpaceRoles).toHaveBeenCalledWith( + 'user_reader', + 'space_1', + { skipCache: true }, + ); + expect(pagePermissionRepo.canUserEditPage).toHaveBeenCalledWith( + 'user_reader', + 'page_1', + { skipCache: true }, + ); + }); + + it('opens readers read-only and rejects users without access', async () => { + const { extension } = await makeExtension({ + user_reader: READER, + user_none: undefined, + }); + const payload = (token: string) => + ({ + documentName: 'page.page_1', + token, + connectionConfig: { readOnly: false }, + }) as any; + + const readerPayload = payload('user_reader'); + await extension.onAuthenticate(readerPayload); + expect(readerPayload.connectionConfig.readOnly).toBe(true); + + await expect(extension.onAuthenticate(payload('user_none'))).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('rejects pages from another workspace', async () => { + const { extension, spaceMemberRepo } = await makeExtension({ + user_writer: WRITER, + }); + + await expect( + extension.onAuthenticate({ + documentName: 'page.page_other_ws', + token: 'user_writer', + connectionConfig: { readOnly: false }, + } as any), + ).rejects.toThrow(NotFoundException); + expect(spaceMemberRepo.getUserSpaceRoles).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts index 1eaed935..8ca5bb6c 100644 --- a/apps/server/src/collaboration/extensions/authentication.extension.ts +++ b/apps/server/src/collaboration/extensions/authentication.extension.ts @@ -1,24 +1,40 @@ -import { Extension, onAuthenticatePayload } from '@hocuspocus/server'; +import { + Connection, + Extension, + Hocuspocus, + onAuthenticatePayload, + onConfigurePayload, +} from '@hocuspocus/server'; +import { Forbidden } from '@hocuspocus/common'; import { Injectable, Logger, NotFoundException, UnauthorizedException, } from '@nestjs/common'; +import { RedisService } from '@nestjs-labs/nestjs-ioredis'; +import type { Redis } from 'ioredis'; import { TokenService } from '../../core/auth/services/token.service'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; +import { Page, User } from '@docmost/db/types/entity.types'; import { SpaceRole } from '../../common/helpers/types/permission'; import { isUserDisabled } from '../../common/helpers'; import { getPageId } from '../collaboration.util'; +import { COLLAB_ACCESS_RECHECK_CHANNEL } from '../constants'; +import { CollabAccessRecheck } from '../services/collab-access.service'; import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload'; +type CollabAccess = 'none' | 'read' | 'write'; + @Injectable() export class AuthenticationExtension implements Extension { private readonly logger = new Logger(AuthenticationExtension.name); + private instance: Hocuspocus; + private subscriber: Redis; constructor( private tokenService: TokenService, @@ -26,8 +42,34 @@ export class AuthenticationExtension implements Extension { private pageRepo: PageRepo, private readonly spaceMemberRepo: SpaceMemberRepo, private readonly pagePermissionRepo: PagePermissionRepo, + private readonly redisService: RedisService, ) {} + async onConfigure({ instance }: onConfigurePayload) { + this.instance = instance; + this.subscriber = this.redisService.getOrThrow().duplicate(); + this.subscriber.on('error', () => {}); + this.subscriber.on('message', async (_channel, message) => { + try { + await this.recheckAccess(JSON.parse(message)); + } catch (err) { + this.logger.error({ err }, 'Failed to recheck collab access'); + } + }); + // resubscribe on every ready, ioredis never replays a SUBSCRIBE that failed while redis was down + this.subscriber.on('ready', () => { + this.subscriber + .subscribe(COLLAB_ACCESS_RECHECK_CHANNEL) + .catch((err) => + this.logger.error({ err }, 'Failed to subscribe to collab rechecks'), + ); + }); + } + + async onDestroy() { + this.subscriber?.disconnect(); + } + async onAuthenticate(data: onAuthenticatePayload) { const { documentName, token } = data; const pageId = getPageId(documentName); @@ -43,61 +85,28 @@ export class AuthenticationExtension implements Extension { const userId = jwtPayload.sub; const workspaceId = jwtPayload.workspaceId; - const user = await this.userRepo.findById(userId, workspaceId); + const [user, page] = await Promise.all([ + this.userRepo.findById(userId, workspaceId), + this.pageRepo.findById(pageId), + ]); - if (!user) { + if (!user || isUserDisabled(user)) { throw new UnauthorizedException(); } - if (isUserDisabled(user)) { - throw new UnauthorizedException(); - } - - const page = await this.pageRepo.findById(pageId); - if (!page) { + if (!page || page.workspaceId !== workspaceId) { this.logger.debug(`Page not found: ${pageId}`); throw new NotFoundException('Page not found'); } - const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles( - user.id, - page.spaceId, - ); + const access = await this.getAccess(user, page); - const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles); - - if (!userSpaceRole) { - this.logger.warn(`User not authorized to access page: ${pageId}`); + if (access === 'none') { + this.logger.warn(`User ${user.id} not authorized to access page: ${pageId}`); throw new UnauthorizedException(); } - // Check page-level permissions - const { hasAnyRestriction, canAccess, canEdit } = - await this.pagePermissionRepo.canUserEditPage(user.id, page.id); - - if (hasAnyRestriction) { - if (!canAccess) { - this.logger.warn( - `User ${user.id} denied page-level access to page: ${pageId}`, - ); - throw new UnauthorizedException(); - } - - if (!canEdit) { - data.connectionConfig.readOnly = true; - this.logger.debug( - `User ${user.id} granted readonly access to restricted page: ${pageId}`, - ); - } - } else { - // No restrictions - use space-level permissions - if (userSpaceRole === SpaceRole.READER) { - data.connectionConfig.readOnly = true; - this.logger.debug(`User granted readonly access to page: ${pageId}`); - } - } - - if (page.deletedAt) { + if (access === 'read') { data.connectionConfig.readOnly = true; } @@ -107,4 +116,92 @@ export class AuthenticationExtension implements Extension { user, }; } + + async recheckAccess({ + userIds = [], + pageIds = [], + }: CollabAccessRecheck): Promise { + if (!this.instance?.documents.size) return; + + const recheckUserIds = new Set(userIds); + const recheckPageIds = new Set(); + for (const pageId of pageIds) { + const pages = await this.pageRepo.getPageAndDescendants(pageId, { + includeContent: false, + }); + pages.forEach((page) => recheckPageIds.add(page.id)); + } + + for (const document of this.instance.documents.values()) { + const pageId = getPageId(document.name); + const connections = document.getConnections().filter((connection) => { + const userId = connection.context?.user?.id; + return ( + userId && (recheckPageIds.has(pageId) || recheckUserIds.has(userId)) + ); + }); + if (connections.length === 0) continue; + + for (const connection of connections) { + try { + await this.recheckConnection(connection, pageId); + } catch (err) { + this.logger.error( + { err }, + `Failed to recheck collab access on ${document.name}`, + ); + } + } + } + } + + private async recheckConnection( + connection: Connection, + pageId: string, + ): Promise { + const { id, workspaceId } = connection.context.user; + const [user, page] = await Promise.all([ + this.userRepo.findById(id, workspaceId), + this.pageRepo.findById(pageId), + ]); + + let access: CollabAccess = 'none'; + if (user && !isUserDisabled(user) && page) { + access = await this.getAccess(user, page); + } + + if (access === 'write' || (access === 'read' && connection.readOnly)) { + return; + } + + connection.sendStateless( + JSON.stringify({ type: 'page.permissions', access }), + ); + connection.readOnly = true; + if (access === 'none') { + connection.close(Forbidden); + } + } + + // a connection outlives this check, so it must not see a stale cached allow + private async getAccess(user: User, page: Page): Promise { + const [spaceRoles, { hasAnyRestriction, canAccess, canEdit }] = + await Promise.all([ + this.spaceMemberRepo.getUserSpaceRoles(user.id, page.spaceId, { + skipCache: true, + }), + this.pagePermissionRepo.canUserEditPage(user.id, page.id, { + skipCache: true, + }), + ]); + + const userSpaceRole = findHighestUserSpaceRole(spaceRoles); + if (!userSpaceRole) return 'none'; + if (hasAnyRestriction && !canAccess) return 'none'; + + const canWrite = hasAnyRestriction + ? canEdit + : userSpaceRole !== SpaceRole.READER; + return canWrite && !page.deletedAt ? 'write' : 'read'; + } } diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index f13ff440..e1694016 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -138,8 +138,6 @@ export class PersistenceExtension implements Extension { return; } - await this.collabHistory.addContributors(pageId, editingUserIds); - let contributorIds = undefined; try { const existingContributors = page.contributorIds || []; @@ -177,6 +175,15 @@ export class PersistenceExtension implements Extension { } if (page) { + try { + await this.collabHistory.addContributors(pageId, editingUserIds); + } catch (err) { + this.logger.error( + { err, pageId }, + 'Failed to add history contributors for page', + ); + } + document.broadcastStateless( JSON.stringify({ type: 'page.updated', diff --git a/apps/server/src/collaboration/services/collab-access.service.spec.ts b/apps/server/src/collaboration/services/collab-access.service.spec.ts new file mode 100644 index 00000000..c29aac6f --- /dev/null +++ b/apps/server/src/collaboration/services/collab-access.service.spec.ts @@ -0,0 +1,29 @@ +import { CollabAccessService } from './collab-access.service'; +import { COLLAB_ACCESS_RECHECK_CHANNEL } from '../constants'; + +function makeService(publish: (...args: any[]) => Promise) { + return new CollabAccessService({ getOrThrow: () => ({ publish }) } as any); +} + +describe('CollabAccessService', () => { + it('publishes the users or pages to recheck', async () => { + const publish = jest.fn(async () => 1); + + await makeService(publish).recheckAccess({ userIds: ['user_1'] }); + await makeService(publish).recheckAccess({ userIds: [], pageIds: [] }); + + expect(publish.mock.calls).toEqual([ + [COLLAB_ACCESS_RECHECK_CHANNEL, JSON.stringify({ userIds: ['user_1'] })], + ]); + }); + + it('does not throw when publishing fails', async () => { + const service = makeService(async () => { + throw new Error('redis down'); + }); + + await expect( + service.recheckAccess({ userIds: ['user_1'] }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/server/src/collaboration/services/collab-access.service.ts b/apps/server/src/collaboration/services/collab-access.service.ts new file mode 100644 index 00000000..59303623 --- /dev/null +++ b/apps/server/src/collaboration/services/collab-access.service.ts @@ -0,0 +1,32 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { RedisService } from '@nestjs-labs/nestjs-ioredis'; +import { COLLAB_ACCESS_RECHECK_CHANNEL } from '../constants'; + +export type CollabAccessRecheck = { + userIds?: string[]; + pageIds?: string[]; +}; + +@Injectable() +export class CollabAccessService { + private readonly logger = new Logger(CollabAccessService.name); + + constructor(private readonly redisService: RedisService) {} + + async recheckAccess(recheck: CollabAccessRecheck): Promise { + if (!recheck.userIds?.length && !recheck.pageIds?.length) return; + // not awaited, ioredis queues the command and can hold the reply for minutes while redis reconnects + try { + this.redisService + .getOrThrow() + .publish(COLLAB_ACCESS_RECHECK_CHANNEL, JSON.stringify(recheck)) + .catch((err) => this.logError(err)); + } catch (err) { + this.logError(err); + } + } + + private logError(err: unknown): void { + this.logger.error({ err }, 'Failed to publish collab access recheck'); + } +} diff --git a/apps/server/src/common/helpers/with-cache.spec.ts b/apps/server/src/common/helpers/with-cache.spec.ts new file mode 100644 index 00000000..d7e5e539 --- /dev/null +++ b/apps/server/src/common/helpers/with-cache.spec.ts @@ -0,0 +1,29 @@ +import { withCache } from './with-cache'; + +function makeCache(cached?: unknown) { + return { + get: jest.fn(async () => (cached === undefined ? undefined : { v: cached })), + set: jest.fn(async () => undefined), + }; +} + +describe('withCache', () => { + it('returns a cached value without running the lookup', async () => { + const cache = makeCache('cached'); + const lookup = jest.fn(async () => 'fresh'); + + await expect(withCache(cache as any, 'key', 1000, lookup)).resolves.toBe('cached'); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('skips the cache entirely when asked', async () => { + const cache = makeCache('cached'); + const lookup = jest.fn(async () => 'fresh'); + + await expect( + withCache(cache as any, 'key', 1000, lookup, { skipCache: true }), + ).resolves.toBe('fresh'); + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/common/helpers/with-cache.ts b/apps/server/src/common/helpers/with-cache.ts index 2db1d3c0..70d98594 100644 --- a/apps/server/src/common/helpers/with-cache.ts +++ b/apps/server/src/common/helpers/with-cache.ts @@ -1,11 +1,16 @@ import { Cache } from 'cache-manager'; +export type CacheOptions = { skipCache?: boolean }; + export async function withCache( cacheManager: Cache, key: string, ttlMs: number, fn: () => Promise, + opts?: CacheOptions, ): Promise { + if (opts?.skipCache) return fn(); + try { const cached = await cacheManager.get<{ v: T }>(key); if (cached !== undefined && cached !== null) { @@ -17,11 +22,9 @@ export async function withCache( const value = await fn(); - try { - await cacheManager.set(key, { v: value }, ttlMs); - } catch (err) { - console.warn(`[withCache] set failed for "${key}"`, err); - } + cacheManager + .set(key, { v: value }, ttlMs) + .catch((err) => console.warn(`[withCache] set failed for "${key}"`, err)); return value; } diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index 30324504..a001fe26 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -33,6 +33,7 @@ import { UserTokenType } from '../auth.constants'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { InjectKysely } from 'nestjs-kysely'; import { executeTx } from '@docmost/db/utils'; +import { WsService } from '../../../ws/ws.service'; import { VerifyUserTokenDto } from '../dto/verify-user-token.dto'; import { DomainService } from '../../../integrations/environment/domain.service'; import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; @@ -61,6 +62,7 @@ export class AuthService { private eventEmitter: EventEmitter2, @InjectKysely() private readonly db: KyselyDB, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + private wsService: WsService, ) {} async login(loginDto: LoginDto, workspaceId: string) { @@ -155,8 +157,10 @@ export class AuthService { userId, workspaceId, ); + this.wsService.disconnectOtherSessions(userId, currentSessionId); } else { await this.userSessionRepo.deleteByUserId(userId, workspaceId); + this.wsService.disconnectUser(userId); } this.auditService.log({ @@ -273,6 +277,7 @@ export class AuthService { }); await this.userSessionRepo.deleteByUserId(user.id, workspace.id); + this.wsService.disconnectUser(user.id); // A failed revocation must not block the reset itself; log loudly instead. try { diff --git a/apps/server/src/core/casl/abilities/space-ability.factory.ts b/apps/server/src/core/casl/abilities/space-ability.factory.ts index 53a57a0c..e895aa74 100644 --- a/apps/server/src/core/casl/abilities/space-ability.factory.ts +++ b/apps/server/src/core/casl/abilities/space-ability.factory.ts @@ -13,14 +13,16 @@ import { SpaceCaslSubject, } from '../interfaces/space-ability.type'; import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; +import { CacheOptions } from '../../../common/helpers/with-cache'; @Injectable() export default class SpaceAbilityFactory { constructor(private readonly spaceMemberRepo: SpaceMemberRepo) {} - async createForUser(user: User, spaceId: string) { + async createForUser(user: User, spaceId: string, opts?: CacheOptions) { const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles( user.id, spaceId, + opts, ); const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles); diff --git a/apps/server/src/core/group/services/group-user.service.ts b/apps/server/src/core/group/services/group-user.service.ts index 2857f70f..8416e1b3 100644 --- a/apps/server/src/core/group/services/group-user.service.ts +++ b/apps/server/src/core/group/services/group-user.service.ts @@ -21,6 +21,11 @@ import { IAuditService, } from '../../../integrations/audit/audit.service'; import { dbOrTx } from '@docmost/db/utils'; +import { WsService } from '../../../ws/ws.service'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; +import { CollabAccessService } from '../../../collaboration/services/collab-access.service'; + +export type GroupAccess = { spaceIds: string[]; pageIds: string[] }; @Injectable() export class GroupUserService { @@ -34,6 +39,9 @@ export class GroupUserService { private readonly favoriteRepo: FavoriteRepo, @InjectKysely() private readonly db: KyselyDB, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + private readonly wsService: WsService, + private readonly pagePermissionRepo: PagePermissionRepo, + private readonly collabAccessService: CollabAccessService, ) {} async getGroupUsers( @@ -134,27 +142,20 @@ export class GroupUserService { throw new BadRequestException('Group member not found'); } - const spaceIds = await this.spaceMemberRepo.getSpaceIdsByGroupId(groupId); + const groupAccess = await this.getGroupAccess([groupId]); // TODO: use queue instead await executeTx(this.db, async (trx) => { await this.groupUserRepo.delete(userId, groupId, { trx }); - - for (const spaceId of spaceIds) { - await this.watcherRepo.deleteByUsersWithoutSpaceAccess( - [userId], - spaceId, - { trx }, - ); - - await this.favoriteRepo.deleteByUsersWithoutSpaceAccess( - [userId], - spaceId, - { trx }, - ); - } + await this.removeWatchersAndFavoritesWithoutAccess( + [userId], + groupAccess.spaceIds, + trx, + ); }); + await this.revokeRealtimeAccess([userId], groupAccess); + this.auditService.log({ event: AuditEvent.GROUP_MEMBER_REMOVED, resourceType: AuditResource.GROUP, @@ -170,4 +171,57 @@ export class GroupUserService { }, }); } + + // read before deleting a group, its page permissions cascade away with it + async getGroupAccess( + groupIds: string[], + opts?: { trx?: KyselyTransaction }, + ): Promise { + const spaceIds = await this.spaceMemberRepo.getSpaceIdsByGroupIds( + groupIds, + opts, + ); + const pageIds = await this.pagePermissionRepo.getPageIdsByGroupIds( + groupIds, + opts, + ); + return { spaceIds, pageIds }; + } + + async removeWatchersAndFavoritesWithoutAccess( + userIds: string[], + spaceIds: string[], + trx: KyselyTransaction, + ): Promise { + for (const spaceId of spaceIds) { + await this.watcherRepo.deleteByUsersWithoutSpaceAccess( + userIds, + spaceId, + { trx }, + ); + await this.favoriteRepo.deleteByUsersWithoutSpaceAccess( + userIds, + spaceId, + { trx }, + ); + } + } + + // call after commit, it never throws + async revokeRealtimeAccess( + userIds: string[], + groupAccess: GroupAccess, + ): Promise { + if (userIds.length === 0) return; + if (!groupAccess.spaceIds.length && !groupAccess.pageIds.length) return; + + await this.collabAccessService.recheckAccess({ userIds }); + await this.wsService.removeUsersWithoutSpaceAccess( + userIds, + groupAccess.spaceIds, + ); + await this.wsService.removeBaseSubscribersWithoutAccess( + groupAccess.pageIds, + ); + } } diff --git a/apps/server/src/core/group/services/group.service.ts b/apps/server/src/core/group/services/group.service.ts index 47de4693..3cb4940d 100644 --- a/apps/server/src/core/group/services/group.service.ts +++ b/apps/server/src/core/group/services/group.service.ts @@ -11,12 +11,9 @@ import { UpdateGroupDto } from '../dto/update-group.dto'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; -import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { GroupUserService } from './group-user.service'; -import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; -import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo'; import { executeTx } from '@docmost/db/utils'; import { InjectKysely } from 'nestjs-kysely'; import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; @@ -31,11 +28,8 @@ export class GroupService { constructor( private groupRepo: GroupRepo, private groupUserRepo: GroupUserRepo, - private spaceMemberRepo: SpaceMemberRepo, @Inject(forwardRef(() => GroupUserService)) private groupUserService: GroupUserService, - private readonly watcherRepo: WatcherRepo, - private readonly favoriteRepo: FavoriteRepo, @InjectKysely() private readonly db: KyselyDB, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @@ -176,30 +170,23 @@ export class GroupService { throw new BadRequestException('You cannot delete a default group'); } - const [userIds, spaceIds] = await Promise.all([ + const [userIds, groupAccess] = await Promise.all([ this.groupUserRepo.getUserIdsByGroupId(groupId), - this.spaceMemberRepo.getSpaceIdsByGroupId(groupId), + this.groupUserService.getGroupAccess([groupId]), ]); // TODO: use queue instead await executeTx(this.db, async (trx) => { await this.groupRepo.delete(groupId, workspaceId, { trx }); - - for (const spaceId of spaceIds) { - await this.watcherRepo.deleteByUsersWithoutSpaceAccess( - userIds, - spaceId, - { trx }, - ); - - await this.favoriteRepo.deleteByUsersWithoutSpaceAccess( - userIds, - spaceId, - { trx }, - ); - } + await this.groupUserService.removeWatchersAndFavoritesWithoutAccess( + userIds, + groupAccess.spaceIds, + trx, + ); }); + await this.groupUserService.revokeRealtimeAccess(userIds, groupAccess); + this.auditService.log({ event: AuditEvent.GROUP_DELETED, resourceType: AuditResource.GROUP, diff --git a/apps/server/src/core/page/page-access/page-access.service.ts b/apps/server/src/core/page/page-access/page-access.service.ts index 6d6db03f..7978ac38 100644 --- a/apps/server/src/core/page/page-access/page-access.service.ts +++ b/apps/server/src/core/page/page-access/page-access.service.ts @@ -7,6 +7,7 @@ import { SpaceCaslSubject, } from '../../casl/interfaces/space-ability.type'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import { CacheOptions } from '../../../common/helpers/with-cache'; @Injectable() export class PageAccessService { @@ -21,9 +22,17 @@ export class PageAccessService { * If page has restrictions: page-level permission determines access. * If no restrictions: space-level permission determines access. */ - async validateCanView(page: Page, user: User): Promise { + async validateCanView( + page: Page, + user: User, + opts?: CacheOptions, + ): Promise { // TODO: cache by pageId and userId. - const ability = await this.spaceAbility.createForUser(user, page.spaceId); + const ability = await this.spaceAbility.createForUser( + user, + page.spaceId, + opts, + ); // User must be at least a space member if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { @@ -33,6 +42,7 @@ export class PageAccessService { const canAccess = await this.pagePermissionRepo.canUserAccessPage( user.id, page.id, + opts, ); if (!canAccess) { throw new ForbiddenException(); diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 6fe9ef5c..d719c44b 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -57,6 +57,8 @@ import { WatcherService } from '../../watcher/watcher.service'; import { sql } from 'kysely'; import { TransclusionService } from '../transclusion/transclusion.service'; import { LabelRepo } from '@docmost/db/repos/label/label.repo'; +import { WsService } from '../../../ws/ws.service'; +import { CollabAccessService } from '../../../collaboration/services/collab-access.service'; @Injectable() export class PageService { @@ -76,6 +78,8 @@ export class PageService { private readonly watcherService: WatcherService, private readonly transclusionService: TransclusionService, private readonly labelRepo: LabelRepo, + private readonly wsService: WsService, + private readonly collabAccessService: CollabAccessService, ) {} async findById( @@ -399,7 +403,7 @@ export class PageService { } async movePageToSpace(rootPage: Page, spaceId: string, userId: string) { - return executeTx(this.db, async (trx) => { + const result = await executeTx(this.db, async (trx) => { await this.pageRepo.lockPageHierarchySpaces( [rootPage.spaceId, spaceId], trx, @@ -536,6 +540,12 @@ export class PageService { return { childPageIds }; }); + + await this.collabAccessService.recheckAccess({ pageIds: [rootPage.id] }); + await this.wsService.invalidateSpaceRestrictionCache(spaceId); + await this.wsService.removeBaseSubscribersWithoutAccess([rootPage.id]); + + return result; } async duplicatePage( @@ -852,7 +862,7 @@ export class PageService { throw new BadRequestException('A page cannot be its own parent'); } - await executeTx(this.db, async (trx) => { + const mayLoseAccess = await executeTx(this.db, async (trx) => { await this.pageRepo.lockPageHierarchySpaces( [movedPage.spaceId], trx, @@ -904,7 +914,21 @@ export class PageService { dto.pageId, trx, ); + + // a new parent only changes access in a space with restricted pages + return ( + parentPageId !== undefined && + (await this.pagePermissionRepo.hasRestrictedPagesInSpace( + currentPage.spaceId, + { trx }, + )) + ); }); + + if (mayLoseAccess) { + await this.collabAccessService.recheckAccess({ pageIds: [dto.pageId] }); + await this.wsService.removeBaseSubscribersWithoutAccess([dto.pageId]); + } } async getPageBreadCrumbs(childPageId: string) { diff --git a/apps/server/src/core/session/session.service.spec.ts b/apps/server/src/core/session/session.service.spec.ts new file mode 100644 index 00000000..c83b1925 --- /dev/null +++ b/apps/server/src/core/session/session.service.spec.ts @@ -0,0 +1,49 @@ +import { SessionService } from './session.service'; + +function makeService(revoked: boolean) { + const userSessionRepo = { + revokeById: jest.fn(async () => revoked), + revokeAllExceptCurrent: jest.fn(async () => undefined), + }; + const wsService = { + disconnectSession: jest.fn(), + disconnectOtherSessions: jest.fn(), + }; + const service = new SessionService( + {} as any, + userSessionRepo as any, + {} as any, + {} as any, + wsService as any, + ); + return { service, wsService }; +} + +describe('SessionService socket disconnects', () => { + it('disconnects the sockets of a revoked session', async () => { + const { service, wsService } = makeService(true); + + await service.revokeSession('session_1', 'user_1', 'ws_1'); + + expect(wsService.disconnectSession).toHaveBeenCalledWith('session_1'); + }); + + it('leaves sockets alone when nothing was revoked for this user', async () => { + const { service, wsService } = makeService(false); + + await service.revokeSession('someone_elses_session', 'user_1', 'ws_1'); + + expect(wsService.disconnectSession).not.toHaveBeenCalled(); + }); + + it('disconnects every other session of the user on revoke-all', async () => { + const { service, wsService } = makeService(true); + + await service.revokeAllOtherSessions('session_1', 'user_1', 'ws_1'); + + expect(wsService.disconnectOtherSessions).toHaveBeenCalledWith( + 'user_1', + 'session_1', + ); + }); +}); diff --git a/apps/server/src/core/session/session.service.ts b/apps/server/src/core/session/session.service.ts index 5b2ec47c..c72864bf 100644 --- a/apps/server/src/core/session/session.service.ts +++ b/apps/server/src/core/session/session.service.ts @@ -10,6 +10,7 @@ import { AUDIT_CONTEXT_KEY, } from '../../common/middlewares/audit-context.middleware'; import * as Bowser from 'bowser'; +import { WsService } from '../../ws/ws.service'; const MAX_SESSIONS_PER_USER = 25; const RETENTION_DAYS = 7; @@ -23,6 +24,7 @@ export class SessionService { private readonly userSessionRepo: UserSessionRepo, private readonly environmentService: EnvironmentService, private readonly cls: ClsService, + private readonly wsService: WsService, ) {} @Interval('session-cleanup', 24 * 60 * 60 * 1000) @@ -86,7 +88,14 @@ export class SessionService { userId: string, workspaceId: string, ): Promise { - await this.userSessionRepo.revokeById(sessionId, userId, workspaceId); + const revoked = await this.userSessionRepo.revokeById( + sessionId, + userId, + workspaceId, + ); + if (revoked) { + this.wsService.disconnectSession(sessionId); + } } async revokeAllOtherSessions( @@ -99,6 +108,7 @@ export class SessionService { userId, workspaceId, ); + this.wsService.disconnectOtherSessions(userId, currentSessionId); } private parseDeviceName(userAgent: string | null): string | null { diff --git a/apps/server/src/core/space/services/space-member.service.ts b/apps/server/src/core/space/services/space-member.service.ts index 42cff32a..b4e96376 100644 --- a/apps/server/src/core/space/services/space-member.service.ts +++ b/apps/server/src/core/space/services/space-member.service.ts @@ -24,6 +24,8 @@ import { AUDIT_SERVICE, IAuditService, } from '../../../integrations/audit/audit.service'; +import { WsService } from '../../../ws/ws.service'; +import { CollabAccessService } from '../../../collaboration/services/collab-access.service'; @Injectable() export class SpaceMemberService { @@ -35,6 +37,8 @@ export class SpaceMemberService { private favoriteRepo: FavoriteRepo, @InjectKysely() private readonly db: KyselyDB, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + private readonly wsService: WsService, + private readonly collabAccessService: CollabAccessService, ) {} async addUserToSpace( @@ -283,6 +287,11 @@ export class SpaceMemberService { return { space, spaceMember }; }); + await this.collabAccessService.recheckAccess({ userIds: affectedUserIds }); + await this.wsService.removeUsersWithoutSpaceAccess(affectedUserIds, [ + dto.spaceId, + ]); + this.auditService.log({ event: AuditEvent.SPACE_MEMBER_REMOVED, resourceType: AuditResource.SPACE_MEMBER, @@ -337,7 +346,7 @@ export class SpaceMemberService { } if (spaceMember.role === dto.role) { - return { changed: false, space, spaceMember }; + return { changed: false, space, spaceMember, demotedUserIds: [] }; } if (spaceMember.role === SpaceRole.ADMIN) { @@ -351,14 +360,25 @@ export class SpaceMemberService { trx, ); - return { changed: true, space, spaceMember }; + let demotedUserIds: string[] = []; + if (dto.role === SpaceRole.READER) { + demotedUserIds = spaceMember.userId + ? [spaceMember.userId] + : await this.groupUserRepo.getUserIdsByGroupId(spaceMember.groupId, { + trx, + }); + } + + return { changed: true, space, spaceMember, demotedUserIds }; }); if (!result.changed) { return; } - const { space, spaceMember } = result; + const { space, spaceMember, demotedUserIds } = result; + + await this.collabAccessService.recheckAccess({ userIds: demotedUserIds }); this.auditService.log({ event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED, diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 855069ec..62b4a5e4 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -50,6 +50,8 @@ import { AUDIT_SERVICE, IAuditService, } from '../../../integrations/audit/audit.service'; +import { WsService } from '../../../ws/ws.service'; +import { CollabAccessService } from '../../../collaboration/services/collab-access.service'; @Injectable() export class WorkspaceService { @@ -75,6 +77,8 @@ export class WorkspaceService { @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, private userSessionRepo: UserSessionRepo, + private wsService: WsService, + private readonly collabAccessService: CollabAccessService, ) {} async findById(workspaceId: string) { @@ -908,6 +912,9 @@ export class WorkspaceService { return user; }); + this.wsService.disconnectUser(userId); + await this.collabAccessService.recheckAccess({ userIds: [userId] }); + this.auditService.log({ event: AuditEvent.USER_DEACTIVATED, resourceType: AuditResource.USER, @@ -1032,6 +1039,9 @@ export class WorkspaceService { return user; }); + this.wsService.disconnectUser(userId); + await this.collabAccessService.recheckAccess({ userIds: [userId] }); + this.auditService.log({ event: AuditEvent.USER_DELETED, resourceType: AuditResource.USER, diff --git a/apps/server/src/database/repos/group/group-user.repo.ts b/apps/server/src/database/repos/group/group-user.repo.ts index 76b023f8..aab867d2 100644 --- a/apps/server/src/database/repos/group/group-user.repo.ts +++ b/apps/server/src/database/repos/group/group-user.repo.ts @@ -155,8 +155,11 @@ export class GroupUserRepo { ); } - async getUserIdsByGroupId(groupId: string): Promise { - const rows = await this.db + async getUserIdsByGroupId( + groupId: string, + opts?: { trx?: KyselyTransaction }, + ): Promise { + const rows = await dbOrTx(this.db, opts?.trx) .selectFrom('groupUsers') .select('userId') .where('groupId', '=', groupId) diff --git a/apps/server/src/database/repos/page/page-permission.repo.ts b/apps/server/src/database/repos/page/page-permission.repo.ts index f753526c..9961d3d0 100644 --- a/apps/server/src/database/repos/page/page-permission.repo.ts +++ b/apps/server/src/database/repos/page/page-permission.repo.ts @@ -19,7 +19,7 @@ import { executeWithCursorPagination, } from '@docmost/db/pagination/cursor-pagination'; import { PagePermissionMember } from './types/page-permission.types'; -import { withCache } from '../../../common/helpers/with-cache'; +import { CacheOptions, withCache } from '../../../common/helpers/with-cache'; import { CacheKey, PERMISSION_CACHE_TTL_MS, @@ -104,6 +104,22 @@ export class PagePermissionRepo { .executeTakeFirst(); } + async getPageIdsByGroupIds( + groupIds: string[], + opts?: { trx?: KyselyTransaction }, + ): Promise { + if (groupIds.length === 0) return []; + const rows = await dbOrTx(this.db, opts?.trx) + .selectFrom('pagePermissions') + .innerJoin('pageAccess', 'pageAccess.id', 'pagePermissions.pageAccessId') + .select('pageAccess.pageId') + .distinct() + .where('pagePermissions.groupId', 'in', groupIds) + .execute(); + + return rows.map((row) => row.pageId); + } + async deletePagePermissionByUserId( pageAccessId: string, userId: string, @@ -368,8 +384,12 @@ export class PagePermissionRepo { /** * Check if user can access a page by verifying they have permission on ALL restricted ancestors. */ - async canUserAccessPage(userId: string, pageId: string): Promise { - const { canAccess } = await this.canUserEditPage(userId, pageId); + async canUserAccessPage( + userId: string, + pageId: string, + opts?: CacheOptions, + ): Promise { + const { canAccess } = await this.canUserEditPage(userId, pageId, opts); return canAccess; } @@ -383,6 +403,7 @@ export class PagePermissionRepo { async canUserEditPage( userId: string, pageId: string, + opts?: CacheOptions, ): Promise<{ hasAnyRestriction: boolean; canAccess: boolean; @@ -431,6 +452,7 @@ export class PagePermissionRepo { canEdit: row.canAccess && (row.canEdit ?? false), }; }, + opts, ); } @@ -886,8 +908,11 @@ export class PagePermissionRepo { * Check if any page in a space has restrictions. * Used as a quick check to skip heavy permission filtering when no restrictions exist. */ - async hasRestrictedPagesInSpace(spaceId: string): Promise { - const result = await this.db + async hasRestrictedPagesInSpace( + spaceId: string, + opts?: { trx?: KyselyTransaction }, + ): Promise { + const result = await dbOrTx(this.db, opts?.trx) .selectNoFrom((eb) => eb .exists( diff --git a/apps/server/src/database/repos/session/user-session.repo.ts b/apps/server/src/database/repos/session/user-session.repo.ts index f3da384b..a9b46d1d 100644 --- a/apps/server/src/database/repos/session/user-session.repo.ts +++ b/apps/server/src/database/repos/session/user-session.repo.ts @@ -61,15 +61,17 @@ export class UserSessionRepo { id: string, userId: string, workspaceId: string, - ): Promise { - await this.db + ): Promise { + const revoked = await this.db .updateTable('userSessions') .set({ revokedAt: new Date() }) .where('id', '=', id) .where('userId', '=', userId) .where('workspaceId', '=', workspaceId) .where('revokedAt', 'is', null) - .execute(); + .returning('id') + .executeTakeFirst(); + return !!revoked; } async revokeAllExceptCurrent( diff --git a/apps/server/src/database/repos/space/space-member.repo.ts b/apps/server/src/database/repos/space/space-member.repo.ts index d6172e28..d1cfbc32 100644 --- a/apps/server/src/database/repos/space/space-member.repo.ts +++ b/apps/server/src/database/repos/space/space-member.repo.ts @@ -15,7 +15,7 @@ import { MemberInfo, UserSpaceRole } from './types'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; -import { withCache } from '../../../common/helpers/with-cache'; +import { CacheOptions, withCache } from '../../../common/helpers/with-cache'; import { CacheKey, PERMISSION_CACHE_TTL_MS, @@ -228,6 +228,7 @@ export class SpaceMemberRepo { async getUserSpaceRoles( userId: string, spaceId: string, + opts?: CacheOptions, ): Promise { return withCache( this.cacheManager, @@ -258,6 +259,7 @@ export class SpaceMemberRepo { } return roles; }, + opts, ); } @@ -285,11 +287,16 @@ export class SpaceMemberRepo { return new Set(rows.map((r) => r.userId)); } - async getSpaceIdsByGroupId(groupId: string): Promise { - const rows = await this.db + async getSpaceIdsByGroupIds( + groupIds: string[], + opts?: { trx?: KyselyTransaction }, + ): Promise { + if (groupIds.length === 0) return []; + const rows = await dbOrTx(this.db, opts?.trx) .selectFrom('spaceMembers') .select('spaceId') - .where('groupId', '=', groupId) + .distinct() + .where('groupId', 'in', groupIds) .execute(); return rows.map((r) => r.spaceId); diff --git a/apps/server/src/ee b/apps/server/src/ee index 35b9514c..1da2915b 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 35b9514c059b4d01e06fde09e6f7aaf3b48f7d87 +Subproject commit 1da2915bf81a43f7f5169445cc2e096dd15c3ef2 diff --git a/apps/server/src/ws/base-realtime.bridge.ts b/apps/server/src/ws/base-realtime.bridge.ts index b8581dd8..adb9dead 100644 --- a/apps/server/src/ws/base-realtime.bridge.ts +++ b/apps/server/src/ws/base-realtime.bridge.ts @@ -46,4 +46,15 @@ export class BaseRealtimeBridge { async handleDisconnect(client: Socket): Promise { await this.resolve()?.handleDisconnect(client); } + + async removeUsersFromSpaceBases( + userIds: string[], + spaceId: string, + ): Promise { + await this.resolve()?.removeUsersFromSpaceBases(userIds, spaceId); + } + + async removeSubscribersWithoutAccess(pageIds: string[]): Promise { + await this.resolve()?.removeSubscribersWithoutAccess(pageIds); + } } diff --git a/apps/server/src/ws/ws.gateway.ts b/apps/server/src/ws/ws.gateway.ts index f265ee5e..bc4189ad 100644 --- a/apps/server/src/ws/ws.gateway.ts +++ b/apps/server/src/ws/ws.gateway.ts @@ -10,10 +10,14 @@ import { import { Server, Socket } from 'socket.io'; import { TokenService } from '../core/auth/services/token.service'; import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload'; -import { OnModuleDestroy } from '@nestjs/common'; -import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import { OnModuleDestroy, UnauthorizedException } from '@nestjs/common'; +import { JsonWebTokenError } from '@nestjs/jwt'; import { WsService } from './ws.service'; -import { getSpaceRoomName, getUserRoomName } from './ws.utils'; +import { + getSessionRoomName, + getSpaceRoomName, + getUserRoomName, +} from './ws.utils'; import { BaseRealtimeBridge } from './base-realtime.bridge'; import * as cookie from 'cookie'; @@ -33,7 +37,6 @@ export class WsGateway constructor( private tokenService: TokenService, - private spaceMemberRepo: SpaceMemberRepo, private wsService: WsService, private baseRealtime: BaseRealtimeBridge, ) {} @@ -45,7 +48,7 @@ export class WsGateway async handleConnection(client: Socket, ...args: any[]): Promise { try { - const cookies = cookie.parse(client.handshake.headers.cookie); + const cookies = cookie.parse(client.handshake.headers.cookie ?? ''); const token: JwtPayload = await this.tokenService.verifyJwt( cookies['authToken'], JwtType.ACCESS, @@ -54,19 +57,30 @@ export class WsGateway const userId = token.sub; const workspaceId = token.workspaceId; + const userSpaceIds = await this.wsService.authorizeConnection(token); + client.data.userId = userId; client.data.workspaceId = workspaceId; - const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); - const userRoom = getUserRoomName(userId); const workspaceRoom = `workspace-${workspaceId}`; const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id)); + const sessionRooms = token.sessionId + ? [getSessionRoomName(token.sessionId)] + : []; - client.join([userRoom, workspaceRoom, ...spaceRooms]); + client.join([userRoom, workspaceRoom, ...spaceRooms, ...sessionRooms]); } catch (err) { - client.emit('Unauthorized'); - client.disconnect(); + if ( + err instanceof UnauthorizedException || + err instanceof JsonWebTokenError + ) { + client.emit('Unauthorized'); + client.disconnect(); + return; + } + // not an auth failure, closing the transport lets the client reconnect + client.conn.close(); } } diff --git a/apps/server/src/ws/ws.service.spec.ts b/apps/server/src/ws/ws.service.spec.ts new file mode 100644 index 00000000..5e0758fd --- /dev/null +++ b/apps/server/src/ws/ws.service.spec.ts @@ -0,0 +1,67 @@ +import { WsService } from './ws.service'; + +function makeService(accessBySpace: Record) { + const spaceMemberRepo = { + getUserIdsWithSpaceAccess: jest.fn( + async (_userIds: string[], spaceId: string) => + new Set(accessBySpace[spaceId] ?? []), + ), + }; + const baseRealtime = { + removeUsersFromSpaceBases: jest.fn(async () => undefined), + }; + const socketsLeave = jest.fn(); + const server = { in: jest.fn(() => ({ socketsLeave })) }; + + const service = new WsService( + {} as any, + {} as any, + spaceMemberRepo as any, + baseRealtime as any, + {} as any, + {} as any, + ); + service.setServer(server as any); + + return { service, server, socketsLeave, baseRealtime }; +} + +describe('WsService.removeUsersWithoutSpaceAccess', () => { + it('removes users who lost access from the space room and its bases', async () => { + const { service, server, socketsLeave, baseRealtime } = makeService({ + space_1: ['user_2'], + }); + + await service.removeUsersWithoutSpaceAccess( + ['user_1', 'user_2'], + ['space_1'], + ); + + expect(server.in).toHaveBeenCalledTimes(1); + expect(server.in).toHaveBeenCalledWith(['user-user_1']); + expect(socketsLeave).toHaveBeenCalledWith('space-space_1'); + expect(baseRealtime.removeUsersFromSpaceBases).toHaveBeenCalledWith( + ['user_1'], + 'space_1', + ); + }); + + it('keeps users in spaces they can still access', async () => { + const { service, server, socketsLeave, baseRealtime } = makeService({ + space_1: ['user_1'], + }); + + await service.removeUsersWithoutSpaceAccess( + ['user_1'], + ['space_1', 'space_2'], + ); + + expect(server.in).toHaveBeenCalledTimes(1); + expect(socketsLeave).toHaveBeenCalledWith('space-space_2'); + expect(baseRealtime.removeUsersFromSpaceBases).toHaveBeenCalledTimes(1); + expect(baseRealtime.removeUsersFromSpaceBases).toHaveBeenCalledWith( + ['user_1'], + 'space_2', + ); + }); +}); diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts index 170f2474..4ff6b8c8 100644 --- a/apps/server/src/ws/ws.service.ts +++ b/apps/server/src/ws/ws.service.ts @@ -1,32 +1,72 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { + Inject, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { Server, Socket } from 'socket.io'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; +import { JwtPayload } from '../core/auth/dto/jwt-payload'; +import { isUserDisabled } from '../common/helpers'; import { SpaceRole } from '../common/helpers/types/permission'; import { TREE_EVENTS, WS_SPACE_RESTRICTION_CACHE_PREFIX, WS_CACHE_TTL_MS, + getSessionRoomName, getSpaceRoomName, getUserRoomName, } from './ws.utils'; +import { BaseRealtimeBridge } from './base-realtime.bridge'; @Injectable() export class WsService { + private readonly logger = new Logger(WsService.name); private server: Server; constructor( private readonly pagePermissionRepo: PagePermissionRepo, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, private readonly spaceMemberRepo: SpaceMemberRepo, + private readonly baseRealtime: BaseRealtimeBridge, + private readonly userRepo: UserRepo, + private readonly userSessionRepo: UserSessionRepo, ) {} setServer(server: Server): void { this.server = server; } + async authorizeConnection(token: JwtPayload): Promise { + const [user, session, spaceIds] = await Promise.all([ + this.userRepo.findById(token.sub, token.workspaceId), + token.sessionId + ? this.userSessionRepo.findActiveById(token.sessionId) + : undefined, + this.spaceMemberRepo.getUserSpaceIds(token.sub), + ]); + + if (!user || isUserDisabled(user)) { + throw new UnauthorizedException(); + } + + if ( + token.sessionId && + (!session || + session.userId !== token.sub || + session.workspaceId !== token.workspaceId) + ) { + throw new UnauthorizedException(); + } + + return spaceIds; + } + async handleTreeEvent(client: Socket, data: any): Promise { const room = getSpaceRoomName(data.spaceId); @@ -73,9 +113,16 @@ export class WsService { } async invalidateSpaceRestrictionCache(spaceId: string): Promise { - await this.cacheManager.del( - `${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`, - ); + try { + await this.cacheManager.del( + `${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`, + ); + } catch (err) { + this.logger.error( + { err }, + `Failed to invalidate space restriction cache for ${spaceId}`, + ); + } } async emitCommentEvent( @@ -107,21 +154,72 @@ export class WsService { this.server.to(rooms).emit('message', data); } + disconnectUser(userId: string): void { + this.server.in(getUserRoomName(userId)).disconnectSockets(true); + } + + disconnectSession(sessionId: string): void { + this.server.in(getSessionRoomName(sessionId)).disconnectSockets(true); + } + + disconnectOtherSessions(userId: string, currentSessionId: string): void { + this.server + .in(getUserRoomName(userId)) + .except(getSessionRoomName(currentSessionId)) + .disconnectSockets(true); + } + + async removeUsersWithoutSpaceAccess( + userIds: string[], + spaceIds: string[], + ): Promise { + if (userIds.length === 0) return; + + for (const spaceId of spaceIds) { + try { + const usersWithAccess = + await this.spaceMemberRepo.getUserIdsWithSpaceAccess( + userIds, + spaceId, + ); + const revokedUserIds = userIds.filter( + (id) => !usersWithAccess.has(id), + ); + if (revokedUserIds.length === 0) continue; + + this.server + .in(revokedUserIds.map((id) => getUserRoomName(id))) + .socketsLeave(getSpaceRoomName(spaceId)); + await this.baseRealtime.removeUsersFromSpaceBases( + revokedUserIds, + spaceId, + ); + } catch (err) { + this.logger.error( + { err }, + `Failed to remove revoked users from space ${spaceId} rooms`, + ); + } + } + } + + async removeBaseSubscribersWithoutAccess(pageIds: string[]): Promise { + try { + await this.baseRealtime.removeSubscribersWithoutAccess(pageIds); + } catch (err) { + this.logger.error({ err }, 'Failed to remove base subscribers'); + } + } + async emitToSpaceExceptUsers( spaceId: string, excludeUserIds: string[], data: any, ): Promise { - const room = getSpaceRoomName(spaceId); - const sockets = await this.server.in(room).fetchSockets(); - const excludeSet = new Set(excludeUserIds); - - for (const socket of sockets) { - const userId = socket.data.userId as string; - if (userId && !excludeSet.has(userId)) { - socket.emit('message', data); - } - } + this.server + .to(getSpaceRoomName(spaceId)) + .except(excludeUserIds.map((id) => getUserRoomName(id))) + .emit('message', data); } isTreeEvent(data: any): boolean { diff --git a/apps/server/src/ws/ws.utils.ts b/apps/server/src/ws/ws.utils.ts index 36683028..7ff7e857 100644 --- a/apps/server/src/ws/ws.utils.ts +++ b/apps/server/src/ws/ws.utils.ts @@ -9,6 +9,10 @@ export function getUserRoomName(userId: string): string { return `user-${userId}`; } +export function getSessionRoomName(sessionId: string): string { + return `session-${sessionId}`; +} + export const TREE_EVENTS = new Set([ 'updateOne', 'addTreeNode', From 5e716a6682a1515b6a4156408bd070960699cb79 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:39:55 +0100 Subject: [PATCH 10/18] fix(docker): copy client package.json so pnpm frozen install passes (#2574) --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 43594146..8e2eed71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,7 @@ WORKDIR /app # Copy apps COPY --from=builder /app/apps/server/dist /app/apps/server/dist COPY --from=builder /app/apps/client/dist /app/apps/client/dist +COPY --from=builder /app/apps/client/package.json /app/apps/client/package.json COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json # Copy packages @@ -51,7 +52,8 @@ RUN chown -R node:node /app USER node -RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm +# client package.json is only copied for the lockfile check; its deps are bundled in dist +RUN pnpm install --frozen-lockfile --prod --filter '!./apps/client' && rm -rf /home/node/.cache/pnpm RUN mkdir -p /app/data/storage From 6950a126bd8543176aa05d9bddd161eac442c24f Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:41:38 +0100 Subject: [PATCH 11/18] New Crowdin updates (#2546) --- .../public/locales/de-DE/translation.json | 41 +++++++++++++++---- .../public/locales/es-ES/translation.json | 29 ++++++++++++- .../public/locales/fr-FR/translation.json | 31 +++++++++++++- .../public/locales/it-IT/translation.json | 33 +++++++++++++-- .../public/locales/ja-JP/translation.json | 37 ++++++++++++++--- .../public/locales/ko-KR/translation.json | 29 ++++++++++++- .../public/locales/nl-NL/translation.json | 29 ++++++++++++- .../public/locales/pt-BR/translation.json | 41 +++++++++++++++---- .../public/locales/ru-RU/translation.json | 29 ++++++++++++- .../public/locales/uk-UA/translation.json | 41 +++++++++++++++---- .../public/locales/zh-CN/translation.json | 29 ++++++++++++- 11 files changed, 333 insertions(+), 36 deletions(-) diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index fb7dda0e..4ebb4022 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "Fehler beim Abrufen der letzten Seiten", "Failed to import pages": "Import der Seiten fehlgeschlagen", "Failed to load page. An error occurred.": "Seite konnte nicht geladen werden. Es ist ein Fehler aufgetreten.", + "Failed to load presentation. An error occurred.": "Präsentation konnte nicht geladen werden. Ein Fehler ist aufgetreten.", "Failed to update data": "Aktualisierung der Daten fehlgeschlagen", "Failed to create base": "Basis konnte nicht erstellt werden", "Failed to update base": "Basis konnte nicht aktualisiert werden", @@ -161,6 +162,14 @@ "Pending": "Ausstehend", "Please confirm your action": "Bitte bestätigen Sie Ihre Aktion", "Preferences": "Einstellungen", + "Presentation mode": "Präsentationsmodus", + "Previous slide": "Vorherige Folie", + "Next slide": "Nächste Folie", + "Exit presentation mode": "Präsentationsmodus beenden", + "Enter fullscreen": "Vollbildmodus aktivieren", + "Exit fullscreen": "Vollbildmodus beenden", + "Laser pointer": "Laserpointer", + "Slide {{current}} of {{total}}": "Folie {{current}} von {{total}}", "Print PDF": "PDF drucken", "Profile": "Profil", "Recently updated": "Kürzlich aktualisiert", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "Stelle diese Basis wieder her, um ihren Inhalt anzuzeigen.", "Subpages": "Unterseiten", "Failed to load subpages": "Unterseiten konnten nicht geladen werden", + "Sort subpages": "Unterseiten sortieren", + "Manual": "Manuell", + "A–Z": "A–Z", + "Recent": "Neueste", "No subpages": "Keine Unterseiten", "Subpages (Child pages)": "Unterseiten (untergeordnete Seiten)", "List all subpages of the current page": "Alle Unterseiten der aktuellen Seite auflisten", @@ -978,6 +991,8 @@ "Page menu": "Seitenmenü", "Expand": "Erweitern", "Collapse": "Reduzieren", + "Expand {{name}}": "{{name}} erweitern", + "Collapse {{name}}": "{{name}} reduzieren", "Comment menu": "Kommentarmenü", "Group menu": "Gruppenmenü", "Show hidden breadcrumbs": "Ausgeblendete Breadcrumbs anzeigen", @@ -989,6 +1004,9 @@ "Use template": "Vorlage verwenden", "Preview template: {{title}}": "Vorlage anzeigen: {{title}}", "Use a template": "Eine Vorlage verwenden", + "Template": "Vorlage", + "Failed to load template": "Vorlage konnte nicht geladen werden", + "This page is no longer empty": "Diese Seite ist nicht mehr leer", "Search templates...": "Vorlagen suchen...", "Search spaces...": "Bereiche suchen...", "No templates found": "Keine Vorlagen gefunden", @@ -1198,8 +1216,8 @@ "Default value": "Standardwert", "Delete property": "Eigenschaft löschen", "Delete view": "Ansicht löschen", - "Delete {{count}} rows?_one": "Delete 1 row?", - "Delete {{count}} rows?_other": "Delete {{count}} rows?", + "Delete {{count}} rows?_one": "1 Zeile löschen?", + "Delete {{count}} rows?_other": "{{count}} Zeilen löschen?", "Descending": "Absteigend", "Discard": "Verwerfen", "Doesn't contain": "Enthält nicht", @@ -1298,10 +1316,10 @@ "Value": "Wert", "View updated for everyone": "Ansicht für alle aktualisiert", "You have unsaved changes. Do you want to discard them?": "Sie haben nicht gespeicherte Änderungen. Möchten Sie sie verwerfen?", - "{{count}} rows deleted_one": "1 row deleted", - "{{count}} rows deleted_other": "{{count}} rows deleted", - "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected", + "{{count}} rows deleted_one": "1 Zeile gelöscht", + "{{count}} rows deleted_other": "{{count}} Zeilen gelöscht", + "{{count}} selected_one": "1 ausgewählt", + "{{count}} selected_other": "{{count}} ausgewählt", "Compare": "Vergleichen", "Compare versions": "Versionen vergleichen", "Select version from {{date}}": "Version vom {{date}} auswählen", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "Testereignis an {{name}} zugestellt", "Updated SIEM destination": "SIEM-Ziel aktualisiert", "Verify TLS certificate": "TLS-Zertifikat verifizieren", - "e.g. Splunk prod": "z. B. Splunk Prod" + "e.g. Splunk prod": "z. B. Splunk Prod", + "Tabs": "Registerkarten", + "Insert a multi-tab content block.": "Einen Inhaltsblock mit mehreren Tabs einfügen.", + "Add tab": "Tab hinzufügen", + "Tab actions": "Tab-Aktionen", + "Move left": "Nach links verschieben", + "Move right": "Nach rechts verschieben", + "Delete tab": "Tab löschen", + "Tab label": "Tab-Beschriftung", + "Tab actions for {{name}}": "Tab-Aktionen für {{name}}" } diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index d3808435..f27b69ad 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "Error al obtener las páginas recientes", "Failed to import pages": "No se pudieron importar las páginas", "Failed to load page. An error occurred.": "Error al cargar la página. Se produjo un error.", + "Failed to load presentation. An error occurred.": "No se pudo cargar la presentación. Se produjo un error.", "Failed to update data": "No se pudo actualizar los datos", "Failed to create base": "No se pudo crear la base", "Failed to update base": "No se pudo actualizar la base", @@ -161,6 +162,14 @@ "Pending": "Pendiente", "Please confirm your action": "Por favor, confirme su acción", "Preferences": "Preferencias", + "Presentation mode": "Modo de presentación", + "Previous slide": "Diapositiva anterior", + "Next slide": "Siguiente diapositiva", + "Exit presentation mode": "Salir del modo de presentación", + "Enter fullscreen": "Entrar en pantalla completa", + "Exit fullscreen": "Salir de pantalla completa", + "Laser pointer": "Puntero láser", + "Slide {{current}} of {{total}}": "Diapositiva {{current}} de {{total}}", "Print PDF": "Imprimir PDF", "Profile": "Perfil", "Recently updated": "Recientemente actualizado", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "Restaura esta base para ver su contenido.", "Subpages": "Subpáginas", "Failed to load subpages": "No se pudieron cargar las subpáginas", + "Sort subpages": "Ordenar subpáginas", + "Manual": "Manual", + "A–Z": "A–Z", + "Recent": "Reciente", "No subpages": "No hay subpáginas", "Subpages (Child pages)": "Subpáginas (páginas hijas)", "List all subpages of the current page": "Listar todas las subpáginas de la página actual", @@ -978,6 +991,8 @@ "Page menu": "Menú de la página", "Expand": "Expandir", "Collapse": "Contraer", + "Expand {{name}}": "Expandir {{name}}", + "Collapse {{name}}": "Contraer {{name}}", "Comment menu": "Menú de comentarios", "Group menu": "Menú del grupo", "Show hidden breadcrumbs": "Mostrar rutas de navegación ocultas", @@ -989,6 +1004,9 @@ "Use template": "Usar plantilla", "Preview template: {{title}}": "Vista previa de la plantilla: {{title}}", "Use a template": "Usar una plantilla", + "Template": "Plantilla", + "Failed to load template": "No se pudo cargar la plantilla", + "This page is no longer empty": "Esta página ya no está vacía", "Search templates...": "Buscar plantillas...", "Search spaces...": "Buscar espacios...", "No templates found": "No se encontraron plantillas", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "Evento de prueba entregado a {{name}}", "Updated SIEM destination": "Destino SIEM actualizado", "Verify TLS certificate": "Verificar certificado TLS", - "e.g. Splunk prod": "p. ej., Splunk producción" + "e.g. Splunk prod": "p. ej., Splunk producción", + "Tabs": "Pestañas", + "Insert a multi-tab content block.": "Inserta un bloque de contenido con varias pestañas.", + "Add tab": "Añadir pestaña", + "Tab actions": "Acciones de pestaña", + "Move left": "Mover a la izquierda", + "Move right": "Mover a la derecha", + "Delete tab": "Eliminar pestaña", + "Tab label": "Etiqueta de la pestaña", + "Tab actions for {{name}}": "Acciones de pestaña para {{name}}" } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 26ded49e..c17bda7d 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "Échec de la récupération des pages récentes", "Failed to import pages": "Échec de l'importation des pages", "Failed to load page. An error occurred.": "Échec du chargement de la page. Une erreur s'est produite.", + "Failed to load presentation. An error occurred.": "Échec du chargement de la présentation. Une erreur s’est produite.", "Failed to update data": "Échec de la mise à jour des données", "Failed to create base": "Échec de la création de la base", "Failed to update base": "Échec de la mise à jour de la base", @@ -161,6 +162,14 @@ "Pending": "En attente", "Please confirm your action": "Veuillez confirmer votre action", "Preferences": "Préférences", + "Presentation mode": "Mode présentation", + "Previous slide": "Diapositive précédente", + "Next slide": "Diapositive suivante", + "Exit presentation mode": "Quitter le mode présentation", + "Enter fullscreen": "Entrer en plein écran", + "Exit fullscreen": "Quitter le plein écran", + "Laser pointer": "Pointeur laser", + "Slide {{current}} of {{total}}": "Diapositive {{current}} sur {{total}}", "Print PDF": "Imprimer PDF", "Profile": "Profil", "Recently updated": "Récemment mis à jour", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "Restaurez cette base pour afficher son contenu.", "Subpages": "Sous-pages", "Failed to load subpages": "Échec du chargement des sous-pages", + "Sort subpages": "Trier les sous-pages", + "Manual": "Manuel", + "A–Z": "A–Z", + "Recent": "Récent", "No subpages": "Aucune sous-page", "Subpages (Child pages)": "Sous-pages (pages enfants)", "List all subpages of the current page": "Lister toutes les sous-pages de la page actuelle", @@ -978,6 +991,8 @@ "Page menu": "Menu de la page", "Expand": "Développer", "Collapse": "Réduire", + "Expand {{name}}": "Développer {{name}}", + "Collapse {{name}}": "Réduire {{name}}", "Comment menu": "Menu du commentaire", "Group menu": "Menu du groupe", "Show hidden breadcrumbs": "Afficher les fils d’Ariane masqués", @@ -989,6 +1004,9 @@ "Use template": "Utiliser le modèle", "Preview template: {{title}}": "Aperçu du modèle : {{title}}", "Use a template": "Utiliser un modèle", + "Template": "Modèle", + "Failed to load template": "Échec du chargement du modèle", + "This page is no longer empty": "Cette page n’est plus vide", "Search templates...": "Rechercher des modèles...", "Search spaces...": "Rechercher des espaces...", "No templates found": "Aucun modèle trouvé", @@ -1421,7 +1439,7 @@ "Guides, references and answers across all our spaces.": "Guides, références et réponses dans tous nos espaces.", "Search documentation...": "Rechercher dans la documentation...", "1 published space": "1 espace publié", - "{{count}} published spaces": "{{count}} published spaces", + "{{count}} published spaces": "{{count}} espaces publiés", "Actions": "Actions", "Add destination": "Ajouter une destination", "Are you sure you want to delete the destination": "Êtes-vous sûr de vouloir supprimer la destination", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "Événement de test envoyé à {{name}}", "Updated SIEM destination": "Destination SIEM mise à jour", "Verify TLS certificate": "Vérifier le certificat TLS", - "e.g. Splunk prod": "p. ex. Splunk prod" + "e.g. Splunk prod": "p. ex. Splunk prod", + "Tabs": "Onglets", + "Insert a multi-tab content block.": "Insérer un bloc de contenu à onglets multiples.", + "Add tab": "Ajouter un onglet", + "Tab actions": "Actions de l’onglet", + "Move left": "Déplacer vers la gauche", + "Move right": "Déplacer vers la droite", + "Delete tab": "Supprimer l’onglet", + "Tab label": "Libellé de l’onglet", + "Tab actions for {{name}}": "Actions de l’onglet pour {{name}}" } diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index 9476815f..7c7f6394 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "Impossibile recuperare le pagine recenti", "Failed to import pages": "Impossibile importare le pagine", "Failed to load page. An error occurred.": "Il caricamento della pagina è fallito. Si è verificato un errore.", + "Failed to load presentation. An error occurred.": "Impossibile caricare la presentazione. Si è verificato un errore.", "Failed to update data": "Impossibile aggiornare i dati", "Failed to create base": "Impossibile creare la base", "Failed to update base": "Impossibile aggiornare la base", @@ -161,6 +162,14 @@ "Pending": "In sospeso", "Please confirm your action": "Si prega di confermare la propria azione", "Preferences": "Preferenze", + "Presentation mode": "Modalità presentazione", + "Previous slide": "Diapositiva precedente", + "Next slide": "Diapositiva successiva", + "Exit presentation mode": "Esci dalla modalità presentazione", + "Enter fullscreen": "Entra a schermo intero", + "Exit fullscreen": "Esci da schermo intero", + "Laser pointer": "Puntatore laser", + "Slide {{current}} of {{total}}": "Diapositiva {{current}} di {{total}}", "Print PDF": "Stampa PDF", "Profile": "Profilo", "Recently updated": "Aggiornato di recente", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "Ripristina questa base per visualizzarne i contenuti.", "Subpages": "Sottopagine", "Failed to load subpages": "Caricamento delle sottopagine non riuscito", + "Sort subpages": "Ordina sottopagine", + "Manual": "Manuale", + "A–Z": "A–Z", + "Recent": "Recenti", "No subpages": "Nessuna sottopagina", "Subpages (Child pages)": "Sottopagine (Pagine figlie)", "List all subpages of the current page": "Elenca tutte le sottopagine della pagina corrente", @@ -978,6 +991,8 @@ "Page menu": "Menu della pagina", "Expand": "Espandi", "Collapse": "Comprimi", + "Expand {{name}}": "Espandi {{name}}", + "Collapse {{name}}": "Comprimi {{name}}", "Comment menu": "Menu dei commenti", "Group menu": "Menu del gruppo", "Show hidden breadcrumbs": "Mostra breadcrumb nascosti", @@ -989,6 +1004,9 @@ "Use template": "Usa modello", "Preview template: {{title}}": "Anteprima modello: {{title}}", "Use a template": "Usa un modello", + "Template": "Modello", + "Failed to load template": "Impossibile caricare il modello", + "This page is no longer empty": "Questa pagina non è più vuota", "Search templates...": "Cerca modelli...", "Search spaces...": "Cerca spazi...", "No templates found": "Nessun modello trovato", @@ -1420,8 +1438,8 @@ "Guides, references and answers across all our published spaces.": "Guide, riferimenti e risposte in tutti i nostri spazi pubblicati.", "Guides, references and answers across all our spaces.": "Guide, riferimenti e risposte in tutti i nostri spazi.", "Search documentation...": "Cerca nella documentazione...", - "1 published space": "1 published space", - "{{count}} published spaces": "{{count}} published spaces", + "1 published space": "1 spazio pubblicato", + "{{count}} published spaces": "{{count}} spazi pubblicati", "Actions": "Azioni", "Add destination": "Aggiungi destinazione", "Are you sure you want to delete the destination": "Sei sicuro di voler eliminare la destinazione", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "Evento di test inviato a {{name}}", "Updated SIEM destination": "Destinazione SIEM aggiornata", "Verify TLS certificate": "Verifica certificato TLS", - "e.g. Splunk prod": "ad es. Splunk produzione" + "e.g. Splunk prod": "ad es. Splunk produzione", + "Tabs": "Schede", + "Insert a multi-tab content block.": "Inserisci un blocco di contenuto con più schede.", + "Add tab": "Aggiungi scheda", + "Tab actions": "Azioni scheda", + "Move left": "Sposta a sinistra", + "Move right": "Sposta a destra", + "Delete tab": "Elimina scheda", + "Tab label": "Etichetta scheda", + "Tab actions for {{name}}": "Azioni scheda per {{name}}" } diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index d3c67354..cb389457 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "最近のページを取得できませんでした", "Failed to import pages": "ページのインポートに失敗しました", "Failed to load page. An error occurred.": "ページの読み込みに失敗しました。エラーが発生しました。", + "Failed to load presentation. An error occurred.": "プレゼンテーションの読み込みに失敗しました。エラーが発生しました。", "Failed to update data": "データの更新に失敗しました", "Failed to create base": "ベースの作成に失敗しました", "Failed to update base": "ベースの更新に失敗しました", @@ -161,6 +162,14 @@ "Pending": "保留中", "Please confirm your action": "アクションを確認してください", "Preferences": "設定", + "Presentation mode": "プレゼンテーションモード", + "Previous slide": "前のスライド", + "Next slide": "次のスライド", + "Exit presentation mode": "プレゼンテーションモードを終了", + "Enter fullscreen": "全画面表示にする", + "Exit fullscreen": "全画面表示を終了", + "Laser pointer": "レーザーポインター", + "Slide {{current}} of {{total}}": "スライド {{current}} / {{total}}", "Print PDF": "PDFを印刷", "Profile": "プロフィール", "Recently updated": "最近の更新", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "内容を表示するには、このベースを復元してください。", "Subpages": "サブページ", "Failed to load subpages": "サブページの読み込みに失敗しました", + "Sort subpages": "サブページを並べ替え", + "Manual": "手動", + "A–Z": "A~Z", + "Recent": "最近", "No subpages": "サブページはありません", "Subpages (Child pages)": "サブページ(子ページ)", "List all subpages of the current page": "現在のページのすべてのサブページを一覧表示", @@ -978,6 +991,8 @@ "Page menu": "ページメニュー", "Expand": "展開", "Collapse": "折りたたむ", + "Expand {{name}}": "{{name}} を展開", + "Collapse {{name}}": "{{name}} を折りたたむ", "Comment menu": "コメントメニュー", "Group menu": "グループメニュー", "Show hidden breadcrumbs": "非表示のパンくずリストを表示", @@ -989,6 +1004,9 @@ "Use template": "テンプレートを使用", "Preview template: {{title}}": "テンプレートをプレビュー: {{title}}", "Use a template": "テンプレートを使用", + "Template": "テンプレート", + "Failed to load template": "テンプレートの読み込みに失敗しました", + "This page is no longer empty": "このページはもう空ではありません", "Search templates...": "テンプレートを検索…", "Search spaces...": "スペースを検索…", "No templates found": "テンプレートが見つかりません", @@ -1199,7 +1217,7 @@ "Delete property": "プロパティを削除", "Delete view": "ビューを削除", "Delete {{count}} rows?_one": "1行を削除しますか?", - "Delete {{count}} rows?_other": "Delete {{count}} rows?", + "Delete {{count}} rows?_other": "{{count}} 行を削除しますか?", "Descending": "降順", "Discard": "破棄", "Doesn't contain": "含まない", @@ -1299,9 +1317,9 @@ "View updated for everyone": "ビューが全員向けに更新されました", "You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?", "{{count}} rows deleted_one": "1行を削除しました", - "{{count}} rows deleted_other": "{{count}} rows deleted", + "{{count}} rows deleted_other": "{{count}} 行を削除しました", "{{count}} selected_one": "1件を選択中", - "{{count}} selected_other": "{{count}} selected", + "{{count}} selected_other": "{{count}} 件を選択中", "Compare": "比較", "Compare versions": "バージョンを比較", "Select version from {{date}}": "{{date}} のバージョンを選択", @@ -1421,7 +1439,7 @@ "Guides, references and answers across all our spaces.": "すべてのスペースにわたるガイド、リファレンス、回答。", "Search documentation...": "ドキュメントを検索…", "1 published space": "1 件の公開スペース", - "{{count}} published spaces": "{{count}} published spaces", + "{{count}} published spaces": "公開済みのスペース {{count}} 件", "Actions": "操作", "Add destination": "宛先を追加", "Are you sure you want to delete the destination": "この宛先を削除してもよろしいですか", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "テストイベントを {{name}} に配信しました", "Updated SIEM destination": "SIEM 宛先を更新しました", "Verify TLS certificate": "TLS 証明書を検証", - "e.g. Splunk prod": "例: Splunk 本番" + "e.g. Splunk prod": "例: Splunk 本番", + "Tabs": "タブ", + "Insert a multi-tab content block.": "複数タブのコンテンツブロックを挿入します。", + "Add tab": "タブを追加", + "Tab actions": "タブのアクション", + "Move left": "左に移動", + "Move right": "右に移動", + "Delete tab": "タブを削除", + "Tab label": "タブラベル", + "Tab actions for {{name}}": "{{name}} のタブアクション" } diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index 02630f62..fb220dc0 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "최근 페이지 불러오기 실패", "Failed to import pages": "페이지 가져오기 실패", "Failed to load page. An error occurred.": "페이지 불러오기 실패. 오류가 발생했습니다.", + "Failed to load presentation. An error occurred.": "프레젠테이션을 불러오지 못했습니다. 오류가 발생했습니다.", "Failed to update data": "데이터 갱신 실패", "Failed to create base": "베이스를 만들지 못했습니다", "Failed to update base": "베이스를 업데이트하지 못했습니다", @@ -161,6 +162,14 @@ "Pending": "대기 중", "Please confirm your action": "작업을 확인해 주세요", "Preferences": "설정", + "Presentation mode": "프레젠테이션 모드", + "Previous slide": "이전 슬라이드", + "Next slide": "다음 슬라이드", + "Exit presentation mode": "프레젠테이션 모드 종료", + "Enter fullscreen": "전체 화면으로 전환", + "Exit fullscreen": "전체 화면 종료", + "Laser pointer": "레이저 포인터", + "Slide {{current}} of {{total}}": "슬라이드 {{total}}개 중 {{current}}", "Print PDF": "PDF로 인쇄", "Profile": "프로필", "Recently updated": "최근 업데이트", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "이 베이스를 복원하여 내용을 보세요.", "Subpages": "하위 페이지", "Failed to load subpages": "하위 페이지를 불러오지 못했습니다", + "Sort subpages": "하위 페이지 정렬", + "Manual": "수동", + "A–Z": "가나다순", + "Recent": "최근순", "No subpages": "하위 페이지가 없습니다", "Subpages (Child pages)": "하위 페이지 (자식 페이지)", "List all subpages of the current page": "현재 페이지의 모든 하위 페이지 나열", @@ -978,6 +991,8 @@ "Page menu": "페이지 메뉴", "Expand": "펼치기", "Collapse": "접기", + "Expand {{name}}": "{{name}} 펼치기", + "Collapse {{name}}": "{{name}} 접기", "Comment menu": "댓글 메뉴", "Group menu": "그룹 메뉴", "Show hidden breadcrumbs": "숨겨진 이동 경로 표시", @@ -989,6 +1004,9 @@ "Use template": "템플릿 사용", "Preview template: {{title}}": "템플릿 미리보기: {{title}}", "Use a template": "템플릿 사용", + "Template": "템플릿", + "Failed to load template": "템플릿을 불러오지 못했습니다", + "This page is no longer empty": "이 페이지는 더 이상 비어 있지 않습니다", "Search templates...": "템플릿 검색...", "Search spaces...": "스페이스 검색...", "No templates found": "템플릿을 찾을 수 없습니다", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "테스트 이벤트가 {{name}}에 전송되었습니다", "Updated SIEM destination": "SIEM 대상 업데이트됨", "Verify TLS certificate": "TLS 인증서 검증", - "e.g. Splunk prod": "예: Splunk 운영" + "e.g. Splunk prod": "예: Splunk 운영", + "Tabs": "탭", + "Insert a multi-tab content block.": "여러 탭이 있는 콘텐츠 블록을 삽입합니다.", + "Add tab": "탭 추가", + "Tab actions": "탭 작업", + "Move left": "왼쪽으로 이동", + "Move right": "오른쪽으로 이동", + "Delete tab": "탭 삭제", + "Tab label": "탭 레이블", + "Tab actions for {{name}}": "{{name}} 탭 작업" } diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index ee1900f8..7f12d485 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "Kan recente pagina's niet ophalen", "Failed to import pages": "Pagina's importeren mislukt", "Failed to load page. An error occurred.": "Laden van pagina mislukt. Er is een fout opgetreden.", + "Failed to load presentation. An error occurred.": "Presentatie laden mislukt. Er is een fout opgetreden.", "Failed to update data": "Bijwerken van gegevens mislukt", "Failed to create base": "Kan basis niet aanmaken", "Failed to update base": "Kan basis niet bijwerken", @@ -161,6 +162,14 @@ "Pending": "Wachtende", "Please confirm your action": "Bevestig alstublieft uw actie", "Preferences": "Voorkeuren", + "Presentation mode": "Presentatiemodus", + "Previous slide": "Vorige dia", + "Next slide": "Volgende dia", + "Exit presentation mode": "Presentatiemodus afsluiten", + "Enter fullscreen": "Volledig scherm openen", + "Exit fullscreen": "Volledig scherm afsluiten", + "Laser pointer": "Laserpointer", + "Slide {{current}} of {{total}}": "Dia {{current}} van {{total}}", "Print PDF": "PDF afdrukken", "Profile": "Profiel", "Recently updated": "Recent bijgewerkt", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "Herstel deze basis om de inhoud te bekijken.", "Subpages": "Subpagina's", "Failed to load subpages": "Subpagina's laden mislukt", + "Sort subpages": "Subpagina's sorteren", + "Manual": "Handmatig", + "A–Z": "A–Z", + "Recent": "Recent", "No subpages": "Geen subpagina's", "Subpages (Child pages)": "Subpagina's (onderliggende pagina's)", "List all subpages of the current page": "Toon alle subpagina's van de huidige pagina", @@ -978,6 +991,8 @@ "Page menu": "Paginamenu", "Expand": "Uitvouwen", "Collapse": "Samenvouwen", + "Expand {{name}}": "{{name}} uitvouwen", + "Collapse {{name}}": "{{name}} samenvouwen", "Comment menu": "Reactiemenu", "Group menu": "Groepsmenu", "Show hidden breadcrumbs": "Verborgen broodkruimels weergeven", @@ -989,6 +1004,9 @@ "Use template": "Sjabloon gebruiken", "Preview template: {{title}}": "Voorbeeld van sjabloon: {{title}}", "Use a template": "Een sjabloon gebruiken", + "Template": "Sjabloon", + "Failed to load template": "Sjabloon laden mislukt", + "This page is no longer empty": "Deze pagina is niet langer leeg", "Search templates...": "Sjablonen zoeken...", "Search spaces...": "Werkruimtes zoeken...", "No templates found": "Geen sjablonen gevonden", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "Testgebeurtenis afgeleverd aan {{name}}", "Updated SIEM destination": "SIEM-bestemming bijgewerkt", "Verify TLS certificate": "TLS-certificaat verifiëren", - "e.g. Splunk prod": "bijv. Splunk prod" + "e.g. Splunk prod": "bijv. Splunk prod", + "Tabs": "Tabbladen", + "Insert a multi-tab content block.": "Voeg een inhoudsblok met meerdere tabbladen in.", + "Add tab": "Tabblad toevoegen", + "Tab actions": "Tabbladacties", + "Move left": "Naar links verplaatsen", + "Move right": "Naar rechts verplaatsen", + "Delete tab": "Tabblad verwijderen", + "Tab label": "Tabbladlabel", + "Tab actions for {{name}}": "Tabbladacties voor {{name}}" } diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 68920e7b..1f3234f3 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "Falha ao buscar páginas recentes", "Failed to import pages": "Falha ao importar páginas", "Failed to load page. An error occurred.": "Falha ao carregar página. Ocorreu um erro.", + "Failed to load presentation. An error occurred.": "Falha ao carregar a apresentação. Ocorreu um erro.", "Failed to update data": "Falha ao atualizar dados", "Failed to create base": "Falha ao criar base", "Failed to update base": "Falha ao atualizar base", @@ -161,6 +162,14 @@ "Pending": "Pendente", "Please confirm your action": "Por favor, confirme sua ação", "Preferences": "Preferências", + "Presentation mode": "Modo de apresentação", + "Previous slide": "Slide anterior", + "Next slide": "Próximo slide", + "Exit presentation mode": "Sair do modo de apresentação", + "Enter fullscreen": "Entrar em tela cheia", + "Exit fullscreen": "Sair da tela cheia", + "Laser pointer": "Apontador laser", + "Slide {{current}} of {{total}}": "Slide {{current}} de {{total}}", "Print PDF": "Imprimir PDF", "Profile": "Perfil", "Recently updated": "Atualizado recentemente", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "Restaure esta base para visualizar seu conteúdo.", "Subpages": "Subpáginas", "Failed to load subpages": "Falha ao carregar as subpáginas", + "Sort subpages": "Ordenar subpáginas", + "Manual": "Manual", + "A–Z": "A–Z", + "Recent": "Recentes", "No subpages": "Nenhuma subpágina", "Subpages (Child pages)": "Subpáginas (páginas filhas)", "List all subpages of the current page": "Listar todas as subpáginas da página atual", @@ -978,6 +991,8 @@ "Page menu": "Menu da página", "Expand": "Expandir", "Collapse": "Recolher", + "Expand {{name}}": "Expandir {{name}}", + "Collapse {{name}}": "Recolher {{name}}", "Comment menu": "Menu de comentários", "Group menu": "Menu do grupo", "Show hidden breadcrumbs": "Mostrar breadcrumbs ocultos", @@ -989,6 +1004,9 @@ "Use template": "Usar modelo", "Preview template: {{title}}": "Visualizar modelo: {{title}}", "Use a template": "Usar um modelo", + "Template": "Modelo", + "Failed to load template": "Falha ao carregar o modelo", + "This page is no longer empty": "Esta página não está mais vazia", "Search templates...": "Pesquisar modelos...", "Search spaces...": "Pesquisar espaços...", "No templates found": "Nenhum modelo encontrado", @@ -1198,8 +1216,8 @@ "Default value": "Valor padrão", "Delete property": "Excluir propriedade", "Delete view": "Excluir visualização", - "Delete {{count}} rows?_one": "Delete 1 row?", - "Delete {{count}} rows?_other": "Delete {{count}} rows?", + "Delete {{count}} rows?_one": "Excluir 1 linha?", + "Delete {{count}} rows?_other": "Excluir {{count}} linhas?", "Descending": "Decrescente", "Discard": "Descartar", "Doesn't contain": "Não contém", @@ -1298,10 +1316,10 @@ "Value": "Valor", "View updated for everyone": "Visualização atualizada para todos", "You have unsaved changes. Do you want to discard them?": "Você tem alterações não salvas. Deseja descartá-las?", - "{{count}} rows deleted_one": "1 row deleted", - "{{count}} rows deleted_other": "{{count}} rows deleted", - "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected", + "{{count}} rows deleted_one": "1 linha excluída", + "{{count}} rows deleted_other": "{{count}} linhas excluídas", + "{{count}} selected_one": "1 selecionado", + "{{count}} selected_other": "{{count}} selecionados", "Compare": "Comparar", "Compare versions": "Comparar versões", "Select version from {{date}}": "Selecionar versão de {{date}}", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "Evento de teste entregue para {{name}}", "Updated SIEM destination": "Destino SIEM atualizado", "Verify TLS certificate": "Verificar certificado TLS", - "e.g. Splunk prod": "ex.: Splunk produção" + "e.g. Splunk prod": "ex.: Splunk produção", + "Tabs": "Abas", + "Insert a multi-tab content block.": "Insira um bloco de conteúdo com várias abas.", + "Add tab": "Adicionar aba", + "Tab actions": "Ações da aba", + "Move left": "Mover para a esquerda", + "Move right": "Mover para a direita", + "Delete tab": "Excluir aba", + "Tab label": "Rótulo da aba", + "Tab actions for {{name}}": "Ações da aba para {{name}}" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 4d3996dd..6ae63200 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "Не удалось получить недавние страницы", "Failed to import pages": "Не удалось импортировать страницы", "Failed to load page. An error occurred.": "Не удалось загрузить страницу. Произошла ошибка.", + "Failed to load presentation. An error occurred.": "Не удалось загрузить презентацию. Произошла ошибка.", "Failed to update data": "Не удалось обновить данные", "Failed to create base": "Не удалось создать базу", "Failed to update base": "Не удалось обновить базу", @@ -161,6 +162,14 @@ "Pending": "В ожидании", "Please confirm your action": "Пожалуйста, подтвердите ваше действие", "Preferences": "Настройки", + "Presentation mode": "Режим презентации", + "Previous slide": "Предыдущий слайд", + "Next slide": "Следующий слайд", + "Exit presentation mode": "Выйти из режима презентации", + "Enter fullscreen": "Перейти в полноэкранный режим", + "Exit fullscreen": "Выйти из полноэкранного режима", + "Laser pointer": "Лазерная указка", + "Slide {{current}} of {{total}}": "Слайд {{current}} из {{total}}", "Print PDF": "Печать PDF", "Profile": "Профиль", "Recently updated": "Обновлено недавно", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "Восстановите эту базу, чтобы просмотреть её содержимое.", "Subpages": "Подстраницы", "Failed to load subpages": "Не удалось загрузить подстраницы", + "Sort subpages": "Сортировать подстраницы", + "Manual": "Вручную", + "A–Z": "А–Я", + "Recent": "Недавние", "No subpages": "Нет подстраниц", "Subpages (Child pages)": "Подстраницы (дочерние страницы)", "List all subpages of the current page": "Показать все подстраницы текущей страницы", @@ -978,6 +991,8 @@ "Page menu": "Меню страницы", "Expand": "Развернуть", "Collapse": "Свернуть", + "Expand {{name}}": "Развернуть {{name}}", + "Collapse {{name}}": "Свернуть {{name}}", "Comment menu": "Меню комментария", "Group menu": "Меню группы", "Show hidden breadcrumbs": "Показать скрытые хлебные крошки", @@ -989,6 +1004,9 @@ "Use template": "Использовать шаблон", "Preview template: {{title}}": "Предпросмотр шаблона: {{title}}", "Use a template": "Использовать шаблон", + "Template": "Шаблон", + "Failed to load template": "Не удалось загрузить шаблон", + "This page is no longer empty": "Эта страница больше не пуста", "Search templates...": "Поиск шаблонов...", "Search spaces...": "Поиск пространств...", "No templates found": "Шаблоны не найдены", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "Тестовое событие доставлено в {{name}}", "Updated SIEM destination": "Обновлено место назначения SIEM", "Verify TLS certificate": "Проверять сертификат TLS", - "e.g. Splunk prod": "например, Splunk prod" + "e.g. Splunk prod": "например, Splunk prod", + "Tabs": "Вкладки", + "Insert a multi-tab content block.": "Вставить блок содержимого с несколькими вкладками.", + "Add tab": "Добавить вкладку", + "Tab actions": "Действия с вкладкой", + "Move left": "Переместить влево", + "Move right": "Переместить вправо", + "Delete tab": "Удалить вкладку", + "Tab label": "Название вкладки", + "Tab actions for {{name}}": "Действия с вкладкой для {{name}}" } diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index 9c94a290..dc6779b4 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки", "Failed to import pages": "Не вдалося імпортувати сторінки", "Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.", + "Failed to load presentation. An error occurred.": "Не вдалося завантажити презентацію. Сталася помилка.", "Failed to update data": "Не вдалося оновити дані", "Failed to create base": "Не вдалося створити базу", "Failed to update base": "Не вдалося оновити базу", @@ -161,6 +162,14 @@ "Pending": "В очікуванні", "Please confirm your action": "Будь ласка, підтвердіть вашу дію", "Preferences": "Налаштування", + "Presentation mode": "Режим презентації", + "Previous slide": "Попередній слайд", + "Next slide": "Наступний слайд", + "Exit presentation mode": "Вийти з режиму презентації", + "Enter fullscreen": "Увійти в повноекранний режим", + "Exit fullscreen": "Вийти з повноекранного режиму", + "Laser pointer": "Лазерна указка", + "Slide {{current}} of {{total}}": "Слайд {{current}} з {{total}}", "Print PDF": "Друк PDF", "Profile": "Профіль", "Recently updated": "Нещодавно оновлено", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "Відновіть цю базу, щоб переглянути її вміст.", "Subpages": "Підсторінки", "Failed to load subpages": "Не вдалося завантажити підсторінки", + "Sort subpages": "Сортувати підсторінки", + "Manual": "Вручну", + "A–Z": "А–Я", + "Recent": "Недавні", "No subpages": "Немає підсторінок", "Subpages (Child pages)": "Підсторінки (дочірні сторінки)", "List all subpages of the current page": "Показати всі підсторінки поточної сторінки", @@ -978,6 +991,8 @@ "Page menu": "Меню сторінки", "Expand": "Розгорнути", "Collapse": "Згорнути", + "Expand {{name}}": "Розгорнути {{name}}", + "Collapse {{name}}": "Згорнути {{name}}", "Comment menu": "Меню коментаря", "Group menu": "Меню групи", "Show hidden breadcrumbs": "Показати приховані \"хлібні крихти\"", @@ -989,6 +1004,9 @@ "Use template": "Використати шаблон", "Preview template: {{title}}": "Попередній перегляд шаблону: {{title}}", "Use a template": "Використати шаблон", + "Template": "Шаблон", + "Failed to load template": "Не вдалося завантажити шаблон", + "This page is no longer empty": "Ця сторінка більше не порожня", "Search templates...": "Шукати шаблони...", "Search spaces...": "Шукати простори...", "No templates found": "Шаблони не знайдено", @@ -1198,8 +1216,8 @@ "Default value": "Значення за замовчуванням", "Delete property": "Видалити властивість", "Delete view": "Видалити подання", - "Delete {{count}} rows?_one": "Delete 1 row?", - "Delete {{count}} rows?_other": "Delete {{count}} rows?", + "Delete {{count}} rows?_one": "Видалити 1 рядок?", + "Delete {{count}} rows?_other": "Видалити {{count}} рядків?", "Descending": "За спаданням", "Discard": "Скасувати", "Doesn't contain": "Не містить", @@ -1298,10 +1316,10 @@ "Value": "Значення", "View updated for everyone": "Подання оновлено для всіх", "You have unsaved changes. Do you want to discard them?": "У вас є незбережені зміни. Хочете їх скасувати?", - "{{count}} rows deleted_one": "1 row deleted", - "{{count}} rows deleted_other": "{{count}} rows deleted", - "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected", + "{{count}} rows deleted_one": "1 рядок видалено", + "{{count}} rows deleted_other": "{{count}} рядків видалено", + "{{count}} selected_one": "1 вибрано", + "{{count}} selected_other": "{{count}} вибрано", "Compare": "Порівняти", "Compare versions": "Порівняти версії", "Select version from {{date}}": "Виберіть версію від {{date}}", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "Тестову подію доставлено до {{name}}", "Updated SIEM destination": "Оновлено призначення SIEM", "Verify TLS certificate": "Перевіряти сертифікат TLS", - "e.g. Splunk prod": "напр., Splunk prod" + "e.g. Splunk prod": "напр., Splunk prod", + "Tabs": "Вкладки", + "Insert a multi-tab content block.": "Вставити блок вмісту з кількома вкладками.", + "Add tab": "Додати вкладку", + "Tab actions": "Дії вкладки", + "Move left": "Перемістити ліворуч", + "Move right": "Перемістити праворуч", + "Delete tab": "Видалити вкладку", + "Tab label": "Назва вкладки", + "Tab actions for {{name}}": "Дії вкладки для {{name}}" } diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 0924e345..4da58631 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -77,6 +77,7 @@ "Failed to fetch recent pages": "获取最近页面失败", "Failed to import pages": "导入页面失败", "Failed to load page. An error occurred.": "页面加载失败。发生了一个错误。", + "Failed to load presentation. An error occurred.": "加载演示文稿失败。发生错误。", "Failed to update data": "数据更新失败", "Failed to create base": "创建数据库失败", "Failed to update base": "更新数据库失败", @@ -161,6 +162,14 @@ "Pending": "待定", "Please confirm your action": "请确认您的操作", "Preferences": "偏好设置", + "Presentation mode": "演示模式", + "Previous slide": "上一张幻灯片", + "Next slide": "下一张幻灯片", + "Exit presentation mode": "退出演示模式", + "Enter fullscreen": "进入全屏", + "Exit fullscreen": "退出全屏", + "Laser pointer": "激光笔", + "Slide {{current}} of {{total}}": "第{{current}}张,共{{total}}张", "Print PDF": "打印 PDF", "Profile": "个人资料", "Recently updated": "最近更新", @@ -626,6 +635,10 @@ "Restore this base to view its contents.": "恢复此数据库以查看其内容。", "Subpages": "子页面", "Failed to load subpages": "加载子页面失败", + "Sort subpages": "对子页面排序", + "Manual": "手动", + "A–Z": "A–Z", + "Recent": "最近", "No subpages": "没有子页面", "Subpages (Child pages)": "子页面(下级页面)", "List all subpages of the current page": "列出当前页面的所有子页面", @@ -978,6 +991,8 @@ "Page menu": "页面菜单", "Expand": "展开", "Collapse": "折叠", + "Expand {{name}}": "展开{{name}}", + "Collapse {{name}}": "折叠{{name}}", "Comment menu": "评论菜单", "Group menu": "群组菜单", "Show hidden breadcrumbs": "显示隐藏的面包屑", @@ -989,6 +1004,9 @@ "Use template": "使用模板", "Preview template: {{title}}": "预览模板:{{title}}", "Use a template": "使用模板", + "Template": "模板", + "Failed to load template": "加载模板失败", + "This page is no longer empty": "此页面不再为空", "Search templates...": "搜索模板……", "Search spaces...": "搜索空间……", "No templates found": "未找到模板", @@ -1476,5 +1494,14 @@ "Test event delivered to {{name}}": "测试事件已发送到 {{name}}", "Updated SIEM destination": "已更新 SIEM 目标", "Verify TLS certificate": "验证 TLS 证书", - "e.g. Splunk prod": "例如:Splunk 生产环境" + "e.g. Splunk prod": "例如:Splunk 生产环境", + "Tabs": "标签页", + "Insert a multi-tab content block.": "插入一个多标签页内容区块。", + "Add tab": "添加标签页", + "Tab actions": "标签页操作", + "Move left": "左移", + "Move right": "右移", + "Delete tab": "删除标签页", + "Tab label": "标签页标签", + "Tab actions for {{name}}": "{{name}}的标签页操作" } From db104b79eb110a5ec5c62075c28466dc0acf077f Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:31:11 +0100 Subject: [PATCH 12/18] fix(editor): apply title save response after the title editor is destroyed (#2576) --- apps/client/src/features/editor/title-editor.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 26925b15..df91e0ff 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -149,7 +149,8 @@ export function TitleEditor({ }, }; - if (page.title !== titleEditor.getText()) return; + if (!titleEditor.isDestroyed && page.title !== titleEditor.getText()) + return; updatePageData(page); From a306d92213a100845b862bc270a94c9534a97b0a Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:07:52 +0100 Subject: [PATCH 13/18] refactor: resolve client IP through trusted proxies (#2580) * refactor: resolve client IP through trusted proxies - replace fastify-ip with an onRequest hook that sets request.ip from trusted proxy hops - read CF-Connecting-IP only when the hop is a Cloudflare edge - strip ports and validate the address before it is stored or used for rate limits - log the resolved IP in request log lines - add @fastify/proxy-addr as a direct dependency * refactor(collab): resolve client IP through trusted proxies - set request.ip with the same onRequest hook as the main server - carry the resolved client IP on collab WebSocket connections --- apps/server/package.json | 2 +- .../collaboration.gateway.spec.ts | 47 ++++ .../collaboration/collaboration.gateway.ts | 3 +- .../src/collaboration/server/collab-main.ts | 19 +- .../common/helpers/client-ip.utils.spec.ts | 215 ++++++++++++++++++ .../src/common/helpers/client-ip.utils.ts | 84 +++++++ apps/server/src/common/helpers/index.ts | 1 + .../src/common/logger/pino.config.spec.ts | 34 +++ apps/server/src/common/logger/pino.config.ts | 3 +- apps/server/src/main.ts | 31 ++- pnpm-lock.yaml | 24 +- 11 files changed, 419 insertions(+), 44 deletions(-) create mode 100644 apps/server/src/collaboration/collaboration.gateway.spec.ts create mode 100644 apps/server/src/common/helpers/client-ip.utils.spec.ts create mode 100644 apps/server/src/common/helpers/client-ip.utils.ts create mode 100644 apps/server/src/common/logger/pino.config.spec.ts diff --git a/apps/server/package.json b/apps/server/package.json index 4b0b5443..fbe8b01c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,7 @@ "@docmost/pdf-inspector": "1.9.6", "@fastify/cookie": "11.1.2", "@fastify/multipart": "10.1.2", + "@fastify/proxy-addr": "5.1.2", "@fastify/static": "10.1.3", "@jmondi/oauth2-server": "^4.3.7", "@keyv/redis": "5.1.6", @@ -80,7 +81,6 @@ "cookie": "1.1.1", "csv-stringify": "6.8.0", "fast-bm25": "0.0.5", - "fastify-ip": "2.0.0", "fs-extra": "11.3.4", "happy-dom": "20.8.9", "ioredis": "5.10.1", diff --git a/apps/server/src/collaboration/collaboration.gateway.spec.ts b/apps/server/src/collaboration/collaboration.gateway.spec.ts new file mode 100644 index 00000000..3eda5967 --- /dev/null +++ b/apps/server/src/collaboration/collaboration.gateway.spec.ts @@ -0,0 +1,47 @@ +import { IncomingMessage } from 'node:http'; +import WebSocket from 'ws'; +import { CollaborationGateway } from './collaboration.gateway'; + +function makeGateway() { + const environmentService = { + getRedisUrl: () => 'redis://localhost:6379', + isCollabDisableRedis: () => true, + }; + const gateway = new CollaborationGateway( + {} as any, + {} as any, + {} as any, + environmentService as any, + {} as any, + ); + const redisSync = { + onSocketOpen: jest.fn(), + onSocketMessage: jest.fn(), + onSocketClose: jest.fn(), + }; + (gateway as any).redisSync = redisSync; + return { gateway, redisSync }; +} + +describe('CollaborationGateway.handleConnection', () => { + it('passes the resolved client ip with the connection', () => { + const { gateway, redisSync } = makeGateway(); + const request = { + method: 'GET', + url: '/collab', + headers: { + 'sec-websocket-key': 'key', + 'x-forwarded-for': '162.158.1.1', + 'cf-connecting-ip': '198.51.100.20', + }, + socket: { remoteAddress: '10.42.0.66' }, + } as unknown as IncomingMessage; + + gateway.handleConnection({ on: jest.fn() } as unknown as WebSocket, request); + + expect(redisSync.onSocketOpen).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ socket: { remoteAddress: '198.51.100.20' } }), + ); + }); +}); diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts index 5556fef1..8e53e321 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -9,6 +9,7 @@ import { createRetryStrategy, parseRedisUrl, RedisConfig, + resolveClientIp, } from '../common/helpers'; import { LoggerExtension } from './extensions/logger.extension'; import { @@ -92,7 +93,7 @@ export class CollaborationGateway { 'sec-websocket-protocol': request.headers['sec-websocket-protocol'] ?? '', }, - socket: { remoteAddress: request.socket?.remoteAddress ?? '' }, + socket: { remoteAddress: resolveClientIp(request) ?? '' }, }; } diff --git a/apps/server/src/collaboration/server/collab-main.ts b/apps/server/src/collaboration/server/collab-main.ts index 89b1605b..5b4e04bd 100644 --- a/apps/server/src/collaboration/server/collab-main.ts +++ b/apps/server/src/collaboration/server/collab-main.ts @@ -8,17 +8,22 @@ import { TransformHttpResponseInterceptor } from '../../common/interceptors/http import { Logger } from '@nestjs/common'; import { Logger as PinoLogger } from 'nestjs-pino'; import { InternalLogFilter } from '../../common/logger/internal-log-filter'; +import { overrideRequestIp } from '../../common/helpers'; async function bootstrap() { + const adapter = new FastifyAdapter({ + routerOptions: { + maxParamLength: 1000, + ignoreTrailingSlash: true, + ignoreDuplicateSlashes: true, + }, + }); + // Added before NestFactory.create so it runs ahead of middie copying request.ip for Nest middleware. + adapter.getInstance().addHook('onRequest', overrideRequestIp); + const app = await NestFactory.create( CollabAppModule, - new FastifyAdapter({ - routerOptions: { - maxParamLength: 1000, - ignoreTrailingSlash: true, - ignoreDuplicateSlashes: true, - }, - }), + adapter, { logger: new InternalLogFilter(), bufferLogs: false, diff --git a/apps/server/src/common/helpers/client-ip.utils.spec.ts b/apps/server/src/common/helpers/client-ip.utils.spec.ts new file mode 100644 index 00000000..c3d4b160 --- /dev/null +++ b/apps/server/src/common/helpers/client-ip.utils.spec.ts @@ -0,0 +1,215 @@ +import { IncomingMessage } from 'node:http'; +import { + Controller, + Get, + MiddlewareConsumer, + Module, + NestModule, + Req, +} from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { + FastifyAdapter, + NestFastifyApplication, +} from '@nestjs/platform-fastify'; +import { FastifyRequest } from 'fastify'; +import { overrideRequestIp, resolveClientIp } from './client-ip.utils'; + +const CLIENT = '198.51.100.20'; +const ATTACKER = '203.0.113.66'; +const PROXY = '172.18.0.2'; +const CLOUDFLARE_EDGE = '162.158.1.1'; + +type Headers = Record; + +function fakeRequest( + remoteAddress: string | undefined, + headers: Headers = {}, +): IncomingMessage { + return { socket: { remoteAddress }, headers } as unknown as IncomingMessage; +} + +describe('resolveClientIp', () => { + it.each<[string, Headers]>([ + ['X-Client-IP', { 'x-client-ip': '9.9.9.9' }], + ['X-Real-IP', { 'x-real-ip': '9.9.9.9' }], + ['True-Client-IP', { 'true-client-ip': '9.9.9.9' }], + ['X-Forwarded-For', { 'x-forwarded-for': '9.9.9.9' }], + ['CF-Connecting-IP', { 'cf-connecting-ip': '9.9.9.9' }], + ])('ignores %s from a client that connects directly', (_header, headers) => { + expect(resolveClientIp(fakeRequest(ATTACKER, headers))).toBe(ATTACKER); + }); + + it.each<[string, string, string]>([ + ['nginx appending to a client-sent header', PROXY, `9.9.9.9, ${CLIENT}`], + ['Traefik or Caddy replacing the header', PROXY, CLIENT], + ['a proxy on loopback', '127.0.0.1', CLIENT], + ['a chain of private proxies', '127.0.0.1', `${CLIENT}, 10.0.0.5`], + ['an AWS ALB after a junk entry', '10.0.1.10', `unknown, ${CLIENT}`], + ['an Azure App Service front end', '169.254.130.1', CLIENT], + ['an Azure Container Apps ingress', '::ffff:100.100.0.52', CLIENT], + ])('reads X-Forwarded-For written by %s', (_setup, peer, forwardedFor) => { + expect( + resolveClientIp(fakeRequest(peer, { 'x-forwarded-for': forwardedFor })), + ).toBe(CLIENT); + }); + + it.each<[string, string, string]>([ + ['Azure Application Gateway', `9.9.9.9, ${CLIENT}:51234`, CLIENT], + ['an ALB with client ports on IPv6', '[2001:db8::5]:443', '2001:db8::5'], + ])('strips the port that %s adds', (_setup, forwardedFor, expected) => { + expect( + resolveClientIp( + fakeRequest('10.1.0.4', { 'x-forwarded-for': forwardedFor }), + ), + ).toBe(expected); + }); + + it.each<[string, string, Headers, string]>([ + [ + 'Cloudflare through a proxy that replaces X-Forwarded-For', + PROXY, + { 'x-forwarded-for': CLOUDFLARE_EDGE, 'cf-connecting-ip': CLIENT }, + CLIENT, + ], + [ + 'Cloudflare through a proxy that trusts Cloudflare', + PROXY, + { + 'x-forwarded-for': `9.9.9.9, ${CLIENT}, ${CLOUDFLARE_EDGE}`, + 'cf-connecting-ip': CLIENT, + }, + CLIENT, + ], + [ + 'Cloudflare connecting to Docmost directly', + CLOUDFLARE_EDGE, + { 'x-forwarded-for': CLIENT, 'cf-connecting-ip': CLIENT }, + CLIENT, + ], + [ + 'Cloudflare over IPv6', + PROXY, + { + 'x-forwarded-for': '2606:4700:10::6816:1', + 'cf-connecting-ip': '2001:db8::7', + }, + '2001:db8::7', + ], + [ + 'a Cloudflare edge without CF-Connecting-IP', + CLOUDFLARE_EDGE, + {}, + CLOUDFLARE_EDGE, + ], + [ + 'a Cloudflare edge with an invalid CF-Connecting-IP', + CLOUDFLARE_EDGE, + { 'cf-connecting-ip': 'not-an-ip' }, + CLOUDFLARE_EDGE, + ], + [ + 'a direct client forging CF-Connecting-IP', + ATTACKER, + { 'cf-connecting-ip': '9.9.9.9' }, + ATTACKER, + ], + [ + 'a client forging a Cloudflare hop behind nginx', + PROXY, + { + 'x-forwarded-for': `9.9.9.9, ${CLOUDFLARE_EDGE}, ${ATTACKER}`, + 'cf-connecting-ip': '9.9.9.9', + }, + ATTACKER, + ], + ])('resolves %s', (_setup, peer, headers, expected) => { + expect(resolveClientIp(fakeRequest(peer, headers))).toBe(expected); + }); + + it.each(['unknown', '(null)'])( + 'falls back to the proxy address when X-Forwarded-For is %s', + (forwardedFor) => { + expect( + resolveClientIp(fakeRequest(PROXY, { 'x-forwarded-for': forwardedFor })), + ).toBe(PROXY); + }, + ); + + it('unwraps IPv4-mapped addresses', () => { + expect(resolveClientIp(fakeRequest(`::ffff:${ATTACKER}`))).toBe(ATTACKER); + }); + + it('drops IPv6 zone ids', () => { + expect(resolveClientIp(fakeRequest('fe80::1%eth0'))).toBe('fe80::1'); + }); + + it('returns undefined when the socket has no address', () => { + expect(resolveClientIp(fakeRequest(undefined))).toBeUndefined(); + }); +}); + +describe('overrideRequestIp', () => { + let app: NestFastifyApplication; + let middlewareIp: string | undefined; + + @Controller('ip') + class IpController { + @Get() + ip(@Req() request: FastifyRequest) { + return { ip: request.ip, middlewareIp }; + } + } + + @Module({ controllers: [IpController] }) + class IpModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply( + ( + req: IncomingMessage & { ip?: string }, + _res: unknown, + next: () => void, + ) => { + middlewareIp = req.ip; + next(); + }, + ) + .forRoutes(IpController); + } + } + + beforeAll(async () => { + const adapter = new FastifyAdapter({ trustProxy: true }); + adapter.getInstance().addHook('onRequest', overrideRequestIp); + app = await NestFactory.create(IpModule, adapter, { + logger: false, + }); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(() => app.close()); + + async function requestIp(remoteAddress: string, headers: Headers) { + const res = await app.inject({ + method: 'GET', + url: '/ip', + remoteAddress, + headers, + }); + return res.json(); + } + + it('replaces the X-Forwarded-For entry trustProxy picks', async () => { + expect( + await requestIp(ATTACKER, { 'x-forwarded-for': '9.9.9.9' }), + ).toEqual({ ip: ATTACKER, middlewareIp: ATTACKER }); + }); + + it('is visible to Nest middleware when added before NestFactory.create', async () => { + expect( + await requestIp(PROXY, { 'x-forwarded-for': `9.9.9.9, ${CLIENT}` }), + ).toEqual({ ip: CLIENT, middlewareIp: CLIENT }); + }); +}); diff --git a/apps/server/src/common/helpers/client-ip.utils.ts b/apps/server/src/common/helpers/client-ip.utils.ts new file mode 100644 index 00000000..b942bbf8 --- /dev/null +++ b/apps/server/src/common/helpers/client-ip.utils.ts @@ -0,0 +1,84 @@ +import { IncomingMessage } from 'node:http'; +import { BlockList, isIP } from 'node:net'; +import proxyAddr from '@fastify/proxy-addr'; +import { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify'; + +// https://www.cloudflare.com/ips/ +const CLOUDFLARE_RANGES = [ + '173.245.48.0/20', + '103.21.244.0/22', + '103.22.200.0/22', + '103.31.4.0/22', + '141.101.64.0/18', + '108.162.192.0/18', + '190.93.240.0/20', + '188.114.96.0/20', + '197.234.240.0/22', + '198.41.128.0/17', + '162.158.0.0/15', + '104.16.0.0/13', + '104.24.0.0/14', + '172.64.0.0/13', + '131.0.72.0/22', + '2400:cb00::/32', + '2606:4700::/32', + '2803:f800::/32', + '2405:b500::/32', + '2405:8100::/32', + '2a06:98c0::/29', + '2c0f:f248::/32', +]; + +const isTrustedProxy = proxyAddr.compile([ + 'loopback', + 'linklocal', + 'uniquelocal', + '100.64.0.0/10', +]); + +const cloudflareEdges = new BlockList(); +for (const range of CLOUDFLARE_RANGES) { + const [network, prefix] = range.split('/'); + cloudflareEdges.addSubnet( + network, + Number(prefix), + isIP(network) === 6 ? 'ipv6' : 'ipv4', + ); +} + +function normalizeIp(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + // Drops the port Azure Application Gateway appends, IPv4-mapped prefixes and IPv6 zone ids. + const ip = value + .trim() + .replace(/^\[(.+)\](:\d+)?$/, '$1') + .replace(/^(\d+\.\d+\.\d+\.\d+):\d+$/, '$1') + .replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/i, '') + .replace(/%.*$/, ''); + return isIP(ip) ? ip : undefined; +} + +export function resolveClientIp(req: IncomingMessage): string | undefined { + const ip = normalizeIp(proxyAddr(req, isTrustedProxy)); + const cloudflareClientIp = normalizeIp(req.headers['cf-connecting-ip']); + if ( + ip && + cloudflareClientIp && + cloudflareEdges.check(ip, isIP(ip) === 6 ? 'ipv6' : 'ipv4') + ) { + return cloudflareClientIp; + } + return ip ?? normalizeIp(req.socket.remoteAddress); +} + +export function overrideRequestIp( + request: FastifyRequest, + _reply: FastifyReply, + done: HookHandlerDoneFunction, +): void { + // Fastify defines ip as a prototype getter, so it can't be assigned. + Object.defineProperty(request, 'ip', { + value: resolveClientIp(request.raw), + }); + done(); +} diff --git a/apps/server/src/common/helpers/index.ts b/apps/server/src/common/helpers/index.ts index c9fac94c..32bbd622 100644 --- a/apps/server/src/common/helpers/index.ts +++ b/apps/server/src/common/helpers/index.ts @@ -4,3 +4,4 @@ export * from './nanoid.utils'; export * from './file.helper'; export * from './constants'; export * from './security-headers'; +export * from './client-ip.utils'; diff --git a/apps/server/src/common/logger/pino.config.spec.ts b/apps/server/src/common/logger/pino.config.spec.ts new file mode 100644 index 00000000..14c39cbf --- /dev/null +++ b/apps/server/src/common/logger/pino.config.spec.ts @@ -0,0 +1,34 @@ +import { IncomingMessage } from 'node:http'; +import { stdSerializers } from 'pino'; +import { createPinoConfig } from './pino.config'; + +type RequestSerializer = (req: unknown) => Record; + +function serializeRequest(raw: object) { + const { serializers } = createPinoConfig().pinoHttp as { + serializers: { req: RequestSerializer }; + }; + // pino-http hands the custom serializer pino's own request summary, not the raw request + return stdSerializers.wrapRequestSerializer(serializers.req)( + raw as unknown as IncomingMessage, + ); +} + +describe('createPinoConfig request serializer', () => { + const raw = { + method: 'POST', + url: '/api/auth/login', + headers: { 'user-agent': 'jest' }, + socket: { remoteAddress: '172.18.0.2', remotePort: 51234 }, + }; + + it('logs the client ip resolved for the request', () => { + expect(serializeRequest({ ...raw, ip: '198.51.100.20' })).toMatchObject({ + ip: '198.51.100.20', + }); + }); + + it('falls back to the socket address when no ip was resolved', () => { + expect(serializeRequest(raw)).toMatchObject({ ip: '172.18.0.2' }); + }); +}); diff --git a/apps/server/src/common/logger/pino.config.ts b/apps/server/src/common/logger/pino.config.ts index 4c3b1993..2a62f2e4 100644 --- a/apps/server/src/common/logger/pino.config.ts +++ b/apps/server/src/common/logger/pino.config.ts @@ -54,7 +54,8 @@ export function createPinoConfig(): Params { req: (req) => ({ method: req.method, url: redactSensitiveUrl(req.url), - ip: req.ip || req.remoteAddress, + // middie copies the resolved request.ip onto the raw request + ip: req.raw?.ip || req.remoteAddress, userAgent: req.headers?.['user-agent'], }), res: (res) => ({ diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index b51d0294..2696d4e1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -10,25 +10,29 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter'; import fastifyMultipart from '@fastify/multipart'; import fastifyCookie from '@fastify/cookie'; -import fastifyIp from 'fastify-ip'; import { InternalLogFilter } from './common/logger/internal-log-filter'; import { EnvironmentService } from './integrations/environment/environment.service'; import { + overrideRequestIp, resolveFrameHeader, resolveFrameHeadersForPath, } from './common/helpers'; async function bootstrap() { + const adapter = new FastifyAdapter({ + trustProxy: true, + routerOptions: { + maxParamLength: 1000, + ignoreTrailingSlash: true, + ignoreDuplicateSlashes: true, + }, + }); + // Added before NestFactory.create so it runs ahead of middie copying request.ip for Nest middleware. + adapter.getInstance().addHook('onRequest', overrideRequestIp); + const app = await NestFactory.create( AppModule, - new FastifyAdapter({ - trustProxy: true, - routerOptions: { - maxParamLength: 1000, - ignoreTrailingSlash: true, - ignoreDuplicateSlashes: true, - }, - }), + adapter, { rawBody: true, // captures NestJS internal errors @@ -61,7 +65,6 @@ async function bootstrap() { app.useWebSocketAdapter(redisIoAdapter); - await app.register(fastifyIp); await app.register(fastifyMultipart); await app.register(fastifyCookie); @@ -90,14 +93,6 @@ async function bootstrap() { done(null, payload); }); - app - .getHttpAdapter() - .getInstance() - .addHook('onRequest', (request, _reply, done) => { - (request.raw as any).ip = request.ip; - done(); - }); - app .getHttpAdapter() .getInstance() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7933e4c8..16c6f2d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -528,6 +528,9 @@ importers: '@fastify/multipart': specifier: 10.1.2 version: 10.1.2 + '@fastify/proxy-addr': + specifier: 5.1.2 + version: 5.1.2 '@fastify/static': specifier: 10.1.3 version: 10.1.3 @@ -642,9 +645,6 @@ importers: fast-bm25: specifier: 0.0.5 version: 0.0.5(typescript@5.9.3) - fastify-ip: - specifier: 2.0.0 - version: 2.0.0 fs-extra: specifier: 11.3.4 version: 11.3.4 @@ -2204,8 +2204,8 @@ packages: '@fastify/multipart@10.1.2': resolution: {integrity: sha512-WHsqW5ffDXCbXXtdBQDryxu58fQvFPVGh4E4MlCO6hwCLNm0lYTCS6GKXUw2P64REDoECUNpMVvtMMstdIyBjQ==} - '@fastify/proxy-addr@5.0.0': - resolution: {integrity: sha512-37qVVA1qZ5sgH7KpHkkC4z9SK6StIsIcOmpjvMPXNb3vx2GQxhZocogVYbr2PbbeLCQxYIPDok307xEvRZOzGA==} + '@fastify/proxy-addr@5.1.2': + resolution: {integrity: sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==} '@fastify/send@4.1.0': resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} @@ -6412,10 +6412,6 @@ packages: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true - fastify-ip@2.0.0: - resolution: {integrity: sha512-7mQyAc7sapawpiriEFoJyQIs41nNIO42UCzgMKrjNGsIegnevj2VhOlXLLTa+q7cxXfJ5fDGmOAdQpaIgA9ObA==} - engines: {node: '>=20.x'} - fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -11714,7 +11710,7 @@ snapshots: fastify-plugin: 6.0.0 secure-json-parse: 4.0.0 - '@fastify/proxy-addr@5.0.0': + '@fastify/proxy-addr@5.1.2': dependencies: '@fastify/forwarded': 3.0.0 ipaddr.js: 2.2.0 @@ -16363,10 +16359,6 @@ snapshots: path-expression-matcher: 1.5.0 strnum: 2.2.3 - fastify-ip@2.0.0: - dependencies: - fastify-plugin: 5.1.0 - fastify-plugin@5.1.0: {} fastify-plugin@6.0.0: {} @@ -16376,8 +16368,8 @@ snapshots: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.0.0 '@fastify/fast-json-stringify-compiler': 5.0.2 - '@fastify/proxy-addr': 5.0.0 - abstract-logging: 2.0.1 + '@fastify/proxy-addr': 5.1.2 + abstract-logging: q2.0.1 avvio: 9.1.0 fast-json-stringify: 7.0.1 find-my-way: 9.7.0 From 00226f6b93f4fcf9b254eee8000f98e571923007 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:10:13 +0100 Subject: [PATCH 14/18] fix: client asset chunk prefetch (#2582) * fix(client): reload once when a lazy chunk fails to load * fix(client): show a fallback instead of a blank page when a route crashes * fix(server): return 404 for missing client assets instead of the app shell * fix(server): mark the missing-asset 404 as no-store * fix(client): skip chunk reloads while offline and widen the cooldown * perf(client): add lazyRoute and a background route prefetch * perf(client): prefetch every route in the background after startup - replaces the 3 s editor warm-up - skipped on share, public docs and PDF render pages, and with Data Saver on * fix(client): only auto-reload when a chunk fails to download - an error thrown while a route module runs no longer reloads the tab, so a broken route can't reload every new tab once routes are prefetched * fix(client): don't reload the tab when a background route prefetch fails - the route recovers when it is actually opened, so a prefetch failure no longer reloads mid-edit or replaces a navigation in progress * perf(client): skip route prefetch on OAuth consent and in dev * perf(client): prefetch non-route lazy components in the background * fix(client): stop the prefetch on failure and retry the stylesheets it failed to load * perf(client): prefetch the page editor before other lazy components --- .../public/locales/en-US/translation.json | 3 + apps/client/src/App.tsx | 104 +++++---- .../src/components/layouts/global/aside.tsx | 3 +- .../layouts/global/global-app-shell.tsx | 3 +- .../src/components/layouts/global/layout.tsx | 5 +- .../components/ui/app-error-boundary.test.tsx | 78 +++++++ .../src/components/ui/app-error-boundary.tsx | 41 ++++ .../client/src/components/ui/emoji-picker.tsx | 3 +- .../components/code-block/code-block-view.tsx | 4 +- .../excalidraw/excalidraw-menu-lazy.tsx | 5 +- .../excalidraw/excalidraw-view-lazy.tsx | 5 +- .../src/lib/chunk-load-recovery.test.ts | 110 +++++++++ apps/client/src/lib/chunk-load-recovery.ts | 49 ++++ apps/client/src/lib/lazy-prefetch.test.ts | 217 ++++++++++++++++++ apps/client/src/lib/lazy-prefetch.ts | 94 ++++++++ apps/client/src/main.tsx | 8 +- .../integrations/static/static.module.spec.ts | 73 ++++++ .../src/integrations/static/static.module.ts | 5 + 18 files changed, 750 insertions(+), 60 deletions(-) create mode 100644 apps/client/src/components/ui/app-error-boundary.test.tsx create mode 100644 apps/client/src/components/ui/app-error-boundary.tsx create mode 100644 apps/client/src/lib/chunk-load-recovery.test.ts create mode 100644 apps/client/src/lib/chunk-load-recovery.ts create mode 100644 apps/client/src/lib/lazy-prefetch.test.ts create mode 100644 apps/client/src/lib/lazy-prefetch.ts create mode 100644 apps/server/src/integrations/static/static.module.spec.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 08426c25..12c13f58 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1495,6 +1495,9 @@ "Updated SIEM destination": "Updated SIEM destination", "Verify TLS certificate": "Verify TLS certificate", "e.g. Splunk prod": "e.g. Splunk prod", + "Something went wrong": "Something went wrong", + "This page failed to load. Reload to try again.": "This page failed to load. Reload to try again.", + "Reload": "Reload" "Tabs": "Tabs", "Insert a multi-tab content block.": "Insert a multi-tab content block.", "Add tab": "Add tab", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index e4238a0a..d5c3a5d1 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -1,89 +1,92 @@ -import { lazy, Suspense, useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; import Layout from "@/components/layouts/global/layout.tsx"; import { Error404 } from "@/components/ui/error-404.tsx"; import { isCloud } from "@/lib/config.ts"; +import { lazyWithPrefetch, prefetchLazyComponents } from "@/lib/lazy-prefetch.ts"; import { useTranslation } from "react-i18next"; import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; import { useTrackOrigin } from "@/hooks/use-track-origin"; -const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx")); -const LoginPage = lazy(() => import("@/pages/auth/login")); -const Home = lazy(() => import("@/pages/dashboard/home")); -const Page = lazy(() => import("@/pages/page/page")); -const AccountSettings = lazy( +const SetupWorkspace = lazyWithPrefetch(() => import("@/pages/auth/setup-workspace.tsx")); +const LoginPage = lazyWithPrefetch(() => import("@/pages/auth/login")); +const Home = lazyWithPrefetch(() => import("@/pages/dashboard/home")); +const Page = lazyWithPrefetch(() => import("@/pages/page/page"), { + prefetchFirst: true, +}); +const AccountSettings = lazyWithPrefetch( () => import("@/pages/settings/account/account-settings"), ); -const WorkspaceMembers = lazy( +const WorkspaceMembers = lazyWithPrefetch( () => import("@/pages/settings/workspace/workspace-members"), ); -const WorkspaceSettings = lazy( +const WorkspaceSettings = lazyWithPrefetch( () => import("@/pages/settings/workspace/workspace-settings"), ); -const Groups = lazy(() => import("@/pages/settings/group/groups")); -const GroupInfo = lazy(() => import("./pages/settings/group/group-info")); -const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx")); -const AccountPreferences = lazy( +const Groups = lazyWithPrefetch(() => import("@/pages/settings/group/groups")); +const GroupInfo = lazyWithPrefetch(() => import("./pages/settings/group/group-info")); +const Spaces = lazyWithPrefetch(() => import("@/pages/settings/space/spaces.tsx")); +const AccountPreferences = lazyWithPrefetch( () => import("@/pages/settings/account/account-preferences.tsx"), ); -const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx")); -const PageRedirect = lazy(() => import("@/pages/page/page-redirect.tsx")); -const InviteSignup = lazy(() => import("@/pages/auth/invite-signup.tsx")); -const ForgotPassword = lazy(() => import("@/pages/auth/forgot-password.tsx")); -const PasswordReset = lazy(() => import("./pages/auth/password-reset")); -const Billing = lazy(() => import("@/ee/billing/pages/billing.tsx")); -const CloudLogin = lazy(() => import("@/ee/pages/cloud-login.tsx")); -const CreateWorkspace = lazy(() => import("@/ee/pages/create-workspace.tsx")); -const Security = lazy(() => import("@/ee/security/pages/security.tsx")); -const License = lazy(() => import("@/ee/licence/pages/license.tsx")); -const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx")); -const PdfRenderPage = lazy(() => import("@/ee/pdf-export/pdf-render-page.tsx")); -const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx")); -const ShareLayout = lazy( +const SpaceHome = lazyWithPrefetch(() => import("@/pages/space/space-home.tsx")); +const PageRedirect = lazyWithPrefetch(() => import("@/pages/page/page-redirect.tsx")); +const InviteSignup = lazyWithPrefetch(() => import("@/pages/auth/invite-signup.tsx")); +const ForgotPassword = lazyWithPrefetch(() => import("@/pages/auth/forgot-password.tsx")); +const PasswordReset = lazyWithPrefetch(() => import("./pages/auth/password-reset")); +const Billing = lazyWithPrefetch(() => import("@/ee/billing/pages/billing.tsx")); +const CloudLogin = lazyWithPrefetch(() => import("@/ee/pages/cloud-login.tsx")); +const CreateWorkspace = lazyWithPrefetch(() => import("@/ee/pages/create-workspace.tsx")); +const Security = lazyWithPrefetch(() => import("@/ee/security/pages/security.tsx")); +const License = lazyWithPrefetch(() => import("@/ee/licence/pages/license.tsx")); +const SharedPage = lazyWithPrefetch(() => import("@/pages/share/shared-page.tsx")); +const PdfRenderPage = lazyWithPrefetch(() => import("@/ee/pdf-export/pdf-render-page.tsx")); +const Shares = lazyWithPrefetch(() => import("@/pages/settings/shares/shares.tsx")); +const ShareLayout = lazyWithPrefetch( () => import("@/features/share/components/share-layout.tsx"), ); -const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx")); -const PublicSpacePage = lazy( +const ShareRedirect = lazyWithPrefetch(() => import("@/pages/share/share-redirect.tsx")); +const PublicSpacePage = lazyWithPrefetch( () => import("@/pages/public-space/public-space-page.tsx"), ); -const PublicSpaceLayout = lazy( +const PublicSpaceLayout = lazyWithPrefetch( () => import("@/features/public-space/components/public-space-layout.tsx"), ); -const PublicSpaceDirectoryPage = lazy( +const PublicSpaceDirectoryPage = lazyWithPrefetch( () => import("@/pages/public-space/public-space-directory-page.tsx"), ); -const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx")); -const MfaChallengePage = lazy(() => +const SpacesPage = lazyWithPrefetch(() => import("@/pages/spaces/spaces.tsx")); +const MfaChallengePage = lazyWithPrefetch(() => import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({ default: m.MfaChallengePage, })), ); -const MfaSetupRequiredPage = lazy(() => +const MfaSetupRequiredPage = lazyWithPrefetch(() => import("@/ee/mfa/pages/mfa-setup-required-page").then((m) => ({ default: m.MfaSetupRequiredPage, })), ); -const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx")); -const UserApiKeys = lazy(() => import("@/ee/api-key/pages/user-api-keys")); -const WorkspaceApiKeys = lazy( +const SpaceTrash = lazyWithPrefetch(() => import("@/pages/space/space-trash.tsx")); +const UserApiKeys = lazyWithPrefetch(() => import("@/ee/api-key/pages/user-api-keys")); +const WorkspaceApiKeys = lazyWithPrefetch( () => import("@/ee/api-key/pages/workspace-api-keys"), ); -const AiSettings = lazy(() => import("@/ee/ai/pages/ai-settings.tsx")); -const BasePage = lazy(() => import("@/ee/base/pages/base-page.tsx")); -const AuditLogs = lazy(() => import("@/ee/audit/pages/audit-logs.tsx")); -const VerifiedPages = lazy( +const AiSettings = lazyWithPrefetch(() => import("@/ee/ai/pages/ai-settings.tsx")); +const BasePage = lazyWithPrefetch(() => import("@/ee/base/pages/base-page.tsx")); +const AuditLogs = lazyWithPrefetch(() => import("@/ee/audit/pages/audit-logs.tsx")); +const VerifiedPages = lazyWithPrefetch( () => import("@/ee/page-verification/pages/verified-pages.tsx"), ); -const TemplateList = lazy(() => import("@/ee/template/pages/template-list")); -const TemplateEditor = lazy( +const TemplateList = lazyWithPrefetch(() => import("@/ee/template/pages/template-list")); +const TemplateEditor = lazyWithPrefetch( () => import("@/ee/template/pages/template-editor"), ); -const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page")); -const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx")); -const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx")); -const LabelPage = lazy(() => import("@/pages/label/label-page")); -const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx")); +const FavoritesPage = lazyWithPrefetch(() => import("@/pages/favorites/favorites-page")); +const AiChat = lazyWithPrefetch(() => import("@/ee/ai-chat/pages/ai-chat.tsx")); +const VerifyEmail = lazyWithPrefetch(() => import("@/ee/pages/verify-email.tsx")); +const LabelPage = lazyWithPrefetch(() => import("@/pages/label/label-page")); +const OAuthConsent = lazyWithPrefetch(() => import("@/ee/oauth/pages/oauth-consent.tsx")); export default function App() { const { t } = useTranslation(); @@ -91,8 +94,11 @@ export default function App() { useTrackOrigin(); useEffect(() => { - // warm the editor chunk so opening a page doesn't wait on the network - const timer = setTimeout(() => import("@/pages/page/page"), 3000); + // the dev server would transform every route module on each reload + if (import.meta.env.DEV) { + return; + } + const timer = setTimeout(prefetchLazyComponents, 3000); return () => clearTimeout(timer); }, []); diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index 856e0214..c29a14f6 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { useAtomValue } from "jotai"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx"; +import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts"; const CommentListWithTabs = lazy( () => import("@/features/comment/components/comment-list-with-tabs.tsx"), @@ -16,7 +17,7 @@ const TableOfContents = lazy(() => "@/features/editor/components/table-of-contents/table-of-contents.tsx" ).then((m) => ({ default: m.TableOfContents })), ); -const AsideChatPanel = lazy( +const AsideChatPanel = lazyWithPrefetch( () => import("@/ee/ai-chat/components/aside-chat-panel"), ); const PageDetailsAside = lazy(() => diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index 272ceba6..d7cf2201 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -11,8 +11,9 @@ import { sidebarWidthAtom, } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx"; +import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts"; -const AiChatSidebar = React.lazy( +const AiChatSidebar = lazyWithPrefetch( () => import("@/ee/ai-chat/components/ai-chat-sidebar.tsx"), ); import { AppHeader } from "@/components/layouts/global/app-header.tsx"; diff --git a/apps/client/src/components/layouts/global/layout.tsx b/apps/client/src/components/layouts/global/layout.tsx index 46e82fe5..b8540fb2 100644 --- a/apps/client/src/components/layouts/global/layout.tsx +++ b/apps/client/src/components/layouts/global/layout.tsx @@ -6,6 +6,7 @@ import { isCloud } from "@/lib/config.ts"; import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx"; import React from "react"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; +import { AppErrorBoundary } from "@/components/ui/app-error-boundary.tsx"; export default function Layout() { const { spaceSlug } = useParams(); @@ -14,7 +15,9 @@ export default function Layout() { return ( - + + + {isCloud() && } diff --git a/apps/client/src/components/ui/app-error-boundary.test.tsx b/apps/client/src/components/ui/app-error-boundary.test.tsx new file mode 100644 index 00000000..41bd078c --- /dev/null +++ b/apps/client/src/components/ui/app-error-boundary.test.tsx @@ -0,0 +1,78 @@ +import { MantineProvider } from "@mantine/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { Link, MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { AppErrorBoundary } from "./app-error-boundary.tsx"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (value: string) => value }), +})); + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function Broken(): never { + throw new TypeError("Failed to fetch dynamically imported module"); +} + +function renderAt(path: string) { + return render( + + + Home + + + } /> + Home page

} /> +
+
+
+
, + ); +} + +describe("AppErrorBoundary", () => { + beforeAll(() => { + setMediaMatch(); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("renders its children when nothing throws", () => { + renderAt("/home"); + expect(screen.getByText("Home page")).toBeTruthy(); + }); + + it("shows a reload prompt and keeps siblings mounted when a route throws", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + renderAt("/broken"); + expect(screen.getByText("Something went wrong")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Reload" })).toBeTruthy(); + expect(screen.getByText("Home")).toBeTruthy(); + }); + + it("recovers when the user navigates elsewhere", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + renderAt("/broken"); + fireEvent.click(screen.getByText("Home")); + expect(screen.getByText("Home page")).toBeTruthy(); + expect(screen.queryByText("Something went wrong")).toBeNull(); + }); +}); diff --git a/apps/client/src/components/ui/app-error-boundary.tsx b/apps/client/src/components/ui/app-error-boundary.tsx new file mode 100644 index 00000000..39f60c1d --- /dev/null +++ b/apps/client/src/components/ui/app-error-boundary.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from "react"; +import { useLocation } from "react-router-dom"; +import { ErrorBoundary } from "react-error-boundary"; +import { Button } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { EmptyState } from "@/components/ui/empty-state.tsx"; + +type AppErrorBoundaryProps = { + children: ReactNode; +}; + +export function AppErrorBoundary({ children }: AppErrorBoundaryProps) { + const { t } = useTranslation(); + const { pathname } = useLocation(); + + return ( + window.location.reload()} + > + {t("Reload")} + + } + /> + } + > + {children} + + ); +} diff --git a/apps/client/src/components/ui/emoji-picker.tsx b/apps/client/src/components/ui/emoji-picker.tsx index c360998a..8c456c3c 100644 --- a/apps/client/src/components/ui/emoji-picker.tsx +++ b/apps/client/src/components/ui/emoji-picker.tsx @@ -8,12 +8,13 @@ import { import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks"; import { Suspense } from "react"; import { useTranslation } from "react-i18next"; +import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts"; // Load the picker module AND the emoji data in parallel inside the lazy // resolution, then bind the data into the component. React.lazy only finishes // suspending once both are in memory, so the Suspense boundary hides the // Remove button until the Picker can render with real content. -const Picker = React.lazy(async () => { +const Picker = lazyWithPrefetch(async () => { const [pickerModule, dataModule] = await Promise.all([ import("@slidoapp/emoji-mart-react"), import("@slidoapp/emoji-mart-data"), diff --git a/apps/client/src/features/editor/components/code-block/code-block-view.tsx b/apps/client/src/features/editor/components/code-block/code-block-view.tsx index 0ff2fe36..197787ab 100644 --- a/apps/client/src/features/editor/components/code-block/code-block-view.tsx +++ b/apps/client/src/features/editor/components/code-block/code-block-view.tsx @@ -4,11 +4,11 @@ import { CopyButton } from "@/components/common/copy-button"; import { useEffect, useState } from "react"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import classes from "./code-block.module.css"; -import React from "react"; import { Suspense } from "react"; import { useTranslation } from "react-i18next"; +import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts"; -const MermaidView = React.lazy( +const MermaidView = lazyWithPrefetch( () => import("@/features/editor/components/code-block/mermaid-view.tsx"), ); diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu-lazy.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu-lazy.tsx index acdf5440..f7084311 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu-lazy.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu-lazy.tsx @@ -1,7 +1,8 @@ -import { lazy, Suspense } from "react"; +import { Suspense } from "react"; +import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; -const ExcalidrawMenu = lazy( +const ExcalidrawMenu = lazyWithPrefetch( () => import("@/features/editor/components/excalidraw/excalidraw-menu.tsx"), ); diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view-lazy.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view-lazy.tsx index 573a25db..8b72f3c6 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view-lazy.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view-lazy.tsx @@ -1,7 +1,8 @@ -import { lazy, Suspense } from "react"; +import { Suspense } from "react"; +import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts"; import { NodeViewProps } from "@tiptap/react"; -const ExcalidrawView = lazy( +const ExcalidrawView = lazyWithPrefetch( () => import("@/features/editor/components/excalidraw/excalidraw-view.tsx"), ); diff --git a/apps/client/src/lib/chunk-load-recovery.test.ts b/apps/client/src/lib/chunk-load-recovery.test.ts new file mode 100644 index 00000000..999c1e22 --- /dev/null +++ b/apps/client/src/lib/chunk-load-recovery.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + claimChunkReload, + claimReloadForPreloadError, + isChunkLoadFailure, +} from "./chunk-load-recovery"; + +const prefetchState = vi.hoisted(() => ({ running: false })); + +vi.mock("@/lib/lazy-prefetch.ts", () => ({ + isPrefetching: () => prefetchState.running, +})); + +const LOAD_FAILURE = new TypeError( + "Failed to fetch dynamically imported module: https://app.docmost.com/assets/groups-DHo4N9RM.js", +); + +describe("claimChunkReload", () => { + beforeEach(() => { + sessionStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-05T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("allows the first reload", () => { + expect(claimChunkReload()).toBe(true); + }); + + it("refuses a second reload inside the cooldown", () => { + claimChunkReload(); + vi.advanceTimersByTime(5_000); + expect(claimChunkReload()).toBe(false); + }); + + it("allows another reload once the cooldown has passed", () => { + claimChunkReload(); + vi.advanceTimersByTime(60_000); + expect(claimChunkReload()).toBe(true); + }); + + it("refuses to reload while offline without using up the cooldown", () => { + const onLine = vi.spyOn(navigator, "onLine", "get").mockReturnValue(false); + expect(claimChunkReload()).toBe(false); + onLine.mockReturnValue(true); + expect(claimChunkReload()).toBe(true); + }); + + it("refuses to reload when session storage is unavailable", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("SecurityError"); + }); + expect(claimChunkReload()).toBe(false); + }); +}); + +describe("isChunkLoadFailure", () => { + it.each([ + "Failed to fetch dynamically imported module: https://app.docmost.com/assets/groups-DHo4N9RM.js", + "error loading dynamically imported module: https://app.docmost.com/assets/groups-DHo4N9RM.js", + "Importing a module script failed.", + "Unable to preload CSS for /assets/groups-BJHsxMuK.css", + ])("treats %s as a load failure", (message) => { + expect(isChunkLoadFailure(new TypeError(message))).toBe(true); + }); + + it("does not treat an error thrown while a module runs as a load failure", () => { + expect( + isChunkLoadFailure( + new ReferenceError("Cannot access 'Groups' before initialization"), + ), + ).toBe(false); + }); + + it("does not treat a missing payload as a load failure", () => { + expect(isChunkLoadFailure(undefined)).toBe(false); + }); +}); + +describe("claimReloadForPreloadError", () => { + beforeEach(() => { + sessionStorage.clear(); + prefetchState.running = false; + }); + + it("claims a reload for a chunk that failed to download", () => { + expect(claimReloadForPreloadError(LOAD_FAILURE)).toBe(true); + }); + + it("ignores failures during the background prefetch without using up the cooldown", () => { + prefetchState.running = true; + expect(claimReloadForPreloadError(LOAD_FAILURE)).toBe(false); + + prefetchState.running = false; + expect(claimReloadForPreloadError(LOAD_FAILURE)).toBe(true); + }); + + it("ignores an error thrown while a module runs without using up the cooldown", () => { + expect( + claimReloadForPreloadError( + new ReferenceError("Cannot access 'Groups' before initialization"), + ), + ).toBe(false); + expect(claimReloadForPreloadError(LOAD_FAILURE)).toBe(true); + }); +}); diff --git a/apps/client/src/lib/chunk-load-recovery.ts b/apps/client/src/lib/chunk-load-recovery.ts new file mode 100644 index 00000000..b62ae2e3 --- /dev/null +++ b/apps/client/src/lib/chunk-load-recovery.ts @@ -0,0 +1,49 @@ +import { isPrefetching } from "@/lib/lazy-prefetch.ts"; + +const RELOAD_AT_KEY = "chunk-reload-at"; +const RELOAD_COOLDOWN_MS = 60_000; +// how Chromium, Firefox and Safari word a module that failed to download, plus Vite's CSS preload failure +const LOAD_FAILURE_MESSAGES = [ + "Failed to fetch dynamically imported module", + "error loading dynamically imported module", + "Importing a module script failed", + "Unable to preload CSS", +]; + +export function claimChunkReload(): boolean { + if (!navigator.onLine) { + return false; + } + + try { + const lastReloadAt = Number(sessionStorage.getItem(RELOAD_AT_KEY)); + if (Date.now() - lastReloadAt < RELOAD_COOLDOWN_MS) { + return false; + } + sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now())); + return true; + } catch { + return false; + } +} + +export function isChunkLoadFailure(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return LOAD_FAILURE_MESSAGES.some((text) => message.includes(text)); +} + +export function claimReloadForPreloadError(error: unknown): boolean { + if (isPrefetching() || !isChunkLoadFailure(error)) { + return false; + } + return claimChunkReload(); +} + +export function installChunkLoadRecovery() { + // a tab opened before a deploy asks for chunks that no longer exist; a reload picks up the new build + window.addEventListener("vite:preloadError", (event) => { + if (claimReloadForPreloadError(event.payload)) { + window.location.reload(); + } + }); +} diff --git a/apps/client/src/lib/lazy-prefetch.test.ts b/apps/client/src/lib/lazy-prefetch.test.ts new file mode 100644 index 00000000..05aeae60 --- /dev/null +++ b/apps/client/src/lib/lazy-prefetch.test.ts @@ -0,0 +1,217 @@ +import { createElement, Suspense } from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +function trackedLoader(name: string, events: string[]) { + return async () => { + events.push(`start ${name}`); + await Promise.resolve(); + events.push(`end ${name}`); + return { default: () => null }; + }; +} + +async function freshModule() { + vi.resetModules(); + return import("./lazy-prefetch"); +} + +describe("lazy prefetch", () => { + afterEach(() => { + cleanup(); + Reflect.deleteProperty(navigator, "connection"); + Reflect.deleteProperty(navigator, "onLine"); + window.history.pushState({}, "", "/"); + document.head.querySelectorAll("link").forEach((link) => link.remove()); + }); + + it("does not load a component when it is declared", async () => { + const { lazyWithPrefetch } = await freshModule(); + const load = vi.fn(async () => ({ default: () => null })); + + lazyWithPrefetch(load); + + expect(load).not.toHaveBeenCalled(); + }); + + it("loads every declared component one at a time", async () => { + const { lazyWithPrefetch, prefetchLazyComponents } = await freshModule(); + const events: string[] = []; + lazyWithPrefetch(trackedLoader("home", events)); + lazyWithPrefetch(trackedLoader("groups", events)); + + await prefetchLazyComponents(); + + expect(events).toEqual([ + "start home", + "end home", + "start groups", + "end groups", + ]); + }); + + it("loads a component marked prefetchFirst before the rest", async () => { + const { lazyWithPrefetch, prefetchLazyComponents } = await freshModule(); + const events: string[] = []; + lazyWithPrefetch(trackedLoader("home", events)); + lazyWithPrefetch(trackedLoader("page", events), { prefetchFirst: true }); + + await prefetchLazyComponents(); + + expect(events).toEqual([ + "start page", + "end page", + "start home", + "end home", + ]); + }); + + it("loads components declared by a chunk it prefetched", async () => { + const { lazyWithPrefetch, prefetchLazyComponents } = await freshModule(); + const events: string[] = []; + lazyWithPrefetch(async () => { + events.push("page"); + lazyWithPrefetch(trackedLoader("mermaid", events)); + return { default: () => null }; + }); + + await prefetchLazyComponents(); + + expect(events).toEqual(["page", "start mermaid", "end mermaid"]); + }); + + it("stops at the first component that fails to load", async () => { + const { lazyWithPrefetch, prefetchLazyComponents, isPrefetching } = + await freshModule(); + const later = vi.fn(async () => ({ default: () => null })); + lazyWithPrefetch(() => + Promise.reject( + new TypeError("Failed to fetch dynamically imported module"), + ), + ); + lazyWithPrefetch(later); + + await expect(prefetchLazyComponents()).resolves.toBeUndefined(); + await prefetchLazyComponents(); + + expect(later).not.toHaveBeenCalled(); + expect(isPrefetching()).toBe(false); + }); + + it("runs only once when started again", async () => { + const { lazyWithPrefetch, prefetchLazyComponents } = await freshModule(); + const events: string[] = []; + lazyWithPrefetch(trackedLoader("home", events)); + lazyWithPrefetch(trackedLoader("groups", events)); + + await Promise.all([prefetchLazyComponents(), prefetchLazyComponents()]); + + expect(events).toEqual([ + "start home", + "end home", + "start groups", + "end groups", + ]); + }); + + it("reports a prefetch as running only while it runs", async () => { + const { lazyWithPrefetch, prefetchLazyComponents, isPrefetching } = + await freshModule(); + let duringPrefetch: boolean | undefined; + lazyWithPrefetch(async () => { + duringPrefetch = isPrefetching(); + return { default: () => null }; + }); + + await prefetchLazyComponents(); + + expect(duringPrefetch).toBe(true); + expect(isPrefetching()).toBe(false); + }); + + it("retries a stylesheet that failed during the prefetch before rendering a component", async () => { + const { lazyWithPrefetch, prefetchLazyComponents } = await freshModule(); + const href = `${window.location.origin}/assets/groups.css`; + const failed = document.createElement("link"); + lazyWithPrefetch(async () => { + failed.rel = "stylesheet"; + failed.href = href; + document.head.appendChild(failed); + failed.dispatchEvent(new Event("error")); + throw new Error(`Unable to preload CSS for ${href}`); + }); + const Groups = lazyWithPrefetch(async () => ({ + default: () => createElement("p", null, "Groups"), + })); + await prefetchLazyComponents(); + + render(createElement(Suspense, { fallback: null }, createElement(Groups))); + const retry = await vi.waitFor(() => { + const link = document.head.querySelector(`link[href="${href}"]`); + expect(link).not.toBe(failed); + return link; + }); + + expect(failed.isConnected).toBe(false); + expect(screen.queryByText("Groups")).toBeNull(); + retry.dispatchEvent(new Event("load")); + expect(await screen.findByText("Groups")).toBeTruthy(); + }); + + it("does nothing while offline", async () => { + const { lazyWithPrefetch, prefetchLazyComponents } = await freshModule(); + Object.defineProperty(navigator, "onLine", { + value: false, + configurable: true, + }); + const load = vi.fn(async () => ({ default: () => null })); + lazyWithPrefetch(load); + + await prefetchLazyComponents(); + + expect(load).not.toHaveBeenCalled(); + }); + + it("does nothing when Data Saver is on", async () => { + const { lazyWithPrefetch, prefetchLazyComponents } = await freshModule(); + Object.defineProperty(navigator, "connection", { + value: { saveData: true }, + configurable: true, + }); + const load = vi.fn(async () => ({ default: () => null })); + lazyWithPrefetch(load); + + await prefetchLazyComponents(); + + expect(load).not.toHaveBeenCalled(); + }); + + it.each([ + "/share/abc123/p/roadmap", + "/share/p/roadmap", + "/docs", + "/docs/handbook/intro", + "/pdf-render/0199a1b2", + "/oauth/consent", + ])("does nothing on %s", async (path) => { + const { lazyWithPrefetch, prefetchLazyComponents } = await freshModule(); + window.history.pushState({}, "", path); + const load = vi.fn(async () => ({ default: () => null })); + lazyWithPrefetch(load); + + await prefetchLazyComponents(); + + expect(load).not.toHaveBeenCalled(); + }); + + it("prefetches on app pages", async () => { + const { lazyWithPrefetch, prefetchLazyComponents } = await freshModule(); + window.history.pushState({}, "", "/s/eng/p/roadmap"); + const load = vi.fn(async () => ({ default: () => null })); + lazyWithPrefetch(load); + + await prefetchLazyComponents(); + + expect(load).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/client/src/lib/lazy-prefetch.ts b/apps/client/src/lib/lazy-prefetch.ts new file mode 100644 index 00000000..4ac1215d --- /dev/null +++ b/apps/client/src/lib/lazy-prefetch.ts @@ -0,0 +1,94 @@ +import { ComponentType, lazy } from "react"; + +type NavigatorWithConnection = Navigator & { + connection?: { saveData?: boolean }; +}; + +type LazyWithPrefetchOptions = { + prefetchFirst?: boolean; +}; + +const prefetchQueue: Array<() => Promise> = []; +// Vite never requests a stylesheet twice, so one that fails during the prefetch stays missing until retried here +const failedStylesheets = new Set(); + +let prefetchState: "idle" | "running" | "done" = "idle"; + +export function isPrefetching() { + return prefetchState === "running"; +} + +export function lazyWithPrefetch>( + load: () => Promise<{ default: T }>, + options: LazyWithPrefetchOptions = {}, +) { + if (options.prefetchFirst) { + prefetchQueue.unshift(load); + } else { + prefetchQueue.push(load); + } + return lazy(async () => { + await retryFailedStylesheets(); + return load(); + }); +} + +export async function prefetchLazyComponents() { + // anonymous share/docs viewers, OAuth consent and the PDF renderer only need the page they opened + const isStandalonePage = /^\/(share|docs|pdf-render|oauth)(\/|$)/.test( + window.location.pathname, + ); + const saveData = (navigator as NavigatorWithConnection).connection?.saveData; + if ( + prefetchState !== "idle" || + isStandalonePage || + saveData || + !navigator.onLine + ) { + return; + } + + prefetchState = "running"; + document.addEventListener("error", collectFailedStylesheet, true); + try { + // the queue also receives components declared by chunks this loop loads, such as the editor's + while (prefetchQueue.length > 0) { + const load = prefetchQueue.shift(); + await load(); + } + } catch { + // stop at the first failure so a flaky network doesn't break more stylesheets; the rest load when rendered + } finally { + document.removeEventListener("error", collectFailedStylesheet, true); + prefetchState = "done"; + } +} + +function collectFailedStylesheet(event: Event) { + const link = event.target; + if ( + link instanceof HTMLLinkElement && + link.rel === "stylesheet" && + link.href.startsWith(`${window.location.origin}/`) + ) { + failedStylesheets.add(link); + } +} + +function retryFailedStylesheets() { + const retries = [...failedStylesheets].map((failed) => { + failedStylesheets.delete(failed); + const retry = failed.cloneNode() as HTMLLinkElement; + const settled = new Promise((resolve) => { + retry.addEventListener("load", () => resolve()); + retry.addEventListener("error", () => { + failedStylesheets.add(retry); + resolve(); + }); + }); + failed.remove(); + document.head.appendChild(retry); + return settled; + }); + return Promise.all(retries); +} diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 5dbf6b81..92c6c96c 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -21,6 +21,8 @@ import { isCloud, isPostHogEnabled, } from "@/lib/config.ts"; +import { installChunkLoadRecovery } from "@/lib/chunk-load-recovery.ts"; +import { AppErrorBoundary } from "@/components/ui/app-error-boundary.tsx"; import posthog from "posthog-js"; export const queryClient = new QueryClient({ @@ -43,6 +45,8 @@ if (isCloud() && isPostHogEnabled) { }); } +installChunkLoadRecovery(); + const container = document.getElementById("root") as HTMLElement; const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container); @@ -54,7 +58,9 @@ root.render( - + + + diff --git a/apps/server/src/integrations/static/static.module.spec.ts b/apps/server/src/integrations/static/static.module.spec.ts new file mode 100644 index 00000000..51a420d3 --- /dev/null +++ b/apps/server/src/integrations/static/static.module.spec.ts @@ -0,0 +1,73 @@ +import { HttpAdapterHost } from '@nestjs/core'; +import { FastifyAdapter } from '@nestjs/platform-fastify'; +import { StaticModule } from './static.module'; +import { EnvironmentService } from '../environment/environment.service'; + +jest.mock('node:fs', () => { + const actual = jest.requireActual('node:fs'); + const { Readable } = jest.requireActual( + 'node:stream', + ); + const isClientDist = (path: unknown) => String(path).includes('client/dist'); + + return { + ...actual, + existsSync: (path: string) => isClientDist(path) || actual.existsSync(path), + readFileSync: (path: string, ...rest: any[]) => + isClientDist(path) + ? '' + : (actual.readFileSync as any)(path, ...rest), + writeFileSync: (path: string, ...rest: any[]) => + isClientDist(path) + ? undefined + : (actual.writeFileSync as any)(path, ...rest), + copyFileSync: (path: string, ...rest: any[]) => + isClientDist(path) + ? undefined + : (actual.copyFileSync as any)(path, ...rest), + createReadStream: (path: string, ...rest: any[]) => + isClientDist(path) + ? Readable.from(['']) + : (actual.createReadStream as any)(path, ...rest), + }; +}); + +describe('StaticModule', () => { + let adapter: FastifyAdapter; + + beforeEach(async () => { + adapter = new FastifyAdapter(); + const environmentService = new Proxy( + {}, + { get: () => () => undefined }, + ) as unknown as EnvironmentService; + + await new StaticModule( + { httpAdapter: adapter } as unknown as HttpAdapterHost, + environmentService, + ).onModuleInit(); + }); + + afterEach(async () => { + await adapter.close(); + }); + + it('returns 404 for an asset the current build does not have', async () => { + const response = await adapter + .getInstance() + .inject({ method: 'GET', url: '/assets/groups-stale.js' }); + + expect(response.statusCode).toBe(404); + expect(response.headers['content-type'] ?? '').not.toContain('text/html'); + expect(response.headers['cache-control']).toContain('no-store'); + }); + + it('serves the app shell for client routes', async () => { + const response = await adapter + .getInstance() + .inject({ method: 'GET', url: '/settings/groups' }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/html'); + }); +}); diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index 16e3f4cf..b15f0bcf 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -84,6 +84,11 @@ export class StaticModule implements OnModuleInit { }); app.get(RENDER_PATH, (req: any, res: any) => { + // a missing hashed asset belongs to another build, so don't answer it with the app shell + if (req.url.startsWith('/assets/')) { + return res.code(404).header('Cache-Control', 'no-store').send(); + } + const stream = fs.createReadStream(indexFilePath); res .header('Cache-Control', 'no-cache, no-store, must-revalidate') From 7300ab4d1d03859f79e8418e97e5275e3b96de9b Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:22:07 +0100 Subject: [PATCH 15/18] feat(attachments): let space admins delete page attachments (#2583) --- .../public/locales/en-US/translation.json | 4 + .../src/ee/audit/lib/audit-event-labels.ts | 8 ++ .../components/page-attachments-modal.tsx | 78 +++++++++++++++++-- .../attachments/queries/attachment-query.ts | 41 +++++++++- .../services/attachment-service.ts | 4 + .../components/header/page-header-menu.tsx | 1 + apps/server/src/common/events/audit-events.ts | 2 +- .../core/attachment/attachment.controller.ts | 53 +++++++++++++ .../src/core/attachment/dto/attachment.dto.ts | 6 ++ .../attachment/services/attachment.service.ts | 5 ++ 10 files changed, 194 insertions(+), 8 deletions(-) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 12c13f58..a6c18a1f 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1331,6 +1331,10 @@ "No attachments on this page yet.": "No attachments on this page yet.", "Uploaded by {{name}}": "Uploaded by {{name}}", "Download {{name}}": "Download {{name}}", + "Delete attachment": "Delete attachment", + "Delete {{name}}": "Delete {{name}}", + "Attachment deleted": "Attachment deleted", + "Failed to delete attachment": "Failed to delete attachment", "Access revoked": "Access revoked", "Authorize application": "Authorize application", "{{name}} wants to access {{workspace}}": "{{name}} wants to access {{workspace}}", diff --git a/apps/client/src/ee/audit/lib/audit-event-labels.ts b/apps/client/src/ee/audit/lib/audit-event-labels.ts index 2f22514f..ffa81470 100644 --- a/apps/client/src/ee/audit/lib/audit-event-labels.ts +++ b/apps/client/src/ee/audit/lib/audit-event-labels.ts @@ -54,6 +54,8 @@ export const auditEventLabels: Record = { "comment.deleted": "Deleted comment", + "attachment.deleted": "Deleted attachment", + "page.trashed": "Trashed page", "page.deleted": "Deleted page", "page.restored": "Restored page", @@ -148,6 +150,12 @@ export const eventFilterOptions: EventGroup[] = [ { value: "comment.deleted", label: "Deleted comment" }, ], }, + { + group: "Attachment", + items: [ + { value: "attachment.deleted", label: "Deleted attachment" }, + ], + }, { group: "Page", items: [ diff --git a/apps/client/src/features/attachments/components/page-attachments-modal.tsx b/apps/client/src/features/attachments/components/page-attachments-modal.tsx index 53189b74..869cd9de 100644 --- a/apps/client/src/features/attachments/components/page-attachments-modal.tsx +++ b/apps/client/src/features/attachments/components/page-attachments-modal.tsx @@ -10,25 +10,38 @@ import { Text, Tooltip, } from "@mantine/core"; -import { IconDownload } from "@tabler/icons-react"; +import { IconDownload, IconTrash } from "@tabler/icons-react"; +import { modals } from "@mantine/modals"; import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; import { SearchInput } from "@/components/common/search-input.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; -import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts"; +import { + useDeleteAttachmentMutation, + usePageAttachmentsQuery, +} from "@/features/attachments/queries/attachment-query.ts"; import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts"; import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx"; import { formatBytes } from "@/lib"; import { getFileUrl } from "@/lib/config.ts"; import { formattedDate } from "@/lib/time.ts"; +import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; +import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from "@/features/space/permissions/permissions.type.ts"; interface PageAttachmentsModalProps { pageId: string; + readOnly?: boolean; open: boolean; onClose: () => void; } export default function PageAttachmentsModal({ pageId, + readOnly, open, onClose, }: PageAttachmentsModalProps) { @@ -42,12 +55,17 @@ export default function PageAttachmentsModal({ size={800} closeButtonProps={{ "aria-label": t("Close") }} > - + ); } -function PageAttachmentsList({ pageId }: { pageId: string }) { +type PageAttachmentsListProps = { + pageId: string; + readOnly?: boolean; +}; + +function PageAttachmentsList({ pageId, readOnly }: PageAttachmentsListProps) { const { t } = useTranslation(); const [search, setSearch] = useState(""); const { @@ -59,6 +77,14 @@ function PageAttachmentsList({ pageId }: { pageId: string }) { hasNextPage, isFetchingNextPage, } = usePageAttachmentsQuery(pageId, search); + const deleteAttachmentMutation = useDeleteAttachmentMutation(pageId); + + const { spaceSlug } = useParams(); + const { data: space } = useSpaceQuery(spaceSlug); + const spaceAbility = useSpaceAbility(space?.membership?.permissions); + const canDelete = + !readOnly && + spaceAbility.can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings); const attachments = useMemo( () => data?.pages.flatMap((page) => page.items) ?? [], @@ -86,6 +112,24 @@ function PageAttachmentsList({ pageId }: { pageId: string }) { const handleSearch = useCallback((value: string) => setSearch(value), []); + const handleDelete = (attachment: IPageAttachment) => { + modals.openConfirmModal({ + title: t("Delete attachment"), + centered: true, + children: ( + + {t( + "Are you sure you want to delete '{{title}}'? This action cannot be undone.", + { title: attachment.fileName }, + )} + + ), + labels: { confirm: t("Delete"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => deleteAttachmentMutation.mutate(attachment.id), + }); + }; + return ( <> {attachments.map((attachment) => ( - + ))} {hasNextPage &&
} {isFetchingNextPage && ( @@ -128,7 +176,12 @@ function PageAttachmentsList({ pageId }: { pageId: string }) { ); } -function AttachmentRow({ attachment }: { attachment: IPageAttachment }) { +type AttachmentRowProps = { + attachment: IPageAttachment; + onDelete?: (attachment: IPageAttachment) => void; +}; + +function AttachmentRow({ attachment, onDelete }: AttachmentRowProps) { const { t } = useTranslation(); const fileUrl = getFileUrl(attachment.url); @@ -186,6 +239,19 @@ function AttachmentRow({ attachment }: { attachment: IPageAttachment }) { + + {onDelete && ( + + onDelete(attachment)} + > + + + + )} ); } diff --git a/apps/client/src/features/attachments/queries/attachment-query.ts b/apps/client/src/features/attachments/queries/attachment-query.ts index e9630bdd..8b41ad13 100644 --- a/apps/client/src/features/attachments/queries/attachment-query.ts +++ b/apps/client/src/features/attachments/queries/attachment-query.ts @@ -3,8 +3,15 @@ import { keepPreviousData, useInfiniteQuery, UseInfiniteQueryResult, + useMutation, + useQueryClient, } from "@tanstack/react-query"; -import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { + deleteAttachment, + getPageAttachments, +} from "@/features/attachments/services/attachment-service.ts"; import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts"; import { IPagination } from "@/lib/types.ts"; @@ -23,3 +30,35 @@ export function usePageAttachmentsQuery( getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined, }); } + +export function useDeleteAttachmentMutation(pageId: string) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (attachmentId) => deleteAttachment(attachmentId), + onSuccess: (_data, attachmentId) => { + queryClient.setQueriesData>>( + { queryKey: ["page-attachments", pageId] }, + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.filter((item) => item.id !== attachmentId), + })), + }; + }, + ); + notifications.show({ message: t("Attachment deleted") }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ + message: errorMessage || t("Failed to delete attachment"), + color: "red", + }); + }, + }); +} diff --git a/apps/client/src/features/attachments/services/attachment-service.ts b/apps/client/src/features/attachments/services/attachment-service.ts index e129c248..f3aadca4 100644 --- a/apps/client/src/features/attachments/services/attachment-service.ts +++ b/apps/client/src/features/attachments/services/attachment-service.ts @@ -15,6 +15,10 @@ export async function getPageAttachments( return req.data; } +export async function deleteAttachment(attachmentId: string): Promise { + await api.post("/attachments/delete", { attachmentId }); +} + async function compressAndResizeIcon( file: File, type: AvatarIconType, diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index ff04943f..f26bd656 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -449,6 +449,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts index 7d088791..3ad2ddd7 100644 --- a/apps/server/src/common/events/audit-events.ts +++ b/apps/server/src/common/events/audit-events.ts @@ -105,7 +105,7 @@ export const AuditEvent = { // Attachment ATTACHMENT_UPLOADED: 'attachment.uploaded', - // ATTACHMENT_DELETED: 'attachment.deleted', + ATTACHMENT_DELETED: 'attachment.deleted', // SIEM streaming SIEM_DESTINATION_CREATED: 'siem_destination.created', diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 13e92901..d2e1ab57 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -56,6 +56,7 @@ import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload'; import * as path from 'path'; import { AttachmentInfoDto, + DeleteAttachmentDto, PageIdDto, RemoveIconDto, } from './dto/attachment.dto'; @@ -457,6 +458,58 @@ export class AttachmentController { }; } + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('attachments/delete') + async deleteAttachment( + @Body() dto: DeleteAttachmentDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const attachment = await this.attachmentRepo.findById(dto.attachmentId); + if ( + !attachment || + !attachment.pageId || + attachment.workspaceId !== workspace.id || + attachment.type !== AttachmentType.File + ) { + throw new NotFoundException('File not found'); + } + + const page = await this.pageRepo.findById(attachment.pageId); + if (!page) { + throw new NotFoundException('File not found'); + } + + await this.pageAccessService.validateCanEdit(page, user); + + const spaceAbility = await this.spaceAbility.createForUser( + user, + page.spaceId, + ); + if ( + spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings) + ) { + throw new ForbiddenException('Only space admins can delete files'); + } + + await this.attachmentService.deleteAttachment(attachment); + + this.auditService.log({ + event: AuditEvent.ATTACHMENT_DELETED, + resourceType: AuditResource.ATTACHMENT, + resourceId: attachment.id, + spaceId: page.spaceId, + changes: { + before: { + fileName: attachment.fileName, + pageId: page.id, + creatorId: attachment.creatorId, + }, + }, + }); + } + @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('attachments/remove-icon') diff --git a/apps/server/src/core/attachment/dto/attachment.dto.ts b/apps/server/src/core/attachment/dto/attachment.dto.ts index 80d51094..b657ac3d 100644 --- a/apps/server/src/core/attachment/dto/attachment.dto.ts +++ b/apps/server/src/core/attachment/dto/attachment.dto.ts @@ -14,6 +14,12 @@ export class AttachmentInfoDto { attachmentId: string; } +export class DeleteAttachmentDto { + @IsNotEmpty() + @IsUUID() + attachmentId: string; +} + export class PageIdDto { @IsString() @IsNotEmpty() diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index 2db13c81..dac06196 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -240,6 +240,11 @@ export class AttachmentService { } } + async deleteAttachment(attachment: Attachment) { + await this.storageService.delete(attachment.filePath); + await this.attachmentRepo.deleteAttachmentById(attachment.id); + } + async uploadToDrive(filePath: string, fileContent: Buffer | Readable) { try { await this.storageService.upload(filePath, fileContent); From b81872c003f26360a24cc439b5694e4400753021 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:27:19 +0100 Subject: [PATCH 16/18] fix lockfile (#2585) --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16c6f2d1..a046f1f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16369,7 +16369,7 @@ snapshots: '@fastify/error': 4.0.0 '@fastify/fast-json-stringify-compiler': 5.0.2 '@fastify/proxy-addr': 5.1.2 - abstract-logging: q2.0.1 + abstract-logging: 2.0.1 avvio: 9.1.0 fast-json-stringify: 7.0.1 find-my-way: 9.7.0 From 6852e0427e3e99843062bc0c906eb01c8497780f Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:26:42 +0100 Subject: [PATCH 17/18] feat: add GitHub, GitLab, Jira, Linear, Figma, Azure Devops integrations (#2547) --- .env.example | 2 + .../public/locales/en-US/translation.json | 65 +- apps/client/src/App.tsx | 14 + .../components/icons/azure-devops-icon.tsx | 29 + .../src/components/icons/github-icon.tsx | 22 + .../src/components/icons/gitlab-icon.tsx | 32 + .../src/components/icons/google-docs-icon.tsx | 28 + apps/client/src/components/icons/index.ts | 11 +- .../client/src/components/icons/jira-icon.tsx | 38 + .../src/components/icons/linear-icon.tsx | 21 + .../client/src/components/icons/miro-icon.tsx | 13 +- .../src/components/icons/slack-icon.tsx | 32 + .../components/settings/settings-sidebar.tsx | 20 + .../src/ee/audit/lib/audit-event-labels.ts | 20 + apps/client/src/ee/features.ts | 2 + .../security/components/allowed-domains.tsx | 1 + .../common/editor-paste-handler.test.ts | 342 +++++ .../common/editor-paste-handler.tsx | 101 ++ .../integration-link/badge-color.ts | 50 + .../integration-card-view.module.css | 27 + .../integration-card-view.tsx | 586 ++++++++ .../integration-display-picker.module.css | 191 +++ .../integration-display-picker.test.tsx | 188 +++ .../integration-display-picker.tsx | 366 +++++ .../integration-display.test.ts | 290 ++++ .../integration-link/integration-display.ts | 309 ++++ .../integration-link-patterns.test.ts | 345 +++++ .../integration-link-url.test.ts | 122 ++ .../integration-mention-view.module.css | 53 + .../integration-mention-view.tsx | 259 ++++ .../integration-paste-menu.module.css | 71 + .../integration-paste-menu.test.tsx | 213 +++ .../integration-paste-menu.tsx | 335 +++++ .../integration-link/use-unfurl.test.tsx | 118 ++ .../components/integration-link/use-unfurl.ts | 45 + .../editor/components/link/link-view.test.tsx | 225 +++ .../editor/components/link/link-view.tsx | 162 +- .../editor/components/link/link.module.css | 63 + .../features/editor/extensions/extensions.ts | 12 + .../extensions/integration-paste-menu.ts | 47 + .../src/features/editor/page-editor.tsx | 12 + .../integration/components/connection-row.tsx | 91 ++ .../components/integration-icons.tsx | 34 + .../components/integration-list-skeleton.tsx | 66 + .../components/integration-row.tsx | 102 ++ .../integration/pages/connections.tsx | 271 ++++ .../integration/pages/integrations.tsx | 123 ++ .../integration/queries/integration-query.ts | 68 + .../services/integration-service.ts | 70 + .../integration/types/integration.types.ts | 62 + .../integration/utils/connect-prompt.test.ts | 30 + .../integration/utils/connect-prompt.ts | 22 + apps/client/src/lib/app-route.ts | 1 + .../src/collaboration/collaboration.util.ts | 4 + apps/server/src/common/events/audit-events.ts | 11 +- .../src/common/events/event.contants.ts | 1 + apps/server/src/common/features.ts | 2 + apps/server/src/common/helpers/utils.ts | 2 +- apps/server/src/common/proxy-fetch.spec.ts | 64 + apps/server/src/common/proxy-fetch.ts | 37 + .../src/core/comment/comment.controller.ts | 14 +- apps/server/src/core/core.module.ts | 9 + .../integration/dto/integration.dto.spec.ts | 57 + .../core/integration/dto/integration.dto.ts | 45 + .../core/integration/dto/unfurl.dto.spec.ts | 36 + .../integration-connection.service.spec.ts | 73 + .../integration-connection.service.ts | 68 + .../integration/integration.controller.ts | 78 + .../core/integration/integration.listener.ts | 29 + .../core/integration/integration.module.ts | 37 + .../integration/integration.processor.spec.ts | 238 +++ .../core/integration/integration.processor.ts | 131 ++ .../core/integration/integration.service.ts | 63 + .../integration/oauth/credential-env.spec.ts | 23 + .../core/integration/oauth/credential-env.ts | 11 + .../oauth/oauth-complete-auth.filter.ts | 27 + .../integration/oauth/oauth-connect.spec.ts | 28 + .../core/integration/oauth/oauth-connect.ts | 9 + .../oauth/oauth-identity-link.spec.ts | 95 ++ .../oauth/oauth.controller.spec.ts | 553 +++++++ .../integration/oauth/oauth.controller.ts | 333 +++++ .../integration/oauth/oauth.service.spec.ts | 1327 +++++++++++++++++ .../core/integration/oauth/oauth.service.ts | 972 ++++++++++++ .../src/core/integration/oauth/oauth.utils.ts | 6 + .../providers/figma/figma-patterns.ts | 9 + .../providers/figma/figma.module.ts | 21 + .../providers/figma/figma.provider.ts | 45 + .../providers/figma/figma.service.spec.ts | 46 + .../providers/figma/figma.service.ts | 63 + .../providers/github/github-patterns.spec.ts | 143 ++ .../providers/github/github-patterns.ts | 70 + .../providers/github/github.module.ts | 21 + .../providers/github/github.provider.spec.ts | 167 +++ .../providers/github/github.provider.ts | 173 +++ .../providers/github/github.service.spec.ts | 146 ++ .../providers/github/github.service.ts | 282 ++++ .../providers/github/github.utils.ts | 9 + .../providers/linear/linear-patterns.spec.ts | 55 + .../providers/linear/linear-patterns.ts | 29 + .../providers/linear/linear.module.ts | 21 + .../providers/linear/linear.provider.ts | 107 ++ .../providers/linear/linear.service.spec.ts | 58 + .../providers/linear/linear.service.ts | 275 ++++ .../integration-provider.interface.ts | 230 +++ .../registry/integration-registry.spec.ts | 25 + .../registry/integration-registry.ts | 51 + .../repos/integration-connection.repo.spec.ts | 236 +++ .../repos/integration-connection.repo.ts | 371 +++++ .../integration/repos/integration.repo.ts | 119 ++ .../unfurl/unfurl.controller.spec.ts | 59 + .../integration/unfurl/unfurl.controller.ts | 43 + .../integration/unfurl/unfurl.service.spec.ts | 324 ++++ .../core/integration/unfurl/unfurl.service.ts | 251 ++++ .../core/integration/utils/canonical-path.ts | 2 + .../utils/integration.utils.spec.ts | 32 + .../integration/utils/integration.utils.ts | 40 + .../integration/utils/provider-fetch.spec.ts | 181 +++ .../core/integration/utils/provider-fetch.ts | 118 ++ .../integration/utils/refresh-lock.spec.ts | 129 ++ .../core/integration/utils/refresh-lock.ts | 72 + .../core/integration/utils/relative-time.ts | 5 + .../notification/notification.processor.ts | 12 +- .../core/notification/notification.service.ts | 5 + .../page/page-access/page-access.service.ts | 11 + .../workspace/dto/update-workspace.dto.ts | 1 + .../services/workspace.service.spec.ts | 83 ++ .../workspace/services/workspace.service.ts | 22 +- .../src/core/workspace/workspace.module.ts | 3 +- .../20260905T126412-integrations.ts | 124 ++ apps/server/src/database/types/db.d.ts | 30 + .../server/src/database/types/entity.types.ts | 15 + apps/server/src/ee | 2 +- .../encryption/a256cbc-hs512.spec.ts | 64 + .../integrations/encryption/a256cbc-hs512.ts | 82 + .../encryption/encryption.service.spec.ts | 245 ++- .../encryption/encryption.service.ts | 244 ++- .../environment/domain.service.ts | 14 + .../environment/environment.service.ts | 4 + .../queue/constants/queue.constants.ts | 11 + .../src/integrations/queue/queue.module.ts | 8 + .../integrations/throttle/throttle.module.ts | 2 + .../integrations/throttle/throttler-names.ts | 2 + apps/server/src/main.ts | 10 +- packages/editor-ext/src/index.ts | 1 + .../src/lib/integration-link/index.ts | 16 + .../lib/integration-link/integration-card.ts | 102 ++ .../integration-link-patterns.ts | 518 +++++++ .../lib/integration-link/integration-link.ts | 17 + .../integration-link/integration-mention.ts | 100 ++ packages/editor-ext/src/lib/link.ts | 9 + .../src/lib/prosemirror-docx/schema.ts | 33 +- packages/editor-ext/src/lib/utils.ts | 10 + 152 files changed, 15891 insertions(+), 142 deletions(-) create mode 100644 apps/client/src/components/icons/azure-devops-icon.tsx create mode 100644 apps/client/src/components/icons/github-icon.tsx create mode 100644 apps/client/src/components/icons/gitlab-icon.tsx create mode 100644 apps/client/src/components/icons/google-docs-icon.tsx create mode 100644 apps/client/src/components/icons/jira-icon.tsx create mode 100644 apps/client/src/components/icons/linear-icon.tsx create mode 100644 apps/client/src/components/icons/slack-icon.tsx create mode 100644 apps/client/src/features/editor/components/common/editor-paste-handler.test.ts create mode 100644 apps/client/src/features/editor/components/integration-link/badge-color.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-card-view.module.css create mode 100644 apps/client/src/features/editor/components/integration-link/integration-card-view.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-display-picker.module.css create mode 100644 apps/client/src/features/editor/components/integration-link/integration-display-picker.test.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-display-picker.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-display.test.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-display.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-link-patterns.test.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-link-url.test.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css create mode 100644 apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-paste-menu.module.css create mode 100644 apps/client/src/features/editor/components/integration-link/integration-paste-menu.test.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/use-unfurl.test.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/use-unfurl.ts create mode 100644 apps/client/src/features/editor/components/link/link-view.test.tsx create mode 100644 apps/client/src/features/editor/extensions/integration-paste-menu.ts create mode 100644 apps/client/src/features/integration/components/connection-row.tsx create mode 100644 apps/client/src/features/integration/components/integration-icons.tsx create mode 100644 apps/client/src/features/integration/components/integration-list-skeleton.tsx create mode 100644 apps/client/src/features/integration/components/integration-row.tsx create mode 100644 apps/client/src/features/integration/pages/connections.tsx create mode 100644 apps/client/src/features/integration/pages/integrations.tsx create mode 100644 apps/client/src/features/integration/queries/integration-query.ts create mode 100644 apps/client/src/features/integration/services/integration-service.ts create mode 100644 apps/client/src/features/integration/types/integration.types.ts create mode 100644 apps/client/src/features/integration/utils/connect-prompt.test.ts create mode 100644 apps/client/src/features/integration/utils/connect-prompt.ts create mode 100644 apps/server/src/common/proxy-fetch.spec.ts create mode 100644 apps/server/src/common/proxy-fetch.ts create mode 100644 apps/server/src/core/integration/dto/integration.dto.spec.ts create mode 100644 apps/server/src/core/integration/dto/integration.dto.ts create mode 100644 apps/server/src/core/integration/dto/unfurl.dto.spec.ts create mode 100644 apps/server/src/core/integration/integration-connection.service.spec.ts create mode 100644 apps/server/src/core/integration/integration-connection.service.ts create mode 100644 apps/server/src/core/integration/integration.controller.ts create mode 100644 apps/server/src/core/integration/integration.listener.ts create mode 100644 apps/server/src/core/integration/integration.module.ts create mode 100644 apps/server/src/core/integration/integration.processor.spec.ts create mode 100644 apps/server/src/core/integration/integration.processor.ts create mode 100644 apps/server/src/core/integration/integration.service.ts create mode 100644 apps/server/src/core/integration/oauth/credential-env.spec.ts create mode 100644 apps/server/src/core/integration/oauth/credential-env.ts create mode 100644 apps/server/src/core/integration/oauth/oauth-complete-auth.filter.ts create mode 100644 apps/server/src/core/integration/oauth/oauth-connect.spec.ts create mode 100644 apps/server/src/core/integration/oauth/oauth-connect.ts create mode 100644 apps/server/src/core/integration/oauth/oauth-identity-link.spec.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.controller.spec.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.controller.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.service.spec.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.service.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.utils.ts create mode 100644 apps/server/src/core/integration/providers/figma/figma-patterns.ts create mode 100644 apps/server/src/core/integration/providers/figma/figma.module.ts create mode 100644 apps/server/src/core/integration/providers/figma/figma.provider.ts create mode 100644 apps/server/src/core/integration/providers/figma/figma.service.spec.ts create mode 100644 apps/server/src/core/integration/providers/figma/figma.service.ts create mode 100644 apps/server/src/core/integration/providers/github/github-patterns.spec.ts create mode 100644 apps/server/src/core/integration/providers/github/github-patterns.ts create mode 100644 apps/server/src/core/integration/providers/github/github.module.ts create mode 100644 apps/server/src/core/integration/providers/github/github.provider.spec.ts create mode 100644 apps/server/src/core/integration/providers/github/github.provider.ts create mode 100644 apps/server/src/core/integration/providers/github/github.service.spec.ts create mode 100644 apps/server/src/core/integration/providers/github/github.service.ts create mode 100644 apps/server/src/core/integration/providers/github/github.utils.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear-patterns.spec.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear-patterns.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear.module.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear.provider.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear.service.spec.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear.service.ts create mode 100644 apps/server/src/core/integration/registry/integration-provider.interface.ts create mode 100644 apps/server/src/core/integration/registry/integration-registry.spec.ts create mode 100644 apps/server/src/core/integration/registry/integration-registry.ts create mode 100644 apps/server/src/core/integration/repos/integration-connection.repo.spec.ts create mode 100644 apps/server/src/core/integration/repos/integration-connection.repo.ts create mode 100644 apps/server/src/core/integration/repos/integration.repo.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.controller.spec.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.controller.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.service.spec.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.service.ts create mode 100644 apps/server/src/core/integration/utils/canonical-path.ts create mode 100644 apps/server/src/core/integration/utils/integration.utils.spec.ts create mode 100644 apps/server/src/core/integration/utils/integration.utils.ts create mode 100644 apps/server/src/core/integration/utils/provider-fetch.spec.ts create mode 100644 apps/server/src/core/integration/utils/provider-fetch.ts create mode 100644 apps/server/src/core/integration/utils/refresh-lock.spec.ts create mode 100644 apps/server/src/core/integration/utils/refresh-lock.ts create mode 100644 apps/server/src/core/integration/utils/relative-time.ts create mode 100644 apps/server/src/core/workspace/services/workspace.service.spec.ts create mode 100644 apps/server/src/database/migrations/20260905T126412-integrations.ts create mode 100644 apps/server/src/integrations/encryption/a256cbc-hs512.spec.ts create mode 100644 apps/server/src/integrations/encryption/a256cbc-hs512.ts create mode 100644 packages/editor-ext/src/lib/integration-link/index.ts create mode 100644 packages/editor-ext/src/lib/integration-link/integration-card.ts create mode 100644 packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts create mode 100644 packages/editor-ext/src/lib/integration-link/integration-link.ts create mode 100644 packages/editor-ext/src/lib/integration-link/integration-mention.ts diff --git a/.env.example b/.env.example index 6c5756fa..0f8b37d9 100644 --- a/.env.example +++ b/.env.example @@ -68,3 +68,5 @@ DEBUG_DB=false # Log http requests LOG_HTTP=false + +# See all environment variables: https://docmost.com/docs/self-hosting/environment-variables diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index a6c18a1f..3f4bde97 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.", + "Assigned to {{name}}": "Assigned to {{name}}", "Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace", "Can create and edit pages in space.": "Can create and edit pages in space.", "Can edit": "Can edit", @@ -22,6 +23,8 @@ "Can view": "Can view", "Can view pages in space but not edit.": "Can view pages in space but not edit.", "Cancel": "Cancel", + "Card": "Card", + "Cards aren't available in this position": "Cards aren't available in this position", "Change email": "Change email", "Change password": "Change password", "Change photo": "Change photo", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred page width.": "Choose your preferred page width.", "Confirm": "Confirm", + "Connect to {{name}} to preview": "Connect to {{name}} to preview", + "Connected as {{label}}": "Connected as {{label}}", "Copy as Markdown": "Copy as Markdown", "Copy link": "Copy link", "Create": "Create", @@ -41,11 +46,25 @@ "Dark": "Dark", "Date": "Date", "Delete": "Delete", + "Initiative": "Initiative", + "Integration link actions": "Integration link actions", + "Last modified by {{name}}": "Last modified by {{name}}", + "Open in Slack": "Open in Slack", + "Open link": "Open link", + "Paid": "Paid", + "Paste as": "Paste as", + "Project": "Project", + "Uninstall {{name}}": "Uninstall {{name}}", + "Disconnect {{name}}": "Disconnect {{name}}", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.", "Remove from page": "Remove from page", "Base options": "Base options", "Delete group": "Delete group", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.", "Description": "Description", + "Display as": "Display as", "Details": "Details", "e.g ACME": "e.g. ACME", "e.g ACME Inc": "e.g. ACME Inc", @@ -151,6 +170,8 @@ "page": "page", "Page deleted successfully": "Page deleted successfully", "Page history": "Page history", + "replies": "replies", + "reply": "reply", "Select version": "Select version", "Highlight changes": "Highlight changes", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", @@ -193,6 +214,8 @@ "Invitation sent": "Invitation sent", "Settings": "Settings", "Setup workspace": "Setup workspace", + "show less": "show less", + "show more": "show more", "Sign In": "Sign In", "Sign Up": "Sign Up", "Slug": "Slug", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.", "Toggle full page width": "Toggle full page width", "Unable to import pages. Please try again.": "Unable to import pages. Please try again.", + "Unassigned": "Unassigned", "untitled": "untitled", "Untitled": "Untitled", "Updated successfully": "Updated successfully", @@ -649,6 +673,8 @@ "Search in all your spaces": "Search in all your spaces", "Type": "Type", "Enterprise": "Enterprise", + "Business": "Business", + "Standard": "Standard", "Download attachment": "Download attachment", "Allowed email domains": "Allowed email domains", "Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can sign up via SSO.", @@ -1457,6 +1483,11 @@ "Defaults to this instance's hostname": "Defaults to this instance's hostname", "Delete destination": "Delete destination", "Deleted SIEM destination": "Deleted SIEM destination", + "Integrations": "Integrations", + "Installed integration": "Installed integration", + "Uninstalled integration": "Uninstalled integration", + "Connected integration account": "Connected integration account", + "Disconnected integration account": "Disconnected integration account", "Destination created": "Destination created", "Destination deleted": "Destination deleted", "Destination updated": "Destination updated", @@ -1499,9 +1530,41 @@ "Updated SIEM destination": "Updated SIEM destination", "Verify TLS certificate": "Verify TLS certificate", "e.g. Splunk prod": "e.g. Splunk prod", + "Manage workspace integrations.": "Manage workspace integrations.", + "No integrations available.": "No integrations available.", + "Install": "Install", + "Uninstall": "Uninstall", + "Failed to start installation": "Failed to start installation", + "Integration uninstalled successfully": "Integration uninstalled successfully", + "Failed to uninstall integration": "Failed to uninstall integration", + "Connections": "Connections", + "Manage the apps you have connected to your account.": "Manage the apps you have connected to your account.", + "Connect": "Connect", + "Connected": "Connected", + "Disconnect": "Disconnect", + "Reconnect": "Reconnect", + "Connection expired": "Connection expired", + "Integration disconnected": "Integration disconnected", + "Failed to disconnect integration": "Failed to disconnect integration", + "Failed to start OAuth connection": "Failed to start OAuth connection", + "OAuth connection failed. Please try again.": "OAuth connection failed. Please try again.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.", + "Connect your {{name}} account so links you paste show rich previews.": "Connect your {{name}} account so links you paste show rich previews.", + "Connect {{name}} to Docmost": "Connect {{name}} to Docmost", + "Link your Slack account to your Docmost account to:": "Link your Slack account to your Docmost account to:", + "Search and open Docmost pages from Slack": "Search and open Docmost pages from Slack", + "Create pages and save Slack messages to Docmost": "Create pages and save Slack messages to Docmost", + "Ask AI questions about your workspace docs": "Ask AI questions about your workspace docs", + "Get your Docmost notifications as Slack DMs": "Get your Docmost notifications as Slack DMs", + "See previews of Docmost links in Slack and Slack links in Docmost": "See previews of Docmost links in Slack and Slack links in Docmost", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.", + "Updated comment": "Updated comment", + "Resolved comment": "Resolved comment", + "Reopened comment": "Reopened comment", "Something went wrong": "Something went wrong", "This page failed to load. Reload to try again.": "This page failed to load. Reload to try again.", - "Reload": "Reload" + "Reload": "Reload", "Tabs": "Tabs", "Insert a multi-tab content block.": "Insert a multi-tab content block.", "Add tab": "Add tab", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index d5c3a5d1..ac7b410f 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -87,6 +87,12 @@ const AiChat = lazyWithPrefetch(() => import("@/ee/ai-chat/pages/ai-chat.tsx")); const VerifyEmail = lazyWithPrefetch(() => import("@/ee/pages/verify-email.tsx")); const LabelPage = lazyWithPrefetch(() => import("@/pages/label/label-page")); const OAuthConsent = lazyWithPrefetch(() => import("@/ee/oauth/pages/oauth-consent.tsx")); +const Integrations = lazyWithPrefetch( + () => import("@/features/integration/pages/integrations.tsx"), +); +const Connections = lazyWithPrefetch( + () => import("@/features/integration/pages/connections.tsx"), +); export default function App() { const { t } = useTranslation(); @@ -146,6 +152,12 @@ export default function App() { } /> } /> } /> + + } + /> }> } /> @@ -179,6 +191,7 @@ export default function App() { path={"account/api-keys/authorized-apps"} element={} /> + } /> } /> } /> } /> @@ -196,6 +209,7 @@ export default function App() { element={} /> } /> + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/icons/azure-devops-icon.tsx b/apps/client/src/components/icons/azure-devops-icon.tsx new file mode 100644 index 00000000..02b1b1af --- /dev/null +++ b/apps/client/src/components/icons/azure-devops-icon.tsx @@ -0,0 +1,29 @@ +import { rem } from '@mantine/core'; + +type Props = { + size?: number | string; +}; + +export function AzureDevOpsIcon({ size }: Props) { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/client/src/components/icons/github-icon.tsx b/apps/client/src/components/icons/github-icon.tsx new file mode 100644 index 00000000..fcac089b --- /dev/null +++ b/apps/client/src/components/icons/github-icon.tsx @@ -0,0 +1,22 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function GithubIcon({ size }: Props) { + return ( + + + + ); +} diff --git a/apps/client/src/components/icons/gitlab-icon.tsx b/apps/client/src/components/icons/gitlab-icon.tsx new file mode 100644 index 00000000..db155579 --- /dev/null +++ b/apps/client/src/components/icons/gitlab-icon.tsx @@ -0,0 +1,32 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function GitlabIcon({ size }: Props) { + return ( + + + + + + + ); +} diff --git a/apps/client/src/components/icons/google-docs-icon.tsx b/apps/client/src/components/icons/google-docs-icon.tsx new file mode 100644 index 00000000..e7e32c0c --- /dev/null +++ b/apps/client/src/components/icons/google-docs-icon.tsx @@ -0,0 +1,28 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function GoogleDocsIcon({ size }: Props) { + return ( + + + + + + ); +} diff --git a/apps/client/src/components/icons/index.ts b/apps/client/src/components/icons/index.ts index 0f5fd1ad..5fd03e99 100644 --- a/apps/client/src/components/icons/index.ts +++ b/apps/client/src/components/icons/index.ts @@ -1,10 +1,17 @@ export { AirtableIcon } from "./airtable-icon.tsx"; +export { AzureDevOpsIcon } from "./azure-devops-icon.tsx"; export { FigmaIcon } from "./figma-icon.tsx"; +export { GithubIcon } from "./github-icon.tsx"; +export { GitlabIcon } from "./gitlab-icon.tsx"; +export { GoogleDocsIcon } from "./google-docs-icon.tsx"; +export { GoogleDriveIcon } from "./google-drive-icon.tsx"; +export { GoogleSheetsIcon } from "./google-sheets-icon.tsx"; +export { JiraIcon } from "./jira-icon.tsx"; +export { LinearIcon } from "./linear-icon.tsx"; export { TypeformIcon } from "./typeform-icon.tsx"; export { VimeoIcon } from "./vimeo-icon.tsx"; export { MiroIcon } from "./miro-icon.tsx"; -export { GoogleDriveIcon } from "./google-drive-icon.tsx"; -export { GoogleSheetsIcon } from "./google-sheets-icon.tsx"; +export { SlackIcon } from "./slack-icon.tsx"; export { FramerIcon } from "./framer-icon.tsx"; export { LoomIcon } from "./loom-icon.tsx"; export { YoutubeIcon } from "./youtube-icon.tsx"; diff --git a/apps/client/src/components/icons/jira-icon.tsx b/apps/client/src/components/icons/jira-icon.tsx new file mode 100644 index 00000000..c7164c14 --- /dev/null +++ b/apps/client/src/components/icons/jira-icon.tsx @@ -0,0 +1,38 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function JiraIcon({ size }: Props) { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/apps/client/src/components/icons/linear-icon.tsx b/apps/client/src/components/icons/linear-icon.tsx new file mode 100644 index 00000000..b44ecce7 --- /dev/null +++ b/apps/client/src/components/icons/linear-icon.tsx @@ -0,0 +1,21 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function LinearIcon({ size }: Props) { + return ( + + + + ); +} diff --git a/apps/client/src/components/icons/miro-icon.tsx b/apps/client/src/components/icons/miro-icon.tsx index 9d07898a..d62b4ef3 100644 --- a/apps/client/src/components/icons/miro-icon.tsx +++ b/apps/client/src/components/icons/miro-icon.tsx @@ -8,11 +8,20 @@ export function MiroIcon({ size }: Props) { return ( + fill="#FFDD33" + d="M3 100.754C3 46.2604 47.2435 2 101.754 2H299.246C353.756 2 398 46.2435 398 100.754V298.246C398 352.756 353.756 397 299.246 397H101.754C47.2435 397 3 352.756 3 298.246V100.754Z" + /> + ); } diff --git a/apps/client/src/components/icons/slack-icon.tsx b/apps/client/src/components/icons/slack-icon.tsx new file mode 100644 index 00000000..d26e9f2c --- /dev/null +++ b/apps/client/src/components/icons/slack-icon.tsx @@ -0,0 +1,32 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function SlackIcon({ size }: Props) { + return ( + + + + + + + ); +} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 1a1aec85..5039b377 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -13,6 +13,7 @@ import { IconKey, IconWorld, IconSparkles, + IconPlug, IconHistory, IconShieldCheck, } from "@tabler/icons-react"; @@ -43,6 +44,7 @@ import AppVersion from "@/components/settings/app-version.tsx"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useSettingsNavigation } from "@/hooks/use-settings-navigation"; +import { useInstalledIntegrations } from "@/features/integration/queries/integration-query"; type DataItem = { label: string; @@ -51,6 +53,7 @@ type DataItem = { feature?: string; role?: "admin" | "owner"; env?: "cloud" | "selfhosted"; + requiresInstalledIntegration?: boolean; }; type DataGroup = { @@ -74,6 +77,12 @@ const groupedData: DataGroup[] = [ path: "/settings/account/api-keys", feature: Feature.API_KEYS, }, + { + label: "Connections", + icon: IconPlug, + path: "/settings/account/connections", + requiresInstalledIntegration: true, + }, ], }, { @@ -125,6 +134,12 @@ const groupedData: DataGroup[] = [ role: "owner", env: "selfhosted", }, + { + label: "Integrations", + icon: IconPlug, + path: "/settings/integrations", + role: "admin", + }, ], }, { @@ -154,6 +169,8 @@ export default function SettingsSidebar() { setActive(location.pathname); }, [location.pathname]); + const { data: installedIntegrations } = useInstalledIntegrations(); + const hasFeature = (f: string) => entitlements?.features?.includes(f) ?? false; @@ -162,6 +179,9 @@ export default function SettingsSidebar() { if (item.env === "selfhosted" && isCloud()) return false; if (item.role === "admin" && !isAdmin) return false; if (item.role === "owner" && !isOwner) return false; + if (item.requiresInstalledIntegration && !installedIntegrations?.length) { + return false; + } return true; }; diff --git a/apps/client/src/ee/audit/lib/audit-event-labels.ts b/apps/client/src/ee/audit/lib/audit-event-labels.ts index ffa81470..e25850e4 100644 --- a/apps/client/src/ee/audit/lib/audit-event-labels.ts +++ b/apps/client/src/ee/audit/lib/audit-event-labels.ts @@ -34,6 +34,11 @@ export const auditEventLabels: Record = { "api_key.updated": "Updated API key", "api_key.deleted": "Deleted API key", + "integration.installed": "Installed integration", + "integration.uninstalled": "Uninstalled integration", + "integration.connected": "Connected integration account", + "integration.disconnected": "Disconnected integration account", + "scim_token.created": "Created SCIM token", "scim_token.updated": "Updated SCIM token", "scim_token.deleted": "Deleted SCIM token", @@ -52,6 +57,9 @@ export const auditEventLabels: Record = { "group.member_added": "Added group member", "group.member_removed": "Removed group member", + "comment.updated": "Updated comment", + "comment.resolved": "Resolved comment", + "comment.reopened": "Reopened comment", "comment.deleted": "Deleted comment", "attachment.deleted": "Deleted attachment", @@ -147,6 +155,9 @@ export const eventFilterOptions: EventGroup[] = [ { group: "Comment", items: [ + { value: "comment.updated", label: "Updated comment" }, + { value: "comment.resolved", label: "Resolved comment" }, + { value: "comment.reopened", label: "Reopened comment" }, { value: "comment.deleted", label: "Deleted comment" }, ], }, @@ -203,6 +214,15 @@ export const eventFilterOptions: EventGroup[] = [ { value: "api_key.deleted", label: "Deleted API key" }, ], }, + { + group: "Integrations", + items: [ + { value: "integration.installed", label: "Installed integration" }, + { value: "integration.uninstalled", label: "Uninstalled integration" }, + { value: "integration.connected", label: "Connected integration account" }, + { value: "integration.disconnected", label: "Disconnected integration account" }, + ], + }, { group: "SCIM token", items: [ diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index 87bb559d..437c8cfe 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -22,6 +22,8 @@ export const Feature = { PERSONAL_SPACES: 'spaces:personal', DOCX_EXPORT: 'export:docx', BASES: 'bases', + INTEGRATIONS: 'integrations', + INTEGRATIONS_ADVANCED: 'integrations:advanced', OAUTH: 'oauth', AI_CONTROLS: 'ai:controls', MCP_CONTROLS: 'mcp:controls', diff --git a/apps/client/src/ee/security/components/allowed-domains.tsx b/apps/client/src/ee/security/components/allowed-domains.tsx index 8c22b001..49dbf48d 100644 --- a/apps/client/src/ee/security/components/allowed-domains.tsx +++ b/apps/client/src/ee/security/components/allowed-domains.tsx @@ -35,6 +35,7 @@ export default function AllowedDomains() { emailDomains: data.emailDomains, }); setWorkspace(updatedWorkspace); + form.setValues({ emailDomains: updatedWorkspace.emailDomains }); notifications.show({ message: t("Updated successfully"), diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.test.ts b/apps/client/src/features/editor/components/common/editor-paste-handler.test.ts new file mode 100644 index 00000000..17971bfc --- /dev/null +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.test.ts @@ -0,0 +1,342 @@ +import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext"; +import { Editor, Node, type JSONContent } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queryClient } from "@/main.tsx"; +import { TiptapDocument } from "@/features/editor/extensions/document"; +import { + IntegrationPasteMenuExtension, + integrationPasteMenuKey, +} from "@/features/editor/extensions/integration-paste-menu"; +import { + convertIntegrationDisplay, + type IntegrationDisplay, +} from "@/features/editor/components/integration-link/integration-display"; +import { handlePaste } from "./editor-paste-handler"; + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +const PR_URL = "https://github.com/org/repo/pull/12"; +const text = (value: string, marks?: JSONContent["marks"]): JSONContent => ({ + type: "text", + text: value, + ...(marks && { marks }), +}); +const paragraph = (...content: JSONContent[]): JSONContent => ({ + type: "paragraph", + ...(content.length && { content }), +}); +const card = (): JSONContent => ({ + type: "integrationCard", + attrs: { url: PR_URL, provider: "github" }, +}); +const mention = (): JSONContent => ({ + type: "integrationMention", + attrs: { url: PR_URL, provider: "github" }, +}); + +// The real footnote nodes bring plugins that clash with editor-ext's CJS build in vitest. +const Footnotes = Node.create({ + name: "footnotes", + group: "", + content: "footnote*", + isolating: true, + renderHTML() { + return ["ol", { class: "footnotes" }, 0]; + }, +}); + +const Footnote = Node.create({ + name: "footnote", + content: "paragraph+", + isolating: true, + renderHTML() { + return ["li", 0]; + }, +}); + +let editor: Editor; + +function createEditor(content: object[], cursor: number) { + editor = new Editor({ + extensions: [ + TiptapDocument, + StarterKit.configure({ document: false }), + Footnotes, + Footnote, + IntegrationCard, + IntegrationMention, + IntegrationPasteMenuExtension, + ], + content: { type: "doc", content }, + }); + editor.commands.setTextSelection(cursor); + return editor; +} + +function paste(text: string, html = "") { + const event = { + clipboardData: { + getData: (type: string) => + type === "text/plain" ? text : type === "text/html" ? html : "", + files: [], + }, + preventDefault: vi.fn(), + }; + const handled = handlePaste( + editor, + event as unknown as ClipboardEvent, + "page-id", + ); + return { handled, preventDefault: event.preventDefault }; +} + +function choosePasteOption(display: IntegrationDisplay) { + const { pos, joinBefore, joinAfter } = integrationPasteMenuKey.getState( + editor.state, + ); + return convertIntegrationDisplay( + editor, + { kind: "node", pos, joinBefore, joinAfter }, + display, + ); +} + +function hasIntegrationCard() { + let found = false; + editor.state.doc.descendants((node) => { + if (node.type.name === "integrationCard") found = true; + }); + return found; +} + +function expectPassThrough(text = PR_URL, html = "") { + const before = editor.getJSON(); + const { handled, preventDefault } = paste(text, html); + + expect(handled).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + expect(hasIntegrationCard()).toBe(false); + expect(editor.getJSON()).toEqual(before); +} + +describe("handlePaste integration links", () => { + beforeEach(() => { + queryClient.setQueryData( + ["installed-integrations"], + [{ type: "github", unfurlHosts: ["github.com"] }], + ); + }); + + afterEach(() => { + editor?.destroy(); + queryClient.clear(); + }); + + it("inserts a card in a paragraph when the provider is installed", () => { + createEditor([paragraph(text("Hello"))], 6); + + const { handled, preventDefault } = paste(PR_URL); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + const pasteMenu = integrationPasteMenuKey.getState(editor.state); + expect(pasteMenu).not.toBeNull(); + expect(editor.state.doc.nodeAt(pasteMenu.pos)?.type.name).toBe( + "integrationCard", + ); + expect(editor.state.doc.nodeAt(pasteMenu.pos)?.attrs).toMatchObject({ + url: PR_URL, + provider: "github", + }); + }); + + it("pastes a GitHub-like path on a host GitHub does not serve as a link", () => { + createEditor([paragraph(text("Hello"))], 6); + + expectPassThrough("https://gitea.example.com/team/app/pulls/7"); + }); + + it.each(["integrationCard", "integrationMention"])( + "leaves a copied %s to ProseMirror instead of pasting its URL as a card", + (type) => { + createEditor([paragraph(text("Hello world"))], 7); + + expectPassThrough( + PR_URL, + `

`, + ); + }, + ); + + it("inserts a card for a repo root on a GitHub Enterprise host", () => { + queryClient.setQueryData( + ["installed-integrations"], + [{ type: "github", unfurlHosts: ["github.acme.com"] }], + ); + createEditor([paragraph(text("Hello"))], 6); + + const { handled } = paste("https://github.acme.com/org/repo"); + + expect(handled).toBe(true); + expect(hasIntegrationCard()).toBe(true); + }); + + it("splits the paragraph around a card pasted mid-sentence", () => { + createEditor([paragraph(text("Hello world"))], 7); + + const { handled, preventDefault } = paste(PR_URL); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Hello ")), + card(), + paragraph(text("world")), + ]); + }); + + it("rejoins the sentence when a mid-sentence paste becomes a mention", () => { + createEditor([paragraph(text("Fix in today"))], 8); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Fix in "), mention(), text(" today")), + ]); + expect(editor.state.selection.from).toBe(10); + }); + + it("adds no second space when the rest of the sentence starts with one", () => { + createEditor([paragraph(text("Fix in today"))], 7); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Fix in"), mention(), text(" today")), + ]); + }); + + it("rejoins the sentence when a mid-sentence paste becomes a URL", () => { + createEditor([paragraph(text("Fix in today"))], 8); + paste(PR_URL); + + expect(choosePasteOption("url")).toBe(true); + expect(editor.getJSON().content).toHaveLength(1); + expect(editor.getJSON().content?.[0]).toMatchObject( + paragraph( + text("Fix in "), + text(PR_URL, [{ type: "link", attrs: { href: PR_URL } }]), + text("today"), + ), + ); + }); + + it("keeps an end-of-paragraph paste turned into a mention in its paragraph", () => { + createEditor([paragraph(text("Fix in ")), paragraph(text("Next"))], 8); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Fix in "), mention(), text(" ")), + paragraph(text("Next")), + ]); + }); + + it("joins a start-of-paragraph paste turned into a mention with the rest of the line", () => { + createEditor([paragraph(text("today"))], 1); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(mention(), text(" today")), + ]); + }); + + it("keeps a paste on an empty line in its own paragraph when it becomes a mention", () => { + createEditor( + [paragraph(text("Before")), paragraph(), paragraph(text("After"))], + 9, + ); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Before")), + paragraph(mention(), text(" ")), + paragraph(text("After")), + ]); + }); + + it("pastes a mention inside a footnote, which cannot hold a card", () => { + createEditor( + [ + paragraph(text("Body")), + { + type: "footnotes", + content: [ + { + type: "footnote", + content: [paragraph(text("See "))], + }, + ], + }, + ], + 13, + ); + + const { handled, preventDefault } = paste(PR_URL); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(hasIntegrationCard()).toBe(false); + expect(editor.getJSON().content?.[1]).toMatchObject({ + type: "footnotes", + content: [ + { + type: "footnote", + content: [paragraph(text("See "), mention())], + }, + ], + }); + }); + + it("leaves a code block paste to the default handler", () => { + createEditor( + [ + { + type: "codeBlock", + content: [text("const a = 1;")], + }, + ], + 4, + ); + + expectPassThrough(); + }); + + it("leaves a heading paste to the default handler", () => { + createEditor( + [ + { + type: "heading", + attrs: { level: 2 }, + content: [text("Title")], + }, + ], + 3, + ); + + expectPassThrough(); + }); + + it("leaves an inline code paste to the default handler", () => { + createEditor([paragraph(text("run "), text("cmd", [{ type: "code" }]))], 6); + + expect(editor.isActive("code")).toBe(true); + expectPassThrough(); + }); +}); diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index b01e2b81..642d05a9 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -5,6 +5,11 @@ import { uploadPdfAction } from "../pdf/upload-pdf-action"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { Editor } from "@tiptap/core"; +import { matchIntegrationLink } from "@docmost/editor-ext"; +import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu"; +import { canPlaceIntegrationCard } from "@/features/editor/components/integration-link/integration-display"; +import { queryClient } from "@/main.tsx"; +import { Integration } from "@/features/integration/types/integration.types"; import { getAttachmentInfo, uploadFile, @@ -22,6 +27,24 @@ const ATTACHMENT_NODE_TYPES = [ const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//; +// copied cards and mentions paste back as nodes +const INTEGRATION_NODE_HTML_RE = /data-type="integration(Card|Mention)"/; + +// Reads the cache the page editor prefetches; a cold cache pastes an ordinary link. +function matchInstalledIntegrationLink(url: string) { + const installed = + queryClient.getQueryData(["installed-integrations"]) ?? []; + const unfurlHosts = Object.fromEntries( + installed + .filter((integration) => integration.unfurlHosts) + .map((integration) => [integration.type, integration.unfurlHosts]), + ); + const match = matchIntegrationLink(url, unfurlHosts); + return match && installed.some((i) => i.type === match.provider) + ? match + : null; +} + export const handlePaste = ( editor: Editor, event: ClipboardEvent, @@ -30,6 +53,84 @@ export const handlePaste = ( ) => { const clipboardData = event.clipboardData.getData("text/plain"); + const integrationMatch = matchInstalledIntegrationLink(clipboardData.trim()); + if ( + integrationMatch && + !INTEGRATION_NODE_HTML_RE.test(event.clipboardData.getData("text/html")) && + editor.state.selection.empty && + editor.state.selection.$from.parent.type.name === "paragraph" && + !editor.isActive("code") + ) { + const pastedUrl = clipboardData.trim(); + const { $from } = editor.state.selection; + + if (canPlaceIntegrationCard(editor, $from.pos)) { + event.preventDefault(); + editor + .chain() + .focus() + .setIntegrationCard({ + url: pastedUrl, + provider: integrationMatch.provider, + }) + // Set the meta in this transaction (BubbleMenu ignores meta-only ones) and find the node by changed range, not url. + .command(({ tr }) => { + let start: number | null = null; + let end: number | null = null; + tr.mapping.maps.forEach((map, index) => { + const rest = tr.mapping.slice(index + 1); + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + const mappedStart = rest.map(newStart, -1); + const mappedEnd = rest.map(newEnd, 1); + start = + start === null ? mappedStart : Math.min(start, mappedStart); + end = end === null ? mappedEnd : Math.max(end, mappedEnd); + }); + }); + if (start === null || end === null) return true; + + let pastedPos: number | null = null; + tr.doc.nodesBetween( + start, + Math.min(end, tr.doc.content.size), + (node, pos) => { + if (node.type.name === "integrationCard") { + pastedPos = pos; + } + }, + ); + if (pastedPos !== null) { + tr.setMeta(integrationPasteMenuKey, { + pos: pastedPos, + joinBefore: $from.parentOffset > 0, + joinAfter: $from.parentOffset < $from.parent.content.size, + }); + } + return true; + }) + .run(); + return true; + } + + // Containers such as footnotes only hold paragraphs, but a mention still fits. + const mentionType = editor.schema.nodes.integrationMention; + if ( + mentionType && + $from.parent.canReplaceWith($from.index(), $from.index(), mentionType) + ) { + event.preventDefault(); + editor + .chain() + .focus() + .setIntegrationMention({ + url: pastedUrl, + provider: integrationMatch.provider, + }) + .run(); + return true; + } + } + if (INTERNAL_LINK_REGEX.test(clipboardData)) { // we have to do this validation here to allow the default link extension to takeover if needs be event.preventDefault(); diff --git a/apps/client/src/features/editor/components/integration-link/badge-color.ts b/apps/client/src/features/editor/components/integration-link/badge-color.ts new file mode 100644 index 00000000..a0caad00 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/badge-color.ts @@ -0,0 +1,50 @@ +// Light-scheme badge text; hexes replace shade 9 for hues that miss AA 4.5:1. +const BADGE_TEXT_LIGHT: Record = { + dark: "var(--mantine-color-dark-9)", + gray: "var(--mantine-color-gray-9)", + red: "var(--mantine-color-red-9)", + pink: "var(--mantine-color-pink-9)", + grape: "var(--mantine-color-grape-9)", + violet: "var(--mantine-color-violet-9)", + indigo: "var(--mantine-color-indigo-9)", + blue: "var(--mantine-color-blue-9)", + cyan: "var(--mantine-color-cyan-9)", + teal: "var(--mantine-color-teal-9)", + green: "#277c38", + lime: "#4e7e0b", + yellow: "#ad5900", + orange: "#c3410e", +}; + +export function badgeTextColor(color?: string): string | undefined { + if (!color) return undefined; + const light = BADGE_TEXT_LIGHT[color]; + if (!light) return undefined; + // Dark scheme keeps Mantine's own light-variant text. + return `light-dark(${light}, var(--mantine-color-${color}-light-color))`; +} + +export function toBadgeColor(raw?: string): string { + if (!raw) return "gray"; + const hex = raw.toLowerCase().replace("#", ""); + if (/^[0-9a-f]{6}$/.test(hex)) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2 / 255; + if (max - min < 30) return l > 0.6 ? "gray" : "dark"; + if (r > g && r > b) return g > 160 ? "orange" : "red"; + if (g > r && g > b) return r > 160 ? "lime" : "green"; + if (b > r && b > g) return r > 100 ? "violet" : "blue"; + if (r > 200 && g > 200) return "yellow"; + if (r > 200 && b > 200) return "pink"; + if (g > 200 && b > 200) return "cyan"; + return "gray"; + } + // Only palette names with a contrast-checked text colour pass through. + return Object.prototype.hasOwnProperty.call(BADGE_TEXT_LIGHT, raw) + ? raw + : "gray"; +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-card-view.module.css b/apps/client/src/features/editor/components/integration-link/integration-card-view.module.css new file mode 100644 index 00000000..76f0189e --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-card-view.module.css @@ -0,0 +1,27 @@ +.card { + max-width: 100%; + cursor: pointer; + transition: background-color 150ms ease; + margin: 4px 0; +} + +.card:hover { + background-color: var(--mantine-color-gray-0); +} + +:global([data-mantine-color-scheme="dark"]) .card:hover { + background-color: var(--mantine-color-dark-5); +} + +.thumbnail { + display: block; + width: 100%; + max-height: 320px; + object-fit: cover; + background-color: var(--mantine-color-gray-0); +} + +:global([data-mantine-color-scheme="dark"]) .thumbnail { + background-color: var(--mantine-color-dark-6); +} + diff --git a/apps/client/src/features/editor/components/integration-link/integration-card-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-card-view.tsx new file mode 100644 index 00000000..6e21f5ed --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-card-view.tsx @@ -0,0 +1,586 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import { + Card, + Group, + Text, + Badge, + Avatar, + Skeleton, + Anchor, + Stack, + Button, +} from "@mantine/core"; +import { useCallback, useState, memo } from "react"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import { describeIntegrationLink, isHttpUrl } from "@docmost/editor-ext"; +import { getIntegrationIcon } from "@/features/integration/components/integration-icons"; +import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service"; +import { timeAgo } from "@/lib/time"; +import { useUnfurl } from "./use-unfurl"; +import { badgeTextColor, toBadgeColor } from "./badge-color"; +import classes from "./integration-card-view.module.css"; + +const SLACK_TEXT_CLAMP_LINES = 4; + +function SlackMessageCard({ + url, + unfurlData, + onSelect, +}: { + url: string; + unfurlData: Record; + onSelect: React.MouseEventHandler; +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + const meta = unfurlData.metadata ?? {}; + const postedAt = meta.ts ? new Date(parseFloat(meta.ts) * 1000) : null; + const text: string = unfurlData.description ?? ""; + const isLong = + text.length > 280 || text.split("\n").length > SLACK_TEXT_CLAMP_LINES; + + const footer = [ + meta.replyCount + ? `${meta.replyCount} ${meta.replyCount === 1 ? t("reply") : t("replies")}` + : null, + unfurlData.status, + meta.teamName, + ] + .filter(Boolean) + .join(" • "); + + return ( + + + + + {(unfurlData.author ?? "?").charAt(0)} + + + + + + {unfurlData.author} + + {postedAt && ( + + {timeAgo(postedAt)} + + )} + + + {text && ( + + {text} + + )} + + {isLong && ( + setExpanded((v) => !v)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setExpanded((v) => !v); + } + }} + > + {expanded ? t("show less") : t("show more")} + + )} + + {footer && ( + + {footer} + + )} + + + + {getIntegrationIcon("slack", 18)} + + + + + ); +} + +function JiraIssueCard({ + url, + unfurlData, + onSelect, +}: { + url: string; + unfurlData: Record; + onSelect: React.MouseEventHandler; +}) { + const { t } = useTranslation(); + const meta = unfurlData.metadata ?? {}; + + const infoLine = [ + meta.issueKey, + unfurlData.author + ? t("Assigned to {{name}}", { name: unfurlData.author }) + : t("Unassigned"), + meta.updatedAt + ? t("Updated {{time}}", { time: timeAgo(new Date(meta.updatedAt)) }) + : null, + ] + .filter(Boolean) + .join(" • "); + + return ( + + + + {unfurlData.authorAvatarUrl ? ( + + ) : ( +
+ {getIntegrationIcon("jira", 28)} +
+ )} + + + + + {unfurlData.title} + + {unfurlData.status && ( + + {unfurlData.status} + + )} + + + + {meta.issueTypeIconUrl && ( + + )} + + {infoLine} + + + + +
+ {getIntegrationIcon("jira", 18)} +
+
+
+
+ ); +} + +function FigmaFileCard({ + url, + unfurlData, + onSelect, +}: { + url: string; + unfurlData: Record; + onSelect: React.MouseEventHandler; +}) { + const { t } = useTranslation(); + // Figma thumbnail urls are pre-signed and expire. + const [thumbnailFailed, setThumbnailFailed] = useState(false); + const meta = unfurlData.metadata ?? {}; + const thumbnailUrl: string | undefined = meta.thumbnailUrl; + const showThumbnail = Boolean(thumbnailUrl) && !thumbnailFailed; + + const subtitle = [ + unfurlData.author + ? t("Last modified by {{name}}", { name: unfurlData.author }) + : unfurlData.description, + meta.lastModified ? timeAgo(new Date(meta.lastModified)) : null, + ] + .filter(Boolean) + .join(" • "); + + return ( + + + {showThumbnail && ( + + setThumbnailFailed(true)} + /> + + )} + + + + {(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)} + + + + + {unfurlData.title} + + {subtitle && ( + + {subtitle} + + )} + + +
+ {getIntegrationIcon("figma", 18)} +
+
+
+
+ ); +} + +function IntegrationCardView(props: any) { + const { editor, getPos, node } = props; + const { url, provider } = node.attrs; + const safeUrl = isHttpUrl(url) ? url : ""; + const { t } = useTranslation(); + + const unfurl = useUnfurl(safeUrl); + const [connecting, setConnecting] = useState(false); + + const needsConnection = + unfurl.state === "needsConnection" ? unfurl.needsConnection : null; + + const selectForEditing = useCallback( + (event: React.MouseEvent) => { + if (!editor.isEditable || event.metaKey || event.ctrlKey) return; + + const target = event.target as Element; + if ( + target.closest( + "button, [role='button'], [data-integration-action='open']", + ) + ) { + return; + } + + const pos = getPos(); + if (typeof pos !== "number") return; + + event.preventDefault(); + event.stopPropagation(); + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setNodeSelection(pos) + .run(); + }, + [editor, getPos], + ); + + const handleConnect = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!needsConnection) return; + + setConnecting(true); + try { + const result = await getOAuthAuthorizeUrl({ + integrationId: needsConnection.integrationId, + returnPath: window.location.pathname, + }); + window.location.href = result.authorizationUrl; + } catch (error) { + setConnecting(false); + notifications.show({ + message: + error?.["response"]?.data?.message || + t("Failed to start OAuth connection"), + color: "red", + }); + } + }, + [needsConnection, t], + ); + + if (needsConnection) { + return ( + + + +
+ {getIntegrationIcon(provider, 28)} +
+ + + + {needsConnection.title} + + {needsConnection.description && ( + + {needsConnection.description} + + )} + + + +
+
+
+ ); + } + + if (unfurl.state === "loading") { + return ( + + + + + + + + + + + + ); + } + + if (unfurl.state !== "loaded") { + // Without unfurl data, describe the link from its url. + const described = describeIntegrationLink(safeUrl); + if (described) { + return ( + + + +
+ {getIntegrationIcon(provider, 28)} +
+ + + {described.title} + + {described.description && ( + + {described.description} + + )} + +
+
+
+ ); + } + return ( + + + {safeUrl ? ( + + {safeUrl} + + ) : ( + {url} + )} + + + ); + } + + const unfurlData = unfurl.data; + + // A message unfurl without metadata.type is identified by metadata.ts. + const slackMeta = provider === "slack" ? unfurlData.metadata : null; + if ( + slackMeta?.type === "message" || + (slackMeta && !slackMeta.type && slackMeta.ts) + ) { + return ( + + ); + } + + if (provider === "jira" && unfurlData.metadata?.issueKey) { + return ( + + ); + } + + if (provider === "figma") { + return ( + + ); + } + + return ( + + + + {unfurlData.authorAvatarUrl ? ( + + ) : ( +
+ {getIntegrationIcon(provider, 28)} +
+ )} + + + + + {unfurlData.title} + + {unfurlData.status && ( + + {unfurlData.status} + + )} + + + {unfurlData.description && ( + + {unfurlData.description} + + )} + + + {provider && ( +
+ {getIntegrationIcon(provider, 18)} +
+ )} +
+
+
+ ); +} + +export default memo(IntegrationCardView); diff --git a/apps/client/src/features/editor/components/integration-link/integration-display-picker.module.css b/apps/client/src/features/editor/components/integration-link/integration-display-picker.module.css new file mode 100644 index 00000000..6391ae3c --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-display-picker.module.css @@ -0,0 +1,191 @@ +.picker { + display: inline-flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; +} + +.option { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + min-width: 32px; + height: 32px; + padding: 0; + border-radius: 6px; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-3)); + background: transparent; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: + color 120ms ease, + background-color 120ms ease; +} + +.option svg { + width: 18px; + height: 18px; +} + +.option:hover:not([aria-disabled="true"]):not([aria-checked="true"]) { + background: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-5) + ); +} + +.option[aria-checked="true"] { + color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)); + background: light-dark( + var(--mantine-color-blue-0), + color-mix(in srgb, var(--mantine-color-blue-9) 55%, transparent) + ); +} + +.option:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: -2px; +} + +.option[aria-disabled="true"] { + opacity: 0.42; + cursor: not-allowed; +} + +.menuTrigger { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 1px; + width: 40px; + min-width: 40px; + height: 32px; + padding: 0 4px; + border-radius: 6px; + color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)); + background: light-dark( + var(--mantine-color-blue-0), + color-mix(in srgb, var(--mantine-color-blue-9) 55%, transparent) + ); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: + color 120ms ease, + background-color 120ms ease; +} + +.menuTrigger:hover { + background: light-dark( + var(--mantine-color-blue-1), + color-mix(in srgb, var(--mantine-color-blue-8) 70%, transparent) + ); +} + +.menuTrigger:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: -2px; +} + +.menuChevron { + margin-inline-end: -2px; +} + +.menuDropdown { + border-color: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-4) + ); + border-radius: 9px; +} + +.menuLabel { + padding: 5px 9px 4px; + color: var(--mantine-color-dimmed); + font-size: var(--mantine-font-size-xs); + font-weight: 600; +} + +.menuItem { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 16px; + align-items: center; + gap: 9px; + width: 100%; + min-height: 36px; + padding: 6px 9px; + border-radius: 6px; + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-gray-2)); + font-size: var(--mantine-font-size-sm); + text-align: start; + cursor: pointer; +} + +.menuItem:hover:not(:disabled) { + background: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-5) + ); +} + +.menuItem[aria-checked="true"] { + color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)); + background: light-dark( + var(--mantine-color-blue-0), + color-mix(in srgb, var(--mantine-color-blue-9) 45%, transparent) + ); +} + +.menuItem:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: -2px; +} + +.menuItem:disabled { + opacity: 0.44; + cursor: not-allowed; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + .option, + .menuTrigger { + transition: none; + } +} + +@media (pointer: coarse) { + .option { + width: 44px; + min-width: 44px; + height: 44px; + border-radius: 8px; + } + + .option svg { + width: 21px; + height: 21px; + } + + .menuTrigger { + width: 50px; + min-width: 50px; + height: 44px; + border-radius: 8px; + } + + .menuItem { + min-height: 44px; + } +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-display-picker.test.tsx b/apps/client/src/features/editor/components/integration-link/integration-display-picker.test.tsx new file mode 100644 index 00000000..3c7aab01 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-display-picker.test.tsx @@ -0,0 +1,188 @@ +import { MantineProvider } from "@mantine/core"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + IntegrationDisplayMenu, + IntegrationDisplayPicker, +} from "./integration-display-picker"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (value: string) => value }), +})); + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function renderPicker( + props: Partial> = {}, +) { + const onChange = vi.fn(); + render( + + + , + ); + return { onChange }; +} + +function renderMenu( + props: Partial> = {}, +) { + const onChange = vi.fn(); + render( + + + , + ); + return { onChange }; +} + +describe("IntegrationDisplayPicker", () => { + beforeEach(setMediaMatch); + afterEach(cleanup); + + it("exposes every display mode directly and identifies the selected mode", () => { + renderPicker({ current: "mention" }); + + expect(screen.getByRole("radiogroup", { name: "Display as" })).toBeTruthy(); + + const card = screen.getByRole("radio", { name: "Card" }); + const mention = screen.getByRole("radio", { name: "Mention" }); + const url = screen.getByRole("radio", { name: "URL" }); + + expect(card.getAttribute("aria-checked")).toBe("false"); + expect(card.getAttribute("tabindex")).toBe("-1"); + expect(mention.getAttribute("aria-checked")).toBe("true"); + expect(mention.getAttribute("tabindex")).toBe("0"); + expect(url.getAttribute("aria-checked")).toBe("false"); + }); + + it("changes mode with standard radio-group arrow navigation", () => { + const { onChange } = renderPicker({ current: "card" }); + const card = screen.getByRole("radio", { name: "Card" }); + + card.focus(); + fireEvent.keyDown(card, { key: "ArrowRight" }); + + expect(document.activeElement).toBe( + screen.getByRole("radio", { name: "Mention" }), + ); + expect(onChange).toHaveBeenCalledWith("mention"); + }); + + it("keeps an unavailable card understandable and skips it during navigation", () => { + const { onChange } = renderPicker({ + current: "url", + canUseCard: false, + }); + + const card = screen.getByRole("radio", { name: "Card" }); + expect(card.getAttribute("aria-disabled")).toBe("true"); + const reasonId = card.getAttribute("aria-describedby"); + expect(reasonId).toBeTruthy(); + expect(document.getElementById(reasonId!)?.textContent).toBe( + "Cards aren't available in this position", + ); + + fireEvent.click(card); + expect(onChange).not.toHaveBeenCalled(); + + const url = screen.getByRole("radio", { name: "URL" }); + url.focus(); + fireEvent.keyDown(url, { key: "ArrowRight" }); + expect(document.activeElement).toBe( + screen.getByRole("radio", { name: "Mention" }), + ); + expect(onChange).toHaveBeenCalledWith("mention"); + }); + + it("reports activation of the current mode so the paste nudge can close", () => { + const { onChange } = renderPicker({ current: "card" }); + + fireEvent.click(screen.getByRole("radio", { name: "Card" })); + + expect(onChange).toHaveBeenCalledWith("card"); + }); +}); + +describe("IntegrationDisplayMenu", () => { + beforeEach(setMediaMatch); + afterEach(cleanup); + + it("identifies the current display and changes it from the dropdown", async () => { + const { onChange } = renderMenu(); + const trigger = screen.getByRole("button", { name: "Display as: URL" }); + + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(trigger); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect( + ( + await screen.findByRole("menuitemradio", { + name: "URL", + hidden: true, + }) + ).getAttribute("aria-checked"), + ).toBe("true"); + + fireEvent.click( + screen.getByRole("menuitemradio", { name: "Mention", hidden: true }), + ); + expect(onChange).toHaveBeenCalledWith("mention"); + }); + + it("supports keyboard opening, option navigation, and Escape", async () => { + renderMenu({ canUseCard: false }); + const trigger = screen.getByRole("button", { name: "Display as: URL" }); + + trigger.focus(); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + + const url = await screen.findByRole("menuitemradio", { + name: "URL", + hidden: true, + }); + await waitFor(() => expect(document.activeElement).toBe(url)); + + fireEvent.keyDown(url, { key: "ArrowDown" }); + expect(document.activeElement).toBe( + screen.getByRole("menuitemradio", { name: "Mention", hidden: true }), + ); + + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/integration-display-picker.tsx b/apps/client/src/features/editor/components/integration-link/integration-display-picker.tsx new file mode 100644 index 00000000..45bc4782 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-display-picker.tsx @@ -0,0 +1,366 @@ +import { Menu, Tooltip, UnstyledButton } from "@mantine/core"; +import { + IconAt, + IconCheck, + IconChevronDown, + IconLink, + type IconProps, +} from "@tabler/icons-react"; +import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"; +import { useTranslation } from "react-i18next"; +import type { IntegrationDisplay } from "./integration-display"; +import classes from "./integration-display-picker.module.css"; + +export type IntegrationDisplayPickerProps = { + current: IntegrationDisplay; + label: string; + canUseCard: boolean; + onChange: (display: IntegrationDisplay) => void; +}; + +const DISPLAY_ORDER: IntegrationDisplay[] = ["card", "mention", "url"]; + +const DISPLAY_ICONS = { + card: CardDisplayIcon, + mention: IconAt, + url: IconLink, +} as const; + +function CardDisplayIcon({ size = 18, stroke = 1.75, ...props }: IconProps) { + return ( + + + + + + ); +} + +function useDisplayLabels() { + const { t } = useTranslation(); + + return { + labels: { + card: t("Card"), + mention: t("Mention"), + url: t("URL"), + } satisfies Record, + getCardUnavailableLabel: () => t("Cards aren't available in this position"), + }; +} + +export function IntegrationDisplayPicker({ + current, + label, + canUseCard, + onChange, +}: IntegrationDisplayPickerProps) { + const { labels, getCardUnavailableLabel } = useDisplayLabels(); + const cardReasonId = useId(); + const optionRefs = useRef< + Record + >({ + card: null, + mention: null, + url: null, + }); + + const cardUnavailableLabel = getCardUnavailableLabel(); + + const isUnavailable = (value: IntegrationDisplay) => + value === "card" && !canUseCard; + + const choose = (value: IntegrationDisplay) => { + if (isUnavailable(value)) return; + onChange(value); + }; + + const handleKeyDown = ( + event: KeyboardEvent, + value: IntegrationDisplay, + ) => { + if ( + ![ + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + ].includes(event.key) + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const available = DISPLAY_ORDER.filter( + (display) => !isUnavailable(display), + ); + const currentIndex = available.indexOf(value); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? available.length - 1 + : ["ArrowRight", "ArrowDown"].includes(event.key) + ? (currentIndex + 1 + available.length) % available.length + : (currentIndex - 1 + available.length) % available.length; + const next = available[nextIndex]; + if (!next) return; + + optionRefs.current[next]?.focus(); + onChange(next); + }; + + return ( +
+ {DISPLAY_ORDER.map((value) => { + const Icon = DISPLAY_ICONS[value]; + const selected = value === current; + const unavailable = isUnavailable(value); + const tooltipLabel = unavailable ? cardUnavailableLabel : labels[value]; + + return ( + + { + optionRefs.current[value] = element; + }} + type="button" + role="radio" + aria-label={labels[value]} + aria-checked={selected} + aria-disabled={unavailable || undefined} + aria-describedby={unavailable ? cardReasonId : undefined} + tabIndex={selected ? 0 : -1} + className={classes.option} + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + choose(value); + }} + onKeyDown={(event) => handleKeyDown(event, value)} + > + + + ); + })} + + {!canUseCard ? ( + + {cardUnavailableLabel} + + ) : null} +
+ ); +} + +export function IntegrationDisplayMenu({ + current, + label, + canUseCard, + onChange, +}: IntegrationDisplayPickerProps) { + const { labels, getCardUnavailableLabel } = useDisplayLabels(); + const [opened, setOpened] = useState(false); + const cardReasonId = useId(); + const triggerRef = useRef(null); + const optionRefs = useRef< + Record + >({ + card: null, + mention: null, + url: null, + }); + const CurrentIcon = DISPLAY_ICONS[current]; + const cardUnavailableLabel = getCardUnavailableLabel(); + const availableOptions = DISPLAY_ORDER.filter( + (value) => value !== "card" || canUseCard, + ); + const initialOption = availableOptions.includes(current) + ? current + : availableOptions[0]; + + useEffect(() => { + if (!opened) return; + + let focusFrame = 0; + const layoutFrame = requestAnimationFrame(() => { + focusFrame = requestAnimationFrame(() => { + if (initialOption) optionRefs.current[initialOption]?.focus(); + }); + }); + + return () => { + cancelAnimationFrame(layoutFrame); + cancelAnimationFrame(focusFrame); + }; + }, [initialOption, opened]); + + const openFromKeyboard = () => { + setOpened(true); + }; + + const closeAndRestoreFocus = () => { + setOpened(false); + triggerRef.current?.focus(); + }; + + const handleOptionKeyDown = ( + event: KeyboardEvent, + value: IntegrationDisplay, + ) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closeAndRestoreFocus(); + return; + } + + if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const currentIndex = availableOptions.indexOf(value); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? availableOptions.length - 1 + : event.key === "ArrowDown" + ? (currentIndex + 1 + availableOptions.length) % + availableOptions.length + : (currentIndex - 1 + availableOptions.length) % + availableOptions.length; + const next = availableOptions[nextIndex]; + if (next) optionRefs.current[next]?.focus(); + }; + + return ( + + + { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + }} + onKeyDown={(event) => { + if ( + !opened && + ["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key) + ) { + event.preventDefault(); + event.stopPropagation(); + openFromKeyboard(); + } + }} + > + + + + event.stopPropagation()} + > +
{label}
+ {DISPLAY_ORDER.map((value) => { + const Icon = DISPLAY_ICONS[value]; + const selected = value === current; + const unavailable = value === "card" && !canUseCard; + + return ( + { + optionRefs.current[value] = element; + }} + type="button" + role="menuitemradio" + aria-checked={selected} + aria-describedby={unavailable ? cardReasonId : undefined} + disabled={unavailable} + className={classes.menuItem} + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + setOpened(false); + onChange(value); + }} + onKeyDown={(event) => handleOptionKeyDown(event, value)} + > + + ); + })} + + {!canUseCard ? ( + + {cardUnavailableLabel} + + ) : null} +
+
+ ); +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-display.test.ts b/apps/client/src/features/editor/components/integration-link/integration-display.test.ts new file mode 100644 index 00000000..07933322 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-display.test.ts @@ -0,0 +1,290 @@ +import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext"; +import { Editor, type JSONContent } from "@tiptap/core"; +import TiptapLink from "@tiptap/extension-link"; +import { StarterKit } from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it } from "vitest"; +import { + convertIntegrationDisplay, + getIntegrationCardAvailability, + getSelectedIntegrationDisplay, +} from "./integration-display"; + +const url = "https://github.com/docmost/docmost/pull/2475"; +const text = (value: string, marks?: JSONContent["marks"]): JSONContent => ({ + type: "text", + text: value, + ...(marks && { marks }), +}); +const paragraph = (...content: JSONContent[]): JSONContent => ({ + type: "paragraph", + ...(content.length && { content }), +}); +const card = (): JSONContent => ({ + type: "integrationCard", + attrs: { url, provider: "github" }, +}); +const mention = (): JSONContent => ({ + type: "integrationMention", + attrs: { url, provider: "github" }, +}); + +const IntegrationAwareLink = TiptapLink.extend({ + addAttributes() { + return { + ...this.parent?.(), + integrationProvider: { default: null }, + }; + }, +}); + +let activeEditor: Editor; +afterEach(() => activeEditor?.destroy()); + +function createEditor(...content: JSONContent[]) { + activeEditor = new Editor({ + extensions: [ + StarterKit.configure({ link: false }), + IntegrationAwareLink, + IntegrationCard, + IntegrationMention, + ], + content: { type: "doc", content }, + }); + return activeEditor; +} + +describe("convertIntegrationDisplay", () => { + it("turns a card into an inline mention without losing its source", () => { + const editor = createEditor(card()); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 0 }, "mention"), + ).toBe(true); + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [paragraph(mention(), text(" "))], + }); + }); + + it("turns a card into a normal linked URL", () => { + const editor = createEditor(card()); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 0 }, "url"), + ).toBe(true); + expect(editor.getJSON().content?.[0]).toMatchObject( + paragraph( + text(url, [ + { + type: "link", + attrs: { href: url, integrationProvider: "github" }, + }, + ]), + ), + ); + }); + + it("keeps the paragraphs around a card apart when it becomes a mention", () => { + const editor = createEditor( + paragraph(text("Intro")), + card(), + paragraph(text("Outro")), + ); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 7 }, "mention"), + ).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Intro")), + paragraph(mention(), text(" ")), + paragraph(text("Outro")), + ]); + }); + + it("turns a standalone mention back into a card and ignores surrounding whitespace", () => { + const editor = createEditor(paragraph(text(" "), mention(), text(" "))); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 2 }, "card"), + ).toBe(true); + expect(editor.getJSON().content?.[0]).toEqual(card()); + }); + + it("splits inline prose around a mention converted to a card", () => { + const editor = createEditor( + paragraph(text("Review "), mention(), text(" before release.")), + ); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 8 }, "card"), + ).toBe(true); + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [ + paragraph(text("Review")), + card(), + paragraph(text("before release.")), + ], + }); + }); + + it("turns a mention inside prose into a URL without disturbing the sentence", () => { + const editor = createEditor( + paragraph(text("Review "), mention(), text(" before release.")), + ); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 8 }, "url"), + ).toBe(true); + expect(editor.getText()).toBe(`Review ${url} before release.`); + expect(editor.getJSON().content?.[0]?.content?.[1]).toMatchObject( + text(url, [ + { + type: "link", + attrs: { href: url, integrationProvider: "github" }, + }, + ]), + ); + }); + + it("turns an existing linked URL into a mention in place", () => { + const from = 8; + const editor = createEditor( + paragraph( + text("Review "), + text(url, [{ type: "link", attrs: { href: url } }]), + text(" before release."), + ), + ); + + expect( + convertIntegrationDisplay( + editor, + { kind: "link", from, to: from + url.length, url, provider: "github" }, + "mention", + ), + ).toBe(true); + expect(editor.getJSON().content?.[0]?.content?.[1]).toEqual(mention()); + expect(editor.getText()).toBe(`Review ${url} before release.`); + }); + + it("converts an entire link when inline formatting splits it into text nodes", () => { + const splitAt = 19; + const editor = createEditor( + paragraph( + text(url.slice(0, splitAt), [{ type: "link", attrs: { href: url } }]), + text(url.slice(splitAt), [ + { type: "link", attrs: { href: url } }, + { type: "bold" }, + ]), + ), + ); + + expect( + convertIntegrationDisplay( + editor, + { + kind: "link", + from: 1, + to: 1 + url.length, + url, + provider: "github", + }, + "mention", + ), + ).toBe(true); + expect(editor.getJSON().content?.[0]?.content).toEqual([mention()]); + }); + + it("turns a standalone linked URL into a card", () => { + const editor = createEditor( + paragraph(text(url, [{ type: "link", attrs: { href: url } }]), text(" ")), + ); + + expect( + convertIntegrationDisplay( + editor, + { kind: "link", from: 1, to: 1 + url.length, url, provider: "github" }, + "card", + ), + ).toBe(true); + expect(editor.getJSON().content?.[0]).toEqual(card()); + }); + + it("splits a paragraph around a linked URL converted to a card", () => { + const before = "Review "; + const after = " before release."; + const editor = createEditor( + paragraph( + text(before), + text(url, [{ type: "link", attrs: { href: url } }]), + text(after), + ), + ); + const from = 1 + before.length; + + expect( + convertIntegrationDisplay( + editor, + { kind: "link", from, to: from + url.length, url, provider: "github" }, + "card", + ), + ).toBe(true); + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [ + paragraph(text("Review")), + card(), + paragraph(text("before release.")), + ], + }); + }); + + it("reports whether an inline source can safely become a card", () => { + const editor = createEditor( + paragraph(text("Review "), mention()), + paragraph(mention()), + ); + + expect( + getIntegrationCardAvailability(editor, { kind: "node", pos: 8 }), + ).toEqual({ canUseCard: true }); + expect( + getIntegrationCardAvailability(editor, { kind: "node", pos: 11 }), + ).toEqual({ canUseCard: true }); + }); + + it("distinguishes unsupported containers from inline prose", () => { + const editor = createEditor({ + type: "bulletList", + content: [ + { + type: "listItem", + content: [paragraph(mention())], + }, + ], + }); + + expect( + getIntegrationCardAvailability(editor, { kind: "node", pos: 3 }), + ).toEqual({ canUseCard: false }); + }); + + it("resolves the selected rich integration to its current display", () => { + const editor = createEditor(card(), paragraph(mention())); + + editor.commands.setNodeSelection(0); + expect(getSelectedIntegrationDisplay(editor)).toEqual({ + current: "card", + source: { kind: "node", pos: 0 }, + url, + }); + + editor.commands.setNodeSelection(2); + expect(getSelectedIntegrationDisplay(editor)).toEqual({ + current: "mention", + source: { kind: "node", pos: 2 }, + url, + }); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/integration-display.ts b/apps/client/src/features/editor/components/integration-link/integration-display.ts new file mode 100644 index 00000000..61b6e330 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-display.ts @@ -0,0 +1,309 @@ +import { getMarkRange, type Editor } from "@tiptap/core"; +import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { NodeSelection } from "@tiptap/pm/state"; + +export type IntegrationDisplay = "card" | "mention" | "url"; + +export type IntegrationCardAvailability = { canUseCard: boolean }; + +export type IntegrationDisplaySource = + | { + kind: "node"; + pos: number; + // Set when a paste split a paragraph around the node. + joinBefore?: boolean; + joinAfter?: boolean; + } + | { + kind: "link"; + from: number; + to: number; + url: string; + provider: string; + }; + +export type SelectedIntegrationDisplay = { + current: Exclude; + source: Extract; + url: string; +}; + +export function getSelectedIntegrationDisplay( + editor: Editor, +): SelectedIntegrationDisplay | null { + const { selection } = editor.state; + if (!(selection instanceof NodeSelection)) return null; + + const current = + selection.node.type.name === "integrationCard" + ? "card" + : selection.node.type.name === "integrationMention" + ? "mention" + : null; + + if (!current || typeof selection.node.attrs.url !== "string") return null; + + return { + current, + source: { kind: "node", pos: selection.from }, + url: selection.node.attrs.url, + }; +} + +function getCardPlacement( + editor: Editor, + from: number, + to: number, + attrs: Record, +): + | { + canPlace: true; + range: { from: number; to: number }; + content: Fragment; + } + | { + canPlace: false; + range: null; + content: null; + } { + const $from = editor.state.doc.resolve(from); + if ($from.parent.type.name !== "paragraph") { + return { canPlace: false, range: null, content: null }; + } + + const parentStart = $from.start(); + const parentEnd = parentStart + $from.parent.content.size; + if (from < parentStart || to > parentEnd || from > to) { + return { canPlace: false, range: null, content: null }; + } + + const depth = $from.depth; + const parentContainer = $from.node(depth - 1); + const parentIndex = $from.index(depth - 1); + const cardType = editor.state.schema.nodes.integrationCard; + if (!cardType) { + return { canPlace: false, range: null, content: null }; + } + + const before = trimInlineBoundary( + $from.parent.content.cut(0, from - parentStart), + "end", + ); + const after = trimInlineBoundary( + $from.parent.content.cut(to - parentStart), + "start", + ); + const replacement: ProseMirrorNode[] = []; + if (before.size) replacement.push($from.parent.copy(before)); + replacement.push(cardType.create(attrs)); + if (after.size) replacement.push($from.parent.copy(after)); + + const content = Fragment.fromArray(replacement); + if (!parentContainer.canReplace(parentIndex, parentIndex + 1, content)) { + return { canPlace: false, range: null, content: null }; + } + + return { + canPlace: true, + range: { from: $from.before(depth), to: $from.after(depth) }, + content, + }; +} + +function trimInlineBoundary( + fragment: Fragment, + boundary: "start" | "end", +): Fragment { + const children: ProseMirrorNode[] = []; + fragment.forEach((child) => children.push(child)); + + while (children.length) { + const index = boundary === "start" ? 0 : children.length - 1; + const child = children[index]; + if (!child?.isText) break; + + const text = + boundary === "start" + ? child.text?.replace(/^\s+/, "") + : child.text?.replace(/\s+$/, ""); + if (text) { + children[index] = child.type.schema.text(text, child.marks); + break; + } + children.splice(index, 1); + } + + return Fragment.fromArray(children); +} + +function replaceWithCard( + editor: Editor, + placement: Extract, { canPlace: true }>, +): boolean { + return editor.commands.command(({ tr }) => { + tr.replaceWith(placement.range.from, placement.range.to, placement.content); + return true; + }); +} + +export function getIntegrationCardAvailability( + editor: Editor, + source: IntegrationDisplaySource, +): IntegrationCardAvailability { + if (source.kind === "link") { + const placement = getCardPlacement(editor, source.from, source.to, { + url: source.url, + provider: source.provider, + }); + return { canUseCard: placement.canPlace }; + } + + const node = editor.state.doc.nodeAt(source.pos); + if (node?.type.name === "integrationCard") { + return { canUseCard: true }; + } + if (node?.type.name !== "integrationMention") { + return { canUseCard: false }; + } + + const placement = getCardPlacement( + editor, + source.pos, + source.pos + node.nodeSize, + { ...node.attrs }, + ); + return { canUseCard: placement.canPlace }; +} + +export function canPlaceIntegrationCard(editor: Editor, pos: number): boolean { + return getCardPlacement(editor, pos, pos, {}).canPlace; +} + +export function convertIntegrationDisplay( + editor: Editor, + source: IntegrationDisplaySource, + target: IntegrationDisplay, +): boolean { + if (source.kind === "link") { + const linkType = editor.state.schema.marks.link; + const sourceRange = linkType + ? getMarkRange(editor.state.doc.resolve(source.from), linkType, { + href: source.url, + }) + : null; + if ( + !sourceRange || + sourceRange.from !== source.from || + sourceRange.to !== source.to + ) { + return false; + } + + if (target === "card") { + const paragraphRange = getCardPlacement(editor, source.from, source.to, { + url: source.url, + provider: source.provider, + }); + if (!paragraphRange.canPlace) return false; + return replaceWithCard(editor, paragraphRange); + } + + if (target !== "mention") return false; + return editor.commands.insertContentAt( + { from: source.from, to: source.to }, + { + type: "integrationMention", + attrs: { url: source.url, provider: source.provider }, + }, + ); + } + + const node = editor.state.doc.nodeAt(source.pos); + if (!node) return false; + + if (node.type.name === "integrationMention" && target === "card") { + const paragraphRange = getCardPlacement( + editor, + source.pos, + source.pos + node.nodeSize, + { ...node.attrs }, + ); + if (!paragraphRange.canPlace) return false; + return replaceWithCard(editor, paragraphRange); + } + + if (node.type.name === "integrationMention" && target === "url") { + return editor.commands.insertContentAt( + { from: source.pos, to: source.pos + node.nodeSize }, + { + type: "text", + text: node.attrs.url, + marks: [ + { + type: "link", + attrs: { + href: node.attrs.url, + integrationProvider: node.attrs.provider, + }, + }, + ], + }, + ); + } + + if (node.type.name !== "integrationCard") return false; + + const $card = editor.state.doc.resolve(source.pos); + const before = source.joinBefore ? $card.nodeBefore : null; + const after = source.joinAfter + ? $card.parent.maybeChild($card.index() + 1) + : null; + const joinBefore = before?.type.name === "paragraph"; + const joinAfter = after?.type.name === "paragraph"; + const spaceFollows = joinAfter && /^\s/.test(after?.textContent ?? ""); + + const content = + target === "mention" + ? [ + { type: "integrationMention", attrs: { ...node.attrs } }, + ...(spaceFollows ? [] : [{ type: "text", text: " " }]), + ] + : target === "url" + ? [ + { + type: "text", + text: node.attrs.url, + marks: [ + { + type: "link", + attrs: { + href: node.attrs.url, + integrationProvider: node.attrs.provider, + }, + }, + ], + }, + ] + : null; + + if (!content) return false; + + return editor + .chain() + .insertContentAt( + { from: source.pos, to: source.pos + node.nodeSize }, + { + type: "paragraph", + attrs: (joinBefore ? before : joinAfter ? after : null)?.attrs, + content, + }, + ) + .command(({ tr }) => { + const paragraph = tr.doc.nodeAt(source.pos); + if (paragraph?.type.name !== "paragraph") return false; + if (joinAfter) tr.join(source.pos + paragraph.nodeSize); + if (joinBefore) tr.join(source.pos); + return true; + }) + .run(); +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-patterns.test.ts b/apps/client/src/features/editor/components/integration-link/integration-link-patterns.test.ts new file mode 100644 index 00000000..f74e2c46 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-link-patterns.test.ts @@ -0,0 +1,345 @@ +import { + describeIntegrationLink, + matchIntegrationLink, +} from "@docmost/editor-ext"; +import { describe, expect, it } from "vitest"; + +const AZURE = "https://dev.azure.com/contoso"; +const LEGACY_AZURE = "https://contoso.visualstudio.com"; +const JIRA_HOSTS = { jira: ["jira.acme.com"] }; + +// Exercise matching through the public API; regex order and counts are implementation details. +describe("integration link matching", () => { + it.each([ + ["https://github.com/docmost/docmost", "github", "github-repo"], + ["https://gitlab.com/docmost/docmost", "gitlab", "gitlab-project"], + ["https://code.example.com/o/r/pull/1", "github", "github-pr"], + ["https://code.example.com/o/r/-/merge_requests/1", "gitlab", "gitlab-mr"], + ])("identifies %s as %s", (url, provider, type) => { + expect(matchIntegrationLink(url)).toMatchObject({ provider, type }); + }); + + it.each([ + "https://example.com/foo/bar", + "https://github.com/o/r/-/merge_requests/1", + "https://gitlab.com/o/r/pull/1", + ])("does not misclassify %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it("restricts GitHub links to the configured Enterprise host", () => { + const hosts = { github: ["github.acme.com"] }; + expect( + matchIntegrationLink("https://github.acme.com/o/r", hosts), + ).toMatchObject({ provider: "github", type: "github-repo" }); + expect( + matchIntegrationLink("https://github.acme.com/o/r/pull/1", hosts), + ).toMatchObject({ provider: "github", type: "github-pr" }); + expect( + matchIntegrationLink("https://github.com/o/r/pull/1", hosts), + ).toBeNull(); + expect( + matchIntegrationLink("https://gitea.example.com/o/r/pull/1", hosts), + ).toBeNull(); + }); + + it("recognizes a self-managed GitLab project root", () => { + expect( + matchIntegrationLink("https://gitlab.acme.com/g/p", { + gitlab: ["gitlab.acme.com"], + }), + ).toMatchObject({ provider: "gitlab", type: "gitlab-project" }); + }); + + it("matches Jira wildcard subdomains without accepting lookalike hosts", () => { + const hosts = { jira: ["*.atlassian.net", "*.jira.com"] }; + expect( + matchIntegrationLink("https://acme.atlassian.net/browse/AB-1", hosts), + ).toMatchObject({ provider: "jira", type: "jira-issue" }); + for (const host of [ + "atlassian.net", + "evilatlassian.net", + "jira.acme.com", + ]) { + expect( + matchIntegrationLink(`https://${host}/browse/AB-1`, hosts), + ).toBeNull(); + } + }); + + it("keeps default matching for providers without a configured host", () => { + expect( + matchIntegrationLink("https://code.example.com/o/r/pull/1", JIRA_HOSTS), + ).toMatchObject({ provider: "github", type: "github-pr" }); + expect( + matchIntegrationLink("https://acme.slack.com/archives/C123", {}), + ).toMatchObject({ provider: "slack", type: "slack-channel" }); + }); + + it.each([ + "https://github.com/o/r/pull/1\n", + "https://github.com/o/r/pulls?" + "a".repeat(9000), + ])("rejects whitespace or oversized input: %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it.each([null, undefined])("rejects a link mark without an href: %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it.each([ + ["https://github.com/o/r/pulls?q=is%3Aopen", "github-pulls-list"], + ["https://github.com/o/r/pulls/extra", "github-pulls-list"], + ["https://github.com/o/r/releases/tag/v1.0.0", "github-releases-list"], + ])("recognizes the collection at %s", (url, type) => { + expect(matchIntegrationLink(url)?.type).toBe(type); + }); + + it("does not treat a longer segment as a collection", () => { + expect(matchIntegrationLink("https://github.com/o/r/pullsx")).toBeNull(); + }); +}); + +describe("Azure DevOps links", () => { + it.each([ + [ + `${AZURE}/Fabrikam/_git/web-app/pullrequest/318`, + "azure-devops-pr", + ["contoso", "Fabrikam", "web-app", "318"], + ], + [ + `${AZURE}/Fabrikam/_workitems/edit/318`, + "azure-devops-work-item", + ["contoso", "Fabrikam", "318"], + ], + [ + `${AZURE}/Fabrikam/_boards/board/?workitem=318`, + "azure-devops-work-item", + ["contoso", "Fabrikam", "318"], + ], + [ + `${LEGACY_AZURE}/Fabrikam/_git/web-app/pullrequest/318`, + "azure-devops-pr", + ["contoso", "Fabrikam", "web-app", "318"], + ], + [ + `${LEGACY_AZURE}/DefaultCollection/Fabrikam/_workitems/edit/318`, + "azure-devops-work-item", + ["contoso", "Fabrikam", "318"], + ], + [ + `${LEGACY_AZURE}/DefaultCollection/Fabrikam/_boards/board/?workitem=318`, + "azure-devops-work-item", + ["contoso", "Fabrikam", "318"], + ], + [ + `${AZURE}/_git/Fabrikam/pullrequest/318`, + "azure-devops-pr", + ["contoso", undefined, "Fabrikam", "318"], + ], + [ + `${LEGACY_AZURE}/_workitems/edit/318`, + "azure-devops-work-item", + ["contoso", undefined, "318"], + ], + ] as const)( + "captures routing fields from %s and rejects invalid IDs", + (url, type, groups) => { + const result = matchIntegrationLink(url); + expect(result).toMatchObject({ provider: "azure_devops", type }); + expect(result?.match.slice(1)).toEqual(groups); + expect(matchIntegrationLink(`${url}x`)).toBeNull(); + expect(matchIntegrationLink(`${url}00000000`)).toBeNull(); + }, + ); + + it("matches Azure links when other providers report their hosts", () => { + expect( + matchIntegrationLink(`${AZURE}/Fabrikam/_git/web-app/pullrequest/318`, { + github: ["github.com"], + jira: ["*.atlassian.net"], + }), + ).toMatchObject({ provider: "azure_devops", type: "azure-devops-pr" }); + expect( + matchIntegrationLink( + "https://dev.azure.com/browse/AB-1/_workitems/edit/5", + ), + ).toMatchObject({ + provider: "azure_devops", + type: "azure-devops-work-item", + }); + }); + + it.each([ + "http://dev.azure.com/contoso/Fabrikam/_workitems/edit/1", + "http://contoso.visualstudio.com/Fabrikam/_workitems/edit/1", + "https://dev.azure.com.evil.com/contoso/Fabrikam/_workitems/edit/1", + "https://contoso.visualstudio.com.evil.com/Fabrikam/_workitems/edit/1", + "https://dev.azure.com/con_toso/Fabrikam/_workitems/edit/1", + "https://dev.azure.com/con%2Ftoso/Fabrikam/_workitems/edit/1", + `${AZURE}/_Fabrikam/_workitems/edit/1`, + `${AZURE}/Fabrikam/_git/web-app?workitem=42`, + `${AZURE}/Fabrikam/_boardsx/board/?workitem=42`, + `${AZURE}/Fabrikam/_boards/board/?xworkitem=42`, + `${LEGACY_AZURE}/Fabrikam/boards/board/?workitem=42`, + ])("rejects an unsupported or misleading URL: %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it("reads work items from board, backlog and sprint hubs on both hosts", () => { + for (const host of [AZURE, LEGACY_AZURE]) { + for (const hub of [ + "_boards/board", + "_backlogs/backlog", + "_sprints/taskboard", + ]) { + const url = `${host}/Fabrikam/${hub}/Team?showParents=true&workitem=42`; + expect(matchIntegrationLink(url)?.match.slice(1), url).toEqual([ + "contoso", + "Fabrikam", + "42", + ]); + } + } + }); + + it.each([ + [ + `${AZURE}/Fabrikam%20Fiber/_workitems/edit/1234`, + "Work Item #1234", + "contoso/Fabrikam Fiber", + ], + [ + `${AZURE}/Fabrikam/_git/web%20app/pullrequest/318`, + "Pull Request #318", + "contoso/Fabrikam/web app", + ], + [ + `${AZURE}/_git/Fabrikam/pullrequest/9`, + "Pull Request #9", + "contoso/Fabrikam", + ], + [`${AZURE}/_workitems/edit/7`, "Work Item #7", "contoso"], + ])("describes %s without a connection", (url, title, description) => { + expect(describeIntegrationLink(url)).toEqual({ + provider: "azure_devops", + title, + description, + }); + }); + + it.each([ + ["Fab%0Arikam", "Fab%0Arikam"], + ["Fab%E2%80%AErikam", "Fab%E2%80%AErikam"], + ["Fab%E2%80%A8rikam", "Fab%E2%80%A8rikam"], + ["Fab%ZZ", "Fab%ZZ"], + ["Fab\u202erikam", "Fab%E2%80%AErikam"], + ["a%2Fb", "a%2Fb"], + ["Caf%C3%A9", "Café"], + ])("keeps decoded project names safe: %s", (project, expected) => { + expect( + describeIntegrationLink(`${AZURE}/${project}/_workitems/edit/1`) + ?.description, + ).toBe(`contoso/${expected}`); + }); +}); + +describe("URL path boundaries", () => { + // Representative providers and failure classes instead of every route permutation. + it.each([ + "https://github.com/a/b/pull/1/../../../../evil/x/pull/2", + "https://github.com/a/b/pull/1/%2e%2e/evil", + "https://github.com/a/b/pull/1\\..\\evil", + "https://gitlab.com/a/b/../../evil/x/-/issues/1", + "https://linear.app/acme/issue/ABC-1/../../../evil/issue/XYZ-2", + "https://acme.atlassian.net/browse/AB-1/%2e%2e/browse/EVIL-2", + `${AZURE}/Fabrikam/_git/web-app/pullrequest/318/../../evil`, + `${LEGACY_AZURE}/Fabrikam/_workitems/edit/1\\..\\evil`, + ])("rejects paths the browser resolves elsewhere: %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it.each([ + ["https://github.com/o/.github/pull/1", "github-pr"], + ["https://github.com/o/my..repo/issues/2", "github-issue"], + [ + "https://github.com/o/r/blob/main/.github/workflows/ci.yml#L1-L3", + "github-file", + ], + ["https://github.com/o/r/pulls?q=../../x", "github-pulls-list"], + ["https://linear.app/acme/issue/ABC-1#comment-1/../..", "linear-issue"], + ["https://acme.atlassian.net/browse/AB-1?atlOrigin=x/../..", "jira-issue"], + [ + `${AZURE}/Fabrikam/_git/web-app/pullrequest/318?_a=files&path=/../x`, + "azure-devops-pr", + ], + ])("allows dots in names, query strings and fragments: %s", (url, type) => { + expect(matchIntegrationLink(url)?.type).toBe(type); + }); + + it("applies the same path guard on an Enterprise host", () => { + expect( + matchIntegrationLink("https://github.acme.com/a/b/pull/1/../../evil", { + github: ["github.acme.com"], + }), + ).toBeNull(); + }); + + it.each([ + "https://github.com/o/r?x=/pull/1", + "https://github.com/o/r#x/pull/1", + "https://github.com/o?x=/r/issues/1", + ])("does not read a route from an owner or repository query: %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it("captures ordinary dots, dashes and underscores in repository names", () => { + expect( + matchIntegrationLink( + "https://github.com/my-org_1/repo.name-x/pull/1", + )?.match.slice(1), + ).toEqual(["my-org_1", "repo.name-x", "1"]); + }); +}); + +describe("Jira Data Center context paths", () => { + it.each([ + "https://jira.acme.com/jira/browse/AB-1", + "https://jira.acme.com/tools/jira/browse/AB-1", + "https://jira.acme.com/jira/secure/RapidBoard.jspa?rapidView=1&selectedIssue=AB-1", + ])("reads the issue under an installed context path: %s", (url) => { + const result = matchIntegrationLink(url, JIRA_HOSTS); + expect(result).toMatchObject({ provider: "jira", type: "jira-issue" }); + expect(result?.match.slice(1)).toEqual(["AB-1"]); + expect(matchIntegrationLink(url)).toBeNull(); + expect(matchIntegrationLink(url, { jira: ["jira.other.com"] })).toBeNull(); + }); + + it("takes the first issue key after the context path", () => { + expect( + matchIntegrationLink( + "https://jira.acme.com/jira/browse/AB-1/browse/CD-2", + JIRA_HOSTS, + )?.match[1], + ).toBe("AB-1"); + }); + + it("handles an uppercase Cloud host when matching a selected board issue", () => { + expect( + matchIntegrationLink( + "https://ACME.Atlassian.net/jira/software/projects/AB/boards/1?selectedIssue=AB-12", + { + jira: ["*.atlassian.net"], + }, + ), + ).toMatchObject({ provider: "jira", type: "jira-issue" }); + }); + + it.each([ + "https://jira.acme.com/jira/../browse/AB-1", + "https://jira.acme.com/jira/%2e%2e/browse/AB-1", + "https://jira.acme.com/jira\\..\\browse/AB-1", + ])("rejects context paths the browser resolves elsewhere: %s", (url) => { + expect(matchIntegrationLink(url, JIRA_HOSTS)).toBeNull(); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-url.test.ts b/apps/client/src/features/editor/components/integration-link/integration-link-url.test.ts new file mode 100644 index 00000000..a8b9ea4f --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-link-url.test.ts @@ -0,0 +1,122 @@ +import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext"; +import { Editor, generateText } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it } from "vitest"; + +const PR_URL = "https://github.com/o/r/pull/1"; +const SCRIPT_URLS = [" JaVaScRiPt:alert(1)", "java\tscript:alert(1)"]; +const ENCODED_URL = "https://github.com/o/r/issues?q=label%3A%22a%2520b%22"; + +let editor: Editor; + +function createEditor(content: string | Record = "") { + editor = new Editor({ + extensions: [StarterKit, IntegrationCard, IntegrationMention], + content, + }); + return editor; +} + +function findAttrs(typeName: string) { + const found: Record[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name === typeName) found.push(node.attrs); + }); + return found[0]; +} + +afterEach(() => editor?.destroy()); + +describe.each([ + { typeName: "integrationCard", tag: "div", command: "setIntegrationCard" }, + { + typeName: "integrationMention", + tag: "span", + command: "setIntegrationMention", + }, +] as const)("$typeName url attr", ({ typeName, tag, command }) => { + it.each(SCRIPT_URLS)("stores an empty url when inserting %j", (url) => { + createEditor(); + editor.commands[command]({ url, provider: "github" }); + + expect(findAttrs(typeName)).toMatchObject({ url: "" }); + }); + + it("preserves percent encoding when inserting", () => { + createEditor(); + editor.commands[command]({ url: ENCODED_URL, provider: "github" }); + + expect(findAttrs(typeName)).toMatchObject({ url: ENCODED_URL }); + }); + + it("trims whitespace around an inserted url", () => { + createEditor(); + editor.commands[command]({ url: ` ${PR_URL}\n`, provider: "github" }); + + expect(findAttrs(typeName)).toMatchObject({ url: PR_URL }); + }); + + it("preserves percent encoding through HTML", () => { + createEditor( + `

x

<${tag} data-type="${typeName}" data-url="${ENCODED_URL}">`, + ); + + expect(findAttrs(typeName)).toMatchObject({ url: ENCODED_URL }); + expect(editor.getHTML()).toContain(`href="${ENCODED_URL}"`); + }); + + it("drops a non-http data-url when parsing HTML", () => { + createEditor( + `

x

<${tag} data-type="${typeName}" data-url="file:///x" data-provider="github">`, + ); + + expect(findAttrs(typeName)).toMatchObject({ url: "", provider: "github" }); + }); + + it("renders no unsafe href for JSON content that bypassed parsing", () => { + const node = { type: typeName, attrs: { url: "file:///etc/passwd" } }; + createEditor({ + type: "doc", + content: [ + typeName === "integrationCard" + ? node + : { type: "paragraph", content: [node] }, + ], + }); + + const html = editor.getHTML(); + expect(html).not.toContain("file:"); + expect(html).toContain('href=""'); + }); +}); + +describe("integration node plain text", () => { + it("renders each node as its url", () => { + const issueUrl = "https://github.com/o/r/issues/2"; + const text = generateText( + { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "See " }, + { + type: "integrationMention", + attrs: { url: PR_URL, provider: "github" }, + }, + { type: "text", text: " today" }, + ], + }, + { + type: "integrationCard", + attrs: { url: issueUrl, provider: "github" }, + }, + ], + }, + [StarterKit, IntegrationCard, IntegrationMention], + ); + + expect(text).toBe(`See ${PR_URL} today\n\n${issueUrl}`); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css b/apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css new file mode 100644 index 00000000..9638902f --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css @@ -0,0 +1,53 @@ +.mention { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + vertical-align: baseline; + text-decoration: none; + color: inherit; + border-radius: var(--mantine-radius-sm); +} + +:global(.node-integrationMention) .mention { + border-bottom: none !important; + font-weight: 400; +} + +.mention:hover { + background-color: var(--mantine-color-gray-0); +} + +:global([data-mantine-color-scheme="dark"]) .mention:hover { + background-color: var(--mantine-color-dark-5); +} + +/* Text runs share the paragraph baseline; icons and badges center on the line. */ +.mentionMeta, +.mentionText { + align-self: baseline; +} + +.mentionMeta { + color: var(--mantine-color-dimmed); + white-space: nowrap; +} + +.mentionText { + text-decoration: underline; + text-decoration-thickness: 0.07em; + text-decoration-color: light-dark( + var(--mantine-color-dark-0), + var(--mantine-color-dark-2) + ); + text-underline-offset: 0.2em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 340px; + font-weight: 500; +} + +.mentionIcon { + flex-shrink: 0; +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx new file mode 100644 index 00000000..473f9317 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx @@ -0,0 +1,259 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import { + Avatar, + Badge, + Button, + Group, + HoverCard, + Stack, + Text, +} from "@mantine/core"; +import { memo, useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import { describeIntegrationLink, isHttpUrl } from "@docmost/editor-ext"; +import { getIntegrationIcon } from "@/features/integration/components/integration-icons"; +import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service"; +import { useUnfurl } from "./use-unfurl"; +import { badgeTextColor, toBadgeColor } from "./badge-color"; +import classes from "./integration-mention-view.module.css"; + +function shortUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.host}${parsed.pathname}`; + } catch { + return url; + } +} + +function IntegrationMentionView(props: any) { + const { editor, getPos, node } = props; + const { url, provider } = node.attrs; + const safeUrl = isHttpUrl(url) ? url : ""; + const { t } = useTranslation(); + + const unfurl = useUnfurl(safeUrl); + const data = unfurl.state === "loaded" ? unfurl.data : null; + const needsConnection = + unfurl.state === "needsConnection" ? unfurl.needsConnection : null; + const [connecting, setConnecting] = useState(false); + + const selectForEditing = useCallback( + (event: React.MouseEvent) => { + if (!editor.isEditable || event.metaKey || event.ctrlKey) return; + + const pos = getPos(); + if (typeof pos !== "number") return; + + event.preventDefault(); + event.stopPropagation(); + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setNodeSelection(pos) + .run(); + }, + [editor, getPos], + ); + + const handleConnect = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!needsConnection) return; + + setConnecting(true); + try { + const result = await getOAuthAuthorizeUrl({ + integrationId: needsConnection.integrationId, + returnPath: window.location.pathname, + }); + window.location.href = result.authorizationUrl; + } catch (error) { + setConnecting(false); + notifications.show({ + message: + error?.["response"]?.data?.message || + t("Failed to start OAuth connection"), + color: "red", + }); + } + }, + [needsConnection, t], + ); + const meta = data?.metadata ?? {}; + const isSlackMessage = + provider === "slack" && + (meta.type === "message" || (!meta.type && meta.ts)); + const issueNumber = meta.iid ?? meta.number; + const typeLabel = + meta.type === "project" + ? t("Project") + : meta.type === "initiative" + ? t("Initiative") + : null; + + const statusBadge = data?.status ? ( + + {data.status} + + ) : null; + + let content; + if (!data) { + // Without unfurl data, describe the link from its url. + content = ( + <> + {getIntegrationIcon(provider, 14)} + + {needsConnection?.title ?? + describeIntegrationLink(safeUrl)?.title ?? + (safeUrl ? shortUrl(safeUrl) : url)} + + + ); + } else if (isSlackMessage) { + content = ( + <> + + {(data.author ?? "?").charAt(0)} + + {data.author && ( + {data.author} + )} + + {(data.description ?? "").split("\n")[0] || shortUrl(url)} + + {getIntegrationIcon("slack", 14)} + {data.status && ( + + {data.status} + + )} + + ); + } else if (meta.issueKey) { + // Jira issue + content = ( + <> + {meta.issueTypeIconUrl ? ( + + ) : ( + getIntegrationIcon(provider, 14) + )} + {meta.issueKey} + {data.title} + {statusBadge} + {meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)} + + ); + } else if (issueNumber) { + content = ( + <> + {getIntegrationIcon(provider, 14)} + #{issueNumber} + {data.title} + {statusBadge} + + ); + } else if (typeLabel) { + content = ( + <> + {getIntegrationIcon(provider, 14)} + {typeLabel} + {data.title} + {statusBadge} + + ); + } else { + content = ( + <> + {getIntegrationIcon(provider, 14)} + + {data.title || shortUrl(url)} + + {statusBadge} + + ); + } + + const anchor = ( + + {content} + + ); + + return ( + + {needsConnection ? ( + + {anchor} + + + + {getIntegrationIcon(provider, 16)} + + {needsConnection.integrationName} + + + + + + + ) : ( + anchor + )} + + ); +} + +export default memo(IntegrationMentionView); diff --git a/apps/client/src/features/editor/components/integration-link/integration-paste-menu.module.css b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.module.css new file mode 100644 index 00000000..25a41674 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.module.css @@ -0,0 +1,71 @@ +.toolbar { + display: inline-flex; + align-items: center; + gap: 1px; + padding: 4px; + border: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + border-radius: 10px; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-2)); + background: light-dark( + var(--mantine-color-white), + var(--mantine-color-dark-6) + ); + box-shadow: 0 5px 18px light-dark(rgba(31, 35, 40, 0.12), rgba(0, 0, 0, 0.36)); +} + +.divider { + width: 1px; + height: 22px; + margin: 0 3px; + flex: 0 0 1px; + background: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-3) + ); +} + +.actionButton { + --ai-size: 32px !important; + + flex: 0 0 32px; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-2)); + border-radius: 8px; + -webkit-tap-highlight-color: transparent; +} + +.actionButton svg { + width: 18px; + height: 18px; +} + +.actionButton:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: -2px; +} + +.pasteOption { + min-height: 30px; +} + +@media (pointer: coarse) { + .pasteOption { + min-height: 44px; + } + + .actionButton { + --ai-size: 44px !important; + + flex-basis: 44px; + } + + .actionButton svg { + width: 21px; + height: 21px; + } + + .divider { + height: 26px; + margin-inline: 4px; + } +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-paste-menu.test.tsx b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.test.tsx new file mode 100644 index 00000000..b2f4faba --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.test.tsx @@ -0,0 +1,213 @@ +import { MantineProvider } from "@mantine/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IntegrationPasteMenu } from "./integration-paste-menu"; + +const mocks = vi.hoisted(() => ({ + pasteState: null as { + pos: number; + joinBefore?: boolean; + joinAfter?: boolean; + } | null, + selectedDisplay: null as Record | null, + convertDisplay: vi.fn(), + hasFocus: true, + blurHandler: null as ((props: { event: FocusEvent }) => void) | null, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (value: string) => value }), +})); + +vi.mock("@tiptap/react", () => ({ + posToDOMRect: vi.fn(), + useEditorState: ({ editor, selector }: Record) => + selector({ editor }), +})); + +vi.mock("@tiptap/react/menus", () => ({ + BubbleMenu: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@/features/editor/extensions/integration-paste-menu", () => ({ + integrationPasteMenuKey: { + getState: () => mocks.pasteState, + }, +})); + +vi.mock("./integration-display", () => ({ + convertIntegrationDisplay: mocks.convertDisplay, + getIntegrationCardAvailability: () => ({ canUseCard: true }), + getSelectedIntegrationDisplay: () => mocks.selectedDisplay, +})); + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function renderMenu(nodeType = "integrationCard") { + const editor = { + state: { + doc: { + nodeAt: () => ({ + type: { name: nodeType }, + attrs: { + url: "https://gitlab.com/gitlab-org/gitlab/-/issues/609576", + }, + nodeSize: 1, + }), + }, + tr: { setMeta: vi.fn() }, + }, + view: { + dispatch: vi.fn(), + nodeDOM: vi.fn(), + hasFocus: () => mocks.hasFocus, + }, + commands: { focus: vi.fn() }, + isDestroyed: false, + on: (name: string, handler: (props: { event: FocusEvent }) => void) => { + if (name === "blur") mocks.blurHandler = handler; + }, + off: vi.fn(), + }; + + render( + + ["editor"] + } + /> + , + ); + + return editor; +} + +describe("IntegrationPasteMenu", () => { + beforeEach(() => { + setMediaMatch(); + mocks.pasteState = null; + mocks.selectedDisplay = null; + mocks.convertDisplay.mockReset(); + mocks.hasFocus = true; + mocks.blurHandler = null; + }); + + afterEach(cleanup); + + it("shows the original vertical Paste as chooser only for a fresh paste", () => { + mocks.pasteState = { pos: 4 }; + + renderMenu(); + + const chooser = screen.getByRole("listbox", { name: "Paste as" }); + expect(chooser).toBeTruthy(); + expect(chooser.getAttribute("aria-orientation")).toBe("vertical"); + expect(screen.getByRole("option", { name: "Card" })).toBeTruthy(); + expect(screen.getByRole("option", { name: "Mention" })).toBeTruthy(); + expect(screen.getByRole("option", { name: "URL" })).toBeTruthy(); + expect( + screen.queryByRole("toolbar", { name: "Integration link actions" }), + ).toBeNull(); + }); + + it("keeps the horizontal display toolbar for an existing node", () => { + mocks.selectedDisplay = { + current: "mention", + source: { kind: "node", pos: 4 }, + url: "https://gitlab.com/gitlab-org/gitlab/-/issues/609576", + }; + + renderMenu("integrationMention"); + + expect( + screen.getByRole("toolbar", { name: "Integration link actions" }), + ).toBeTruthy(); + expect(screen.getByRole("radiogroup", { name: "Display as" })).toBeTruthy(); + expect(screen.queryByRole("listbox", { name: "Paste as" })).toBeNull(); + }); + + it("handles Paste as keys while the editor has focus", () => { + mocks.pasteState = { pos: 4 }; + + renderMenu(); + fireEvent.keyDown(window, { key: "ArrowDown" }); + fireEvent.keyDown(window, { key: "Enter" }); + + expect(mocks.convertDisplay).toHaveBeenCalledWith( + expect.anything(), + { kind: "node", pos: 4 }, + "mention", + ); + }); + + it("hands the paste split to the conversion so the sentence can rejoin", () => { + mocks.pasteState = { pos: 4, joinBefore: true, joinAfter: false }; + + renderMenu(); + fireEvent.click(screen.getByRole("option", { name: "URL" })); + + expect(mocks.convertDisplay).toHaveBeenCalledWith( + expect.anything(), + { kind: "node", pos: 4, joinBefore: true, joinAfter: false }, + "url", + ); + }); + + it("leaves keys alone when focus is outside the editor and the prompt", () => { + mocks.pasteState = { pos: 4 }; + mocks.hasFocus = false; + + renderMenu(); + const enter = new KeyboardEvent("keydown", { + key: "Enter", + cancelable: true, + }); + window.dispatchEvent(enter); + + expect(enter.defaultPrevented).toBe(false); + expect(mocks.convertDisplay).not.toHaveBeenCalled(); + }); + + it("dismisses the prompt when the editor loses focus elsewhere", () => { + mocks.pasteState = { pos: 4 }; + + const editor = renderMenu(); + mocks.blurHandler?.({ + event: new FocusEvent("blur", { relatedTarget: document.body }), + }); + + expect(editor.state.tr.setMeta).toHaveBeenCalledWith(expect.anything(), null); + expect(editor.view.dispatch).toHaveBeenCalled(); + }); + + it("keeps the prompt when focus moves into it", () => { + mocks.pasteState = { pos: 4 }; + + const editor = renderMenu(); + mocks.blurHandler?.({ + event: new FocusEvent("blur", { + relatedTarget: screen.getByRole("option", { name: "Card" }), + }), + }); + + expect(editor.view.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx new file mode 100644 index 00000000..43fdc945 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx @@ -0,0 +1,335 @@ +import { + ActionIcon, + Button, + Paper, + Stack, + Text, + Tooltip, + VisuallyHidden, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { IconCopy, IconExternalLink } from "@tabler/icons-react"; +import { posToDOMRect, useEditorState } from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { copyToClipboard } from "@docmost/editor-ext"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; +import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu"; +import { + convertIntegrationDisplay, + getIntegrationCardAvailability, + getSelectedIntegrationDisplay, + type IntegrationDisplay, + type IntegrationDisplaySource, +} from "./integration-display"; +import { IntegrationDisplayPicker } from "./integration-display-picker"; +import classes from "./integration-paste-menu.module.css"; + +const PASTE_OPTIONS: { value: IntegrationDisplay; label: string }[] = [ + { value: "card", label: "Card" }, + { value: "mention", label: "Mention" }, + { value: "url", label: "URL" }, +]; + +type DisplayTarget = { + current: Exclude; + source: Extract; + url: string; + isPaste: boolean; +}; + +function getDisplayTarget( + editor: EditorMenuProps["editor"], +): DisplayTarget | null { + const pasteState = integrationPasteMenuKey.getState(editor.state); + if (pasteState) { + const node = editor.state.doc.nodeAt(pasteState.pos); + const current = + node?.type.name === "integrationCard" + ? "card" + : node?.type.name === "integrationMention" + ? "mention" + : null; + + if (current && typeof node?.attrs.url === "string") { + return { + current, + source: { + kind: "node", + pos: pasteState.pos, + joinBefore: pasteState.joinBefore, + joinAfter: pasteState.joinAfter, + }, + url: node.attrs.url, + isPaste: true, + }; + } + } + + const selected = getSelectedIntegrationDisplay(editor); + return selected ? { ...selected, isPaste: false } : null; +} + +export function IntegrationPasteMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + const [selectedPasteIndex, setSelectedPasteIndex] = useState(0); + const pasteOptionIdPrefix = useId(); + const promptRef = useRef(null); + + const target = useEditorState({ + editor, + selector: ({ editor: currentEditor }) => + currentEditor ? getDisplayTarget(currentEditor) : null, + }); + + const findTarget = useCallback(() => getDisplayTarget(editor), [editor]); + const shouldShow = useCallback(() => Boolean(findTarget()), [findTarget]); + + const getReferencedVirtualElement = useCallback(() => { + const currentTarget = findTarget(); + if (!currentTarget) return undefined; + + const { pos } = currentTarget.source; + const node = editor.state.doc.nodeAt(pos); + if (!node) return undefined; + + const dom = editor.view.nodeDOM(pos) as HTMLElement | null; + const domRect = + dom?.getBoundingClientRect?.() ?? + posToDOMRect(editor.view, pos, pos + node.nodeSize); + + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor, findTarget]); + + const dismissPastePrompt = useCallback(() => { + if (!integrationPasteMenuKey.getState(editor.state)) return; + editor.view.dispatch( + editor.state.tr.setMeta(integrationPasteMenuKey, null), + ); + + requestAnimationFrame(() => { + const activeElement = document.activeElement; + if ( + !activeElement || + activeElement === document.body || + !activeElement.isConnected + ) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }); + }, [editor]); + + const changeDisplay = useCallback( + (display: IntegrationDisplay) => { + const currentTarget = findTarget(); + if (!currentTarget) return; + + dismissPastePrompt(); + if (display === currentTarget.current) { + editor.commands.focus(undefined, { scrollIntoView: false }); + return; + } + + if (convertIntegrationDisplay(editor, currentTarget.source, display)) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }, + [dismissPastePrompt, editor, findTarget], + ); + + useEffect(() => { + if (target?.isPaste) setSelectedPasteIndex(0); + }, [target?.isPaste, target?.source.pos]); + + // Focus stays in the editor, so capture keys before ProseMirror moves past the atom node. + useEffect(() => { + if (!target?.isPaste) return; + + const ownsFocus = () => + editor.view.hasFocus() || + Boolean(promptRef.current?.contains(document.activeElement)); + + const onKeyDown = (event: KeyboardEvent) => { + if (!ownsFocus()) return; + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + dismissPastePrompt(); + return; + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + const delta = event.key === "ArrowDown" ? 1 : -1; + setSelectedPasteIndex( + (current) => + (current + delta + PASTE_OPTIONS.length) % PASTE_OPTIONS.length, + ); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + const option = PASTE_OPTIONS[selectedPasteIndex]; + if (option) changeDisplay(option.value); + } + }; + + // Leaving the editor dismisses the prompt, unless focus moved into the prompt itself. + const onBlur = ({ event }: { event: FocusEvent }) => { + const next = event.relatedTarget; + if (next instanceof Node && promptRef.current?.contains(next)) return; + if (editor.isDestroyed) return; + editor.view.dispatch( + editor.state.tr.setMeta(integrationPasteMenuKey, null), + ); + }; + + window.addEventListener("keydown", onKeyDown, true); + editor.on("blur", onBlur); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + editor.off("blur", onBlur); + }; + }, [ + changeDisplay, + dismissPastePrompt, + editor, + selectedPasteIndex, + target?.isPaste, + ]); + + const copyLink = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const currentTarget = findTarget(); + if (!currentTarget) return; + + copyToClipboard(currentTarget.url); + notifications.show({ message: t("Link copied") }); + }, + [findTarget, t], + ); + + const cardAvailability = target + ? getIntegrationCardAvailability(editor, target.source) + : null; + + return ( + + {target?.isPaste ? ( + + + {t(PASTE_OPTIONS[selectedPasteIndex].label)} + + + {t("Paste as")} + + + {PASTE_OPTIONS.map((option, index) => ( + + ))} + + + ) : target ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/apps/client/src/features/editor/components/integration-link/use-unfurl.test.tsx b/apps/client/src/features/editor/components/integration-link/use-unfurl.test.tsx new file mode 100644 index 00000000..bcd234e5 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/use-unfurl.test.tsx @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createStore, Provider } from "jotai"; +import { ReactNode } from "react"; +import { useUnfurl } from "./use-unfurl"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; +import { unfurlUrl } from "@/features/integration/services/integration-service"; +import { + UnfurlNeedsConnection, + UnfurlResult, +} from "@/features/integration/types/integration.types"; + +vi.mock("@/features/integration/services/integration-service", () => ({ + unfurlUrl: vi.fn(), +})); + +const mockedUnfurlUrl = vi.mocked(unfurlUrl); + +const ISSUE_URL = "https://github.com/acme/repo/issues/42"; + +const loadedResult: UnfurlResult = { + title: "Fix race condition in file watcher", + url: ISSUE_URL, + provider: "github", + status: "open", +}; + +const needsConnectionResult: UnfurlNeedsConnection = { + needsConnection: true, + integrationId: "int-1", + integrationType: "github", + integrationName: "GitHub", + title: "GitHub link", + description: "github.com/acme/repo/issues/42", +}; + +function createWrapper(loggedIn: boolean) { + const store = createStore(); + if (loggedIn) { + store.set(currentUserAtom, { user: { id: "user-1" } } as any); + } + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +describe("useUnfurl", () => { + beforeEach(() => { + localStorage.clear(); + vi.resetAllMocks(); + }); + + it("never fetches for anonymous viewers and reports anonymous", () => { + const { result } = renderHook(() => useUnfurl(ISSUE_URL), { + wrapper: createWrapper(false), + }); + + expect(result.current.state).toBe("anonymous"); + expect(mockedUnfurlUrl).not.toHaveBeenCalled(); + }); + + it("starts loading then exposes the unfurl result", async () => { + mockedUnfurlUrl.mockResolvedValue(loadedResult); + + const { result } = renderHook(() => useUnfurl(ISSUE_URL), { + wrapper: createWrapper(true), + }); + + expect(result.current.state).toBe("loading"); + await waitFor(() => expect(result.current.state).toBe("loaded")); + expect( + result.current.state === "loaded" && result.current.data, + ).toEqual(loadedResult); + expect(mockedUnfurlUrl).toHaveBeenCalledWith({ url: ISSUE_URL }); + }); + + it("maps a needsConnection response without treating it as an error", async () => { + mockedUnfurlUrl.mockResolvedValue(needsConnectionResult); + + const { result } = renderHook(() => useUnfurl(ISSUE_URL), { + wrapper: createWrapper(true), + }); + + await waitFor(() => + expect(result.current.state).toBe("needsConnection"), + ); + expect( + result.current.state === "needsConnection" && + result.current.needsConnection, + ).toEqual(needsConnectionResult); + }); + + it("maps a null result (no matching provider) to error", async () => { + mockedUnfurlUrl.mockResolvedValue(null); + + const { result } = renderHook(() => useUnfurl(ISSUE_URL), { + wrapper: createWrapper(true), + }); + + await waitFor(() => expect(result.current.state).toBe("error")); + }); + + it("maps a rejected request to error", async () => { + mockedUnfurlUrl.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useUnfurl(ISSUE_URL), { + wrapper: createWrapper(true), + }); + + await waitFor(() => expect(result.current.state).toBe("error")); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/use-unfurl.ts b/apps/client/src/features/editor/components/integration-link/use-unfurl.ts new file mode 100644 index 00000000..e0a19fea --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/use-unfurl.ts @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; +import { unfurlUrl } from "@/features/integration/services/integration-service"; +import { + UnfurlNeedsConnection, + UnfurlResult, +} from "@/features/integration/types/integration.types"; + +const UNFURL_STALE_TIME = 5 * 60 * 1000; // mirrors the server-side Redis TTL + +export type UnfurlState = + | { state: "anonymous" } + | { state: "loading" } + | { state: "error" } + | { state: "needsConnection"; needsConnection: UnfurlNeedsConnection } + | { state: "loaded"; data: UnfurlResult }; + +// Fetched per viewer and never written into the doc, so each viewer's third-party access applies. +export function useUnfurl(url: string): UnfurlState { + const currentUser = useAtomValue(currentUserAtom); + const isAuthenticated = Boolean(currentUser?.user); + + const query = useQuery({ + queryKey: ["unfurl", url], + queryFn: () => unfurlUrl({ url }), + enabled: isAuthenticated && Boolean(url), + staleTime: UNFURL_STALE_TIME, + retry: false, + }); + + if (!isAuthenticated || !url) { + return { state: "anonymous" }; + } + if (query.isPending) { + return { state: "loading" }; + } + if (query.isError || !query.data) { + return { state: "error" }; + } + if ("needsConnection" in query.data) { + return { state: "needsConnection", needsConnection: query.data }; + } + return { state: "loaded", data: query.data }; +} diff --git a/apps/client/src/features/editor/components/link/link-view.test.tsx b/apps/client/src/features/editor/components/link/link-view.test.tsx new file mode 100644 index 00000000..782bd7cb --- /dev/null +++ b/apps/client/src/features/editor/components/link/link-view.test.tsx @@ -0,0 +1,225 @@ +import { MantineProvider } from "@mantine/core"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LinkView from "./link-view"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (value: string) => value }), +})); + +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ pathname: "/s/example/p/page" }), + useNavigate: () => vi.fn(), + useParams: () => ({}), +})); + +const installedIntegrations = vi.hoisted(() => [ + { type: "github", unfurlHosts: ["github.com"] }, +]); + +vi.mock("@/features/integration/queries/integration-query", () => ({ + useInstalledIntegrations: () => ({ data: installedIntegrations }), +})); + +vi.mock("@/features/page/queries/page-query.ts", () => ({ + usePageQuery: () => ({ data: undefined }), +})); + +vi.mock("@/features/share/queries/share-query.ts", () => ({ + useSharePageQuery: () => ({ data: undefined }), +})); + +vi.mock("@/features/public-space/queries/public-space-query.ts", () => ({ + usePublicSpacePageQuery: () => ({ data: undefined }), +})); + +vi.mock("@/features/editor/components/link/link-editor-panel.tsx", () => ({ + LinkEditorPanel: () => null, +})); + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function renderDetachedLinkView( + attrs: Record = { + href: "https://github.com/docmost/docmost/pull/2475", + integrationProvider: "github", + internal: false, + }, +) { + const props = { + editor: { + isEditable: true, + state: { + doc: { + content: { size: 100 }, + resolve: (position: number) => { + if (position < 0) { + throw new RangeError(`Position ${position} out of range`); + } + return {}; + }, + }, + }, + view: { posAtDOM: () => -1 }, + }, + mark: { + attrs, + type: { name: "link" }, + }, + }; + + const renderView = () => ( + + )} /> + + ); + + return { ...render(renderView()), renderView }; +} + +describe("LinkView", () => { + beforeEach(setMediaMatch); + afterEach(cleanup); + + it("does not resolve the mark position while the popover is closed", () => { + const { rerender, renderView } = renderDetachedLinkView(); + + expect(() => rerender(renderView())).not.toThrow(); + }); + + it("does not resolve a negative mark-view position after opening", () => { + const { container } = renderDetachedLinkView(); + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + + expect(() => fireEvent.click(link!)).not.toThrow(); + }); + + it("opens URL-current display choices from the existing link action row", async () => { + const { container } = renderDetachedLinkView(); + const link = container.querySelector("a"); + const popoverTarget = container.querySelector('[aria-haspopup="dialog"]'); + expect(link).not.toBeNull(); + expect(popoverTarget).not.toBeNull(); + + fireEvent.click(link!); + + const menu = await screen.findByRole("dialog", { hidden: true }); + const menuQueries = within(menu); + const display = menuQueries.getByRole("button", { + name: "Display as: URL", + hidden: true, + }); + fireEvent.click(display); + + const displayMenu = await screen.findByRole("menu", { hidden: true }); + expect( + within(displayMenu) + .getByRole("menuitemradio", { name: "URL", hidden: true }) + .getAttribute("aria-checked"), + ).toBe("true"); + fireEvent.mouseDown( + within(displayMenu).getByRole("menuitemradio", { + name: "Mention", + hidden: true, + }), + ); + expect(popoverTarget?.getAttribute("aria-expanded")).toBe("true"); + }); + + it("offers display choices for a plain link on a host the provider serves", async () => { + const { container } = renderDetachedLinkView({ + href: "https://github.com/docmost/docmost/pull/2475", + internal: false, + }); + + fireEvent.click(container.querySelector("a")!); + + const menu = await screen.findByRole("dialog", { hidden: true }); + expect( + within(menu).getByRole("button", { + name: "Display as: URL", + hidden: true, + }), + ).toBeTruthy(); + }); + + it("hides display choices for a GitHub-like path on a host GitHub does not serve", async () => { + const { container } = renderDetachedLinkView({ + href: "https://gitea.example.com/team/app/pulls/7", + internal: false, + }); + + fireEvent.click(container.querySelector("a")!); + + const menu = await screen.findByRole("dialog", { hidden: true }); + expect( + within(menu).getByRole("button", { name: "Edit link", hidden: true }), + ).toBeTruthy(); + expect( + within(menu).queryByRole("button", { + name: "Display as: URL", + hidden: true, + }), + ).toBeNull(); + }); + + it("supports keyboard-generated clicks on link-menu actions", async () => { + const { container } = renderDetachedLinkView(); + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + + fireEvent.click(link!); + + const menu = await screen.findByRole("dialog", { hidden: true }); + const edit = within(menu).getByRole("button", { + name: "Edit link", + hidden: true, + }); + fireEvent.click(edit); + + expect(await within(menu).findByText("Page or URL")).toBeTruthy(); + }); + + it("closes the integrated link menu when Escape is pressed on the selector", async () => { + const { container } = renderDetachedLinkView(); + const link = container.querySelector("a"); + const target = container.querySelector('[aria-haspopup="dialog"]'); + expect(link).not.toBeNull(); + expect(target).not.toBeNull(); + + fireEvent.click(link!); + expect(target?.getAttribute("aria-expanded")).toBe("true"); + + const menu = await screen.findByRole("dialog", { hidden: true }); + const display = within(menu).getByRole("button", { + name: "Display as: URL", + hidden: true, + }); + fireEvent.keyDown(display, { key: "Escape" }); + + expect(target?.getAttribute("aria-expanded")).toBe("false"); + }); +}); diff --git a/apps/client/src/features/editor/components/link/link-view.tsx b/apps/client/src/features/editor/components/link/link-view.tsx index 39de1d66..0722f4ae 100644 --- a/apps/client/src/features/editor/components/link/link-view.tsx +++ b/apps/client/src/features/editor/components/link/link-view.tsx @@ -1,4 +1,5 @@ import { MarkViewContent, MarkViewProps } from "@tiptap/react"; +import { getMarkRange } from "@tiptap/core"; import { useNavigate, useLocation, useParams } from "react-router-dom"; import { IconFileDescription, @@ -8,7 +9,7 @@ import { IconPencil, IconWorld, } from "@tabler/icons-react"; -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { notifications } from "@mantine/notifications"; import { Divider, @@ -33,8 +34,20 @@ import { buildSharedPageUrl, } from "@/features/page/page.utils.ts"; import { extractPageSlugId } from "@/lib"; -import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext"; +import { + matchIntegrationLink, + sanitizeUrl, + copyToClipboard, + isEditorReady, +} from "@docmost/editor-ext"; import { normalizeUrl } from "@/lib/utils"; +import { useInstalledIntegrations } from "@/features/integration/queries/integration-query"; +import { + convertIntegrationDisplay, + getIntegrationCardAvailability, + type IntegrationDisplaySource, +} from "@/features/editor/components/integration-link/integration-display"; +import { IntegrationDisplayMenu } from "@/features/editor/components/integration-link/integration-display-picker"; const parseInternalLink = ( href: string, @@ -80,6 +93,31 @@ export default function LinkView(props: MarkViewProps) { const wrapperRef = useRef(null); const dropdownRef = useRef(null); const isEditable = editor.isEditable; + const integrationMatch = useMemo(() => matchIntegrationLink(href), [href]); + const markIntegrationProvider = + typeof mark.attrs.integrationProvider === "string" + ? mark.attrs.integrationProvider + : null; + const { data: installedIntegrations } = useInstalledIntegrations( + Boolean(markIntegrationProvider || integrationMatch) && isEditable, + ); + // Only hosts an installed provider unfurls get "Display as". + const hostIntegrationProvider = useMemo(() => { + if (!installedIntegrations) return null; + const unfurlHosts = Object.fromEntries( + installedIntegrations + .filter((integration) => integration.unfurlHosts) + .map((integration) => [integration.type, integration.unfurlHosts]), + ); + return matchIntegrationLink(href, unfurlHosts)?.provider ?? null; + }, [href, installedIntegrations]); + const detectedIntegrationProvider = + markIntegrationProvider ?? hostIntegrationProvider; + const integrationProvider = installedIntegrations?.some( + (integration) => integration.type === detectedIntegrationProvider, + ) + ? detectedIntegrationProvider + : null; const { isInternal, slugId, @@ -124,12 +162,36 @@ export default function LinkView(props: MarkViewProps) { const getLinkPos = useCallback((): number | null => { if (!wrapperRef.current) return null; try { - return editor.view.posAtDOM(wrapperRef.current, 0); + const pos = editor.view.posAtDOM(wrapperRef.current, 0); + return pos >= 0 && pos <= editor.state.doc.content.size ? pos : null; } catch { return null; } }, [editor]); + const getIntegrationSource = useCallback((): Extract< + IntegrationDisplaySource, + { kind: "link" } + > | null => { + if (!integrationProvider) return null; + + const pos = getLinkPos(); + if (pos === null) return null; + + const range = getMarkRange(editor.state.doc.resolve(pos), mark.type, { + href, + }); + if (!range) return null; + + return { + kind: "link", + from: range.from, + to: range.to, + url: href, + provider: integrationProvider, + }; + }, [editor, getLinkPos, href, integrationProvider, mark.type]); + const handleUpdateLinkTitle = useCallback( (newTitle: string) => { if (!newTitle) return; @@ -225,7 +287,9 @@ export default function LinkView(props: MarkViewProps) { const target = e.target as Node; if ( wrapperRef.current?.contains(target) || - dropdownRef.current?.contains(target) + dropdownRef.current?.contains(target) || + (target instanceof Element && + target.closest("[data-integration-display-menu-dropdown]")) ) { return; } @@ -233,6 +297,12 @@ export default function LinkView(props: MarkViewProps) { }; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { + if ( + e.target instanceof Element && + e.target.closest("[data-integration-display-menu-dropdown]") + ) { + return; + } setPopoverState("closed"); } }; @@ -366,6 +436,22 @@ export default function LinkView(props: MarkViewProps) { setPopoverState("closed"); }, [editor]); + const handleIntegrationDisplay = useCallback( + (display: "card" | "mention" | "url") => { + const source = getIntegrationSource(); + setPopoverState("closed"); + if (!source || display === "url") { + editor.commands.focus(undefined, { scrollIntoView: false }); + return; + } + + if (convertIntegrationDisplay(editor, source, display)) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }, + [editor, getIntegrationSource], + ); + const internalHref = () => { if (isShareRoute && slugId) { return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle }); @@ -440,7 +526,13 @@ export default function LinkView(props: MarkViewProps) { return ( e.stopPropagation()} > {activeView === "edit" ? ( @@ -560,7 +658,7 @@ export default function LinkView(props: MarkViewProps) { )} ) : ( - + { e.preventDefault(); handleNavigate(); @@ -591,15 +683,43 @@ export default function LinkView(props: MarkViewProps) { - + + + {isPopoverVisible && + integrationProvider && + (() => { + const source = getIntegrationSource(); + const cardAvailability = source + ? getIntegrationCardAvailability(editor, source) + : null; + + return ( + + ); + })()} { e.preventDefault(); e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); setShowSearch(false); setPopoverState("edit"); }} @@ -612,9 +732,16 @@ export default function LinkView(props: MarkViewProps) { { e.preventDefault(); e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); handleCopy(e); }} > @@ -626,9 +753,16 @@ export default function LinkView(props: MarkViewProps) { { e.preventDefault(); e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); handleRemoveLink(); }} > diff --git a/apps/client/src/features/editor/components/link/link.module.css b/apps/client/src/features/editor/components/link/link.module.css index dab288c2..616390c4 100644 --- a/apps/client/src/features/editor/components/link/link.module.css +++ b/apps/client/src/features/editor/components/link/link.module.css @@ -3,6 +3,69 @@ display: inline; } +.linkWrapper[data-active="true"] > a { + border-radius: 4px; + outline: 2px solid + light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-6)); + outline-offset: 2px; +} + +.integrationPopover { + overflow: hidden; + border-color: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-4) + ); + border-radius: 10px; + box-shadow: 0 5px 18px light-dark(rgba(31, 35, 40, 0.12), rgba(0, 0, 0, 0.36)); +} + +.previewRow { + min-width: 0; +} + +.previewDestination { + min-width: 0; + max-width: min(250px, calc(100vw - 170px)); + flex: 1 1 auto; + cursor: pointer; + text-decoration: none; + color: inherit; + user-select: none; +} + +.previewDivider { + height: 22px; + margin: 0 3px; +} + +.previewAction { + --ai-size: 32px !important; + + flex: 0 0 32px; + border-radius: 6px; + -webkit-tap-highlight-color: transparent; +} + +.previewAction:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: -2px; +} + +@media (pointer: coarse) { + .previewDivider { + height: 26px; + margin-inline: 4px; + } + + .previewAction { + --ai-size: 44px !important; + + flex-basis: 44px; + border-radius: 8px; + } +} + .linkInput { border: 1.5px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 6543f777..8079eaf7 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -57,6 +57,8 @@ import { Indent, UniqueID, SharedStorage, + IntegrationCard, + IntegrationMention, Columns, Column, Status, @@ -99,6 +101,9 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; +import IntegrationCardView from "@/features/editor/components/integration-link/integration-card-view.tsx"; +import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx"; +import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu"; import TabsView from "@/features/editor/components/tabs/tabs-view.tsx"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; @@ -396,6 +401,13 @@ export const mainExtensions = [ Subpages.configure({ view: SubpagesView, }), + IntegrationCard.configure({ + view: IntegrationCardView, + }), + IntegrationMention.configure({ + view: IntegrationMentionView, + }), + IntegrationPasteMenuExtension, Status.configure({ view: StatusView, }), diff --git a/apps/client/src/features/editor/extensions/integration-paste-menu.ts b/apps/client/src/features/editor/extensions/integration-paste-menu.ts new file mode 100644 index 00000000..07883d21 --- /dev/null +++ b/apps/client/src/features/editor/extensions/integration-paste-menu.ts @@ -0,0 +1,47 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +export type IntegrationPasteMenuState = { + pos: number; + joinBefore: boolean; + joinAfter: boolean; +} | null; + +export const integrationPasteMenuKey = new PluginKey( + "integrationPasteMenu", +); + +// Position of a just-pasted integration node, where the "Paste as" menu anchors, +// and which sides of it hold the halves of the paragraph the paste split. +export const IntegrationPasteMenuExtension = Extension.create({ + name: "integrationPasteMenu", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: integrationPasteMenuKey, + state: { + init: (): IntegrationPasteMenuState => null, + apply(tr, prev): IntegrationPasteMenuState { + const meta = tr.getMeta(integrationPasteMenuKey); + if (meta !== undefined) return meta; + if (!prev) return null; + // Clicking or typing elsewhere dismisses. + if (tr.selectionSet) return null; + // Follow-up transactions (unique ids, trailing node) keep the anchor. + if (tr.docChanged) { + const pos = tr.mapping.map(prev.pos); + const node = tr.doc.nodeAt(pos); + const isIntegrationNode = + node && + (node.type.name === "integrationCard" || + node.type.name === "integrationMention"); + return isIntegrationNode ? { ...prev, pos } : null; + } + return prev; + }, + }, + }), + ]; + }, +}); diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 643b7b8d..4f7a8b18 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -82,6 +82,8 @@ import { useEditorScroll } from "./hooks/use-editor-scroll"; import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; +import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx"; +import { getInstalledIntegrations } from "@/features/integration/services/integration-service"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { useTranslation } from "react-i18next"; import { @@ -388,6 +390,15 @@ function CollabPageEditor({ [pageId, editable, extensions], ); + useEffect(() => { + // Read by the paste handler with no observer, so it must not be garbage collected. + queryClient.prefetchQuery({ + queryKey: ["installed-integrations"], + queryFn: getInstalledIntegrations, + gcTime: Infinity, + }); + }, []); + useLayoutEffect(() => { if (editor && !editor.isDestroyed) { // @ts-ignore @@ -515,6 +526,7 @@ function CollabPageEditor({ +
)} {editor && !editorIsEditable && (editable || canComment) && ( diff --git a/apps/client/src/features/integration/components/connection-row.tsx b/apps/client/src/features/integration/components/connection-row.tsx new file mode 100644 index 00000000..f5408a34 --- /dev/null +++ b/apps/client/src/features/integration/components/connection-row.tsx @@ -0,0 +1,91 @@ +import { Group, Text, Button, Box, Stack } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { IntegrationDefinition, UserConnection } from "../types/integration.types"; +import { getIntegrationIcon } from "./integration-icons"; + +type ConnectionRowProps = { + definition: IntegrationDefinition; + connection?: UserConnection; + onConnect: (type: string) => void; + onDisconnect: (integrationId: string) => void; + disconnectingId?: string; +}; + +export default function ConnectionRow({ + definition, + connection, + onConnect, + onDisconnect, + disconnectingId, +}: ConnectionRowProps) { + const { t } = useTranslation(); + const connectedLabel = + connection?.providerDisplayName || connection?.providerUserId; + + return ( + + + + {getIntegrationIcon(definition.type, 28)} + + + {definition.name} + + {connection?.invalidatedAt ? ( + + {t("Connection expired")} + + ) : connection && connectedLabel ? ( + + {t("Connected as {{label}}", { label: connectedLabel })} + + ) : ( + + {definition.description} + + )} + + + + + {connection ? ( + <> + {connection.invalidatedAt && ( + + )} + + + ) : ( + + )} + + + + ); +} diff --git a/apps/client/src/features/integration/components/integration-icons.tsx b/apps/client/src/features/integration/components/integration-icons.tsx new file mode 100644 index 00000000..a4fd0260 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-icons.tsx @@ -0,0 +1,34 @@ +import { ReactNode } from "react"; +import { + AzureDevOpsIcon, + FigmaIcon, + GithubIcon, + GitlabIcon, + GoogleDocsIcon, + JiraIcon, + LinearIcon, + SlackIcon, +} from "@/components/icons"; +import { IconPuzzle } from "@tabler/icons-react"; + +const integrationIconMap: Record ReactNode> = { + github: (size) => , + gitlab: (size) => , + slack: (size) => , + linear: (size) => , + jira: (size) => , + figma: (size) => , + google_docs: (size) => , + azure_devops: (size) => , +}; + +export function getIntegrationIcon( + type: string, + size: number, +): ReactNode { + // The type can come from a document; ignore inherited keys like "constructor". + if (Object.prototype.hasOwnProperty.call(integrationIconMap, type)) { + return integrationIconMap[type](size); + } + return ; +} diff --git a/apps/client/src/features/integration/components/integration-list-skeleton.tsx b/apps/client/src/features/integration/components/integration-list-skeleton.tsx new file mode 100644 index 00000000..798def1a --- /dev/null +++ b/apps/client/src/features/integration/components/integration-list-skeleton.tsx @@ -0,0 +1,66 @@ +import { Box, Group, Skeleton, Stack } from "@mantine/core"; + +const TITLE_WIDTHS = [64, 52, 60, 44, 58, 96, 56]; +const DESCRIPTION_WIDTHS = [300, 250, 320, 180, 290, 270, 260]; + +type IntegrationListSkeletonProps = { + rows?: number; + withBadges?: boolean; +}; + +export default function IntegrationListSkeleton({ + rows = 7, + withBadges = true, +}: IntegrationListSkeletonProps) { + return ( + + ); +} diff --git a/apps/client/src/features/integration/components/integration-row.tsx b/apps/client/src/features/integration/components/integration-row.tsx new file mode 100644 index 00000000..ab6e0204 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-row.tsx @@ -0,0 +1,102 @@ +import { + Group, + Text, + Badge, + Button, + Box, + Stack, + Tooltip, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { + IntegrationDefinition, + Integration, +} from "../types/integration.types"; +import { getIntegrationIcon } from "./integration-icons"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; +import { Feature } from "@/ee/features"; +import { isCloud } from "@/lib/config.ts"; + +// Keep in sync with apps/server/src/ee/licence/feature-registry.ts. +const MINIMUM_PLAN: Record = { + [Feature.INTEGRATIONS]: { selfHosted: "Business", cloud: "Standard" }, + [Feature.INTEGRATIONS_ADVANCED]: { selfHosted: "Enterprise", cloud: "Business" }, +}; + +type IntegrationRowProps = { + definition: IntegrationDefinition; + installation?: Integration; + onInstall: (type: string) => void; + onUninstall: (integrationId: string) => void; +}; + +export default function IntegrationRow({ + definition, + installation, + onInstall, + onUninstall, +}: IntegrationRowProps) { + const { t } = useTranslation(); + const isInstalled = !!installation; + const hasAccess = useHasFeature(definition.requiresFeature ?? ""); + const locked = !!definition.requiresFeature && !hasAccess; + const upgradeLabel = useUpgradeLabel(); + const minimumPlan = MINIMUM_PLAN[definition.requiresFeature ?? ""]; + + return ( + + + + {getIntegrationIcon(definition.type, 28)} + + + + {definition.name} + + {locked && ( + + {minimumPlan + ? t(isCloud() ? minimumPlan.cloud : minimumPlan.selfHosted) + : t("Paid")} + + )} + + + {definition.description} + + + + + + {isInstalled ? ( + + ) : ( + + + + )} + + + + ); +} diff --git a/apps/client/src/features/integration/pages/connections.tsx b/apps/client/src/features/integration/pages/connections.tsx new file mode 100644 index 00000000..18763442 --- /dev/null +++ b/apps/client/src/features/integration/pages/connections.tsx @@ -0,0 +1,271 @@ +import { useEffect, useRef } from "react"; +import { Text, Alert, Stack, Button, Group, List } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { notifications } from "@mantine/notifications"; +import { getAppName } from "@/lib/config"; +import SettingsTitle from "@/components/settings/settings-title"; +import ConnectionRow from "../components/connection-row"; +import IntegrationListSkeleton from "../components/integration-list-skeleton"; +import { + useAvailableIntegrations, + useInstalledIntegrations, + useMyConnections, + useDisconnectIntegration, +} from "../queries/integration-query"; +import * as integrationService from "../services/integration-service"; +import { resolveConnectPrompt } from "../utils/connect-prompt"; +import type { IntegrationDefinition } from "../types/integration.types"; + +export default function Connections() { + const { t } = useTranslation(); + const { data: available, isLoading: loadingAvailable } = + useAvailableIntegrations(); + const { data: installed, isLoading: loadingInstalled } = + useInstalledIntegrations(); + const { data: myConnections, isLoading: loadingConnections } = + useMyConnections(); + const disconnectMutation = useDisconnectIntegration(); + + const [searchParams, setSearchParams] = useSearchParams(); + const connectType = searchParams.get("connect"); + const prompted = useRef(false); + const mounted = useRef(true); + + const isLoading = loadingAvailable || loadingInstalled || loadingConnections; + + const handleConnect = async (type: string) => { + const integration = installed?.find((i) => i.type === type); + if (!integration) return; + + try { + // Workspace-scoped providers otherwise return to the admin integrations page. + const result = await integrationService.getOAuthAuthorizeUrl({ + integrationId: integration.id, + returnPath: "/settings/account/connections", + }); + window.location.href = result.authorizationUrl; + } catch (error) { + const errorMessage = error["response"]?.data?.message; + notifications.show({ + message: errorMessage || t("Failed to start OAuth connection"), + color: "red", + }); + } + }; + + useEffect(() => { + if (isLoading) return; + const definition = resolveConnectPrompt( + connectType, + available, + installed, + myConnections, + ); + if (!definition) { + if (connectType) { + modals.close("connect-prompt"); + setSearchParams({}, { replace: true }); + } else { + prompted.current = false; + } + return; + } + if (prompted.current) return; + prompted.current = true; + modals.open({ + modalId: "connect-prompt", + title: t("Connect {{name}} to Docmost", { name: definition.name }), + centered: true, + // Mantine also calls onClose on unmount, and navigating then would bounce the user back here. + onClose: () => { + if (mounted.current) setSearchParams({}, { replace: true }); + }, + children: ( + { + modals.close("connect-prompt"); + handleConnect(definition.type); + }} + onCancel={() => modals.close("connect-prompt")} + /> + ), + }); + }, [connectType, available, installed, myConnections, isLoading]); + + // The modal lives on the app-root provider, so it must not outlive this page. + useEffect( + () => () => { + mounted.current = false; + modals.close("connect-prompt"); + }, + [], + ); + + const handleDisconnect = (integrationId: string) => { + const installation = installed?.find((i) => i.id === integrationId); + const name = + available?.find((d) => d.type === installation?.type)?.name ?? + installation?.type ?? + ""; + modals.openConfirmModal({ + title: t("Disconnect {{name}}", { name }), + centered: true, + children: ( + + {t( + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.", + { name }, + )} + + ), + labels: { confirm: t("Disconnect"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => disconnectMutation.mutate({ integrationId }), + }); + }; + + // Only the row being disconnected shows a loader. + const disconnectingId = disconnectMutation.isPending + ? disconnectMutation.variables?.integrationId + : undefined; + + const error = new URLSearchParams(window.location.search).get("error"); + + return ( + <> + + + {t("Connections")} - {getAppName()} + + + + + + + {t("Manage the apps you have connected to your account.")} + + + {error === "oauth_failed" && ( + + {t("OAuth connection failed. Please try again.")} + + )} + + {error === "identity_mismatch" && ( + + {t( + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.", + )} + + )} + + {error === "tenant_mismatch" && ( + + {t( + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.", + )} + + )} + + {isLoading ? ( + + ) : !available?.length ? ( + + {t("No integrations available.")} + + ) : ( + + {available + .filter((def) => { + if (!def.capabilities.includes("oauth")) return false; + return installed?.some((i) => i.type === def.type); + }) + .map((def) => { + const connection = myConnections?.find( + (c) => c.type === def.type, + ); + + return ( + + ); + })} + + )} + + ); +} + +type ConnectPromptBodyProps = { + definition: IntegrationDefinition; + onConnect: () => void; + onCancel: () => void; +}; + +function ConnectPromptBody({ + definition, + onConnect, + onCancel, +}: ConnectPromptBodyProps) { + const { t } = useTranslation(); + + return ( + + {definition.type === "slack" ? ( + <> + + {t("Link your Slack account to your Docmost account to:")} + + + + {t("Search and open Docmost pages from Slack")} + + + {t("Create pages and save Slack messages to Docmost")} + + + {t("Ask AI questions about your workspace docs")} + + + {t("Get your Docmost notifications as Slack DMs")} + + + {t( + "See previews of Docmost links in Slack and Slack links in Docmost", + )} + + + + {t( + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.", + )} + + + ) : ( + + {t( + "Connect your {{name}} account so links you paste show rich previews.", + { name: definition.name }, + )} + + )} + + + + + + + ); +} diff --git a/apps/client/src/features/integration/pages/integrations.tsx b/apps/client/src/features/integration/pages/integrations.tsx new file mode 100644 index 00000000..4f3ae4a2 --- /dev/null +++ b/apps/client/src/features/integration/pages/integrations.tsx @@ -0,0 +1,123 @@ +import { Text, Alert, Stack } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { useCallback } from "react"; +import { getAppName } from "@/lib/config"; +import SettingsTitle from "@/components/settings/settings-title"; +import IntegrationRow from "../components/integration-row"; +import IntegrationListSkeleton from "../components/integration-list-skeleton"; +import { + useAvailableIntegrations, + useInstalledIntegrations, + useUninstallIntegration, +} from "../queries/integration-query"; +import { getOAuthInstallUrl } from "../services/integration-service"; +import { notifications } from "@mantine/notifications"; + +export default function Integrations() { + const { t } = useTranslation(); + const { data: available, isLoading: loadingAvailable } = + useAvailableIntegrations(); + const { data: installed, isLoading: loadingInstalled } = + useInstalledIntegrations(); + const uninstallMutation = useUninstallIntegration(); + + const handleInstall = useCallback( + async (type: string) => { + try { + const { authorizationUrl } = await getOAuthInstallUrl({ type }); + window.location.href = authorizationUrl; + } catch (err: any) { + notifications.show({ + message: + err?.response?.data?.message ?? t("Failed to start installation"), + color: "red", + }); + } + }, + [t], + ); + + const handleUninstall = useCallback( + (integrationId: string) => { + const installation = installed?.find((i) => i.id === integrationId); + const name = + available?.find((d) => d.type === installation?.type)?.name ?? + installation?.type ?? + ""; + modals.openConfirmModal({ + title: t("Uninstall {{name}}", { name }), + centered: true, + children: ( + + {t( + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.", + { name }, + )} + + ), + labels: { confirm: t("Uninstall"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => uninstallMutation.mutate({ integrationId }), + }); + }, + [uninstallMutation, installed, available, t], + ); + + const isLoading = loadingAvailable || loadingInstalled; + const error = new URLSearchParams(window.location.search).get("error"); + + return ( + <> + + + {t("Integrations")} - {getAppName()} + + + + + + + {t("Manage workspace integrations.")} + + + {error === "oauth_failed" && ( + + {t("OAuth connection failed. Please try again.")} + + )} + + {error === "tenant_in_use" && ( + + {t( + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.", + )} + + )} + + {isLoading ? ( + + ) : !available?.length ? ( + + {t("No integrations available.")} + + ) : ( + + {available.map((def) => { + const installation = installed?.find((i) => i.type === def.type); + return ( + + ); + })} + + )} + + ); +} diff --git a/apps/client/src/features/integration/queries/integration-query.ts b/apps/client/src/features/integration/queries/integration-query.ts new file mode 100644 index 00000000..b487a42c --- /dev/null +++ b/apps/client/src/features/integration/queries/integration-query.ts @@ -0,0 +1,68 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import * as integrationService from "../services/integration-service"; + +export function useAvailableIntegrations() { + return useQuery({ + queryKey: ["available-integrations"], + queryFn: integrationService.getAvailableIntegrations, + }); +} + +export function useInstalledIntegrations(enabled = true) { + return useQuery({ + queryKey: ["installed-integrations"], + queryFn: integrationService.getInstalledIntegrations, + enabled, + }); +} + +export function useUninstallIntegration() { + const qc = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: integrationService.uninstallIntegration, + onSuccess: () => { + notifications.show({ + message: t("Integration uninstalled successfully"), + }); + qc.invalidateQueries({ queryKey: ["installed-integrations"] }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ + message: errorMessage || t("Failed to uninstall integration"), + color: "red", + }); + }, + }); +} + +export function useMyConnections() { + return useQuery({ + queryKey: ["my-connections"], + queryFn: integrationService.getMyConnections, + }); +} + +export function useDisconnectIntegration() { + const qc = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: integrationService.disconnectIntegration, + onSuccess: () => { + notifications.show({ message: t("Integration disconnected") }); + qc.invalidateQueries({ queryKey: ["my-connections"] }); + // refetchOnMount is false, so invalidated inactive queries would stay stale. + qc.removeQueries({ queryKey: ["unfurl"] }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ + message: errorMessage || t("Failed to disconnect integration"), + color: "red", + }); + }, + }); +} diff --git a/apps/client/src/features/integration/services/integration-service.ts b/apps/client/src/features/integration/services/integration-service.ts new file mode 100644 index 00000000..7b63f42c --- /dev/null +++ b/apps/client/src/features/integration/services/integration-service.ts @@ -0,0 +1,70 @@ +import api from "@/lib/api-client"; +import { + IntegrationDefinition, + Integration, + UserConnection, + UnfurlResult, + UnfurlNeedsConnection, +} from "../types/integration.types"; + +export async function getAvailableIntegrations(): Promise< + IntegrationDefinition[] +> { + const req = await api.post( + "/integrations/available", + ); + return req.data; +} + +export async function getInstalledIntegrations(): Promise { + const req = await api.post("/integrations"); + return req.data; +} + +export async function uninstallIntegration(data: { + integrationId: string; +}): Promise { + await api.post("/integrations/uninstall", data); +} + +export async function getMyConnections(): Promise { + const req = await api.post("/integrations/connections"); + return req.data; +} + +export async function getOAuthAuthorizeUrl(data: { + integrationId: string; + returnPath?: string; +}): Promise<{ authorizationUrl: string }> { + const req = await api.post<{ authorizationUrl: string }>( + "/integrations/oauth/authorize", + data, + ); + return req.data; +} + +// The integration row is only created when the OAuth callback succeeds. +export async function getOAuthInstallUrl(data: { + type: string; +}): Promise<{ authorizationUrl: string }> { + const req = await api.post<{ authorizationUrl: string }>( + "/integrations/oauth/install", + data, + ); + return req.data; +} + +export async function disconnectIntegration(data: { + integrationId: string; +}): Promise { + await api.post("/integrations/oauth/disconnect", data); +} + +export async function unfurlUrl(data: { + url: string; +}): Promise { + const req = await api.post<{ + data: UnfurlResult | UnfurlNeedsConnection | null; + }>("/integrations/unfurl", data); + return req.data.data; +} diff --git a/apps/client/src/features/integration/types/integration.types.ts b/apps/client/src/features/integration/types/integration.types.ts new file mode 100644 index 00000000..cb39d46b --- /dev/null +++ b/apps/client/src/features/integration/types/integration.types.ts @@ -0,0 +1,62 @@ +export type IntegrationCapability = "oauth" | "unfurl" | "actions"; + +export type OAuthConfig = { + authUrl: string; + tokenUrl: string; + scopes: string[]; + connectionScope?: 'workspace' | 'user'; + credentialsKey?: string; +}; + +export type IntegrationDefinition = { + type: string; + name: string; + description: string; + icon: string; + capabilities: IntegrationCapability[]; + oauth?: OAuthConfig; + requiresFeature?: string; +}; + +export type Integration = { + id: string; + workspaceId: string; + type: string; + settings: Record | null; + installedById: string | null; + createdAt: string; + updatedAt: string; + // Hostnames the provider unfurls; "*.example.com" means any subdomain. + unfurlHosts?: string[]; +}; + +export type UserConnection = { + integrationId: string; + type: string; + providerUserId: string | null; + providerDisplayName: string | null; + connectedAt: string; + invalidatedAt: string | null; +}; + +export type UnfurlResult = { + title: string; + description?: string; + url: string; + provider: string; + providerIcon?: string; + status?: string; + statusColor?: string; + author?: string; + authorAvatarUrl?: string; + metadata?: Record; +}; + +export type UnfurlNeedsConnection = { + needsConnection: true; + integrationId: string; + integrationType: string; + integrationName: string; + title: string; + description?: string; +}; diff --git a/apps/client/src/features/integration/utils/connect-prompt.test.ts b/apps/client/src/features/integration/utils/connect-prompt.test.ts new file mode 100644 index 00000000..c95c377d --- /dev/null +++ b/apps/client/src/features/integration/utils/connect-prompt.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { resolveConnectPrompt } from "./connect-prompt"; + +const slack = { type: "slack", name: "Slack", capabilities: ["oauth", "unfurl"] } as any; +const github = { type: "github", name: "GitHub", capabilities: ["oauth", "unfurl"] } as any; +const installed = [{ id: "i-slack", type: "slack" }] as any; + +describe("resolveConnectPrompt", () => { + it("returns nothing without a connect parameter", () => { + expect(resolveConnectPrompt(null, [slack], installed, [])).toBeNull(); + }); + + it("returns nothing for a provider that is not installed", () => { + expect(resolveConnectPrompt("github", [slack, github], installed, [])).toBeNull(); + }); + + it("returns nothing when the user already has a live connection", () => { + const live = [{ type: "slack", invalidatedAt: null }] as any; + expect(resolveConnectPrompt("slack", [slack], installed, live)).toBeNull(); + }); + + it("prompts when the user is unlinked", () => { + expect(resolveConnectPrompt("slack", [slack], installed, [])).toBe(slack); + }); + + it("prompts when the existing connection is invalidated", () => { + const dead = [{ type: "slack", invalidatedAt: "2026-09-01T00:00:00Z" }] as any; + expect(resolveConnectPrompt("slack", [slack], installed, dead)).toBe(slack); + }); +}); diff --git a/apps/client/src/features/integration/utils/connect-prompt.ts b/apps/client/src/features/integration/utils/connect-prompt.ts new file mode 100644 index 00000000..81a25a28 --- /dev/null +++ b/apps/client/src/features/integration/utils/connect-prompt.ts @@ -0,0 +1,22 @@ +import type { + Integration, + IntegrationDefinition, + UserConnection, +} from "../types/integration.types"; + +// The provider a `?connect=` deep link should prompt for, if any. +export function resolveConnectPrompt( + connectType: string | null, + available: IntegrationDefinition[] | undefined, + installed: Integration[] | undefined, + myConnections: UserConnection[] | undefined, +): IntegrationDefinition | null { + if (!connectType) return null; + const definition = available?.find((d) => d.type === connectType); + if (!definition || !definition.capabilities.includes("oauth")) return null; + if (!installed?.some((i) => i.type === connectType)) return null; + const live = myConnections?.find( + (c) => c.type === connectType && !c.invalidatedAt, + ); + return live ? null : definition; +} diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index 48c1b87d..6a907c09 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -27,6 +27,7 @@ const APP_ROUTE = { SPACES: "/settings/spaces", BILLING: "/settings/billing", SECURITY: "/settings/security", + INTEGRATIONS: "/settings/integrations", }, }, }; diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 312d7762..c59f18b2 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -49,6 +49,8 @@ import { Footnotes, Footnote, FootnoteReference, + IntegrationCard, + IntegrationMention, Tabs, Tab, TabLabel, @@ -137,6 +139,8 @@ export const tiptapExtensions = [ Footnotes, Footnote, FootnoteReference, + IntegrationCard, + IntegrationMention ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts index 3ad2ddd7..4d563991 100644 --- a/apps/server/src/common/events/audit-events.ts +++ b/apps/server/src/common/events/audit-events.ts @@ -29,6 +29,12 @@ export const AuditEvent = { OAUTH_GRANT_CREATED: 'oauth_grant.created', OAUTH_GRANT_REVOKED: 'oauth_grant.revoked', + // Integrations + INTEGRATION_INSTALLED: 'integration.installed', + INTEGRATION_UNINSTALLED: 'integration.uninstalled', + INTEGRATION_CONNECTED: 'integration.connected', + INTEGRATION_DISCONNECTED: 'integration.disconnected', + // SCIM Tokens SCIM_TOKEN_CREATED: 'scim_token.created', SCIM_TOKEN_UPDATED: 'scim_token.updated', @@ -124,10 +130,6 @@ export const EXCLUDED_AUDIT_EVENTS: Set = new Set([ AuditEvent.PAGE_CREATED, AuditEvent.PAGE_MOVED_TO_SPACE, AuditEvent.PAGE_DUPLICATED, - AuditEvent.COMMENT_CREATED, - AuditEvent.COMMENT_UPDATED, - AuditEvent.COMMENT_RESOLVED, - AuditEvent.COMMENT_REOPENED, AuditEvent.ATTACHMENT_UPLOADED, AuditEvent.SIEM_DESTINATION_TEST, ]); @@ -144,6 +146,7 @@ export const AuditResource = { API_KEY: 'api_key', OAUTH_CLIENT: 'oauth_client', OAUTH_GRANT: 'oauth_grant', + INTEGRATION: 'integration', SCIM_TOKEN: 'scim_token', SSO_PROVIDER: 'sso_provider', WORKSPACE_INVITATION: 'workspace_invitation', diff --git a/apps/server/src/common/events/event.contants.ts b/apps/server/src/common/events/event.contants.ts index b52a3b72..8c3c2b9b 100644 --- a/apps/server/src/common/events/event.contants.ts +++ b/apps/server/src/common/events/event.contants.ts @@ -17,6 +17,7 @@ export enum EventName { WORKSPACE_CREATED = 'workspace.created', WORKSPACE_UPDATED = 'workspace.updated', WORKSPACE_DELETED = 'workspace.deleted', + NOTIFICATION_CREATED = 'notification.created', BASE_CREATED = 'base.created', BASE_UPDATED = 'base.updated', diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index ac446d32..961e31fb 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -23,6 +23,8 @@ export const Feature = { PERSONAL_SPACES: 'spaces:personal', DOCX_EXPORT: 'export:docx', BASES: 'bases', + INTEGRATIONS: 'integrations', + INTEGRATIONS_ADVANCED: 'integrations:advanced', OAUTH: 'oauth', AI_CONTROLS: 'ai:controls', MCP_CONTROLS: 'mcp:controls', diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index aebe0538..b0e341fd 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -189,7 +189,7 @@ export function isUserDisabled(user: { return !!(user.deactivatedAt || user.deletedAt); } -const SENSITIVE_URL_PREFIXES = ['/api/sso/']; +const SENSITIVE_URL_PREFIXES = ['/api/sso/', '/api/integrations/oauth/']; export function redactSensitiveUrl(url: string): string { if (url && SENSITIVE_URL_PREFIXES.some((prefix) => url.includes(prefix))) { diff --git a/apps/server/src/common/proxy-fetch.spec.ts b/apps/server/src/common/proxy-fetch.spec.ts new file mode 100644 index 00000000..25cbc5f8 --- /dev/null +++ b/apps/server/src/common/proxy-fetch.spec.ts @@ -0,0 +1,64 @@ +import { getProxyAwareFetch, proxyFetch } from './proxy-fetch'; + +describe('getProxyAwareFetch', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns undefined when no proxy env vars are set', () => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + + expect(getProxyAwareFetch()).toBeUndefined(); + }); + + it('returns a fetch function when HTTP_PROXY is set', () => { + delete process.env.HTTPS_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + process.env.HTTP_PROXY = 'http://proxy.example.com:8080'; + + expect(typeof getProxyAwareFetch()).toBe('function'); + }); + + it('returns a fetch function when HTTPS_PROXY is set', () => { + delete process.env.HTTP_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + process.env.HTTPS_PROXY = 'http://proxy.example.com:8080'; + + expect(typeof getProxyAwareFetch()).toBe('function'); + }); + + it('returns a fetch function when lowercase http_proxy is set', () => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.https_proxy; + process.env.http_proxy = 'http://proxy.example.com:8080'; + + expect(typeof getProxyAwareFetch()).toBe('function'); + }); + + it('proxyFetch delegates to the platform fetch when no proxy is configured', async () => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + + const original = globalThis.fetch; + const response = new Response('ok'); + const spy = jest.fn().mockResolvedValue(response); + globalThis.fetch = spy as unknown as typeof fetch; + + try { + await expect(proxyFetch('https://example.com')).resolves.toBe(response); + expect(spy).toHaveBeenCalledWith('https://example.com', undefined); + } finally { + globalThis.fetch = original; + } + }); +}); diff --git a/apps/server/src/common/proxy-fetch.ts b/apps/server/src/common/proxy-fetch.ts new file mode 100644 index 00000000..23b5b177 --- /dev/null +++ b/apps/server/src/common/proxy-fetch.ts @@ -0,0 +1,37 @@ +import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici'; + +const LOOPBACK_BYPASS = ['localhost', '127.0.0.1', '::1']; + +let cachedAgent: EnvHttpProxyAgent | undefined; + +function hasProxyEnv(): boolean { + return Boolean( + process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.https_proxy, + ); +} + +function buildAgent(): EnvHttpProxyAgent { + const existing = process.env.NO_PROXY || process.env.no_proxy || ''; + const merged = [existing, ...LOOPBACK_BYPASS] + .map((s) => s.trim()) + .filter(Boolean) + .join(','); + return new EnvHttpProxyAgent({ noProxy: merged }); +} + +export function getProxyAwareFetch(): typeof fetch | undefined { + if (!hasProxyEnv()) return undefined; + cachedAgent ??= buildAgent(); + const agent = cachedAgent; + return ((input, init) => + undiciFetch(input as any, { + ...(init as any), + dispatcher: agent, + }) as unknown as Promise) as typeof fetch; +} + +export const proxyFetch: typeof fetch = (input, init) => + (getProxyAwareFetch() ?? fetch)(input, init); diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 3902c584..fbe0e064 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -145,7 +145,19 @@ export class CommentController { await this.pageAccessService.validateCanComment(page, user, workspace.id); - return this.commentService.update(comment, dto, user); + const updatedComment = await this.commentService.update(comment, dto, user); + + this.auditService.log({ + event: AuditEvent.COMMENT_UPDATED, + resourceType: AuditResource.COMMENT, + resourceId: comment.id, + spaceId: comment.spaceId, + metadata: { + pageId: comment.pageId, + }, + }); + + return updatedComment; } @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index 5bd9be1c..e60a15b3 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -22,6 +22,10 @@ import { PublicSpaceModule } from './public-space/public-space.module'; import { LabelModule } from './label/label.module'; import { NotificationModule } from './notification/notification.module'; import { WatcherModule } from './watcher/watcher.module'; +import { IntegrationModule } from './integration/integration.module'; +import { GitHubModule } from './integration/providers/github/github.module'; +import { FigmaModule } from './integration/providers/figma/figma.module'; +import { LinearModule } from './integration/providers/linear/linear.module'; import { FavoriteModule } from './favorite/favorite.module'; import { SessionModule } from './session/session.module'; import { ClsMiddleware } from 'nestjs-cls'; @@ -45,6 +49,10 @@ import { ClsMiddleware } from 'nestjs-cls'; LabelModule, NotificationModule, WatcherModule, + IntegrationModule, + GitHubModule, + FigmaModule, + LinearModule, SessionModule, ], }) @@ -55,6 +63,7 @@ export class CoreModule implements NestModule { { path: 'health', method: RequestMethod.GET }, { path: 'health/live', method: RequestMethod.GET }, { path: 'billing/stripe/webhook', method: RequestMethod.POST }, + { path: 'integrations/oauth/*/callback', method: RequestMethod.GET }, ]; consumer diff --git a/apps/server/src/core/integration/dto/integration.dto.spec.ts b/apps/server/src/core/integration/dto/integration.dto.spec.ts new file mode 100644 index 00000000..fad2af0b --- /dev/null +++ b/apps/server/src/core/integration/dto/integration.dto.spec.ts @@ -0,0 +1,57 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + OAuthAuthorizeDto, + OAuthDisconnectDto, + UninstallIntegrationDto, +} from './integration.dto'; + +describe.each([ + ['UninstallIntegrationDto', UninstallIntegrationDto], + ['OAuthAuthorizeDto', OAuthAuthorizeDto], + ['OAuthDisconnectDto', OAuthDisconnectDto], +])('%s integrationId', (_name, Dto) => { + const violations = async (integrationId: unknown) => { + const errors = await validate(plainToInstance(Dto, { integrationId })); + return errors.flatMap((e) => Object.keys(e.constraints ?? {})); + }; + + it('accepts a uuid7', async () => { + expect(await violations('019a1b2c-3d4e-7f00-8a00-123456789abc')).toEqual([]); + }); + + it.each([ + ['a non-UUID string', 'integration-1'], + ['an empty string', ''], + ['a number', 42], + ['a missing value', undefined], + ])('rejects %s', async (_label, integrationId) => { + expect(await violations(integrationId)).toContain('isUuid'); + }); +}); + +describe('OAuthAuthorizeDto returnPath', () => { + const violations = async (returnPath: string) => { + const errors = await validate( + plainToInstance(OAuthAuthorizeDto, { + integrationId: '019a1b2c-3d4e-7f00-8a00-123456789abc', + returnPath, + }), + ); + return errors.flatMap((e) => Object.keys(e.constraints ?? {})); + }; + + it('accepts a workspace path with a query', async () => { + expect(await violations('/settings/account/connections?connect=github')).toEqual([]); + }); + + it.each([ + ['a protocol-relative path', '//evil.example'], + ['a backslash', '/\\evil.example'], + ['an escape character', '/settings\x1b[2J'], + ['a NUL byte', '/settings\x00'], + ['DEL', '/settings\x7f'], + ])('rejects %s', async (_label, returnPath) => { + expect(await violations(returnPath)).toContain('matches'); + }); +}); diff --git a/apps/server/src/core/integration/dto/integration.dto.ts b/apps/server/src/core/integration/dto/integration.dto.ts new file mode 100644 index 00000000..689400e0 --- /dev/null +++ b/apps/server/src/core/integration/dto/integration.dto.ts @@ -0,0 +1,45 @@ +import { + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, + MaxLength, +} from 'class-validator'; + +export class UninstallIntegrationDto { + @IsUUID() + integrationId: string; +} + +export class UnfurlDto { + // Caps the input the unfurl regexes run on + @IsNotEmpty() + @IsString() + @MaxLength(8192) + @Matches(/^[^\s\x00-\x1f\x7f]+$/) + url: string; +} + +export class OAuthAuthorizeDto { + @IsUUID() + integrationId: string; + + // A single leading slash, so "//host" can't redirect off the workspace origin + @IsOptional() + @IsString() + @MaxLength(512) + @Matches(/^\/(?!\/)[^\s\\\x00-\x1f\x7f]*$/) + returnPath?: string; +} + +export class OAuthDisconnectDto { + @IsUUID() + integrationId: string; +} + +export class OAuthInstallDto { + @IsNotEmpty() + @IsString() + type: string; +} diff --git a/apps/server/src/core/integration/dto/unfurl.dto.spec.ts b/apps/server/src/core/integration/dto/unfurl.dto.spec.ts new file mode 100644 index 00000000..344475f1 --- /dev/null +++ b/apps/server/src/core/integration/dto/unfurl.dto.spec.ts @@ -0,0 +1,36 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { UnfurlDto } from './integration.dto'; + +async function violations(url: string): Promise { + const errors = await validate(plainToInstance(UnfurlDto, { url })); + return errors.flatMap((e) => Object.keys(e.constraints ?? {})); +} + +describe('UnfurlDto', () => { + it('accepts an ordinary provider URL', async () => { + expect(await violations('https://github.com/o/r/pulls?q=is%3Aopen')).toEqual([]); + }); + + it('rejects a URL longer than 8192 characters', async () => { + expect(await violations('https://github.com/o/r/pulls?' + 'a'.repeat(9000))).toContain('maxLength'); + }); + + it('rejects a URL containing whitespace', async () => { + expect(await violations('https://github.com/o/r/pulls/' + '?'.repeat(100) + '\n')).toContain('matches'); + }); + + it.each([ + ['an escape', '\x1b'], + ['a NUL', '\x00'], + ['a backspace', '\x08'], + ['a unit separator', '\x1f'], + ['a DEL', '\x7f'], + ])('rejects a URL containing %s character', async (_label, char) => { + expect(await violations(`https://github.com/o/r/pull/1${char}[31m`)).toContain('matches'); + }); + + it('accepts non-ASCII characters in a URL', async () => { + expect(await violations('https://docs.google.com/document/d/abc/edit#heading=été')).toEqual([]); + }); +}); diff --git a/apps/server/src/core/integration/integration-connection.service.spec.ts b/apps/server/src/core/integration/integration-connection.service.spec.ts new file mode 100644 index 00000000..aee95576 --- /dev/null +++ b/apps/server/src/core/integration/integration-connection.service.spec.ts @@ -0,0 +1,73 @@ +import { IntegrationConnectionService } from './integration-connection.service'; + +const connectedAt = new Date('2026-10-07T09:00:00.000Z'); + +function buildService(rows: Record[]) { + const connectionRepo = { + findByUserAndWorkspace: jest.fn().mockResolvedValue(rows), + }; + return new IntegrationConnectionService( + connectionRepo as any, + {} as any, + { log: jest.fn() } as any, + ); +} + +describe('IntegrationConnectionService connections list', () => { + it("shows the Slack link's account name and id", async () => { + const service = buildService([ + { + integrationId: 'integration-slack', + type: 'slack', + providerUserId: 'U04ABCD1234', + metadata: { + account: { id: 'U04ABCD1234', displayName: 'Philip' }, + notifyEnabled: true, + }, + createdAt: connectedAt, + invalidatedAt: null, + }, + ]); + + await expect( + service.getUserConnections('user-1', 'workspace-1'), + ).resolves.toEqual([ + { + integrationId: 'integration-slack', + type: 'slack', + providerUserId: 'U04ABCD1234', + providerDisplayName: 'Philip', + connectedAt, + invalidatedAt: null, + }, + ]); + }); + + it('keeps the account of every other provider out of the list', async () => { + const service = buildService([ + { + integrationId: 'integration-github', + type: 'github', + providerUserId: '583231', + metadata: { + account: { id: '583231', displayName: 'Philip', username: 'Philipinho' }, + }, + createdAt: connectedAt, + invalidatedAt: null, + }, + ]); + + await expect( + service.getUserConnections('user-1', 'workspace-1'), + ).resolves.toEqual([ + { + integrationId: 'integration-github', + type: 'github', + providerUserId: null, + providerDisplayName: null, + connectedAt, + invalidatedAt: null, + }, + ]); + }); +}); diff --git a/apps/server/src/core/integration/integration-connection.service.ts b/apps/server/src/core/integration/integration-connection.service.ts new file mode 100644 index 00000000..8c973022 --- /dev/null +++ b/apps/server/src/core/integration/integration-connection.service.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { IntegrationRepo } from './repos/integration.repo'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; +import { + AuditEvent, + AuditResource, +} from '../../common/events/audit-events'; +import { ProviderAccount } from './registry/integration-provider.interface'; + +@Injectable() +export class IntegrationConnectionService { + constructor( + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly integrationRepo: IntegrationRepo, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + ) {} + + async getUserConnections(userId: string, workspaceId: string) { + const rows = await this.connectionRepo.findByUserAndWorkspace( + userId, + workspaceId, + ); + + return rows.map((row) => { + // Only Slack shows its linked account until the connections page uses it. + const isSlack = row.type === 'slack'; + const account = (row.metadata as { account?: ProviderAccount } | null) + ?.account; + return { + integrationId: row.integrationId, + type: row.type, + providerUserId: isSlack ? (row.providerUserId ?? null) : null, + providerDisplayName: isSlack ? (account?.displayName ?? null) : null, + connectedAt: row.createdAt, + invalidatedAt: row.invalidatedAt ?? null, + }; + }); + } + + async disconnect( + integrationId: string, + userId: string, + workspaceId: string, + ): Promise { + const integration = await this.integrationRepo.findById(integrationId); + if (!integration || integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + + const disconnected = await this.connectionRepo.deleteByIntegrationAndUser( + integrationId, + userId, + ); + + if (disconnected) { + this.auditService.log({ + event: AuditEvent.INTEGRATION_DISCONNECTED, + resourceType: AuditResource.INTEGRATION, + resourceId: integrationId, + changes: { before: { provider: integration.type } }, + }); + } + } +} diff --git a/apps/server/src/core/integration/integration.controller.ts b/apps/server/src/core/integration/integration.controller.ts new file mode 100644 index 00000000..951cfbe3 --- /dev/null +++ b/apps/server/src/core/integration/integration.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { IntegrationService } from './integration.service'; +import { IntegrationConnectionService } from './integration-connection.service'; +import { UninstallIntegrationDto } from './dto/integration.dto'; +import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; +import { + WorkspaceCaslAction, + WorkspaceCaslSubject, +} from '../casl/interfaces/workspace-ability.type'; + +@Controller('integrations') +export class IntegrationController { + constructor( + private readonly integrationService: IntegrationService, + private readonly connectionService: IntegrationConnectionService, + private readonly workspaceAbility: WorkspaceAbilityFactory, + ) {} + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('available') + async getAvailableIntegrations() { + return this.integrationService.getAvailableIntegrations(); + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('/') + async getInstalledIntegrations( + @AuthWorkspace() workspace: Workspace, + ) { + return this.integrationService.getInstalledIntegrations(workspace.id); + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('uninstall') + async uninstall( + @Body() dto: UninstallIntegrationDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const ability = this.workspaceAbility.createForUser(user, workspace); + if ( + ability.cannot( + WorkspaceCaslAction.Manage, + WorkspaceCaslSubject.Settings, + ) + ) { + throw new ForbiddenException(); + } + + await this.integrationService.uninstall(dto.integrationId, workspace.id); + return { success: true }; + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('connections') + async getMyConnections( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.connectionService.getUserConnections(user.id, workspace.id); + } +} diff --git a/apps/server/src/core/integration/integration.listener.ts b/apps/server/src/core/integration/integration.listener.ts new file mode 100644 index 00000000..f8af541d --- /dev/null +++ b/apps/server/src/core/integration/integration.listener.ts @@ -0,0 +1,29 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants'; + +const TOKEN_REFRESH_SCHEDULER_ID = 'integration-token-refresh-scheduler'; +const TOKEN_REFRESH_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes + +@Injectable() +export class IntegrationListener implements OnApplicationBootstrap { + private readonly logger = new Logger(IntegrationListener.name); + + constructor( + @InjectQueue(QueueName.INTEGRATION_QUEUE) + private readonly integrationQueue: Queue, + ) {} + + async onApplicationBootstrap() { + await this.integrationQueue.upsertJobScheduler( + TOKEN_REFRESH_SCHEDULER_ID, + { every: TOKEN_REFRESH_INTERVAL_MS }, + { + name: QueueJob.INTEGRATION_TOKEN_REFRESH, + data: {}, + }, + ); + this.logger.debug('Integration token refresh scheduler created'); + } +} diff --git a/apps/server/src/core/integration/integration.module.ts b/apps/server/src/core/integration/integration.module.ts new file mode 100644 index 00000000..3b152145 --- /dev/null +++ b/apps/server/src/core/integration/integration.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { IntegrationRegistry } from './registry/integration-registry'; +import { IntegrationService } from './integration.service'; +import { IntegrationConnectionService } from './integration-connection.service'; +import { IntegrationController } from './integration.controller'; +import { OAuthController } from './oauth/oauth.controller'; +import { OAuthService } from './oauth/oauth.service'; +import { UnfurlController } from './unfurl/unfurl.controller'; +import { UnfurlService } from './unfurl/unfurl.service'; +import { IntegrationRepo } from './repos/integration.repo'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { IntegrationListener } from './integration.listener'; +import { IntegrationProcessor } from './integration.processor'; + +@Module({ + controllers: [IntegrationController, OAuthController, UnfurlController], + providers: [ + IntegrationRegistry, + IntegrationService, + IntegrationConnectionService, + OAuthService, + UnfurlService, + IntegrationRepo, + IntegrationConnectionRepo, + IntegrationListener, + IntegrationProcessor, + ], + exports: [ + IntegrationRegistry, + IntegrationService, + IntegrationConnectionService, + OAuthService, + IntegrationRepo, + IntegrationConnectionRepo, + ], +}) +export class IntegrationModule {} diff --git a/apps/server/src/core/integration/integration.processor.spec.ts b/apps/server/src/core/integration/integration.processor.spec.ts new file mode 100644 index 00000000..6bd262ba --- /dev/null +++ b/apps/server/src/core/integration/integration.processor.spec.ts @@ -0,0 +1,238 @@ +import { Logger, NotFoundException } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { IntegrationProcessor } from './integration.processor'; +import { TokenInvalidError } from './registry/integration-provider.interface'; +import { QueueJob } from '../../integrations/queue/constants/queue.constants'; + +describe('IntegrationProcessor token refresh', () => { + const connections = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }]; + const manyConnections = (count: number) => + Array.from({ length: count }, (_, i) => ({ id: `c${i + 1}` })); + const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + const refreshJob = { name: QueueJob.INTEGRATION_TOKEN_REFRESH } as Job; + + function build(refreshAccessToken: jest.Mock, rows: object[] = connections) { + const connectionRepo = { + findExpiringTokens: jest.fn().mockResolvedValue(rows), + invalidate: jest.fn().mockResolvedValue(undefined), + }; + const oauthService = { refreshAccessToken }; + const locks = new Map(); + const redis = { + set: jest.fn(async (key: string, value: string) => { + if (locks.has(key)) return null; + locks.set(key, value); + return 'OK'; + }), + eval: jest.fn(async (_script: string, _numKeys: number, key: string, token: string) => { + if (locks.get(key) !== token) return 0; + locks.delete(key); + return 1; + }), + }; + const processor = new IntegrationProcessor( + connectionRepo as any, + oauthService as any, + { getOrThrow: () => redis } as any, + ); + return { processor, connectionRepo, oauthService, locks }; + } + + it('loads at most 500 connections expiring within 15 minutes', async () => { + const { processor, connectionRepo } = build(jest.fn()); + + await processor.process(refreshJob); + + expect(connectionRepo.findExpiringTokens).toHaveBeenCalledWith( + 15 * 60 * 1000, + 500, + ); + }); + + it('refreshes every selected connection exactly once', async () => { + const rows = manyConnections(12); + const refreshAccessToken = jest.fn().mockResolvedValue(undefined); + const { processor } = build(refreshAccessToken, rows); + + await processor.process(refreshJob); + + expect(refreshAccessToken.mock.calls.map(([row]) => row.id).sort()).toEqual( + rows.map((row) => row.id).sort(), + ); + }); + + it('refreshes five connections at a time', async () => { + let running = 0; + let mostRunning = 0; + const refreshAccessToken = jest.fn(async () => { + running += 1; + mostRunning = Math.max(mostRunning, running); + await flushPromises(); + running -= 1; + }); + const { processor } = build(refreshAccessToken, manyConnections(12)); + + await processor.process(refreshJob); + + expect(refreshAccessToken).toHaveBeenCalledTimes(12); + expect(mostRunning).toBe(5); + }); + + it('keeps refreshing the rest while one refresh hangs', async () => { + const refreshAccessToken = jest + .fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValue(undefined); + const { processor } = build(refreshAccessToken, manyConnections(8)); + const controller = new AbortController(); + + const run = processor.process(refreshJob, undefined, controller.signal); + await flushPromises(); + + expect(refreshAccessToken).toHaveBeenCalledTimes(8); + controller.abort('lock lost'); + await expect(run).rejects.toThrow('lock lost'); + }); + + it('never retires a connection itself and keeps refreshing the rest', async () => { + const refreshAccessToken = jest + .fn() + .mockRejectedValueOnce(new TokenInvalidError()) + .mockRejectedValueOnce(new NotFoundException()) + .mockRejectedValueOnce(new Error('network')); + const { processor, connectionRepo } = build(refreshAccessToken); + + await processor.process({ + name: QueueJob.INTEGRATION_TOKEN_REFRESH, + } as Job); + + expect(refreshAccessToken).toHaveBeenCalledTimes(3); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + }); + + it('skips a connection whose refresh lock someone else holds', async () => { + const refreshAccessToken = jest.fn().mockResolvedValue('token'); + const { processor, locks } = build(refreshAccessToken); + locks.set('integration:refresh:c2', 'request-holder'); + + await processor.process(refreshJob); + + expect(refreshAccessToken.mock.calls.map(([row]) => row.id)).toEqual(['c1', 'c3']); + expect([...locks]).toEqual([['integration:refresh:c2', 'request-holder']]); + }); + + it('holds the lock during each refresh and releases it whether the refresh succeeds or fails', async () => { + const heldDuringRefresh: boolean[] = []; + let locks: Map; + const refreshAccessToken = jest.fn(async ({ id }: { id: string }) => { + heldDuringRefresh.push(locks.has(`integration:refresh:${id}`)); + if (id === 'c2') throw new Error('network'); + return 'token'; + }); + const built = build(refreshAccessToken); + locks = built.locks; + + await built.processor.process(refreshJob); + + expect(heldDuringRefresh).toEqual([true, true, true]); + expect(locks.size).toBe(0); + }); + + describe('cancellation', () => { + const job = { id: 'j1', name: QueueJob.INTEGRATION_TOKEN_REFRESH } as Job; + const hang = () => new Promise(() => {}); + + afterEach(() => jest.restoreAllMocks()); + + it('starts no further refreshes once the job is aborted', async () => { + const finishers: Array<(token: string) => void> = []; + const refreshAccessToken = jest.fn( + () => new Promise((resolve) => finishers.push(resolve)), + ); + const { processor } = build(refreshAccessToken, manyConnections(7)); + const controller = new AbortController(); + + const run = processor.process(job, undefined, controller.signal); + await flushPromises(); + expect(refreshAccessToken).toHaveBeenCalledTimes(5); + controller.abort('lock lost'); + + await expect(run).rejects.toThrow('lock lost'); + finishers.forEach((finish) => finish('token')); + await flushPromises(); + expect(refreshAccessToken).toHaveBeenCalledTimes(5); + }); + + it('fails a hung run when the job timeout fires', async () => { + const timeout = new AbortController(); + jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal); + const { processor } = build(jest.fn(hang)); + + const run = processor.process( + job, + undefined, + new AbortController().signal, + ); + await flushPromises(); + timeout.abort(new DOMException('timed out', 'TimeoutError')); + + await expect(run).rejects.toThrow('timed out'); + }, 1000); + + it('logs the connections in flight when aborted', async () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const { processor } = build(jest.fn(hang)); + const controller = new AbortController(); + + const run = processor.process(job, undefined, controller.signal); + await flushPromises(); + controller.abort('lock lost'); + + await expect(run).rejects.toThrow(); + expect(warn.mock.calls).toEqual([ + [ + 'Token refresh aborted while refreshing connections c1, c2, c3: lock lost', + ], + ]); + }); + + it('logs the load step when aborted before any refresh starts', async () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const { processor, connectionRepo } = build(jest.fn()); + connectionRepo.findExpiringTokens.mockImplementation(hang); + const controller = new AbortController(); + + const run = processor.process(job, undefined, controller.signal); + await flushPromises(); + controller.abort('lock lost'); + + await expect(run).rejects.toThrow(); + expect(warn.mock.calls).toEqual([ + ['Token refresh aborted while loading expiring tokens: lock lost'], + ]); + }); + + it('does not log an abort after the run completes', async () => { + const timeout = new AbortController(); + jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal); + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const { processor } = build(jest.fn().mockResolvedValue('token')); + + await processor.process(job, undefined, new AbortController().signal); + timeout.abort(new DOMException('timed out', 'TimeoutError')); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('cancels every job whose lock renewal failed', () => { + const { processor } = build(jest.fn()); + const cancelJob = jest.fn(); + Object.assign(processor, { _worker: { cancelJob } }); + + processor.onLockRenewalFailed(['j1', 'j2']); + + expect(cancelJob).toHaveBeenCalledWith('j1', 'lock lost'); + expect(cancelJob).toHaveBeenCalledWith('j2', 'lock lost'); + }); + }); +}); diff --git a/apps/server/src/core/integration/integration.processor.ts b/apps/server/src/core/integration/integration.processor.ts new file mode 100644 index 00000000..818f3877 --- /dev/null +++ b/apps/server/src/core/integration/integration.processor.ts @@ -0,0 +1,131 @@ +import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { OAuthService } from './oauth/oauth.service'; +import { RedisService } from '@nestjs-labs/nestjs-ioredis'; +import { RefreshLock } from './utils/refresh-lock'; + +const TOKEN_REFRESH_WINDOW_MS = 15 * 60 * 1000; +// Even at 2 s per refresh, a full batch finishes inside JOB_TIMEOUT_MS. +const TOKEN_REFRESH_BATCH_SIZE = 500; +const TOKEN_REFRESH_CONCURRENCY = 5; +const JOB_TIMEOUT_MS = 5 * 60 * 1000; + +@Processor(QueueName.INTEGRATION_QUEUE) +export class IntegrationProcessor extends WorkerHost { + private readonly logger = new Logger(IntegrationProcessor.name); + private readonly refreshLock: RefreshLock; + + constructor( + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly oauthService: OAuthService, + private readonly redisService: RedisService, + ) { + super(); + this.refreshLock = new RefreshLock(this.redisService.getOrThrow()); + } + + async process(job: Job, token?: string, signal?: AbortSignal): Promise { + const timeout = AbortSignal.timeout(JOB_TIMEOUT_MS); + const jobSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; + // a hung db query can't be cancelled, so stop waiting on it and free the worker + return Promise.race([ + this.runJob(job, jobSignal), + new Promise((_, reject) => { + jobSignal.addEventListener( + 'abort', + () => + reject(new Error(`Job ${job.id} aborted: ${jobSignal.reason}`)), + { once: true }, + ); + }), + ]); + } + + private async runJob(job: Job, signal: AbortSignal): Promise { + switch (job.name) { + case QueueJob.INTEGRATION_TOKEN_REFRESH: + await this.handleTokenRefresh(signal); + break; + default: + this.logger.warn(`Unknown job: ${job.name}`); + } + } + + @OnWorkerEvent('error') + onError(err: Error): void { + this.logger.error(`Worker error: ${err.message}`); + } + + @OnWorkerEvent('lockRenewalFailed') + onLockRenewalFailed(jobIds: string[]): void { + for (const jobId of jobIds) { + this.worker.cancelJob(jobId, 'lock lost'); + } + } + + private async handleTokenRefresh(signal: AbortSignal): Promise { + const inFlight = new Set(); + const logAbort = () => { + const step = + inFlight.size > 0 + ? `refreshing connections ${[...inFlight].join(', ')}` + : 'loading expiring tokens'; + this.logger.warn(`Token refresh aborted while ${step}: ${signal.reason}`); + }; + signal.addEventListener('abort', logAbort, { once: true }); + + try { + const connections = await this.connectionRepo.findExpiringTokens( + TOKEN_REFRESH_WINDOW_MS, + TOKEN_REFRESH_BATCH_SIZE, + ); + + if (connections.length === 0) { + return; + } + + let next = 0; + let refreshed = 0; + // Each connection is taken once, and refreshes of different rows never conflict. + const refreshRemaining = async () => { + while (next < connections.length) { + signal.throwIfAborted(); + const connection = connections[next++]; + inFlight.add(connection.id); + const lockToken = await this.refreshLock.acquire(connection.id); + // Someone else is refreshing this connection right now. + if (!lockToken) { + inFlight.delete(connection.id); + continue; + } + try { + await this.oauthService.refreshAccessToken(connection); + refreshed += 1; + } catch (err) { + this.logger.error( + `Token refresh failed for connection ${connection.id}: ${(err as Error).message}`, + ); + } finally { + inFlight.delete(connection.id); + await this.refreshLock.release(connection.id, lockToken); + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(TOKEN_REFRESH_CONCURRENCY, connections.length) }, + () => refreshRemaining(), + ), + ); + + this.logger.debug( + `Refreshed ${refreshed} of ${connections.length} expiring token(s)`, + ); + } finally { + signal.removeEventListener('abort', logAbort); + } + } +} diff --git a/apps/server/src/core/integration/integration.service.ts b/apps/server/src/core/integration/integration.service.ts new file mode 100644 index 00000000..2a2c911e --- /dev/null +++ b/apps/server/src/core/integration/integration.service.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { IntegrationRepo } from './repos/integration.repo'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { IntegrationRegistry } from './registry/integration-registry'; +import { Integration } from '@docmost/db/types/entity.types'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; +import { + AuditEvent, + AuditResource, +} from '../../common/events/audit-events'; + +@Injectable() +export class IntegrationService { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly integrationRepo: IntegrationRepo, + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly registry: IntegrationRegistry, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + ) {} + + async getAvailableIntegrations() { + return this.registry.getAvailableIntegrations(); + } + + async getInstalledIntegrations( + workspaceId: string, + ): Promise<(Integration & { unfurlHosts?: string[] })[]> { + const integrations = + await this.integrationRepo.findAllByWorkspace(workspaceId); + return integrations.map((integration) => { + const unfurlHosts = this.registry + .getProvider(integration.type) + ?.getUnfurlHosts?.((integration.settings as Record) ?? {}); + return unfurlHosts ? { ...integration, unfurlHosts } : integration; + }); + } + + async uninstall(integrationId: string, workspaceId: string): Promise { + const integration = await this.integrationRepo.findById(integrationId); + if (!integration || integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + // Soft delete doesn't cascade to connections + await executeTx(this.db, async (trx) => { + await this.connectionRepo.deleteByIntegration(integrationId, trx); + await this.integrationRepo.softDelete(integrationId, trx); + }); + + this.auditService.log({ + event: AuditEvent.INTEGRATION_UNINSTALLED, + resourceType: AuditResource.INTEGRATION, + resourceId: integrationId, + changes: { before: { provider: integration.type } }, + }); + } +} diff --git a/apps/server/src/core/integration/oauth/credential-env.spec.ts b/apps/server/src/core/integration/oauth/credential-env.spec.ts new file mode 100644 index 00000000..6864e0ac --- /dev/null +++ b/apps/server/src/core/integration/oauth/credential-env.spec.ts @@ -0,0 +1,23 @@ +import { credentialEnvKey } from './credential-env'; + +describe('credentialEnvKey', () => { + it('names credentials after the provider type by default', () => { + expect(credentialEnvKey('CLIENT_ID', 'github')).toBe( + 'INTEGRATION_GITHUB_CLIENT_ID', + ); + expect(credentialEnvKey('CLIENT_SECRET', 'gitlab', {})).toBe( + 'INTEGRATION_GITLAB_CLIENT_SECRET', + ); + }); + + it('names credentials after the shared authorization server when declared', () => { + expect( + credentialEnvKey('CLIENT_ID', 'jira', { credentialsKey: 'atlassian' }), + ).toBe('INTEGRATION_ATLASSIAN_CLIENT_ID'); + expect( + credentialEnvKey('CLIENT_SECRET', 'google_docs', { + credentialsKey: 'google', + }), + ).toBe('INTEGRATION_GOOGLE_CLIENT_SECRET'); + }); +}); diff --git a/apps/server/src/core/integration/oauth/credential-env.ts b/apps/server/src/core/integration/oauth/credential-env.ts new file mode 100644 index 00000000..6e7bea96 --- /dev/null +++ b/apps/server/src/core/integration/oauth/credential-env.ts @@ -0,0 +1,11 @@ +import { OAuthConfig } from '../registry/integration-provider.interface'; + +// Products on one authorization server (Atlassian, Google) share credentials via credentialsKey +export function credentialEnvKey( + field: 'CLIENT_ID' | 'CLIENT_SECRET', + type: string, + oauthConfig?: Pick, +): string { + const key = oauthConfig?.credentialsKey ?? type; + return `INTEGRATION_${key.toUpperCase()}_${field}`; +} diff --git a/apps/server/src/core/integration/oauth/oauth-complete-auth.filter.ts b/apps/server/src/core/integration/oauth/oauth-complete-auth.filter.ts new file mode 100644 index 00000000..4fd75c59 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth-complete-auth.filter.ts @@ -0,0 +1,27 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + ForbiddenException, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { COMPLETION_FALLBACK_REDIRECT } from './oauth.utils'; + +// Redirects a browser back into the app instead of showing a JSON auth error. +@Catch(UnauthorizedException, ForbiddenException) +export class OAuthCompleteAuthFilter implements ExceptionFilter { + private readonly logger = new Logger(OAuthCompleteAuthFilter.name); + + catch( + exception: UnauthorizedException | ForbiddenException, + host: ArgumentsHost, + ) { + const http = host.switchToHttp(); + this.logger.warn( + `OAuth completion on ${http.getRequest().host} refused without a usable session: ${exception.message}`, + ); + http.getResponse().redirect(COMPLETION_FALLBACK_REDIRECT, 302); + } +} diff --git a/apps/server/src/core/integration/oauth/oauth-connect.spec.ts b/apps/server/src/core/integration/oauth/oauth-connect.spec.ts new file mode 100644 index 00000000..325bc1d8 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth-connect.spec.ts @@ -0,0 +1,28 @@ +import { supportsOAuthConnect } from './oauth-connect'; + +const base = { authUrl: 'https://p.example/authorize', tokenUrl: 'https://p.example/token', scopes: [] }; + +describe('supportsOAuthConnect', () => { + it('is false for providers without OAuth', () => { + expect(supportsOAuthConnect(undefined)).toBe(false); + }); + + it('is true for per-user providers', () => { + expect(supportsOAuthConnect(base)).toBe(true); + expect(supportsOAuthConnect({ ...base, connectionScope: 'user' })).toBe(true); + }); + + it('is false for workspace-scoped providers without an identity flow', () => { + expect(supportsOAuthConnect({ ...base, connectionScope: 'workspace' })).toBe(false); + }); + + it('is true for workspace-scoped providers with an identity flow', () => { + expect( + supportsOAuthConnect({ + ...base, + connectionScope: 'workspace', + identity: { authUrl: 'https://p.example/oidc', tokenUrl: 'https://p.example/oidc/token', scopes: ['openid'] }, + }), + ).toBe(true); + }); +}); diff --git a/apps/server/src/core/integration/oauth/oauth-connect.ts b/apps/server/src/core/integration/oauth/oauth-connect.ts new file mode 100644 index 00000000..4e19bae1 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth-connect.ts @@ -0,0 +1,9 @@ +import { OAuthConfig } from '../registry/integration-provider.interface'; + +// Workspace-scoped providers can only connect through the identity flow +export function supportsOAuthConnect( + oauth: OAuthConfig | undefined, +): boolean { + if (!oauth) return false; + return (oauth.connectionScope ?? 'user') === 'user' || !!oauth.identity; +} diff --git a/apps/server/src/core/integration/oauth/oauth-identity-link.spec.ts b/apps/server/src/core/integration/oauth/oauth-identity-link.spec.ts new file mode 100644 index 00000000..6576c714 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth-identity-link.spec.ts @@ -0,0 +1,95 @@ +import { BadRequestException } from '@nestjs/common'; +import { OAuthService } from './oauth.service'; + +const integrationId = 'integration-1'; +const workspaceId = 'workspace-1'; +const providerUserId = 'U-slack-1'; + +function buildService(existingLinkUserId?: string) { + const provider = { + definition: { + oauth: { + connectionScope: 'workspace', + identity: { tokenUrl: 'https://slack.example/token' }, + }, + }, + resolveIdentity: jest.fn().mockResolvedValue({ account: { id: providerUserId } }), + }; + + const findUserLink = jest + .fn() + .mockResolvedValue( + existingLinkUserId ? { userId: existingLinkUserId } : undefined, + ); + const upsertUserLink = jest.fn().mockResolvedValue({ id: 'connection-1' }); + + const service = new OAuthService( + {} as any, + {} as any, + {} as any, + { getProvider: jest.fn().mockReturnValue(provider) } as any, + { + findById: jest.fn().mockResolvedValue({ id: integrationId, workspaceId, settings: {} }), + } as any, + { findUserLink, upsertUserLink } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { log: jest.fn() } as any, + { getOrThrow: () => ({}) } as any, + ); + + // Stub the live token exchange with the provider + (service as any).requestTokens = jest + .fn() + .mockResolvedValue({ access_token: 'token' }); + + return { service, findUserLink, upsertUserLink }; +} + +function connectState(userId: string) { + return { + flow: 'connect' as const, + integrationId, + type: 'slack', + userId, + workspaceId, + }; +} + +describe('OAuthService identity link rebinding', () => { + it('refuses a provider account already linked to another Docmost user', async () => { + const { service, upsertUserLink } = buildService('user-a'); + + await expect( + service.exchangeCodeForTokens('slack', 'code', connectState('user-b')), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(upsertUserLink).not.toHaveBeenCalled(); + }); + + it('allows the same user to relink their own provider account', async () => { + const { service, upsertUserLink } = buildService('user-a'); + + await expect( + service.exchangeCodeForTokens('slack', 'code', connectState('user-a')), + ).resolves.toEqual({ id: 'connection-1' }); + + expect(upsertUserLink).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-a', providerUserId }), + ); + }); + + it('allows a first-time link', async () => { + const { service, upsertUserLink } = buildService(); + + await expect( + service.exchangeCodeForTokens('slack', 'code', connectState('user-b')), + ).resolves.toEqual({ id: 'connection-1' }); + + expect(upsertUserLink).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-b', providerUserId }), + ); + }); +}); diff --git a/apps/server/src/core/integration/oauth/oauth.controller.spec.ts b/apps/server/src/core/integration/oauth/oauth.controller.spec.ts new file mode 100644 index 00000000..4174aeb6 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.controller.spec.ts @@ -0,0 +1,553 @@ +import { + BadRequestException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { OAuthController } from './oauth.controller'; +import { OAuthCompleteAuthFilter } from './oauth-complete-auth.filter'; +import { OAuthStatePayload } from './oauth.service'; +import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory'; +import { UserRole } from '../../../common/helpers/types/permission'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { + IdentityEmailMismatchError, + IdentityTenantMismatchError, + IntegrationTenantInUseError, +} from '../registry/integration-provider.interface'; +import { + EncryptionPurpose, + EncryptionService, +} from '../../../integrations/encryption/encryption.service'; +import { Test } from '@nestjs/testing'; +import { + FastifyAdapter, + NestFastifyApplication, +} from '@nestjs/platform-fastify'; +import { JwtStrategy } from '../../auth/strategies/jwt.strategy'; +import { OAuthService } from './oauth.service'; +import { IntegrationConnectionService } from '../integration-connection.service'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import { LicenseCheckService } from '../../../integrations/environment/license-check.service'; +import { EnvironmentService } from '../../../integrations/environment/environment.service'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; +import { SessionActivityService } from '../../session/session-activity.service'; + +const workspace = { id: 'workspace-1' } as Workspace; +const encryptionService = new EncryptionService({ + getAppSecret: () => 'test-secret', +} as any); +const environmentService = { + isHttps: () => true, + isCloud: () => false, + getAppUrl: () => 'https://acme.example', + getSubdomainHost: () => undefined, +} as any; +const cloudEnvironment = { + ...environmentService, + isCloud: () => true, + getAppUrl: () => 'https://app.docmost.example', + getSubdomainHost: () => 'docmost.example', +}; +const authorizationUrl = 'https://provider.example/authorize'; +const fallbackRedirect = '/settings/account/connections?error=oauth_failed'; + +function userWithRole(role: UserRole): User { + return { id: `user-${role}`, role } as User; +} + +function cookieReply() { + return { setCookie: jest.fn() } as any; +} + +function redirectReply() { + return { redirect: jest.fn(), clearCookie: jest.fn() } as any; +} + +const signedStatePayload: OAuthStatePayload = { + flow: 'connect', + integrationId: 'integration-1', + type: 'slack', + userId: 'user-1', + workspaceId: 'workspace-1', + returnUrl: 'https://acme.example', + returnPath: '/s/general/p/roadmap', + nonce: 'nonce-1', + exp: Date.now() + 10 * 60_000, +}; + +function buildController( + state: Partial = {}, + environment = environmentService, +) { + const getInstallAuthorizationUrl = jest.fn().mockResolvedValue({ authorizationUrl, nonce: 'install-nonce' }); + const getAuthorizationUrl = jest.fn().mockResolvedValue({ authorizationUrl, type: 'github', nonce: 'connect-nonce' }); + const exchangeCodeForTokens = jest.fn().mockResolvedValue({}); + const verifySignedState = jest.fn((signed: string) => + signed === 'signed-state' ? { ...signedStatePayload, ...state } : null, + ); + + const controller = new OAuthController( + { getInstallAuthorizationUrl, getAuthorizationUrl, exchangeCodeForTokens, verifySignedState } as any, + {} as any, + new WorkspaceAbilityFactory(), + { hasFeature: jest.fn().mockReturnValue(true) } as any, + { getProvider: jest.fn().mockReturnValue({ definition: {} }) } as any, + encryptionService, + environment, + ); + + return { controller, exchangeCodeForTokens, getInstallAuthorizationUrl, getAuthorizationUrl }; +} + +function issueTicket(fields: Record = {}, purpose = EncryptionPurpose.OAUTH_COMPLETION) { + return encryptionService.encrypt( + JSON.stringify({ + purpose: 'oauth-completion', + state: 'signed-state', + code: 'auth-code', + exp: Date.now() + 60_000, + ...fields, + }), + purpose, + ); +} + +let warn: jest.SpyInstance; +beforeAll(() => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); +}); +beforeEach(() => warn.mockClear()); +afterAll(() => jest.restoreAllMocks()); + +describe('OAuthController install authorization', () => { + it('refuses a workspace member', async () => { + const { controller, getInstallAuthorizationUrl } = buildController(); + const res = cookieReply(); + + await expect( + controller.installAndAuthorize( + { type: 'slack' } as any, + userWithRole(UserRole.MEMBER), + workspace, + res, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + + expect(getInstallAuthorizationUrl).not.toHaveBeenCalled(); + expect(res.setCookie).not.toHaveBeenCalled(); + }); + + it.each([UserRole.ADMIN, UserRole.OWNER])('allows a workspace %s', async (role) => { + const { controller, getInstallAuthorizationUrl } = buildController(); + const res = cookieReply(); + + await expect( + controller.installAndAuthorize( + { type: 'slack' } as any, + userWithRole(role), + workspace, + res, + ), + ).resolves.toEqual({ authorizationUrl }); + + expect(getInstallAuthorizationUrl).toHaveBeenCalledWith( + 'slack', + workspace.id, + `user-${role}`, + ); + expect(res.setCookie).toHaveBeenCalledWith('integration_oauth_slack', 'install-nonce', expect.objectContaining({ + httpOnly: true, secure: true, sameSite: 'lax', path: '/api/integrations/oauth', + })); + }); +}); + +describe('OAuthController nonce cookie', () => { + const cookieOptions = { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/api/integrations/oauth', + }; + + it('binds a connect flow to the browser that started it', async () => { + const { controller } = buildController(); + const res = cookieReply(); + + await expect( + controller.authorize( + { integrationId: 'integration-1' } as any, + userWithRole(UserRole.MEMBER), + workspace, + res, + ), + ).resolves.toEqual({ authorizationUrl }); + + expect(res.setCookie).toHaveBeenCalledWith( + 'integration_oauth_github', + 'connect-nonce', + expect.objectContaining(cookieOptions), + ); + const { expires } = res.setCookie.mock.calls[0][2]; + expect(expires.getTime() - Date.now()).toBeGreaterThan(9 * 60_000); + expect(expires.getTime() - Date.now()).toBeLessThanOrEqual(10 * 60_000); + }); +}); + +describe('OAuthController callback', () => { + it('hands the code to the workspace host instead of exchanging it', async () => { + const { controller, exchangeCodeForTokens } = buildController(); + const res = redirectReply(); + const before = Date.now(); + + await controller.callback('slack', 'auth-code', 'signed-state', res); + + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + const [location, status] = res.redirect.mock.calls[0]; + expect(status).toBe(302); + const url = new URL(location); + expect(`${url.origin}${url.pathname}`).toBe( + 'https://acme.example/api/integrations/oauth/complete', + ); + const ticket = JSON.parse( + encryptionService.decrypt( + url.searchParams.get('ticket') ?? '', + EncryptionPurpose.OAUTH_COMPLETION, + ), + ); + expect(ticket).toEqual({ + purpose: 'oauth-completion', + state: 'signed-state', + code: 'auth-code', + exp: expect.any(Number), + }); + expect(ticket.exp).toBeGreaterThanOrEqual(before + 60_000); + expect(ticket.exp).toBeLessThanOrEqual(Date.now() + 60_000); + }); + + it('sends a cancelled consent back with oauth_failed', async () => { + const { controller, exchangeCodeForTokens } = buildController(); + const res = redirectReply(); + + await controller.callback('slack', undefined, 'signed-state', res); + + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith( + 'https://acme.example/s/general/p/roadmap?error=oauth_failed', + 302, + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('slack')); + }); + + it("logs the provider's error code and the first line of its description when no code comes back", async () => { + const { controller } = buildController(); + const res = redirectReply(); + + await controller.callback( + 'slack', + undefined, + 'signed-state', + res, + 'invalid_client', + "AADSTS650052: The app needs access to a service that your organization hasn't subscribed to.\r\nTrace ID: 1f2e\r\nCorrelation ID: 9a8b", + ); + + expect(warn).toHaveBeenCalledTimes(1); + const line = warn.mock.calls[0][0] as string; + expect(line).toContain('slack'); + expect(line).toContain('invalid_client'); + expect(line).toContain( + "AADSTS650052: The app needs access to a service that your organization hasn't subscribed to.", + ); + expect(line).not.toContain('Trace ID'); + expect(line).not.toContain('Correlation ID'); + expect(res.redirect).toHaveBeenCalledWith( + 'https://acme.example/s/general/p/roadmap?error=oauth_failed', + 302, + ); + }); + + it('logs only a printable, bounded line from the provider error', async () => { + const { controller } = buildController(); + const res = redirectReply(); + + await controller.callback( + 'slack', + undefined, + 'signed-state', + res, + 'Access_Denied\nforged', + `denied\u001b[31m‮${'x'.repeat(1000)}`, + ); + + const line = warn.mock.calls[0][0] as string; + expect(line).not.toContain('Access_Denied'); + expect(line).not.toMatch(/[^\x20-\x7e]/); + expect(line).toContain('denied[31m'); + expect(line.length).toBeLessThan(400); + }); + + it('refuses a state signed for a different provider', async () => { + const { controller, exchangeCodeForTokens } = buildController(); + const res = redirectReply(); + + await controller.callback('github', 'auth-code', 'signed-state', res); + + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledTimes(1); + expect(res.redirect).toHaveBeenCalledWith( + 'https://acme.example/s/general/p/roadmap?error=oauth_failed', + 302, + ); + }); + + it('rejects a state that does not verify', async () => { + const { controller } = buildController(); + const res = redirectReply(); + + await expect( + controller.callback('slack', 'auth-code', 'forged-state', res), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(res.redirect).not.toHaveBeenCalled(); + }); + + describe('return host', () => { + it.each([ + ['a custom domain', cloudEnvironment, 'https://wiki.tenant.example'], + ['a lookalike of the cloud domain', cloudEnvironment, 'https://acmedocmost.example'], + ['a userinfo trick', cloudEnvironment, 'https://acme.docmost.example@evil.example'], + [ + 'a subdomain while self-hosted', + { ...environmentService, getSubdomainHost: () => 'docmost.example' }, + 'https://acme.docmost.example', + ], + [ + 'any host when the subdomain host is unset', + { ...cloudEnvironment, getSubdomainHost: () => undefined }, + 'https://acme.undefined', + ], + ['a malformed return URL', cloudEnvironment, 'not a url'], + ])('refuses to hand a ticket to %s', async (_label, environment, returnUrl) => { + const { controller, exchangeCodeForTokens } = buildController( + { returnUrl }, + environment, + ); + const res = redirectReply(); + + await controller.callback('slack', 'auth-code', 'signed-state', res); + + expect(res.redirect.mock.calls[0][0]).not.toContain('ticket='); + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledTimes(1); + expect(res.redirect).toHaveBeenCalledWith( + `${returnUrl}/s/general/p/roadmap?error=oauth_failed`, + 302, + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('is not a Docmost host'), + ); + }); + + it.each([ + ['the APP_URL host on cloud', cloudEnvironment, 'https://app.docmost.example'], + ['a cloud workspace subdomain', cloudEnvironment, 'https://acme.docmost.example'], + [ + 'a cloud workspace subdomain with a port', + { + ...cloudEnvironment, + getAppUrl: () => 'http://localhost:3000', + getSubdomainHost: () => 'localhost:3000', + }, + 'http://acme.localhost:3000', + ], + ])('hands the ticket to %s', async (_label, environment, returnUrl) => { + const { controller } = buildController({ returnUrl }, environment); + const res = redirectReply(); + + await controller.callback('slack', 'auth-code', 'signed-state', res); + + const url = new URL(res.redirect.mock.calls[0][0]); + expect(`${url.origin}${url.pathname}`).toBe( + `${returnUrl}/api/integrations/oauth/complete`, + ); + expect(url.searchParams.get('ticket')).toBeTruthy(); + }); + }); +}); + +describe('OAuthController complete', () => { + const sessionUser = { id: 'user-1' } as User; + + async function complete( + options: { + ticket?: string; + user?: User; + sessionWorkspace?: Workspace; + cookies?: Record; + state?: Partial; + error?: Error; + } = {}, + ) { + const { + user = sessionUser, + sessionWorkspace = workspace, + cookies = { integration_oauth_slack: 'nonce-1' }, + state = {}, + } = options; + const ticket = 'ticket' in options ? options.ticket : issueTicket(); + const { controller, exchangeCodeForTokens } = buildController(state); + const res = redirectReply(); + if (options.error) exchangeCodeForTokens.mockRejectedValue(options.error); + + await controller.complete( + ticket, + user, + sessionWorkspace, + { cookies } as any, + res, + ); + + return { res, exchangeCodeForTokens }; + } + + it('exchanges the code for the user who started the flow', async () => { + const { controller, exchangeCodeForTokens } = buildController(); + const callbackRes = redirectReply(); + await controller.callback('slack', 'auth-code', 'signed-state', callbackRes); + const ticket = new URL(callbackRes.redirect.mock.calls[0][0]).searchParams.get('ticket'); + const res = redirectReply(); + + await controller.complete( + ticket, + sessionUser, + workspace, + { cookies: { integration_oauth_slack: 'nonce-1' } } as any, + res, + ); + + expect(exchangeCodeForTokens).toHaveBeenCalledWith( + 'slack', + 'auth-code', + expect.objectContaining({ type: 'slack', userId: 'user-1', workspaceId: 'workspace-1' }), + ); + expect(res.clearCookie).toHaveBeenCalledWith('integration_oauth_slack', { + path: '/api/integrations/oauth', + }); + expect(res.redirect).toHaveBeenCalledWith('/s/general/p/roadmap', 302); + }); + + it.each([ + ['a different user', { user: { id: 'user-2' } as User }], + ['a different workspace', { sessionWorkspace: { id: 'workspace-2' } as Workspace }], + ['a wrong nonce cookie', { cookies: { integration_oauth_slack: 'nonce-2' } }], + ['a nonce cookie of a different length', { cookies: { integration_oauth_slack: 'nonce-10' } }], + ['a nonce cookie for another provider', { cookies: { integration_oauth_github: 'nonce-1' } }], + ['a state without a nonce', { state: { nonce: undefined } }], + ])('refuses %s', async (_label, options) => { + const { res, exchangeCodeForTokens } = await complete(options); + + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(res.clearCookie).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith('/s/general/p/roadmap?error=oauth_failed', 302); + }); + + it.each([ + ['an expired ticket', () => issueTicket({ exp: Date.now() - 1 })], + ['an undecryptable ticket', () => 'not-a-ticket'], + ['a ticket whose state does not verify', () => issueTicket({ state: 'forged-state' })], + ['a ticket without a code', () => issueTicket({ code: undefined })], + ['a ticket without an expiry', () => issueTicket({ exp: undefined })], + ['a ticket minted for another purpose', () => issueTicket({ purpose: 'session' })], + ['a ticket encrypted for SIEM credentials', () => issueTicket({}, EncryptionPurpose.SIEM_CREDENTIALS)], + ])('refuses %s', async (_label, ticket) => { + const { res, exchangeCodeForTokens } = await complete({ ticket: ticket() }); + + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(res.clearCookie).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith(fallbackRedirect, 302); + }); + + it.each([ + [IdentityEmailMismatchError, 'identity_mismatch'], + [IdentityTenantMismatchError, 'tenant_mismatch'], + [IntegrationTenantInUseError, 'tenant_in_use'], + [Error, 'oauth_failed'], + ])('reports %p as %s', async (ErrorClass, code) => { + const { res } = await complete({ error: new ErrorClass() }); + + expect(res.clearCookie).toHaveBeenCalledTimes(1); + expect(res.redirect).toHaveBeenCalledWith( + `/s/general/p/roadmap?error=${code}`, + 302, + ); + }); +}); + +describe('OAuthController complete without a session', () => { + it('redirects a forbidden completion without logging the ticket', () => { + const exception = new ForbiddenException(); + const res = redirectReply(); + const req = { + host: 'acme.example', + url: '/api/integrations/oauth/complete?ticket=sealed-ticket', + }; + + new OAuthCompleteAuthFilter().catch(exception, { + switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }), + } as any); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('acme.example'); + expect(warn.mock.calls[0][0]).not.toContain('sealed-ticket'); + expect(res.redirect).toHaveBeenCalledTimes(1); + expect(res.redirect).toHaveBeenCalledWith(fallbackRedirect, 302); + }); + + it('redirects instead of returning 401 through the Fastify pipeline', async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [OAuthController], + providers: [ + JwtStrategy, + { + provide: EnvironmentService, + useValue: { ...environmentService, getAppSecret: () => 'test-secret' }, + }, + { provide: EncryptionService, useValue: encryptionService }, + { provide: WorkspaceAbilityFactory, useValue: {} }, + ...[ + OAuthService, + IntegrationConnectionService, + LicenseCheckService, + IntegrationRegistry, + UserRepo, + WorkspaceRepo, + UserSessionRepo, + SessionActivityService, + ].map((provide) => ({ provide, useValue: {} })), + ], + }).compile(); + const app = moduleRef.createNestApplication( + new FastifyAdapter(), + ); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + + try { + const response = await app.inject({ + method: 'GET', + url: `/integrations/oauth/complete?ticket=${encodeURIComponent(issueTicket())}`, + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe(fallbackRedirect); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('refused without a usable session'), + ); + } finally { + await app.close(); + } + }); +}); diff --git a/apps/server/src/core/integration/oauth/oauth.controller.ts b/apps/server/src/core/integration/oauth/oauth.controller.ts new file mode 100644 index 00000000..3f531e54 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.controller.ts @@ -0,0 +1,333 @@ +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + Get, + HttpCode, + HttpStatus, + Logger, + Param, + Post, + Query, + Req, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { addMinutes } from 'date-fns'; +import * as crypto from 'crypto'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator'; +import { RequireSessionAuth } from '../../../common/decorators/require-session-auth.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { OAuthService } from './oauth.service'; +import { + OAuthAuthorizeDto, + OAuthDisconnectDto, + OAuthInstallDto, +} from '../dto/integration.dto'; +import { IntegrationConnectionService } from '../integration-connection.service'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory'; +import { + WorkspaceCaslAction, + WorkspaceCaslSubject, +} from '../../casl/interfaces/workspace-ability.type'; +import { LicenseCheckService } from '../../../integrations/environment/license-check.service'; +import { + IdentityEmailMismatchError, + IdentityTenantMismatchError, + IntegrationTenantInUseError, +} from '../registry/integration-provider.interface'; +import { + EncryptionPurpose, + EncryptionService, +} from '../../../integrations/encryption/encryption.service'; +import { EnvironmentService } from '../../../integrations/environment/environment.service'; +import { COMPLETION_FALLBACK_REDIRECT, nonceCookieName } from './oauth.utils'; +import { OAuthCompleteAuthFilter } from './oauth-complete-auth.filter'; + +const OAUTH_ROUTE_PREFIX = '/api/integrations/oauth'; +const COMPLETION_TICKET_PURPOSE = 'oauth-completion'; +const COMPLETION_TICKET_TTL_MS = 60_000; + +type CompletionTicket = { + purpose: typeof COMPLETION_TICKET_PURPOSE; + state: string; + code: string; + exp: number; +}; + +@Controller('integrations/oauth') +export class OAuthController { + private readonly logger = new Logger(OAuthController.name); + + constructor( + private readonly oauthService: OAuthService, + private readonly connectionService: IntegrationConnectionService, + private readonly workspaceAbility: WorkspaceAbilityFactory, + private readonly licenseCheckService: LicenseCheckService, + private readonly registry: IntegrationRegistry, + private readonly encryptionService: EncryptionService, + private readonly environmentService: EnvironmentService, + ) {} + + @RequireSessionAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('authorize') + async authorize( + @Body() dto: OAuthAuthorizeDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Res({ passthrough: true }) res: FastifyReply, + ) { + const { authorizationUrl, type, nonce } = + await this.oauthService.getAuthorizationUrl( + dto.integrationId, + workspace.id, + user.id, + dto.returnPath, + ); + + this.setNonceCookie(res, type, nonce); + return { authorizationUrl }; + } + + @RequireSessionAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('install') + async installAndAuthorize( + @Body() dto: OAuthInstallDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Res({ passthrough: true }) res: FastifyReply, + ) { + const ability = this.workspaceAbility.createForUser(user, workspace); + if ( + ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings) + ) { + throw new ForbiddenException(); + } + + const requiredFeature = + this.registry.getProvider(dto.type)?.definition.requiresFeature; + if ( + requiredFeature && + !this.licenseCheckService.hasFeature( + workspace.licenseKey, + requiredFeature, + workspace.plan, + ) + ) { + throw new ForbiddenException('This feature requires a valid license'); + } + + const { authorizationUrl, nonce } = + await this.oauthService.getInstallAuthorizationUrl( + dto.type, + workspace.id, + user.id, + ); + + this.setNonceCookie(res, dto.type, nonce); + return { authorizationUrl }; + } + + @Get(':type/callback') + async callback( + @Param('type') type: string, + @Query('code') code: string, + @Query('state') state: string, + @Res() res: FastifyReply, + @Query('error') providerError?: string, + @Query('error_description') providerErrorDescription?: string, + ) { + if (!state) { + throw new BadRequestException('Missing state parameter'); + } + + const statePayload = this.oauthService.verifySignedState(state); + if (!statePayload) { + throw new BadRequestException('Invalid or expired OAuth state'); + } + + // Built server-side and signed into the state, never user input. + const returnUrl = statePayload.returnUrl; + const returnPath = statePayload.returnPath; + + // No code means the provider refused or the user cancelled. + if (!code) { + const errorCode = /^[a-z_]{1,40}$/.test(providerError ?? '') + ? providerError + : 'none'; + const reason = + typeof providerErrorDescription === 'string' + ? providerErrorDescription + .split(/[\r\n]/)[0] + .replace(/[^\x20-\x7e]/g, '') + .slice(0, 200) + : ''; + this.logger.warn( + `OAuth callback for ${statePayload.type} returned no code: error=${errorCode}${reason ? ` ${reason}` : ''}`, + ); + return res.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302); + } + + if (statePayload.type !== type) { + this.logger.error( + `OAuth callback error for ${type}: OAuth state does not match this provider`, + ); + return res.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302); + } + + // A customDomain is tenant-controlled DNS, so the ticket only goes to hosts Docmost serves. + let returnHost: string | undefined; + try { + returnHost = new URL(returnUrl).host; + } catch { + returnHost = undefined; + } + const subdomainHost = this.environmentService.getSubdomainHost(); + const isDocmostHost = + returnHost === new URL(this.environmentService.getAppUrl()).host || + (this.environmentService.isCloud() && + !!subdomainHost && + !!returnHost?.endsWith(`.${subdomainHost}`)); + + if (!isDocmostHost) { + this.logger.warn( + `OAuth callback refused for ${type}: ${returnUrl} is not a Docmost host`, + ); + return res.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302); + } + + // The code is redeemed on the workspace host, where the session and nonce cookie prove who started the flow. + const ticket = this.encryptionService.encrypt( + JSON.stringify({ + purpose: COMPLETION_TICKET_PURPOSE, + state, + code, + exp: Date.now() + COMPLETION_TICKET_TTL_MS, + } satisfies CompletionTicket), + EncryptionPurpose.OAUTH_COMPLETION, + ); + + return res.redirect( + `${returnUrl}${OAUTH_ROUTE_PREFIX}/complete?ticket=${encodeURIComponent(ticket)}`, + 302, + ); + } + + @RequireSessionAuth() + @UseGuards(JwtAuthGuard) + @UseFilters(OAuthCompleteAuthFilter) + @Get('complete') + async complete( + @Query('ticket') ticket: string, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Req() req: FastifyRequest, + @Res() res: FastifyReply, + ) { + let completion: Partial | null; + try { + completion = JSON.parse( + this.encryptionService.decrypt(ticket, EncryptionPurpose.OAUTH_COMPLETION), + ); + } catch { + completion = null; + } + + const { purpose, state, code, exp } = completion ?? {}; + const statePayload = + purpose === COMPLETION_TICKET_PURPOSE && + typeof state === 'string' && + typeof code === 'string' && + typeof exp === 'number' && + exp >= Date.now() + ? this.oauthService.verifySignedState(state) + : null; + + if (!statePayload) { + this.logger.warn('OAuth completion refused: invalid or expired ticket'); + return res.redirect(COMPLETION_FALLBACK_REDIRECT, 302); + } + + const returnPath = statePayload.returnPath; + const cookieName = nonceCookieName(statePayload.type); + const cookieBuf = Buffer.from(req.cookies?.[cookieName] ?? ''); + const nonceBuf = Buffer.from(statePayload.nonce ?? ''); + const nonceMatches = + nonceBuf.length > 0 && + cookieBuf.length === nonceBuf.length && + crypto.timingSafeEqual(cookieBuf, nonceBuf); + + if ( + statePayload.userId !== user.id || + statePayload.workspaceId !== workspace.id || + !nonceMatches + ) { + this.logger.warn( + `OAuth completion refused for ${statePayload.type}: the session did not start this flow`, + ); + return res.redirect(`${returnPath}?error=oauth_failed`, 302); + } + + res.clearCookie(cookieName, { path: OAUTH_ROUTE_PREFIX }); + + try { + await this.oauthService.exchangeCodeForTokens( + statePayload.type, + code, + statePayload, + ); + + return res.redirect(returnPath, 302); + } catch (err) { + this.logger.error( + `OAuth completion error for ${statePayload.type}: ${(err as Error).message}`, + ); + let errorCode = 'oauth_failed'; + if (err instanceof IdentityTenantMismatchError) { + errorCode = 'tenant_mismatch'; + } else if (err instanceof IdentityEmailMismatchError) { + errorCode = 'identity_mismatch'; + } else if (err instanceof IntegrationTenantInUseError) { + errorCode = 'tenant_in_use'; + } + return res.redirect(`${returnPath}?error=${errorCode}`, 302); + } + } + + @RequireSessionAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('disconnect') + async disconnect( + @Body() dto: OAuthDisconnectDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + await this.connectionService.disconnect( + dto.integrationId, + user.id, + workspace.id, + ); + return { success: true }; + } + + private setNonceCookie(res: FastifyReply, type: string, nonce: string) { + res.setCookie(nonceCookieName(type), nonce, { + httpOnly: true, + path: OAUTH_ROUTE_PREFIX, + secure: this.environmentService.isHttps(), + sameSite: 'lax', + expires: addMinutes(new Date(), 10), + }); + } +} diff --git a/apps/server/src/core/integration/oauth/oauth.service.spec.ts b/apps/server/src/core/integration/oauth/oauth.service.spec.ts new file mode 100644 index 00000000..e75e05aa --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.service.spec.ts @@ -0,0 +1,1327 @@ +import { + BadRequestException, + ForbiddenException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as crypto from 'crypto'; +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { IntegrationConnection } from '@docmost/db/types/entity.types'; +import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory'; +import { UserRole } from '../../../common/helpers/types/permission'; +import { OAuthService, OAuthStatePayload } from './oauth.service'; +import { + IdentityEmailMismatchError, + ProviderApiError, + TokenExpiredError, + TokenInvalidError, +} from '../registry/integration-provider.interface'; + +type RecordedRequest = { method: string; url: string; body: string; authorization?: string }; + +type TestServer = { + server: http.Server; + url: string; + requests: RecordedRequest[]; +}; + +type Reply = (req: RecordedRequest, res: http.ServerResponse) => void; + +function startServer(respond: Reply): Promise { + const requests: RecordedRequest[] = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + const recorded: RecordedRequest = { method: req.method ?? '', url: req.url ?? '', body }; + if (req.headers.authorization) recorded.authorization = req.headers.authorization; + requests.push(recorded); + respond(recorded, res); + }); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo; + resolve({ server, url: `http://127.0.0.1:${port}`, requests }); + }); + }); +} + +function stopServer({ server }: TestServer): Promise { + server.closeAllConnections(); + return new Promise((resolve) => server.close(() => resolve())); +} + +function tokenJson(res: http.ServerResponse, body: Record | null) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +const tokenError = (status: number, body: string): Reply => (_req, res) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(body); +}; + +// Never ends the body, so the request settles only if the client stops reading. +function streamUntilClosed(res: http.ServerResponse): void { + res.writeHead(200, { 'Content-Type': 'application/json' }); + const chunk = Buffer.from(`{"access_token":"${'a'.repeat(64 * 1024)}"}`); + const pump = () => { + while (!res.destroyed && res.write(chunk)) { /* Keep streaming until backpressure. */ } + if (!res.destroyed) res.once('drain', pump); + }; + pump(); +} + +// Emulates SET NX, the compare-and-delete release script and EXISTS over a Map. +function fakeRedis() { + const keys = new Map(); + return { + keys, + set: jest.fn(async (key: string, value: string) => { + if (keys.has(key)) return null; + keys.set(key, value); + return 'OK'; + }), + eval: jest.fn(async (_script: string, _numKeys: number, key: string, token: string) => { + if (keys.get(key) !== token) return 0; + keys.delete(key); + return 1; + }), + exists: jest.fn(async (key: string) => (keys.has(key) ? 1 : 0)), + }; +} + +function decodeState(authorizationUrl: string): OAuthStatePayload { + const state = new URL(authorizationUrl).searchParams.get('state') ?? ''; + const data = state.substring(0, state.lastIndexOf('.')); + return JSON.parse(Buffer.from(data, 'base64url').toString()); +} + +describe('OAuthService', () => { + const workspaceId = 'workspace-1'; + const userId = 'user-1'; + const integrations: Record = { + 'integration-acme': { id: 'integration-acme', type: 'acme', workspaceId, settings: {} }, + 'integration-chat': { id: 'integration-chat', type: 'chat', workspaceId, settings: { tenantId: 'T-1' } }, + 'integration-legacy': { id: 'integration-legacy', type: 'legacy', workspaceId, settings: {} }, + 'integration-hidden': { id: 'integration-hidden', type: 'hidden', workspaceId, settings: {} }, + 'integration-entra': { id: 'integration-entra', type: 'entra', workspaceId, settings: {} }, + 'integration-design': { id: 'integration-design', type: 'design', workspaceId, settings: {} }, + 'integration-tracker': { id: 'integration-tracker', type: 'tracker', workspaceId, settings: {} }, + 'integration-foreign': { id: 'integration-foreign', type: 'acme', workspaceId: 'workspace-2', settings: {} }, + }; + + let sink: TestServer; + let tokenEndpoint: TestServer; + let tokenEndpointReply: Reply; + let currentRole: UserRole; + let currentUserDisabledAt: Date | null; + let currentUserEmail: string; + let resolveIdentity: jest.Mock; + let chatOnConnected: jest.Mock; + let acmeOnConnected: jest.Mock; + let chatResolveAccount: jest.Mock; + let trackerResolveAccount: jest.Mock; + let connectionRepo: Record; + let integrationRepo: Record; + let db: { transaction: jest.Mock }; + let auditService: { log: jest.Mock }; + let redis: ReturnType; + let service: OAuthService; + const trx = { name: 'install-trx' }; + + const installState = (type: string) => ({ flow: 'install' as const, integrationId: null, type, userId, workspaceId }); + const connectState = (integrationId: string, type: string) => ({ flow: 'connect' as const, integrationId, type, userId, workspaceId }); + + beforeAll(async () => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + for (const type of ['ACME', 'CHAT', 'LEGACY', 'HIDDEN', 'ENTRA', 'DESIGN', 'TRACKER']) { + process.env[`INTEGRATION_${type}_CLIENT_ID`] = `${type.toLowerCase()}-client-id`; + process.env[`INTEGRATION_${type}_CLIENT_SECRET`] = `${type.toLowerCase()}-client-secret`; + } + sink = await startServer((_req, res) => tokenJson(res, { access_token: 'stolen' })); + tokenEndpoint = await startServer((req, res) => tokenEndpointReply(req, res)); + }); + + afterAll(async () => { + jest.restoreAllMocks(); + for (const type of ['ACME', 'CHAT', 'LEGACY', 'HIDDEN', 'ENTRA', 'DESIGN', 'TRACKER']) { + delete process.env[`INTEGRATION_${type}_CLIENT_ID`]; + delete process.env[`INTEGRATION_${type}_CLIENT_SECRET`]; + } + await Promise.all([stopServer(tokenEndpoint), stopServer(sink)]); + }); + + beforeEach(() => { + sink.requests.length = 0; + tokenEndpoint.requests.length = 0; + tokenEndpointReply = (_req, res) => { + res.writeHead(307, { Location: `${sink.url}/collect` }); + res.end(); + }; + currentRole = UserRole.ADMIN; + currentUserDisabledAt = null; + currentUserEmail = 'user@example.com'; + resolveIdentity = jest.fn(async ({ settings }) => ({ + account: { id: 'U-42' }, + metadata: { tenantId: settings.tenantId }, + })); + chatResolveAccount = jest.fn(async () => ({ id: 'U-INSTALLER', displayName: 'Ada' })); + trackerResolveAccount = jest.fn(async () => ({ + id: '583231', + displayName: 'Ada Lovelace', + username: 'ada', + })); + chatOnConnected = jest.fn(async () => undefined); + acmeOnConnected = jest.fn(async () => undefined); + + const providerOAuth = (scopes: string[]) => ({ + authUrl: `${tokenEndpoint.url}/oauth/authorize`, + tokenUrl: `${tokenEndpoint.url}/oauth/token`, + scopes, + }); + const chatOAuth = { + ...providerOAuth(['bot']), + authParams: { prompt: 'consent' }, + connectionScope: 'workspace' as const, + identity: { + authUrl: `${tokenEndpoint.url}/openid/authorize`, + tokenUrl: `${tokenEndpoint.url}/openid/token`, + scopes: ['openid', 'profile'], + }, + }; + const providers: Record = { + acme: { + definition: { + type: 'acme', + oauth: { + ...providerOAuth(['read']), + authParams: { audience: 'api.acme.test', client_id: 'forged' }, + }, + }, + onConnected: acmeOnConnected, + }, + chat: { + definition: { type: 'chat', oauth: chatOAuth }, + getOAuthConfig: (settings: Record) => ({ + ...chatOAuth, + identity: { ...chatOAuth.identity, authParams: { team: settings.tenantId } }, + }), + resolveIdentity, + resolveAccount: chatResolveAccount, + onConnected: chatOnConnected, + }, + legacy: { + definition: { + type: 'legacy', + oauth: { ...providerOAuth(['bot']), connectionScope: 'workspace' as const }, + }, + }, + hidden: { + definition: { type: 'hidden', hidden: true, oauth: providerOAuth(['read']) }, + }, + entra: { + definition: { + type: 'entra', + oauth: { + ...providerOAuth(['api://resource/items.read', 'offline_access']), + scopeOnRefresh: true, + }, + }, + }, + design: { + definition: { + type: 'design', + oauth: { + ...providerOAuth(['file_metadata:read']), + refreshUrl: `${tokenEndpoint.url}/oauth/refresh`, + clientAuth: 'basic' as const, + }, + }, + }, + tracker: { + definition: { type: 'tracker', oauth: providerOAuth(['read']) }, + resolveAccount: trackerResolveAccount, + }, + }; + + connectionRepo = { + upsert: jest.fn(async (values) => values), + upsertWorkspaceConnection: jest.fn(async (values) => values), + upsertUserLink: jest.fn(async (values) => values), + findUserLink: jest.fn(async () => undefined), + findById: jest.fn(async () => undefined), + updateIfTokensMatch: jest.fn(async () => true), + invalidate: jest.fn(async () => undefined), + }; + integrationRepo = { + findById: jest.fn(async (id: string) => integrations[id] ?? null), + findByWorkspaceAndType: jest.fn(async () => null), + insertOrRestore: jest.fn(async ({ type }: { type: string }) => ({ + id: `integration-${type}`, + type, + workspaceId, + settings: {}, + })), + }; + + auditService = { log: jest.fn() }; + redis = fakeRedis(); + db = { + transaction: jest.fn(() => ({ + execute: (callback: (t: typeof trx) => Promise) => callback(trx), + })), + }; + + service = new OAuthService( + db as any, + { + getAppUrl: () => 'http://localhost:3000', + getAppSecret: () => 'test-secret', + } as any, + { getWorkspaceUrl: () => 'http://localhost:3000' } as any, + { getProvider: (type: string) => providers[type] } as any, + integrationRepo as any, + connectionRepo as any, + { + findById: async () => ({ id: workspaceId, hostname: null, customDomain: null }), + } as any, + { + encrypt: (value: string) => value, + decrypt: (value: string) => value, + } as any, + { + findById: async () => ({ + id: userId, + role: currentRole, + deactivatedAt: currentUserDisabledAt, + email: currentUserEmail, + }), + } as any, + new WorkspaceAbilityFactory(), + auditService as any, + { getOrThrow: () => redis } as any, + ); + }); + + describe('install flow', () => { + it('stores the issued tokens and audits the installation', async () => { + tokenEndpointReply = (_req, res) => + tokenJson(res, { + access_token: 'issued-access-token', + refresh_token: 'issued-refresh-token', + expires_in: 3600, + }); + + const connection = await service.exchangeCodeForTokens('acme', 'auth-code', installState('acme')); + + expect(tokenEndpoint.requests).toEqual([ + { + method: 'POST', + url: '/oauth/token', + body: + 'grant_type=authorization_code&client_id=acme-client-id&client_secret=acme-client-secret&code=auth-code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fintegrations%2Foauth%2Facme%2Fcallback', + }, + ]); + expect(integrationRepo.insertOrRestore).toHaveBeenCalledWith( + { type: 'acme', workspaceId, installedById: userId }, + trx, + ); + expect(connection).toMatchObject({ + accessToken: 'issued-access-token', + refreshToken: 'issued-refresh-token', + }); + expect(auditService.log).toHaveBeenCalledWith({ + event: 'integration.installed', + resourceType: 'integration', + resourceId: 'integration-acme', + changes: { after: { provider: 'acme' } }, + }); + expect(acmeOnConnected).toHaveBeenCalledWith( + expect.objectContaining({ + integrationId: 'integration-acme', + userId, + accessToken: 'issued-access-token', + }), + ); + }); + + it('writes the shared connection for a workspace-scoped provider', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'bot-token' }); + + await service.exchangeCodeForTokens('chat', 'auth-code', installState('chat')); + + expect(connectionRepo.upsertWorkspaceConnection).toHaveBeenCalledWith( + expect.objectContaining({ integrationId: 'integration-chat', userId, accessToken: 'bot-token' }), + trx, + ); + expect(chatOnConnected).toHaveBeenCalledWith( + expect.objectContaining({ integrationId: 'integration-chat', userId, accessToken: 'bot-token' }), + ); + }); + + it.each(['lost permission', 'deactivated'])('refuses an installer who is %s', async (reason) => { + if (reason === 'lost permission') currentRole = UserRole.MEMBER; + else currentUserDisabledAt = new Date(); + + await expect( + service.exchangeCodeForTokens('chat', 'auth-code', installState('chat')), + ).rejects.toBeInstanceOf(ForbiddenException); + + expect(tokenEndpoint.requests).toEqual([]); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + expect(connectionRepo.upsertWorkspaceConnection).not.toHaveBeenCalled(); + }); + + it('stops at a redirecting token endpoint instead of re-POSTing the client secret', async () => { + const outcome = await service + .exchangeCodeForTokens('acme', 'auth-code', installState('acme')) + .catch((err) => err); + + expect(sink.requests).toEqual([]); + expect(tokenEndpoint.requests).toHaveLength(1); + expect(outcome).toBeInstanceOf(BadRequestException); + expect(auditService.log).not.toHaveBeenCalled(); + }); + + it('refuses an install state naming an integration from another workspace', async () => { + await expect( + service.exchangeCodeForTokens('acme', 'auth-code', { + flow: 'install', + integrationId: 'integration-foreign', + type: 'acme', + userId, + workspaceId, + }), + ).rejects.toBeInstanceOf(NotFoundException); + + expect(tokenEndpoint.requests).toEqual([]); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + }); + + it('refuses to complete an install for a hidden provider without writing', async () => { + const outcome = await service + .exchangeCodeForTokens('hidden', 'auth-code', installState('hidden')) + .catch((err) => err); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(tokenEndpoint.requests).toEqual([]); + expect(db.transaction).not.toHaveBeenCalled(); + }); + }); + + describe('connect flow', () => { + it('connects a member with a per-user token and audits without reinstalling', async () => { + currentRole = UserRole.MEMBER; + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'user-token' }); + + const connection = await service.exchangeCodeForTokens( + 'acme', + 'auth-code', + connectState('integration-acme', 'acme'), + ); + + expect(connectionRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ integrationId: 'integration-acme', userId, accessToken: 'user-token' }), + undefined, + ); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ accessToken: 'user-token' }); + expect(acmeOnConnected).not.toHaveBeenCalled(); + expect(auditService.log).toHaveBeenCalledWith({ + event: 'integration.connected', + resourceType: 'integration', + resourceId: 'integration-acme', + changes: { after: { provider: 'acme' } }, + }); + }); + + it('sends the client credentials as HTTP Basic, not in the body, for a provider that asks for it', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'user-token' }); + + await service.exchangeCodeForTokens('design', 'auth-code', connectState('integration-design', 'design')); + + expect(tokenEndpoint.requests).toEqual([ + { + method: 'POST', + url: '/oauth/token', + body: 'grant_type=authorization_code&code=auth-code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fintegrations%2Foauth%2Fdesign%2Fcallback', + authorization: `Basic ${Buffer.from('design-client-id:design-client-secret').toString('base64')}`, + }, + ]); + }); + + it('links a member with a matching email without replacing the shared connection', async () => { + currentRole = UserRole.MEMBER; + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'identity-token' }); + + resolveIdentity.mockResolvedValue({ + account: { id: 'U-42', displayName: 'Ada' }, + email: 'User@Example.com ', + metadata: { tenantId: 'T-1' }, + }); + const link = await service.exchangeCodeForTokens( + 'chat', + 'auth-code', + connectState('integration-chat', 'chat'), + ); + + expect(tokenEndpoint.requests.map((r) => r.url)).toEqual(['/openid/token']); + expect(resolveIdentity).toHaveBeenCalledWith( + expect.objectContaining({ + integrationId: 'integration-chat', + workspaceId, + userId, + tokenResponse: { access_token: 'identity-token' }, + settings: { tenantId: 'T-1' }, + }), + ); + expect(connectionRepo.upsertUserLink).toHaveBeenCalledWith({ + integrationId: 'integration-chat', + workspaceId, + userId, + providerUserId: 'U-42', + metadata: { tenantId: 'T-1', account: { id: 'U-42', displayName: 'Ada' } }, + }); + expect(connectionRepo.upsertWorkspaceConnection).not.toHaveBeenCalled(); + expect(connectionRepo.upsert).not.toHaveBeenCalled(); + expect(chatOnConnected).not.toHaveBeenCalled(); + expect(link).toMatchObject({ providerUserId: 'U-42' }); + }); + + it('writes nothing when the provider rejects the identity', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'identity-token' }); + resolveIdentity.mockRejectedValue(new Error('wrong tenant')); + + await expect( + service.exchangeCodeForTokens('chat', 'auth-code', connectState('integration-chat', 'chat')), + ).rejects.toThrow('wrong tenant'); + + expect(connectionRepo.upsertUserLink).not.toHaveBeenCalled(); + expect(connectionRepo.upsertWorkspaceConnection).not.toHaveBeenCalled(); + }); + + it('refuses a workspace-scoped provider without an identity flow', async () => { + await expect( + service.exchangeCodeForTokens('legacy', 'auth-code', connectState('integration-legacy', 'legacy')), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.upsertWorkspaceConnection).not.toHaveBeenCalled(); + }); + + it('refuses a state signed for a different provider', async () => { + await expect( + service.exchangeCodeForTokens('chat', 'auth-code', connectState('integration-acme', 'acme')), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.upsert).not.toHaveBeenCalled(); + expect(connectionRepo.upsertUserLink).not.toHaveBeenCalled(); + }); + + it('refuses when the provider account email differs from the Docmost user', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'identity-token' }); + resolveIdentity.mockResolvedValue({ account: { id: 'U-42' }, email: 'someone-else@example.com' }); + + await expect( + service.exchangeCodeForTokens('chat', 'auth-code', connectState('integration-chat', 'chat')), + ).rejects.toBeInstanceOf(IdentityEmailMismatchError); + + expect(connectionRepo.upsertUserLink).not.toHaveBeenCalled(); + expect(connectionRepo.findUserLink).not.toHaveBeenCalled(); + }); + + it('still connects a member to an existing installation of a hidden provider', async () => { + currentRole = UserRole.MEMBER; + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'user-token' }); + const { authorizationUrl } = await service.getAuthorizationUrl('integration-hidden', workspaceId, userId); + expect(decodeState(authorizationUrl)).toMatchObject({ flow: 'connect', integrationId: 'integration-hidden' }); + + await service.exchangeCodeForTokens('hidden', 'auth-code', connectState('integration-hidden', 'hidden')); + + expect(connectionRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ integrationId: 'integration-hidden', userId, accessToken: 'user-token' }), + undefined, + ); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + }); + }); + + describe('connected account', () => { + const account = { id: '583231', displayName: 'Ada Lovelace', username: 'ada' }; + + beforeEach(() => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'user-token' }); + }); + + it('stores the account a member connected with and audits it', async () => { + await service.exchangeCodeForTokens('tracker', 'auth-code', connectState('integration-tracker', 'tracker')); + + expect(trackerResolveAccount).toHaveBeenCalledWith({ + accessToken: 'user-token', + tokenResponse: { access_token: 'user-token' }, + }); + expect(connectionRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ userId, providerUserId: '583231', metadata: { account } }), + undefined, + ); + expect(auditService.log).toHaveBeenCalledWith({ + event: 'integration.connected', + resourceType: 'integration', + resourceId: 'integration-tracker', + changes: { after: { provider: 'tracker', account } }, + }); + }); + + it('still connects when the profile read fails, without an account', async () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + trackerResolveAccount.mockRejectedValue(new Error('tracker API error: 500')); + + await service.exchangeCodeForTokens('tracker', 'auth-code', connectState('integration-tracker', 'tracker')); + + expect(connectionRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ providerUserId: null, metadata: null }), + undefined, + ); + expect(connectionRepo.findUserLink).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith('Could not read the connected tracker account: tracker API error: 500'); + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ changes: { after: { provider: 'tracker' } } }), + ); + }); + + it('refuses an account already connected to another Docmost user', async () => { + connectionRepo.findUserLink.mockResolvedValue({ userId: 'user-2' }); + + await expect( + service.exchangeCodeForTokens('tracker', 'auth-code', connectState('integration-tracker', 'tracker')), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(connectionRepo.findUserLink).toHaveBeenCalledWith('integration-tracker', '583231', undefined); + expect(connectionRepo.upsert).not.toHaveBeenCalled(); + expect(auditService.log).not.toHaveBeenCalled(); + }); + + it('lets a member reconnect the account they already hold', async () => { + connectionRepo.findUserLink.mockResolvedValue({ userId }); + + await service.exchangeCodeForTokens('tracker', 'auth-code', connectState('integration-tracker', 'tracker')); + + expect(connectionRepo.upsert).toHaveBeenCalledTimes(1); + }); + + it("stores the installing admin's account and audits the install with it", async () => { + await service.exchangeCodeForTokens('tracker', 'auth-code', installState('tracker')); + + expect(connectionRepo.findUserLink).toHaveBeenCalledWith('integration-tracker', '583231', trx); + expect(connectionRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ providerUserId: '583231', metadata: { account } }), + trx, + ); + expect(auditService.log).toHaveBeenCalledWith({ + event: 'integration.installed', + resourceType: 'integration', + resourceId: 'integration-tracker', + changes: { after: { provider: 'tracker', account } }, + }); + }); + + it('reads the installer account before the transaction and hands it to onConnected, not the shared row', async () => { + const order: string[] = []; + chatResolveAccount.mockImplementation(async () => { + order.push('resolveAccount'); + return { id: 'U-INSTALLER', displayName: 'Ada' }; + }); + db.transaction.mockImplementation(() => ({ + execute: (callback: (t: typeof trx) => Promise) => { + order.push('transaction'); + return callback(trx); + }, + })); + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'bot-token' }); + + await service.exchangeCodeForTokens('chat', 'auth-code', installState('chat')); + + expect(order).toEqual(['resolveAccount', 'transaction']); + expect(connectionRepo.upsertWorkspaceConnection.mock.calls[0][0]).not.toHaveProperty('providerUserId'); + expect(chatOnConnected).toHaveBeenCalledWith( + expect.objectContaining({ account: { id: 'U-INSTALLER', displayName: 'Ada' } }), + ); + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ + changes: { after: { provider: 'chat', account: { id: 'U-INSTALLER', displayName: 'Ada' } } }, + }), + ); + }); + + it('audits the account of a linked identity without reading it again', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'identity-token' }); + resolveIdentity.mockResolvedValue({ account: { id: 'U-42', displayName: 'Ada' }, metadata: {} }); + + await service.exchangeCodeForTokens('chat', 'auth-code', connectState('integration-chat', 'chat')); + + expect(chatResolveAccount).not.toHaveBeenCalled(); + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ + changes: { after: { provider: 'chat', account: { id: 'U-42', displayName: 'Ada' } } }, + }), + ); + }); + }); + + describe('tokens from the code exchange', () => { + const malformed: [string, Record][] = [ + ['an access token with a line break', { access_token: 'abc\ndef-SECRET' }], + ['an access token with a space', { access_token: 'abc def-SECRET' }], + ['an access token with a non-ASCII character', { access_token: 'abcédef-SECRET' }], + ['no access token', { error: 'invalid_code' }], + ['an access token that is not a string', { access_token: { value: 'def-SECRET' } }], + ['a refresh token with an escape character', { access_token: 'access-token', refresh_token: 'abc\x1bdef-SECRET' }], + ]; + const errorLog = () => jest.mocked(Logger.prototype.error).mock.calls; + + beforeEach(() => { + jest.mocked(Logger.prototype.error).mockClear(); + }); + + it.each(malformed)('fails an install on %s without writing or logging the token', async (_label, body) => { + tokenEndpointReply = (_req, res) => tokenJson(res, body); + + const outcome = await service + .exchangeCodeForTokens('acme', 'auth-code', installState('acme')) + .catch((err) => err); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('OAuth token exchange failed'); + expect(db.transaction).not.toHaveBeenCalled(); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + expect(acmeOnConnected).not.toHaveBeenCalled(); + expect(auditService.log).not.toHaveBeenCalled(); + expect(JSON.stringify(errorLog())).not.toContain('SECRET'); + }); + + it('fails an identity link on a malformed access token before the provider resolves the identity', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'abc\ndef-SECRET' }); + + await expect( + service.exchangeCodeForTokens('chat', 'auth-code', connectState('integration-chat', 'chat')), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(resolveIdentity).not.toHaveBeenCalled(); + expect(connectionRepo.upsertUserLink).not.toHaveBeenCalled(); + }); + }); + + describe('token endpoint responses during the code exchange', () => { + const errorLog = () => jest.mocked(Logger.prototype.error).mock.calls; + const exchange = (type = 'acme') => + service.exchangeCodeForTokens(type, 'auth-code', installState(type)).catch((err) => err); + const expectNothingWritten = () => { + expect(db.transaction).not.toHaveBeenCalled(); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + expect(connectionRepo.upsert).not.toHaveBeenCalled(); + }; + + beforeEach(() => { + jest.mocked(Logger.prototype.error).mockClear(); + }); + + it('logs the status and error code of a refused exchange and nothing else from the body', async () => { + tokenEndpointReply = tokenError( + 401, + JSON.stringify({ + error: 'invalid_client', + error_description: 'PRIVATE tenant and secret details', + }), + ); + + const outcome = await exchange('entra'); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('OAuth token exchange failed'); + expect(errorLog()).toEqual([['Token exchange failed for entra: 401 invalid_client']]); + expectNothingWritten(); + }); + + it('rejects truncated JSON during exchange without writing or logging the body', async () => { + tokenEndpointReply = (_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"access_token":"leaked-0f1e2d3c'); + }; + + const outcome = await exchange(); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('OAuth token exchange failed'); + expect(errorLog()).toEqual([['Token exchange for acme returned a response that is not JSON']]); + expectNothingWritten(); + }); + + it('stops reading a response that passes the size cap and writes nothing', async () => { + tokenEndpointReply = (_req, res) => streamUntilClosed(res); + + const outcome = await exchange(); + + expect(outcome).toBeInstanceOf(ProviderApiError); + expect(outcome.message).toBe('acme token endpoint API error: 502 response too large'); + expectNothingWritten(); + expect(acmeOnConnected).not.toHaveBeenCalled(); + }, 5000); + }); + + describe('authorization URLs and signed state', () => { + it('creates an install URL with provider params and a signed browser nonce', async () => { + const { authorizationUrl, nonce } = await service.getInstallAuthorizationUrl('chat', workspaceId, userId); + const url = new URL(authorizationUrl); + + expect(url.origin + url.pathname).toBe(tokenEndpoint.url + '/oauth/authorize'); + expect(url.searchParams.get('prompt')).toBe('consent'); + expect(service.verifySignedState(url.searchParams.get('state'))).toMatchObject({ + flow: 'install', integrationId: null, type: 'chat', userId, workspaceId, nonce, + }); + expect(nonce).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it('creates a per-user URL without letting provider params override core params', async () => { + const first = await service.getAuthorizationUrl('integration-acme', workspaceId, userId); + const second = await service.getAuthorizationUrl('integration-acme', workspaceId, userId); + const url = new URL(first.authorizationUrl); + const state = url.searchParams.get('state'); + + expect(url.origin + url.pathname).toBe(tokenEndpoint.url + '/oauth/authorize'); + expect(url.searchParams.get('scope')).toBe('read'); + expect(url.searchParams.get('audience')).toBe('api.acme.test'); + expect(url.searchParams.getAll('client_id')).toEqual(['acme-client-id']); + expect(service.verifySignedState(state)).toMatchObject({ + flow: 'connect', integrationId: 'integration-acme', type: 'acme', userId, workspaceId, nonce: first.nonce, + }); + expect(second.nonce).not.toBe(first.nonce); + + const now = jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 11 * 60_000); + try { + expect(service.verifySignedState(state)).toBeNull(); + } finally { + now.mockRestore(); + } + }); + + it('uses the workspace identity endpoint, scopes and settings instead of install params', async () => { + const { authorizationUrl } = await service.getAuthorizationUrl('integration-chat', workspaceId, userId); + const url = new URL(authorizationUrl); + + expect(url.origin + url.pathname).toBe(tokenEndpoint.url + '/openid/authorize'); + expect(url.searchParams.get('team')).toBe('T-1'); + expect(url.searchParams.get('scope')).toBe('openid profile'); + expect(url.searchParams.get('client_id')).toBe('chat-client-id'); + expect(url.searchParams.has('prompt')).toBe(false); + expect(decodeState(authorizationUrl)).toMatchObject({ + flow: 'connect', integrationId: 'integration-chat', returnPath: '/settings/account/connections', + }); + }); + + it('refuses connect URLs for a workspace provider without an identity flow', async () => { + await expect(service.getAuthorizationUrl('integration-legacy', workspaceId, userId)) + .rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses new installs for a hidden provider', async () => { + await expect(service.getInstallAuthorizationUrl('hidden', workspaceId, userId)) + .rejects.toBeInstanceOf(BadRequestException); + expect(integrationRepo.findByWorkspaceAndType).not.toHaveBeenCalled(); + }); + + it.each(['tampered', 'short signature', 'no separator', 'raw secret'])('rejects a state with %s', async (kind) => { + const { authorizationUrl } = await service.getAuthorizationUrl('integration-acme', workspaceId, userId); + const state = new URL(authorizationUrl).searchParams.get('state'); + const [data, signature] = state.split('.'); + const invalid = { + tampered: data + '.' + (signature[0] === 'A' ? 'B' : 'A') + signature.slice(1), + 'short signature': state.slice(0, -1), + 'no separator': data + signature, + 'raw secret': data + '.' + crypto.createHmac('sha256', 'test-secret').update(data).digest('base64url'), + }[kind]; + + expect(service.verifySignedState(invalid)).toBeNull(); + }); + }); + + describe('access token for a request', () => { + const storedConnection = (overrides: Record = {}) => + ({ + id: 'connection-1', + integrationId: 'integration-acme', + userId, + workspaceId, + accessToken: 'stored-access-token', + refreshToken: 'refresh-token', + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + invalidatedAt: null, + ...overrides, + }) as unknown as IntegrationConnection; + + it.each<[string, Record]>([ + ['that expires in two minutes', { tokenExpiresAt: new Date(Date.now() + 2 * 60 * 1000) }], + ['that expires in a minute and has no refresh token', { tokenExpiresAt: new Date(Date.now() + 60 * 1000), refreshToken: null }], + ['without an expiry', { tokenExpiresAt: null }], + ])('returns the stored token of a connection %s without refreshing', async (_label, overrides) => { + await expect(service.getValidAccessToken(storedConnection(overrides))).resolves.toBe('stored-access-token'); + + expect(tokenEndpoint.requests).toEqual([]); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it('reports an expired token that has no refresh token as invalid', async () => { + const outcome = await service + .getValidAccessToken(storedConnection({ tokenExpiresAt: new Date(Date.now() - 1000), refreshToken: null })) + .catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenInvalidError); + expect(tokenEndpoint.requests).toEqual([]); + }); + + it('reports a retired connection as invalid', async () => { + const outcome = await service + .getValidAccessToken(storedConnection({ invalidatedAt: new Date(), refreshToken: null, tokenExpiresAt: null })) + .catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenInvalidError); + expect(tokenEndpoint.requests).toEqual([]); + }); + }); + + describe('token refresh', () => { + const refreshBody = (refreshToken: string) => + `grant_type=refresh_token&client_id=acme-client-id&client_secret=acme-client-secret&refresh_token=${refreshToken}`; + const expiringConnection = (overrides: Record = {}) => + ({ + id: 'connection-1', + integrationId: 'integration-acme', + userId, + workspaceId, + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + tokenExpiresAt: new Date(Date.now() - 1000), + invalidatedAt: null, + ...overrides, + }) as unknown as IntegrationConnection; + const rejectGrant = (status: number): Reply => (_req, res) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid_grant' })); + }; + // Backs the repo with one row that honours the expected tokens, so a race can be replayed. + const storeRow = (row: Record) => { + const matches = (expected: Record = {}) => + Object.entries(expected).every(([column, value]) => row[column] === value); + connectionRepo.findById.mockImplementation(async () => ({ ...row })); + connectionRepo.updateIfTokensMatch.mockImplementation(async (_id, expected, data) => { + if (!matches(expected)) return false; + Object.assign(row, data); + return true; + }); + connectionRepo.invalidate.mockImplementation(async (_id, expected) => { + if (matches(expected)) Object.assign(row, { invalidatedAt: new Date(), refreshToken: null, tokenExpiresAt: null }); + }); + return row; + }; + const reconnectDuring = (reply: Reply, row: Record): Reply => (req, res) => { + Object.assign(row, { + accessToken: 'reconnected-access-token', + refreshToken: 'reconnected-refresh-token', + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); + reply(req, res); + }; + + beforeEach(() => { + connectionRepo.findById.mockResolvedValue(expiringConnection()); + }); + + it('stops at a redirecting token endpoint instead of re-POSTing the client secret', async () => { + const outcome = await service.refreshAccessToken(expiringConnection()).catch((err) => err); + + expect(sink.requests).toEqual([]); + expect(tokenEndpoint.requests).toHaveLength(1); + expect(outcome).toBeInstanceOf(BadRequestException); + }); + + it('refreshes using the current row and conditionally stores and returns the new tokens', async () => { + const row = storeRow({ ...expiringConnection({ refreshToken: 'current-refresh-token' }) }); + tokenEndpointReply = (_req, res) => + tokenJson(res, { access_token: 'new-access-token', refresh_token: 'new-refresh-token', expires_in: 7200 }); + + await expect(service.refreshAccessToken(expiringConnection({ refreshToken: 'stale-refresh-token' }))) + .resolves.toBe('new-access-token'); + + expect(tokenEndpoint.requests).toEqual([ + { method: 'POST', url: '/oauth/token', body: refreshBody('current-refresh-token') }, + ]); + expect(connectionRepo.updateIfTokensMatch).toHaveBeenCalledWith( + 'connection-1', { refreshToken: 'current-refresh-token' }, expect.objectContaining({ + accessToken: 'new-access-token', refreshToken: 'new-refresh-token', invalidatedAt: null, + }), + ); + expect(row).toMatchObject({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); + expect(new Date(row.tokenExpiresAt as Date).getTime()).toBeGreaterThan(Date.now() + 7100 * 1000); + }); + + it('drops the refreshed tokens of a connection reconnected during the refresh', async () => { + const row = storeRow({ ...expiringConnection() }); + tokenEndpointReply = reconnectDuring( + (_req, res) => tokenJson(res, { access_token: 'new-access-token', refresh_token: 'new-refresh-token', expires_in: 7200 }), + row, + ); + + await expect(service.refreshAccessToken(expiringConnection())).resolves.toBe('reconnected-access-token'); + + expect(tokenEndpoint.requests).toHaveLength(1); + expect(row).toMatchObject({ accessToken: 'reconnected-access-token', refreshToken: 'reconnected-refresh-token' }); + }); + + it('repeats the scopes on refresh for a provider that asks for them', async () => { + connectionRepo.findById.mockResolvedValue(expiringConnection({ integrationId: 'integration-entra' })); + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'new-access-token', expires_in: 3600 }); + + await service.refreshAccessToken(expiringConnection({ integrationId: 'integration-entra' })); + + expect(tokenEndpoint.requests.map((r) => r.body)).toEqual([ + 'grant_type=refresh_token&client_id=entra-client-id&client_secret=entra-client-secret&refresh_token=refresh-token&scope=api%3A%2F%2Fresource%2Fitems.read+offline_access', + ]); + }); + + it("refreshes at the provider's own refresh endpoint with HTTP Basic credentials and keeps a refresh token it does not replace", async () => { + connectionRepo.findById.mockResolvedValue(expiringConnection({ integrationId: 'integration-design' })); + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'new-access-token', expires_in: 7776000 }); + + await service.refreshAccessToken(expiringConnection({ integrationId: 'integration-design' })); + + expect(tokenEndpoint.requests).toEqual([ + { + method: 'POST', + url: '/oauth/refresh', + body: 'grant_type=refresh_token&refresh_token=refresh-token', + authorization: `Basic ${Buffer.from('design-client-id:design-client-secret').toString('base64')}`, + }, + ]); + expect(connectionRepo.updateIfTokensMatch).toHaveBeenCalledWith( + 'connection-1', + { refreshToken: 'refresh-token' }, + expect.objectContaining({ accessToken: 'new-access-token', refreshToken: 'refresh-token' }), + ); + }); + + it.each<[string, Record]>([ + ['refreshed by a non-rotating provider', { accessToken: 'fresh-access-token' }], + ['reconnected without a refresh token', { accessToken: 'fresh-access-token', refreshToken: null }], + ])('skips a connection %s since the caller read it and returns its current token', async (_label, changes) => { + connectionRepo.findById.mockResolvedValue( + expiringConnection({ ...changes, tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000) }), + ); + tokenEndpointReply = rejectGrant(400); + + await expect(service.refreshAccessToken(expiringConnection())).resolves.toBe('fresh-access-token'); + + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + }); + + it.each<[string, Record | undefined]>([ + ['refreshed to a token that has expired too', { accessToken: 'fresh-access-token', refreshToken: 'rotated-refresh-token' }], + ['retired', { invalidatedAt: new Date(), refreshToken: null, tokenExpiresAt: null }], + ['deleted', undefined], + ])('skips a connection %s since the caller read it and reports it expired', async (_label, changes) => { + connectionRepo.findById.mockResolvedValue(changes && expiringConnection(changes)); + tokenEndpointReply = rejectGrant(400); + + const outcome = await service.refreshAccessToken(expiringConnection()).catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenExpiredError); + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + }); + + it.each(['acme', 'entra'])('preserves a %s connection reconnected while the old token is rejected', async (type) => { + const connection = expiringConnection({ integrationId: 'integration-' + type }); + const row = storeRow({ ...connection }); + tokenEndpointReply = reconnectDuring(rejectGrant(400), row); + + await expect(service.refreshAccessToken(connection)).rejects.toBeInstanceOf(TokenInvalidError); + + expect(row).toMatchObject({ + accessToken: 'reconnected-access-token', refreshToken: 'reconnected-refresh-token', invalidatedAt: null, + }); + }); + + it.each<[string, Reply]>([ + ['a 5xx', (_req, res) => { + res.writeHead(503); + res.end(); + }], + ['a dropped connection', (_req, res) => res.socket?.destroy()], + ])('maps %s from the token endpoint to a failed refresh without invalidating', async (_label, reply) => { + tokenEndpointReply = reply; + + const outcome = await service.refreshAccessToken(expiringConnection()).catch((err) => err); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('Failed to refresh token'); + expect(tokenEndpoint.requests).toHaveLength(1); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + }); + + it.each<[string, Record | null]>([ + ['an access token with a line break', { access_token: 'abc\ndef-SECRET', expires_in: 3600 }], + ['an access token that is not a string', { access_token: 12345, expires_in: 3600 }], + ['no access token', { expires_in: 3600 }], + ['a JSON null', null], + ['a refresh token with a NUL', { access_token: 'new-access-token', refresh_token: 'abc\x00def-SECRET' }], + ])('keeps the connection as it was when the refresh returns %s, without logging the token', async (_label, body) => { + const logged = jest.mocked(Logger.prototype.error); + logged.mockClear(); + tokenEndpointReply = (_req, res) => tokenJson(res, body); + + const outcome = await service.refreshAccessToken(expiringConnection()).catch((err) => err); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('Failed to refresh token'); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + expect(JSON.stringify(logged.mock.calls)).not.toContain('SECRET'); + }); + + it('rejects truncated JSON during refresh without changing the connection or logging the body', async () => { + const logged = jest.mocked(Logger.prototype.error); + logged.mockClear(); + tokenEndpointReply = (_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"access_token":"leaked-0f1e2d3c'); + }; + + const outcome = await service.refreshAccessToken(expiringConnection()).catch((err) => err); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('Failed to refresh token'); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + expect(logged.mock.calls).toEqual([ + ['Token refresh for acme returned a response that is not JSON'], + ['Token refresh error: Token refresh failed'], + ]); + }); + + it('stops an oversized refresh response without retiring or updating the connection', async () => { + tokenEndpointReply = (_req, res) => streamUntilClosed(res); + + await expect(service.refreshAccessToken(expiringConnection())).rejects.toBeInstanceOf(BadRequestException); + + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + }, 5000); + + describe('when the token endpoint rejects the refresh', () => { + beforeEach(() => { + jest.mocked(Logger.prototype.error).mockClear(); + }); + + it.each([ + [401, 'invalid_client'], + [400, 'invalid_client'], + [400, 'unauthorized_client'], + [400, 'invalid_scope'], + [200, 'incorrect_client_credentials'], + ])('keeps the connection on %s %s', async (status, error) => { + storeRow({ ...expiringConnection() }); + tokenEndpointReply = tokenError(status, JSON.stringify({ error, error_description: 'PRIVATE' })); + + await expect(service.getValidAccessToken(expiringConnection())).rejects.toBeInstanceOf(TokenExpiredError); + + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + const logs = JSON.stringify(jest.mocked(Logger.prototype.error).mock.calls); + expect(logs).toContain(status + ' ' + error); + expect(logs).not.toContain('PRIVATE'); + expect(redis.keys.size).toBe(0); + }); + + it.each(['acme', 'entra'])('retires a %s connection on invalid_grant', async (type) => { + const connection = expiringConnection({ integrationId: 'integration-' + type }); + const row = storeRow({ ...connection }); + tokenEndpointReply = tokenError(400, JSON.stringify({ error: 'invalid_grant' })); + + await expect(service.getValidAccessToken(connection)).rejects.toBeInstanceOf(TokenInvalidError); + + expect(connectionRepo.invalidate).toHaveBeenCalledWith('connection-1', { refreshToken: 'refresh-token' }); + expect(row).toMatchObject({ invalidatedAt: expect.any(Date), refreshToken: null }); + expect(jest.mocked(Logger.prototype.error).mock.calls).toEqual([[`Token refresh failed for ${type}: 400 invalid_grant`]]); + }); + + it('retires the connection on interaction_required', async () => { + const row = storeRow({ ...expiringConnection() }); + tokenEndpointReply = tokenError(400, JSON.stringify({ error: 'interaction_required' })); + + await expect(service.getValidAccessToken(expiringConnection())).rejects.toBeInstanceOf(TokenInvalidError); + + expect(connectionRepo.invalidate).toHaveBeenCalledWith('connection-1', { refreshToken: 'refresh-token' }); + expect(row).toMatchObject({ invalidatedAt: expect.any(Date), refreshToken: null }); + expect(redis.keys.size).toBe(0); + }); + + it("retires the connection on GitHub's bad_refresh_token, which comes with a 200", async () => { + const row = storeRow({ ...expiringConnection() }); + tokenEndpointReply = tokenError(200, JSON.stringify({ + error: 'bad_refresh_token', + error_description: 'The refresh token passed is incorrect or expired.', + error_uri: 'https://docs.github.com/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens', + })); + + await expect(service.getValidAccessToken(expiringConnection())).rejects.toBeInstanceOf(TokenInvalidError); + + expect(connectionRepo.invalidate).toHaveBeenCalledWith('connection-1', { refreshToken: 'refresh-token' }); + expect(row).toMatchObject({ invalidatedAt: expect.any(Date), refreshToken: null }); + expect(jest.mocked(Logger.prototype.error).mock.calls).toEqual([['Token refresh failed for acme: 200 bad_refresh_token']]); + }); + + it.each([ + 'invalid_grant PRIVATE', + JSON.stringify({ error: 'invalid_grant\nPRIVATE' }), + JSON.stringify({ error: 'a'.repeat(41) }), + JSON.stringify({ error: { code: 'invalid_grant', detail: 'PRIVATE' } }), + ])('does not retire on a malformed error body: %s', async (body) => { + connectionRepo.findById.mockResolvedValue(expiringConnection()); + tokenEndpointReply = tokenError(400, body); + + await expect(service.refreshAccessToken(expiringConnection())).rejects.toBeInstanceOf(BadRequestException); + + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(jest.mocked(Logger.prototype.error).mock.calls).toEqual([ + ['Token refresh failed for acme: 400'], ['Token refresh error: Token refresh failed'], + ]); + }); + }); + + describe('on the request path', () => { + const lockKey = 'integration:refresh:connection-1'; + const issueNewTokens: Reply = (_req, res) => + tokenJson(res, { access_token: 'new-access-token', refresh_token: 'new-refresh-token', expires_in: 7200 }); + const inAnHour = () => new Date(Date.now() + 60 * 60 * 1000); + + it.each<[string, Date]>([ + ['has expired', new Date(Date.now() - 1000)], + ['expires within a minute', new Date(Date.now() + 30 * 1000)], + ])('refreshes a token that %s and returns the new one', async (_label, tokenExpiresAt) => { + const row = storeRow({ ...expiringConnection({ tokenExpiresAt }) }); + tokenEndpointReply = issueNewTokens; + + await expect(service.getValidAccessToken(expiringConnection({ tokenExpiresAt }))).resolves.toBe( + 'new-access-token', + ); + + expect(tokenEndpoint.requests.map((r) => r.body)).toEqual([refreshBody('refresh-token')]); + expect(row).toMatchObject({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); + expect(redis.set).toHaveBeenCalledWith(lockKey, expect.any(String), 'PX', 20_000, 'NX'); + expect(redis.keys.has(lockKey)).toBe(false); + }); + + it('makes one token request for ten concurrent callers', async () => { + storeRow({ ...expiringConnection() }); + tokenEndpointReply = issueNewTokens; + + const tokens = await Promise.all( + Array.from({ length: 10 }, () => service.getValidAccessToken(expiringConnection())), + ); + + expect(tokens).toEqual(Array(10).fill('new-access-token')); + expect(tokenEndpoint.requests).toHaveLength(1); + expect(redis.set).toHaveBeenCalledTimes(1); + }); + + it('lets a later request refresh after a shared refresh failed', async () => { + storeRow({ ...expiringConnection() }); + tokenEndpointReply = tokenError(503, ''); + await expect(service.getValidAccessToken(expiringConnection())).rejects.toBeInstanceOf(TokenExpiredError); + + tokenEndpointReply = issueNewTokens; + await expect(service.getValidAccessToken(expiringConnection())).resolves.toBe('new-access-token'); + + expect(tokenEndpoint.requests).toHaveLength(2); + }); + + it('returns the token another process saved after the caller read the row', async () => { + storeRow({ + ...expiringConnection({ + accessToken: 'saved-access-token', + refreshToken: 'rotated-refresh-token', + tokenExpiresAt: inAnHour(), + }), + }); + + await expect(service.getValidAccessToken(expiringConnection())).resolves.toBe('saved-access-token'); + + expect(tokenEndpoint.requests).toEqual([]); + expect(redis.keys.has(lockKey)).toBe(false); + }); + + it('waits for a refresh another process holds and returns the token it saved', async () => { + const row = storeRow({ ...expiringConnection() }); + redis.keys.set(lockKey, 'other-process'); + redis.exists.mockImplementationOnce(async () => { + Object.assign(row, { + accessToken: 'other-access-token', + refreshToken: 'other-refresh-token', + tokenExpiresAt: inAnHour(), + }); + redis.keys.delete(lockKey); + return 1; + }); + + await expect(service.getValidAccessToken(expiringConnection())).resolves.toBe('other-access-token'); + + expect(redis.exists).toHaveBeenCalledTimes(2); + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + }); + + it('gives up after five seconds while another process still holds the lock, without retiring the connection', async () => { + jest.useFakeTimers(); + try { + const row = storeRow({ ...expiringConnection() }); + redis.keys.set(lockKey, 'other-process'); + + const outcome = service.getValidAccessToken(expiringConnection()).catch((err) => err); + await jest.advanceTimersByTimeAsync(4_800); + expect(connectionRepo.findById).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(400); + + expect(await outcome).toBeInstanceOf(TokenExpiredError); + expect(connectionRepo.findById).toHaveBeenCalledTimes(1); + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(row).toMatchObject({ refreshToken: 'refresh-token', invalidatedAt: null }); + expect(redis.keys.get(lockKey)).toBe('other-process'); + } finally { + jest.useRealTimers(); + } + }); + + it('reports the token expired without refreshing when Redis fails', async () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + storeRow({ ...expiringConnection() }); + redis.set.mockRejectedValue(new Error('connection lost')); + redis.exists.mockRejectedValue(new Error('connection lost')); + tokenEndpointReply = issueNewTokens; + + const outcome = await service.getValidAccessToken(expiringConnection()).catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenExpiredError); + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/server/src/core/integration/oauth/oauth.service.ts b/apps/server/src/core/integration/oauth/oauth.service.ts new file mode 100644 index 00000000..c6f6d4ae --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.service.ts @@ -0,0 +1,972 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { EnvironmentService } from '../../../integrations/environment/environment.service'; +import { DomainService } from '../../../integrations/environment/domain.service'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import { IntegrationRepo } from '../repos/integration.repo'; +import { IntegrationConnectionRepo } from '../repos/integration-connection.repo'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { + EncryptionPurpose, + EncryptionService, +} from '../../../integrations/encryption/encryption.service'; +import { IntegrationConnection } from '@docmost/db/types/entity.types'; +import { + IdentityEmailMismatchError, + IntegrationProvider, + OAuthConfig, + ProviderAccount, + TokenExpiredError, + TokenInvalidError, +} from '../registry/integration-provider.interface'; +import { proxyFetch } from '../../../common/proxy-fetch'; +import { readCappedResponse } from '../utils/provider-fetch'; +import { isUserDisabled } from '../../../common/helpers/utils'; +import { credentialEnvKey } from './credential-env'; +import { supportsOAuthConnect } from './oauth-connect'; +import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory'; +import { + WorkspaceCaslAction, + WorkspaceCaslSubject, +} from '../../casl/interfaces/workspace-ability.type'; +import * as crypto from 'crypto'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; +import { + AuditEvent, + AuditResource, +} from '../../../common/events/audit-events'; +import { RedisService } from '@nestjs-labs/nestjs-ioredis'; +import { RefreshLock } from '../utils/refresh-lock'; + +const OAUTH_HTTP_TIMEOUT_MS = 10_000; +// Room for the provider call that follows, and far inside the refresh job's 15-minute window. +const REQUEST_REFRESH_WINDOW_MS = 60_000; +const REFRESH_WAIT_TIMEOUT_MS = 5_000; +const REFRESH_WAIT_POLL_MS = 200; + +const CONNECT_UNSUPPORTED_MESSAGE = + 'This integration does not support linking your account from Docmost'; + +export type OAuthFlow = 'install' | 'connect'; + +// RFC 6749 token charset; a control character would make undici echo the token in a header error. +const isWellFormedToken = (token: unknown): token is string => + typeof token === 'string' && /^[\x21-\x7e]+$/.test(token); + +// Only the error code is safe to log; the rest of the body can name the tenant, app and user. +function tokenErrorCode(body: { error?: unknown } | null): string { + return typeof body?.error === 'string' && /^[a-z_]{1,40}$/.test(body.error) + ? body.error + : ''; +} + +type OAuthTokenResponse = { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; + scope?: string; + error?: string; +}; + +type CompletedConnection = { + connection: IntegrationConnection; + account?: ProviderAccount; +}; + +export type OAuthStatePayload = { + // Only admin-gated install states may write the shared workspace connection. + flow: OAuthFlow; + // null for installs, since completion creates the row. + integrationId: string | null; + type: string; + userId: string; + workspaceId: string; + // Workspace URL the central callback hands the code back to. + returnUrl: string; + // Path on returnUrl to land on after the callback. + returnPath: string; + // Bound to a cookie on the browser that started the flow. + nonce: string; + exp: number; +}; + +@Injectable() +export class OAuthService { + private readonly logger = new Logger(OAuthService.name); + private readonly refreshLock: RefreshLock; + // Concurrent requests for one connection share a single refresh in this process. + private readonly pendingRefreshes = new Map>(); + + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly environmentService: EnvironmentService, + private readonly domainService: DomainService, + private readonly registry: IntegrationRegistry, + private readonly integrationRepo: IntegrationRepo, + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly workspaceRepo: WorkspaceRepo, + private readonly encryptionService: EncryptionService, + private readonly userRepo: UserRepo, + private readonly workspaceAbility: WorkspaceAbilityFactory, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + private readonly redisService: RedisService, + ) { + this.refreshLock = new RefreshLock(this.redisService.getOrThrow()); + } + + async getAuthorizationUrl( + integrationId: string, + workspaceId: string, + userId: string, + returnPathOverride?: string, + ): Promise<{ authorizationUrl: string; type: string; nonce: string }> { + const integration = await this.integrationRepo.findById(integrationId); + if (!integration || integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + + const provider = this.registry.getProvider(integration.type); + if (!provider || !provider.definition.oauth) { + throw new BadRequestException('Integration does not support OAuth'); + } + + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig((integration.settings as Record) ?? {}) + : provider.definition.oauth; + + if (!supportsOAuthConnect(oauthConfig)) { + throw new BadRequestException(CONNECT_UNSUPPORTED_MESSAGE); + } + + // Workspace-scoped providers link a member's identity instead of re-running the install. + const identity = + (oauthConfig.connectionScope ?? 'user') === 'workspace' + ? oauthConfig.identity + : undefined; + + const callbackUrl = this.buildCallbackUrl(integration.type); + + const workspace = await this.workspaceRepo.findById(workspaceId); + const returnUrl = this.domainService.getWorkspaceUrl( + workspace ?? { hostname: null, customDomain: null }, + ); + + const returnPath = returnPathOverride ?? '/settings/account/connections'; + const nonce = crypto.randomBytes(32).toString('base64url'); + + const state = this.createSignedState({ + flow: 'connect', + integrationId, + type: integration.type, + userId, + workspaceId, + returnUrl, + returnPath, + nonce, + exp: Date.now() + 10 * 60 * 1000, + }); + + const params = new URLSearchParams({ + ...((identity ? identity.authParams : oauthConfig.authParams) ?? {}), + client_id: this.getClientId(integration.type, oauthConfig), + redirect_uri: callbackUrl, + response_type: 'code', + state, + }); + + const scope = (identity?.scopes ?? oauthConfig.scopes) + .map((s) => encodeURIComponent(s)) + .join('%20'); + + return { + authorizationUrl: `${identity?.authUrl ?? oauthConfig.authUrl}?${params.toString()}&scope=${scope}`, + type: integration.type, + nonce, + }; + } + + async getInstallAuthorizationUrl( + type: string, + workspaceId: string, + userId: string, + ): Promise<{ authorizationUrl: string; nonce: string }> { + const provider = this.registry.getProvider(type); + if (!provider || provider.definition.hidden) { + throw new BadRequestException(`Unknown integration type: ${type}`); + } + if (!provider.definition.oauth) { + throw new BadRequestException('Integration does not support OAuth'); + } + + const existing = await this.integrationRepo.findByWorkspaceAndType( + workspaceId, + type, + ); + if (existing) { + throw new BadRequestException( + `Integration "${type}" is already installed`, + ); + } + + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig({}) + : provider.definition.oauth; + + const callbackUrl = this.buildCallbackUrl(type); + + const workspace = await this.workspaceRepo.findById(workspaceId); + const returnUrl = this.domainService.getWorkspaceUrl( + workspace ?? { hostname: null, customDomain: null }, + ); + + const nonce = crypto.randomBytes(32).toString('base64url'); + + const state = this.createSignedState({ + flow: 'install', + integrationId: null, + type, + userId, + workspaceId, + returnUrl, + returnPath: '/settings/integrations', + nonce, + exp: Date.now() + 10 * 60 * 1000, + }); + + const params = new URLSearchParams({ + ...(oauthConfig.authParams ?? {}), + client_id: this.getClientId(type, oauthConfig), + redirect_uri: callbackUrl, + response_type: 'code', + state, + }); + + const scope = oauthConfig.scopes + .map((s) => encodeURIComponent(s)) + .join('%20'); + + return { + authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`, + nonce, + }; + } + + verifySignedState(state: string): OAuthStatePayload | null { + const dotIndex = state.lastIndexOf('.'); + if (dotIndex === -1) return null; + + const data = state.substring(0, dotIndex); + const signature = state.substring(dotIndex + 1); + + const secret = this.stateKey(); + const expected = crypto + .createHmac('sha256', secret) + .update(data) + .digest('base64url'); + + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + if ( + sigBuf.length !== expBuf.length || + !crypto.timingSafeEqual(sigBuf, expBuf) + ) { + return null; + } + + try { + const payload: OAuthStatePayload = JSON.parse( + Buffer.from(data, 'base64url').toString(), + ); + + if (payload.exp < Date.now()) return null; + + return payload; + } catch { + return null; + } + } + + async exchangeCodeForTokens( + type: string, + code: string, + state: Pick< + OAuthStatePayload, + 'flow' | 'integrationId' | 'type' | 'userId' | 'workspaceId' + >, + ): Promise { + if (state.type !== type) { + throw new BadRequestException('OAuth state does not match this provider'); + } + + const provider = this.registry.getProvider(type); + if (!provider || !provider.definition.oauth) { + throw new BadRequestException('Integration does not support OAuth'); + } + + if (state.flow === 'install') { + if (provider.definition.hidden) { + throw new BadRequestException(`Unknown integration type: ${type}`); + } + await this.assertCanManageWorkspace(state.userId, state.workspaceId); + const { connection, account } = await this.completeInstall( + provider, + type, + code, + state, + ); + this.auditService.log({ + event: AuditEvent.INTEGRATION_INSTALLED, + resourceType: AuditResource.INTEGRATION, + resourceId: connection.integrationId, + changes: { after: { provider: type, ...(account && { account }) } }, + }); + return connection; + } + const { connection, account } = await this.completeConnect( + provider, + type, + code, + state, + ); + this.auditService.log({ + event: AuditEvent.INTEGRATION_CONNECTED, + resourceType: AuditResource.INTEGRATION, + resourceId: state.integrationId, + changes: { after: { provider: type, ...(account && { account }) } }, + }); + return connection; + } + + + // Catches an admin role lost between authorize and completion. + private async assertCanManageWorkspace( + userId: string, + workspaceId: string, + ): Promise { + const [user, workspace] = await Promise.all([ + this.userRepo.findById(userId, workspaceId), + this.workspaceRepo.findById(workspaceId), + ]); + if (!user || isUserDisabled(user) || !workspace) { + throw new ForbiddenException(); + } + const ability = this.workspaceAbility.createForUser(user, workspace); + if ( + ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings) + ) { + throw new ForbiddenException(); + } + } + + // The integration row is only created after a successful token exchange. + private async completeInstall( + provider: IntegrationProvider, + type: string, + code: string, + state: Pick, + ): Promise { + const { userId, workspaceId } = state; + const integration = state.integrationId + ? await this.integrationRepo.findById(state.integrationId) + : null; + if (integration && integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + + const settings = (integration?.settings as Record) ?? {}; + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig(settings) + : provider.definition.oauth; + + const tokenResponse = await this.requestTokens(oauthConfig, type, code); + // Read before the transaction so no provider call runs inside it. + const account = await this.resolveAccount(provider, type, tokenResponse); + + // A failing onConnected must not leave the row and bot token behind. + const connection = await executeTx(this.db, async (trx) => { + const installed = + integration ?? + (await this.integrationRepo.insertOrRestore( + { type, workspaceId, installedById: userId }, + trx, + )); + + const stored = await this.storeConnection( + installed.id, + userId, + workspaceId, + tokenResponse, + provider.definition.oauth?.connectionScope ?? 'user', + account, + trx, + ); + + // Only the install flow fires onConnected. + if (provider.onConnected) { + await provider.onConnected({ + integrationId: installed.id, + workspaceId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + userId, + metadata: tokenResponse, + account, + trx, + }); + } + + return stored; + }); + + return { connection, account }; + } + + // Writes only the caller's own row, never the shared workspace connection. + private async completeConnect( + provider: IntegrationProvider, + type: string, + code: string, + state: Pick, + ): Promise { + const { userId, workspaceId } = state; + const integration = state.integrationId + ? await this.integrationRepo.findById(state.integrationId) + : null; + if (!integration || integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + + const settings = (integration.settings as Record) ?? {}; + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig(settings) + : provider.definition.oauth; + if (!supportsOAuthConnect(oauthConfig)) { + throw new BadRequestException(CONNECT_UNSUPPORTED_MESSAGE); + } + + if ((oauthConfig.connectionScope ?? 'user') === 'user') { + const tokenResponse = await this.requestTokens(oauthConfig, type, code); + const account = await this.resolveAccount(provider, type, tokenResponse); + const connection = await this.storeConnection( + integration.id, + userId, + workspaceId, + tokenResponse, + 'user', + account, + ); + return { connection, account }; + } + + const identity = oauthConfig.identity; + if (!identity || !provider.resolveIdentity) { + throw new BadRequestException(CONNECT_UNSUPPORTED_MESSAGE); + } + const tokenResponse = await this.requestTokens( + oauthConfig, + type, + code, + identity.tokenUrl, + ); + const resolved = await provider.resolveIdentity({ + integrationId: integration.id, + workspaceId, + userId, + tokenResponse, + settings, + }); + // Stops a phished completion from binding someone else's provider account. + if (resolved.email) { + const user = await this.userRepo.findById(userId, workspaceId); + const userEmail = user?.email?.trim().toLowerCase(); + if (!userEmail || userEmail !== resolved.email.trim().toLowerCase()) { + throw new IdentityEmailMismatchError(); + } + } + await this.assertAccountNotLinkedElsewhere( + integration.id, + resolved.account.id, + userId, + ); + + const connection = await this.connectionRepo.upsertUserLink({ + integrationId: integration.id, + workspaceId, + userId, + providerUserId: resolved.account.id, + metadata: { ...resolved.metadata, account: resolved.account }, + }); + return { connection, account: resolved.account }; + } + + private async storeConnection( + integrationId: string, + userId: string, + workspaceId: string, + tokenResponse: OAuthTokenResponse, + connectionScope: 'workspace' | 'user', + account: ProviderAccount | undefined, + trx?: KyselyTransaction, + ): Promise { + const encryptedAccessToken = this.encryptionService.encrypt( + tokenResponse.access_token, + EncryptionPurpose.INTEGRATION_TOKENS, + ); + const encryptedRefreshToken = tokenResponse.refresh_token + ? this.encryptionService.encrypt( + tokenResponse.refresh_token, + EncryptionPurpose.INTEGRATION_TOKENS, + ) + : null; + + const tokenExpiresAt = tokenResponse.expires_in + ? new Date(Date.now() + tokenResponse.expires_in * 1000) + : null; + + const values = { + integrationId, + userId, + workspaceId, + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + tokenExpiresAt, + scopes: tokenResponse.scope ?? null, + }; + + // The installer's account never goes on the shared bot row. + if (connectionScope === 'workspace') { + return this.connectionRepo.upsertWorkspaceConnection(values, trx); + } + if (account) { + await this.assertAccountNotLinkedElsewhere( + integrationId, + account.id, + userId, + trx, + ); + } + // A missing account clears the old one, so a reconnect never keeps a stale identity. + return this.connectionRepo.upsert( + { + ...values, + providerUserId: account?.id ?? null, + metadata: account ? { account } : null, + }, + trx, + ); + } + + private async resolveAccount( + provider: IntegrationProvider, + type: string, + tokenResponse: OAuthTokenResponse, + ): Promise { + if (!provider.resolveAccount) return undefined; + try { + return await provider.resolveAccount({ + accessToken: tokenResponse.access_token, + tokenResponse, + }); + } catch (err) { + this.logger.warn( + `Could not read the connected ${type} account: ${(err as Error).message}`, + ); + return undefined; + } + } + + // Checked up front so a rebind gets a clear error, not a unique-index violation. + private async assertAccountNotLinkedElsewhere( + integrationId: string, + accountId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const existingLink = await this.connectionRepo.findUserLink( + integrationId, + accountId, + trx, + ); + if (existingLink && existingLink.userId !== userId) { + throw new BadRequestException( + 'This account is already connected to a different Docmost user. Disconnect it there first.', + ); + } + } + + async getValidAccessToken( + connection: IntegrationConnection, + ): Promise { + if (connection.invalidatedAt) { + throw new TokenInvalidError(); + } + + const expiresAt = connection.tokenExpiresAt + ? new Date(connection.tokenExpiresAt).getTime() + : null; + if ( + expiresAt === null || + expiresAt - Date.now() > REQUEST_REFRESH_WINDOW_MS + ) { + return this.encryptionService.decrypt( + connection.accessToken, + EncryptionPurpose.INTEGRATION_TOKENS, + ); + } + + if (!connection.refreshToken) { + // Not every provider answers an expired token with a 401. + if (expiresAt < Date.now()) { + throw new TokenInvalidError( + 'Access token expired and cannot be refreshed', + ); + } + return this.encryptionService.decrypt( + connection.accessToken, + EncryptionPurpose.INTEGRATION_TOKENS, + ); + } + + let pending = this.pendingRefreshes.get(connection.id); + if (!pending) { + pending = this.refreshForRequest(connection).finally(() => + this.pendingRefreshes.delete(connection.id), + ); + this.pendingRefreshes.set(connection.id, pending); + } + return pending; + } + + private async refreshForRequest( + connection: IntegrationConnection, + ): Promise { + const lockToken = await this.refreshLock.acquire(connection.id); + if (!lockToken) { + await this.refreshLock.waitForRelease(connection.id, { + timeoutMs: REFRESH_WAIT_TIMEOUT_MS, + pollMs: REFRESH_WAIT_POLL_MS, + }); + const accessToken = this.usableAccessToken( + await this.connectionRepo.findById(connection.id), + ); + if (!accessToken) { + throw new TokenExpiredError(); + } + return accessToken; + } + + try { + return await this.refreshAccessToken(connection); + } catch (err) { + if (err instanceof TokenInvalidError || err instanceof TokenExpiredError) { + throw err; + } + // A transient failure keeps the connection; a later request tries again. + throw new TokenExpiredError(); + } finally { + await this.refreshLock.release(connection.id, lockToken); + } + } + + private usableAccessToken( + row: IntegrationConnection | undefined, + ): string | null { + if (!row?.accessToken || row.invalidatedAt) { + return null; + } + if ( + row.tokenExpiresAt && + new Date(row.tokenExpiresAt).getTime() <= Date.now() + ) { + return null; + } + return this.encryptionService.decrypt( + row.accessToken, + EncryptionPurpose.INTEGRATION_TOKENS, + ); + } + + // Callers must hold the connection's refresh lock. + async refreshAccessToken(connection: IntegrationConnection): Promise { + const integration = await this.integrationRepo.findById( + connection.integrationId, + ); + if (!integration) { + throw new NotFoundException('Integration not found'); + } + + const provider = this.registry.getProvider(integration.type); + if (!provider || !provider.definition.oauth) { + throw new BadRequestException('Integration does not support OAuth'); + } + + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig((integration.settings as Record) ?? {}) + : provider.definition.oauth; + + const clientId = this.getClientId(integration.type, oauthConfig); + const clientSecret = this.getClientSecret(integration.type, oauthConfig); + + try { + const current = await this.connectionRepo.findById(connection.id); + // Gone, retired, refreshed or reconnected since the caller read it. + if ( + !current?.refreshToken || + current.invalidatedAt || + current.accessToken !== connection.accessToken + ) { + const accessToken = this.usableAccessToken(current); + if (!accessToken) { + throw new TokenExpiredError(); + } + return accessToken; + } + // A reconnect or another refresh meanwhile turns the writes below into no-ops. + const expected = { refreshToken: current.refreshToken }; + + const basicAuth = oauthConfig.clientAuth === 'basic'; + const params = new URLSearchParams({ + grant_type: 'refresh_token', + ...(basicAuth ? {} : { client_id: clientId, client_secret: clientSecret }), + refresh_token: this.encryptionService.decrypt( + current.refreshToken, + EncryptionPurpose.INTEGRATION_TOKENS, + ), + }); + if (oauthConfig.scopeOnRefresh) { + params.set('scope', oauthConfig.scopes.join(' ')); + } + + const response = await this.postTokenRequest( + integration.type, + oauthConfig.refreshUrl ?? oauthConfig.tokenUrl, + params, + basicAuth ? { clientId, clientSecret } : undefined, + ); + + // A parse error would quote the body. + const data: OAuthTokenResponse | null = await response + .json() + .catch(() => null); + + // GitHub reports token errors with a 200 and an error body. + if (!response.ok || data?.error !== undefined) { + const errorCode = tokenErrorCode(data); + this.logger.error( + `Token refresh failed for ${integration.type}: ${response.status} ${errorCode}`.trimEnd(), + ); + // invalid_client etc. are config errors, not a dead grant + if ( + errorCode === 'invalid_grant' || + errorCode === 'interaction_required' || + errorCode === 'bad_refresh_token' + ) { + await this.connectionRepo.invalidate(current.id, expected); + throw new TokenInvalidError( + `Refresh token rejected for ${integration.type}`, + ); + } + throw new BadRequestException('Token refresh failed'); + } + + if (!data) { + this.logger.error( + `Token refresh for ${integration.type} returned a response that is not JSON`, + ); + throw new BadRequestException('Token refresh failed'); + } + if ( + !isWellFormedToken(data.access_token) || + (data.refresh_token && !isWellFormedToken(data.refresh_token)) + ) { + this.logger.error( + `Token refresh for ${integration.type} returned a malformed token`, + ); + throw new BadRequestException('Token refresh failed'); + } + const encryptedAccessToken = this.encryptionService.encrypt( + data.access_token, + EncryptionPurpose.INTEGRATION_TOKENS, + ); + const encryptedRefreshToken = data.refresh_token + ? this.encryptionService.encrypt( + data.refresh_token, + EncryptionPurpose.INTEGRATION_TOKENS, + ) + : current.refreshToken; + const tokenExpiresAt = data.expires_in + ? new Date(Date.now() + data.expires_in * 1000) + : null; + + const saved = await this.connectionRepo.updateIfTokensMatch( + current.id, + expected, + { + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + tokenExpiresAt, + invalidatedAt: null, + }, + ); + if (saved) { + return data.access_token; + } + // Reconnected meanwhile; the response belongs to the old grant. + const accessToken = this.usableAccessToken( + await this.connectionRepo.findById(current.id), + ); + if (!accessToken) { + throw new TokenExpiredError(); + } + return accessToken; + } catch (err) { + if (err instanceof TokenInvalidError || err instanceof TokenExpiredError) { + throw err; + } + this.logger.error(`Token refresh error: ${(err as Error).message}`); + throw new BadRequestException('Failed to refresh token'); + } + } + + private async requestTokens( + oauthConfig: OAuthConfig, + type: string, + code: string, + tokenUrl = oauthConfig.tokenUrl, + ): Promise { + const clientId = this.getClientId(type, oauthConfig); + const clientSecret = this.getClientSecret(type, oauthConfig); + const basicAuth = oauthConfig.clientAuth === 'basic'; + const params = new URLSearchParams({ + grant_type: 'authorization_code', + ...(basicAuth ? {} : { client_id: clientId, client_secret: clientSecret }), + code, + redirect_uri: this.buildCallbackUrl(type), + }); + + const response = await this.postTokenRequest( + type, + tokenUrl, + params, + basicAuth ? { clientId, clientSecret } : undefined, + ); + + if (!response.ok) { + const errorCode = tokenErrorCode(await response.json().catch(() => null)); + this.logger.error( + `Token exchange failed for ${type}: ${response.status} ${errorCode}`.trimEnd(), + ); + throw new BadRequestException('OAuth token exchange failed'); + } + + // A parse error would quote the body. + const data: OAuthTokenResponse | null = await response + .json() + .catch(() => null); + if (!data) { + this.logger.error( + `Token exchange for ${type} returned a response that is not JSON`, + ); + throw new BadRequestException('OAuth token exchange failed'); + } + if (!data.access_token) { + this.logger.error(`Token exchange for ${type} returned no access token`); + throw new BadRequestException('OAuth token exchange failed'); + } + if ( + !isWellFormedToken(data.access_token) || + (data.refresh_token && !isWellFormedToken(data.refresh_token)) + ) { + this.logger.error(`Token exchange for ${type} returned a malformed token`); + throw new BadRequestException('OAuth token exchange failed'); + } + return data; + } + + private async postTokenRequest( + type: string, + tokenUrl: string, + params: URLSearchParams, + basicAuth?: { clientId: string; clientSecret: string }, + ): Promise { + const response = await proxyFetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + ...(basicAuth + ? { + Authorization: `Basic ${Buffer.from(`${basicAuth.clientId}:${basicAuth.clientSecret}`).toString('base64')}`, + } + : {}), + }, + body: params.toString(), + // Never follow: a 3xx would re-POST the client secret and code to the Location target. + redirect: 'manual', + signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS), + }); + return readCappedResponse(`${type} token endpoint`, response); + } + + buildCallbackUrl(type: string): string { + const appUrl = this.environmentService.getAppUrl(); + return `${appUrl}/api/integrations/oauth/${type}/callback`; + } + + // State gets its own key so a signature made with APP_SECRET for anything else never verifies here. + private stateKey(): Buffer { + return Buffer.from( + crypto.hkdfSync( + 'sha256', + this.environmentService.getAppSecret(), + '', + 'integration-oauth-state', + 32, + ), + ); + } + + private createSignedState(payload: OAuthStatePayload): string { + const data = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const secret = this.stateKey(); + const signature = crypto + .createHmac('sha256', secret) + .update(data) + .digest('base64url'); + return `${data}.${signature}`; + } + + private getClientId(type: string, oauthConfig: OAuthConfig): string { + return this.requireEnv(credentialEnvKey('CLIENT_ID', type, oauthConfig)); + } + + private getClientSecret(type: string, oauthConfig: OAuthConfig): string { + return this.requireEnv( + credentialEnvKey('CLIENT_SECRET', type, oauthConfig), + ); + } + + private requireEnv(envKey: string): string { + const value = process.env[envKey]; + if (!value) { + throw new BadRequestException( + `Missing environment variable: ${envKey}`, + ); + } + return value; + } +} diff --git a/apps/server/src/core/integration/oauth/oauth.utils.ts b/apps/server/src/core/integration/oauth/oauth.utils.ts new file mode 100644 index 00000000..a708318d --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.utils.ts @@ -0,0 +1,6 @@ +export const COMPLETION_FALLBACK_REDIRECT = + '/settings/account/connections?error=oauth_failed'; + +export function nonceCookieName(type: string): string { + return `integration_oauth_${type}`; +} diff --git a/apps/server/src/core/integration/providers/figma/figma-patterns.ts b/apps/server/src/core/integration/providers/figma/figma-patterns.ts new file mode 100644 index 00000000..cadea5dd --- /dev/null +++ b/apps/server/src/core/integration/providers/figma/figma-patterns.ts @@ -0,0 +1,9 @@ +import { UnfurlPattern } from '../../registry/integration-provider.interface'; + +export const figmaPatterns: UnfurlPattern[] = [ + { + regex: + /^https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|board|design)\/([0-9a-zA-Z]{22,128})/, + type: 'figma-file', + }, +]; diff --git a/apps/server/src/core/integration/providers/figma/figma.module.ts b/apps/server/src/core/integration/providers/figma/figma.module.ts new file mode 100644 index 00000000..68273e07 --- /dev/null +++ b/apps/server/src/core/integration/providers/figma/figma.module.ts @@ -0,0 +1,21 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { FigmaProvider } from './figma.provider'; +import { FigmaService } from './figma.service'; +import { IntegrationRegistry } from '../../registry/integration-registry'; +import { IntegrationModule } from '../../integration.module'; + +@Module({ + imports: [IntegrationModule], + providers: [FigmaProvider, FigmaService], + exports: [FigmaProvider], +}) +export class FigmaModule implements OnModuleInit { + constructor( + private readonly registry: IntegrationRegistry, + private readonly figmaProvider: FigmaProvider, + ) {} + + onModuleInit() { + this.registry.register(this.figmaProvider); + } +} diff --git a/apps/server/src/core/integration/providers/figma/figma.provider.ts b/apps/server/src/core/integration/providers/figma/figma.provider.ts new file mode 100644 index 00000000..00f3fb48 --- /dev/null +++ b/apps/server/src/core/integration/providers/figma/figma.provider.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationProvider, + IntegrationDefinition, + ProviderAccount, + ResolveAccountOpts, + UnfurlOpts, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { FigmaService } from './figma.service'; +import { figmaPatterns } from './figma-patterns'; + +@Injectable() +export class FigmaProvider extends IntegrationProvider { + definition: IntegrationDefinition = { + type: 'figma', + name: 'Figma', + description: 'Link previews for Figma designs, prototypes, and FigJam boards', + icon: 'figma', + capabilities: ['oauth', 'unfurl'], + oauth: { + authUrl: 'https://www.figma.com/oauth', + tokenUrl: 'https://api.figma.com/v1/oauth/token', + refreshUrl: 'https://api.figma.com/v1/oauth/refresh', + scopes: ['file_metadata:read', 'current_user:read'], + clientAuth: 'basic', + }, + unfurlPatterns: figmaPatterns, + }; + + constructor(private readonly figmaService: FigmaService) { + super(); + } + + async resolveAccount(opts: ResolveAccountOpts): Promise { + return this.figmaService.getAccount(opts.accessToken); + } + + async unfurl(opts: UnfurlOpts): Promise { + const { match, accessToken, url } = opts; + const fileType = match[2]; + const fileKey = match[3]; + return this.figmaService.unfurlFile(accessToken, fileKey, fileType, url); + } +} diff --git a/apps/server/src/core/integration/providers/figma/figma.service.spec.ts b/apps/server/src/core/integration/providers/figma/figma.service.spec.ts new file mode 100644 index 00000000..fa1fa264 --- /dev/null +++ b/apps/server/src/core/integration/providers/figma/figma.service.spec.ts @@ -0,0 +1,46 @@ +import { proxyFetch } from '../../../../common/proxy-fetch'; +import { FigmaProvider } from './figma.provider'; +import { FigmaService } from './figma.service'; + +// Mock only proxyFetch so the real providerApiFetch handles statuses. +jest.mock('../../../../common/proxy-fetch', () => ({ + proxyFetch: jest.fn(), +})); + +const fetchMock = proxyFetch as jest.Mock; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('Figma connected account', () => { + const provider = new FigmaProvider(new FigmaService()); + + beforeEach(() => fetchMock.mockReset()); + + it('asks for the profile scope beside file metadata', () => { + expect(provider.definition.oauth?.scopes).toEqual([ + 'file_metadata:read', + 'current_user:read', + ]); + }); + + it('reads the connected account from /v1/me and keeps no email', async () => { + fetchMock.mockResolvedValue( + json({ + id: '1234567890', + handle: 'Philip Okugbe', + email: 'philip@docmost.com', + img_url: 'https://s3-alpha.figma.com/profile/1234567890', + }), + ); + + await expect( + provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }), + ).resolves.toEqual({ id: '1234567890', displayName: 'Philip Okugbe' }); + expect(fetchMock.mock.calls[0][0]).toBe('https://api.figma.com/v1/me'); + }); +}); diff --git a/apps/server/src/core/integration/providers/figma/figma.service.ts b/apps/server/src/core/integration/providers/figma/figma.service.ts new file mode 100644 index 00000000..7b6f20d3 --- /dev/null +++ b/apps/server/src/core/integration/providers/figma/figma.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { + ProviderAccount, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { providerApiFetch } from '../../utils/provider-fetch'; +import { toProviderAccount } from '../../utils/integration.utils'; + +const FIGMA_API = 'https://api.figma.com/v1'; + +const FILE_TYPE_LABELS: Record = { + design: 'Design file', + file: 'Design file', + proto: 'Prototype', + board: 'FigJam board', +}; + +@Injectable() +export class FigmaService { + async getAccount(accessToken: string): Promise { + const data = await this.apiGet(accessToken, '/me'); + return toProviderAccount({ id: data.id, displayName: data.handle }); + } + + async unfurlFile( + accessToken: string, + fileKey: string, + fileType: string, + url: string, + ): Promise { + const data = await this.apiGet(accessToken, `/files/${fileKey}/meta`); + const file = data.file; + + const typeLabel = FILE_TYPE_LABELS[fileType] ?? 'Figma file'; + + return { + title: file.name, + description: typeLabel, + url, + provider: 'figma', + providerIcon: 'figma', + author: file.last_touched_by?.handle, + authorAvatarUrl: file.last_touched_by?.img_url, + metadata: { + type: fileType, + fileKey, + thumbnailUrl: file.thumbnail_url, + lastModified: file.last_touched_at, + }, + }; + } + + private async apiGet(accessToken: string, path: string): Promise { + const response = await providerApiFetch('Figma', `${FIGMA_API}${path}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + return response.json(); + } +} diff --git a/apps/server/src/core/integration/providers/github/github-patterns.spec.ts b/apps/server/src/core/integration/providers/github/github-patterns.spec.ts new file mode 100644 index 00000000..0897eab3 --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github-patterns.spec.ts @@ -0,0 +1,143 @@ +import { integrationLinkPatterns } from '@docmost/editor-ext'; +import { buildGitHubPatterns } from './github-patterns'; + +function firstMatch(patterns: { type: string; regex: RegExp }[], url: string) { + for (const pattern of patterns) { + const match = url.match(pattern.regex); + if (match) return { type: pattern.type, groups: match.slice(1) }; + } + return null; +} + +// Each of these matched before the guard; the browser resolves them to a different path. +const RESOLVED_ELSEWHERE = [ + 'https://github.com/a/b/pull/1/../../../../evil/x/pull/2', + 'https://github.com/../user?x=/pull/1', + 'https://github.com/a/b/pull/1/%2e%2e/%2E%2E/%2e./.%2e/evil/x/pull/2', + 'https://github.com/a/b/pull/1/commits/abc123/../../../../../evil/x/pull/2', + 'https://github.com/a/b/issues/1/..', + 'https://github.com/a/b/issues/1/..?x=1', + 'https://github.com/a/b/commit/abc123/./x', + 'https://github.com/a/b/blob/main/../../../evil/x/blob/main/secret.txt', + 'https://github.com/a/b/pulls/../../evil/x/pulls', + 'https://github.com/a/b/releases/./tag/v1', + 'https://github.com/a/b/issues/created_by/../../../evil/x/issues', + 'https://github.com/a/b/pull/1\\..\\..\\evil', + 'https://github.com/a\\b/c/pull/1', +]; + +const STILL_MATCHED: [string, string, (string | undefined)[]][] = [ + ['https://github.com/o/.github/pull/1', 'github-pr', ['o', '.github', '1']], + ['https://github.com/o/my..repo/issues/2', 'github-issue', ['o', 'my..repo', '2']], + ['https://github.com/o/.../issues/3', 'github-issue', ['o', '...', '3']], + ['https://github.com/o/r/pull/1#discussion_r1/../..', 'github-pr', ['o', 'r', '1']], + ['https://github.com/o/r/pulls?q=../../x', 'github-pulls-list', ['o', 'r']], + [ + 'https://github.com/o/r/blob/main/.github/workflows/ci.yml#L1-L3', + 'github-file', + ['o', 'r', 'main', '.github/workflows/ci.yml', '1', '3'], + ], + [ + 'https://github.com/o/r/blob/main/src/..hidden/a.ts', + 'github-file', + ['o', 'r', 'main', 'src/..hidden/a.ts', undefined, undefined], + ], + ['https://github.com/o/r/commit/abc123', 'github-commit', ['o', 'r', 'abc123']], + ['https://github.com/o/r', 'github-repo', ['o', 'r']], + ['https://github.com/my-org_1/repo.name-x/pull/1', 'github-pr', ['my-org_1', 'repo.name-x', '1']], +]; + +// The browser opens the repo or owner for these; the rest of the route is query or fragment. +const ROUTE_IN_QUERY_OR_FRAGMENT = [ + 'https://github.com/o/r?x=/pull/1', + 'https://github.com/o/r#x/pull/1', + 'https://github.com/o?x=/r/issues/1', + 'https://github.com/o/r?/commit/abc123', + 'https://github.com/o/r?x=/pull/1/commits/abc123', + 'https://github.com/o/r#/blob/main/a.ts', + 'https://github.com/o/r?x=/pulls', + 'https://github.com/o/r#/releases', + 'https://github.com/o/r?x=/issues', +]; + +describe('buildGitHubPatterns', () => { + const patterns = buildGitHubPatterns('https://github.com'); + const byType = (type: string) => patterns.find((p) => p.type === type)!; + const crafted = 'https://github.com/o/r/pulls/' + '?'.repeat(40000) + '\n'; + + it('keeps the pulls and releases list patterns linear on a crafted input', () => { + for (const type of ['github-pulls-list', 'github-releases-list']) { + const started = process.hrtime.bigint(); + expect(byType(type).regex.test(crafted)).toBe(false); + expect(Number(process.hrtime.bigint() - started) / 1e6).toBeLessThan(100); + } + }); + + it.each([ + ['https://github.com/o/r/pulls', 'github-pulls-list'], + ['https://github.com/o/r/pulls/', 'github-pulls-list'], + ['https://github.com/o/r/pulls?q=is%3Aopen', 'github-pulls-list'], + ['https://github.com/o/r/pulls/extra', 'github-pulls-list'], + ['https://github.com/o/r/releases', 'github-releases-list'], + ['https://github.com/o/r/releases/tag/v1.0.0?x=1', 'github-releases-list'], + ])('still matches %s as %s', (url, type) => { + expect(byType(type).regex.test(url)).toBe(true); + }); + + it('does not match a longer path segment as the list', () => { + expect(byType('github-pulls-list').regex.test('https://github.com/o/r/pullsx')).toBe(false); + }); + + it.each(ROUTE_IN_QUERY_OR_FRAGMENT)('does not match %s', (url) => { + expect(firstMatch(patterns, url)).toBeNull(); + }); + + describe('paths the browser would resolve elsewhere', () => { + it.each(RESOLVED_ELSEWHERE)('does not match %s', (url) => { + expect(firstMatch(patterns, url)).toBeNull(); + }); + + it.each(STILL_MATCHED)('still matches %s as %s', (url, type, groups) => { + expect(firstMatch(patterns, url)).toEqual({ type, groups }); + }); + + it('guards a GitHub Enterprise host the same way', () => { + const enterprise = buildGitHubPatterns('https://github.acme.com'); + expect( + firstMatch(enterprise, 'https://github.acme.com/a/b/pull/1/../../../../evil/x/pull/2'), + ).toBeNull(); + expect(firstMatch(enterprise, 'https://github.acme.com/../user?x=/pull/1')).toBeNull(); + expect(firstMatch(enterprise, 'https://github.acme.com/o/r/pull/1')).toEqual({ + type: 'github-pr', + groups: ['o', 'r', '1'], + }); + }); + + it('stays linear on hostile input', () => { + const crafted = [ + 'https://github.com/' + 'a/'.repeat(30000), + 'https://github.com/o/r/pull/1' + '/.x'.repeat(10000), + 'https://github.com/o/r/blob/main/' + '%2e'.repeat(10000) + '\n', + ]; + for (const input of crafted) { + const started = process.hrtime.bigint(); + for (const pattern of patterns) pattern.regex.test(input); + expect(Number(process.hrtime.bigint() - started) / 1e6).toBeLessThan(100); + } + }); + + it.each([ + ...RESOLVED_ELSEWHERE, + ...ROUTE_IN_QUERY_OR_FRAGMENT, + ...STILL_MATCHED.map(([url]) => url), + ])( + 'agrees with the editor patterns on %s', + (url) => { + const editorPatterns = integrationLinkPatterns.filter( + (pattern) => pattern.provider === 'github', + ); + expect(firstMatch(editorPatterns, url)).toEqual(firstMatch(patterns, url)); + }, + ); + }); +}); diff --git a/apps/server/src/core/integration/providers/github/github-patterns.ts b/apps/server/src/core/integration/providers/github/github-patterns.ts new file mode 100644 index 00000000..e409a56c --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github-patterns.ts @@ -0,0 +1,70 @@ +import { UnfurlPattern } from '../../registry/integration-provider.interface'; +import { CANONICAL_PATH } from '../../utils/canonical-path'; +import { escapeForRegex } from '../../utils/integration.utils'; + +export function buildGitHubPatterns(baseUrl: string): UnfurlPattern[] { + const prefix = `^${escapeForRegex(baseUrl)}${CANONICAL_PATH}`; + return [ + // Commit within a PR: /:owner/:repo/pull/:num/commits/:sha + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/pull\\/(\\d+)\\/commits\\/([a-f0-9]+)`, + ), + type: 'github-pr-commit', + }, + // PR sub-pages: /:owner/:repo/pull/:num(/checks|/commits|/files)? + { + regex: new RegExp(`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/pull\\/(\\d+)`), + type: 'github-pr', + }, + // Single issue: /:owner/:repo/issues/:num + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/issues\\/(\\d+)`, + ), + type: 'github-issue', + }, + // Commit: /:owner/:repo/commit(s)/:sha + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/commits?\\/([a-f0-9]+)`, + ), + type: 'github-commit', + }, + // File/blob: /:owner/:repo/blob/:ref/:path(#L:start(-L:end))? + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/blob\\/([^\\/]+)\\/(.+?)(?:#L(\\d+)(?:-L(\\d+))?)?$`, + ), + type: 'github-file', + }, + // Pulls list: /:owner/:repo/pulls + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/pulls(?:[\\/?].*)?$`, + ), + type: 'github-pulls-list', + }, + // Issues list: /:owner/:repo/issues(/created_by/...|/assigned/...)? + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/issues(?:\\/(?:created_by|assigned)\\/[\\w.\\/-]+)?\\/?(?:\\?.*)?$`, + ), + type: 'github-issues-list', + }, + // Releases: /:owner/:repo/releases + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/releases(?:[\\/?].*)?$`, + ), + type: 'github-releases-list', + }, + // Repo: /:owner/:repo + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/?$`, + ), + type: 'github-repo', + }, + ]; +} diff --git a/apps/server/src/core/integration/providers/github/github.module.ts b/apps/server/src/core/integration/providers/github/github.module.ts new file mode 100644 index 00000000..ed4c52be --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.module.ts @@ -0,0 +1,21 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { GitHubProvider } from './github.provider'; +import { GitHubService } from './github.service'; +import { IntegrationRegistry } from '../../registry/integration-registry'; +import { IntegrationModule } from '../../integration.module'; + +@Module({ + imports: [IntegrationModule], + providers: [GitHubProvider, GitHubService], + exports: [GitHubProvider], +}) +export class GitHubModule implements OnModuleInit { + constructor( + private readonly registry: IntegrationRegistry, + private readonly githubProvider: GitHubProvider, + ) {} + + onModuleInit() { + this.registry.register(this.githubProvider); + } +} diff --git a/apps/server/src/core/integration/providers/github/github.provider.spec.ts b/apps/server/src/core/integration/providers/github/github.provider.spec.ts new file mode 100644 index 00000000..4afb54f0 --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.provider.spec.ts @@ -0,0 +1,167 @@ +import { Logger } from '@nestjs/common'; +import { GitHubProvider } from './github.provider'; +import { IntegrationRegistry } from '../../registry/integration-registry'; +import { UnfurlPattern } from '../../registry/integration-provider.interface'; + +const GHE_BASE = 'https://github.acme.com'; +const PUBLIC_PR = 'https://github.com/o/r/pull/1'; +const GHE_PR = `${GHE_BASE}/o/r/pull/1`; + +function buildProvider() { + const unfurlPullRequest = jest.fn().mockResolvedValue({}); + const provider = new GitHubProvider({ unfurlPullRequest } as any); + return { provider, unfurlPullRequest }; +} + +function firstMatch(patterns: UnfurlPattern[], url: string) { + for (const pattern of patterns) { + const match = url.match(pattern.regex); + if (match) return { match, patternType: pattern.type }; + } + return null; +} + +function matches(provider: GitHubProvider, url: string): boolean { + return firstMatch(provider.getUnfurlPatterns({}), url) !== null; +} + +async function unfurlWith(provider: GitHubProvider, url: string) { + const found = firstMatch(provider.getUnfurlPatterns({}), url)!; + await provider.unfurl({ ...found, url, accessToken: 'token' } as any); +} + +describe('GitHubProvider host matching', () => { + const original = process.env.INTEGRATION_GITHUB_URL; + + beforeEach(() => delete process.env.INTEGRATION_GITHUB_URL); + + afterEach(() => { + if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = original; + }); + + it('declares no static unfurl patterns', () => { + const { provider } = buildProvider(); + expect(provider.definition.unfurlPatterns).toBeUndefined(); + + const registry = new IntegrationRegistry(); + registry.register(provider); + expect(registry.findUnfurlProvider(PUBLIC_PR)).toBeNull(); + }); + + describe('github.com (no INTEGRATION_GITHUB_URL)', () => { + it('matches github.com links', () => { + const { provider } = buildProvider(); + expect(matches(provider, PUBLIC_PR)).toBe(true); + expect(matches(provider, 'https://github.com/o/r')).toBe(true); + expect(matches(provider, GHE_PR)).toBe(false); + }); + + it('calls api.github.com', async () => { + const { provider, unfurlPullRequest } = buildProvider(); + await unfurlWith(provider, PUBLIC_PR); + expect(unfurlPullRequest).toHaveBeenCalledWith( + 'token', 'https://api.github.com', 'o', 'r', 1, PUBLIC_PR, + ); + }); + }); + + describe('GitHub Enterprise (INTEGRATION_GITHUB_URL set)', () => { + beforeEach(() => { + process.env.INTEGRATION_GITHUB_URL = GHE_BASE; + }); + + it('does not match github.com links', () => { + const { provider } = buildProvider(); + expect(matches(provider, PUBLIC_PR)).toBe(false); + expect(matches(provider, 'https://github.com/o/r')).toBe(false); + }); + + it('matches links on the configured host', () => { + const { provider } = buildProvider(); + expect(matches(provider, GHE_PR)).toBe(true); + expect(matches(provider, `${GHE_BASE}/o/r`)).toBe(true); + }); + + it('sends the token to the configured instance api', async () => { + const { provider, unfurlPullRequest } = buildProvider(); + await unfurlWith(provider, GHE_PR); + expect(unfurlPullRequest).toHaveBeenCalledWith( + 'token', `${GHE_BASE}/api/v3`, 'o', 'r', 1, GHE_PR, + ); + }); + + it.each(['https://GitHub.Acme.com', 'https://github.acme.com/', 'HTTPS://GITHUB.ACME.COM//'])( + 'normalizes %s to match lowercase pasted links', + async (configured) => { + process.env.INTEGRATION_GITHUB_URL = configured; + const { provider, unfurlPullRequest } = buildProvider(); + expect(matches(provider, GHE_PR)).toBe(true); + await unfurlWith(provider, GHE_PR); + expect(unfurlPullRequest.mock.calls[0][1]).toBe(`${GHE_BASE}/api/v3`); + }, + ); + }); + + describe('INTEGRATION_GITHUB_URL validation', () => { + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => warn.mockRestore()); + + it.each(['github.acme.com', 'github.acme.com:8443', 'ftp://github.acme.com'])( + 'warns once and falls back to github.com for %s', + async (configured) => { + process.env.INTEGRATION_GITHUB_URL = configured; + const { provider, unfurlPullRequest } = buildProvider(); + + expect(matches(provider, PUBLIC_PR)).toBe(true); + expect(matches(provider, GHE_PR)).toBe(false); + expect(provider.getOAuthConfig({})).toMatchObject({ + authUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + }); + await unfurlWith(provider, PUBLIC_PR); + expect(unfurlPullRequest.mock.calls[0][1]).toBe('https://api.github.com'); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + 'INTEGRATION_GITHUB_URL is not a valid http(s) URL; falling back to https://github.com', + ); + }, + ); + + it('does not warn for a valid url', async () => { + process.env.INTEGRATION_GITHUB_URL = GHE_BASE; + const { provider } = buildProvider(); + provider.getOAuthConfig({}); + await unfurlWith(provider, GHE_PR); + expect(warn).not.toHaveBeenCalled(); + }); + }); + + describe('getUnfurlHosts', () => { + it('reports github.com when INTEGRATION_GITHUB_URL is unset', () => { + const { provider } = buildProvider(); + expect(provider.getUnfurlHosts({})).toEqual(['github.com']); + }); + + it('reports the configured host', () => { + process.env.INTEGRATION_GITHUB_URL = GHE_BASE; + const { provider } = buildProvider(); + expect(provider.getUnfurlHosts({})).toEqual(['github.acme.com']); + }); + + it.each(['https://GitHub.Acme.com', 'https://github.acme.com/'])( + 'reports %s as a lowercase hostname', + (configured) => { + process.env.INTEGRATION_GITHUB_URL = configured; + const { provider } = buildProvider(); + expect(provider.getUnfurlHosts({})).toEqual(['github.acme.com']); + }, + ); + }); +}); diff --git a/apps/server/src/core/integration/providers/github/github.provider.ts b/apps/server/src/core/integration/providers/github/github.provider.ts new file mode 100644 index 00000000..a179e029 --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.provider.ts @@ -0,0 +1,173 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + IntegrationProvider, + IntegrationDefinition, + LinkDescription, + OAuthConfig, + ProviderAccount, + ResolveAccountOpts, + UnfurlPattern, + UnfurlOpts, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { GitHubService } from './github.service'; +import { buildGitHubPatterns } from './github-patterns'; +import { normalizeBaseUrl } from '../../utils/integration.utils'; + +const DEFAULT_BASE_URL = 'https://github.com'; + +@Injectable() +export class GitHubProvider extends IntegrationProvider { + private readonly logger = new Logger(GitHubProvider.name); + + definition: IntegrationDefinition = { + type: 'github', + name: 'GitHub', + description: 'Link previews for repos, pull requests, issues, commits, and files', + icon: 'github', + capabilities: ['oauth', 'unfurl'], + oauth: { + authUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + scopes: ['repo', 'read:user'], + }, + }; + + constructor(private readonly githubService: GitHubService) { + super(); + const configured = process.env.INTEGRATION_GITHUB_URL; + if (configured && !normalizeBaseUrl(configured)) { + this.logger.warn( + `INTEGRATION_GITHUB_URL is not a valid http(s) URL; falling back to ${DEFAULT_BASE_URL}`, + ); + } + } + + getOAuthConfig(settings: Record): OAuthConfig { + const baseUrl = this.resolveBaseUrl(); + return { + authUrl: `${baseUrl}/login/oauth/authorize`, + tokenUrl: `${baseUrl}/login/oauth/access_token`, + scopes: ['repo', 'read:user'], + }; + } + + // Configured host only, so a GitHub Enterprise token never reaches github.com. + getUnfurlPatterns(settings: Record): UnfurlPattern[] { + return buildGitHubPatterns(this.resolveBaseUrl()); + } + + getUnfurlHosts(settings: Record): string[] { + return [new URL(this.resolveBaseUrl()).hostname]; + } + + async resolveAccount(opts: ResolveAccountOpts): Promise { + return this.githubService.getAccount( + opts.accessToken, + this.resolveApiBaseUrl(), + ); + } + + async unfurl(opts: UnfurlOpts): Promise { + const { match, patternType, accessToken, url } = opts; + const apiBaseUrl = this.resolveApiBaseUrl(); + const owner = match[1]; + const repo = match[2]; + + switch (patternType) { + case 'github-pr': { + const number = parseInt(match[3], 10); + return this.githubService.unfurlPullRequest( + accessToken, apiBaseUrl, owner, repo, number, url, + ); + } + + case 'github-issue': { + const number = parseInt(match[3], 10); + return this.githubService.unfurlIssue( + accessToken, apiBaseUrl, owner, repo, number, url, + ); + } + + case 'github-repo': + return this.githubService.unfurlRepo( + accessToken, apiBaseUrl, owner, repo, url, + ); + + case 'github-commit': { + const sha = match[3]; + return this.githubService.unfurlCommit( + accessToken, apiBaseUrl, owner, repo, sha, url, + ); + } + + case 'github-pr-commit': { + const sha = match[4]; + return this.githubService.unfurlCommit( + accessToken, apiBaseUrl, owner, repo, sha, url, + ); + } + + case 'github-file': { + const ref = match[3]; + const path = match[4]; + const startLine = match[5] ? parseInt(match[5], 10) : undefined; + const endLine = match[6] ? parseInt(match[6], 10) : undefined; + return this.githubService.unfurlFile( + owner, repo, ref, path, startLine, endLine, url, + ); + } + + case 'github-pulls-list': + case 'github-issues-list': + case 'github-releases-list': + return this.githubService.unfurlCollectionPage( + accessToken, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url, + ); + + default: + throw new Error(`Unknown GitHub pattern type: ${patternType}`); + } + } + + describeLink( + patternType: string, + match: RegExpMatchArray, + ): LinkDescription | null { + const repo = `${match[1]}/${match[2]}`; + switch (patternType) { + case 'github-pr': + return { title: `Pull Request #${match[3]}`, description: repo }; + case 'github-pr-commit': + return { title: `Commit ${match[4].slice(0, 7)}`, description: repo }; + case 'github-issue': + return { title: `Issue #${match[3]}`, description: repo }; + case 'github-commit': + return { title: `Commit ${match[3].slice(0, 7)}`, description: repo }; + case 'github-file': + return { title: match[4], description: repo }; + case 'github-pulls-list': + return { title: 'Pull Requests', description: repo }; + case 'github-issues-list': + return { title: 'Issues', description: repo }; + case 'github-releases-list': + return { title: 'Releases', description: repo }; + case 'github-repo': + return { title: repo }; + default: + return null; + } + } + + private resolveBaseUrl(): string { + const baseUrl = process.env.INTEGRATION_GITHUB_URL; + return (baseUrl && normalizeBaseUrl(baseUrl)) || DEFAULT_BASE_URL; + } + + private resolveApiBaseUrl(): string { + const baseUrl = this.resolveBaseUrl(); + return baseUrl === DEFAULT_BASE_URL + ? 'https://api.github.com' + : `${baseUrl}/api/v3`; + } +} diff --git a/apps/server/src/core/integration/providers/github/github.service.spec.ts b/apps/server/src/core/integration/providers/github/github.service.spec.ts new file mode 100644 index 00000000..63703b7b --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.service.spec.ts @@ -0,0 +1,146 @@ +import { proxyFetch } from '../../../../common/proxy-fetch'; +import { UnfurlForbiddenError } from '../../registry/integration-provider.interface'; +import { GitHubProvider } from './github.provider'; +import { GitHubService } from './github.service'; + +// Mock only proxyFetch so the real providerApiFetch handles statuses. +jest.mock('../../../../common/proxy-fetch', () => ({ + proxyFetch: jest.fn(), +})); + +const fetchMock = proxyFetch as jest.Mock; + +const API = 'https://api.github.com'; +const ENTERPRISE_API = 'https://github.acme.com/api/v3'; +const PAGE = 'https://github.com/o/r/pull/1'; +const WHEN = '2026-10-01T10:00:00.000Z'; + +const BODY = { + number: 1, + title: 'Add dark mode', + created_at: WHEN, + updated_at: WHEN, + full_name: 'o/r', + sha: 'abc1234', + commit: { message: 'Fix header', author: { date: WHEN } }, +}; + +const service = new GitHubService(); + +const REQUESTS: [string, (api: string, owner: string, repo: string) => Promise, string][] = [ + ['pull request', (api, owner, repo) => service.unfurlPullRequest('token', api, owner, repo, 1, PAGE), '/pulls/1'], + ['issue', (api, owner, repo) => service.unfurlIssue('token', api, owner, repo, 2, PAGE), '/issues/2'], + ['repo', (api, owner, repo) => service.unfurlRepo('token', api, owner, repo, PAGE), ''], + ['commit', (api, owner, repo) => service.unfurlCommit('token', api, owner, repo, 'abc1234', PAGE), '/commits/abc1234'], + ['collection page', (api, owner, repo) => service.unfurlCollectionPage('token', api, owner, repo, 'pulls-list', PAGE), ''], +]; + +function requestedUrls(): string[] { + return fetchMock.mock.calls.map(([url]) => url); +} + +describe('GitHubService API paths', () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation( + async () => + new Response(JSON.stringify(BODY), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + + it.each(REQUESTS)('requests a %s under the plain repo path', async (_, request, suffix) => { + await request(API, 'o', 'r'); + + expect(requestedUrls()).toEqual([`${API}/repos/o/r${suffix}`]); + }); + + it.each(REQUESTS)( + 'percent-encodes owner and repo for a %s, so neither can add a query or path', + async (_, request, suffix) => { + await request(API, 'a b', 'r?x=/#'); + + const requested = new URL(requestedUrls()[0]); + expect(requested.pathname).toBe(`/repos/a%20b/r%3Fx%3D%2F%23${suffix}`); + expect(requested.search).toBe(''); + }, + ); + + it.each(REQUESTS)( + 'keeps an encoded dot segment for a %s inside the Enterprise api root', + async (_, request, suffix) => { + await request(ENTERPRISE_API, '%2e%2e', '..x'); + + expect(new URL(requestedUrls()[0]).pathname).toBe( + `/api/v3/repos/%252e%252e/..x${suffix}`, + ); + }, + ); + + describe.each([ + ['.', 'r'], + ['..', 'r'], + ['o', '.'], + ['o', '..'], + ])('with owner %j and repo %j', (owner, repo) => { + it.each(REQUESTS)('refuses a %s without calling GitHub', async (_, request) => { + await expect(request(ENTERPRISE_API, owner, repo)).rejects.toBeInstanceOf( + UnfurlForbiddenError, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); + +describe('GitHub connected account', () => { + const original = process.env.INTEGRATION_GITHUB_URL; + + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + id: 583231, + login: 'Philipinho', + name: 'Philip Okugbe', + email: 'philip@docmost.com', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + }); + + afterEach(() => { + if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = original; + }); + + it('reads the account from api.github.com and keeps no email', async () => { + delete process.env.INTEGRATION_GITHUB_URL; + const provider = new GitHubProvider(new GitHubService()); + + await expect( + provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }), + ).resolves.toEqual({ + id: '583231', + displayName: 'Philip Okugbe', + username: 'Philipinho', + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.github.com/user'); + expect(init.headers.Authorization).toBe('Bearer token'); + }); + + it('reads the account from the GitHub Enterprise API', async () => { + process.env.INTEGRATION_GITHUB_URL = 'https://github.acme.com'; + const provider = new GitHubProvider(new GitHubService()); + + await provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }); + + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://github.acme.com/api/v3/user', + ); + }); +}); diff --git a/apps/server/src/core/integration/providers/github/github.service.ts b/apps/server/src/core/integration/providers/github/github.service.ts new file mode 100644 index 00000000..e5f02241 --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.service.ts @@ -0,0 +1,282 @@ +import { Injectable } from '@nestjs/common'; +import { + ProviderAccount, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { relativeTime } from '../../utils/relative-time'; +import { providerApiFetch } from '../../utils/provider-fetch'; +import { toProviderAccount } from '../../utils/integration.utils'; +import { repoApiPath } from './github.utils'; + +@Injectable() +export class GitHubService { + async getAccount( + accessToken: string, + apiBaseUrl: string, + ): Promise { + const data = await this.apiGet(accessToken, apiBaseUrl, '/user'); + return toProviderAccount({ + id: data.id, + displayName: data.name, + username: data.login, + }); + } + + async unfurlPullRequest( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + number: number, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `${repoApiPath(owner, repo)}/pulls/${number}`, + ); + + const prAuthor = data.user?.login; + const prDesc = [ + `#${data.number}`, + relativeTime(data.updated_at ?? data.created_at), + prAuthor, + ].filter(Boolean).join(' · '); + + return { + title: data.title, + description: prDesc, + url, + provider: 'github', + providerIcon: 'github', + status: this.formatPrStatus(data), + statusColor: this.getPrStatusColor(data), + author: prAuthor, + authorAvatarUrl: data.user?.avatar_url, + metadata: { + type: 'pr', + number: data.number, + repo: `${owner}/${repo}`, + labels: data.labels?.map((l: any) => l.name) ?? [], + draft: data.draft, + additions: data.additions, + deletions: data.deletions, + }, + }; + } + + async unfurlIssue( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + number: number, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `${repoApiPath(owner, repo)}/issues/${number}`, + ); + + const issueAuthor = data.user?.login; + const issueDesc = [ + `#${data.number}`, + relativeTime(data.updated_at ?? data.created_at), + issueAuthor, + ].filter(Boolean).join(' · '); + + return { + title: data.title, + description: issueDesc, + url, + provider: 'github', + providerIcon: 'github', + status: data.state, + statusColor: data.state === 'open' ? 'green' : 'violet', + author: issueAuthor, + authorAvatarUrl: data.user?.avatar_url, + metadata: { + type: 'issue', + number: data.number, + repo: `${owner}/${repo}`, + labels: data.labels?.map((l: any) => l.name) ?? [], + assignees: data.assignees?.map((a: any) => a.login) ?? [], + }, + }; + } + + async unfurlRepo( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + repoApiPath(owner, repo), + ); + + const visibility = data.private ? 'Private' : 'Public'; + + return { + title: data.full_name, + description: data.description?.slice(0, 200) ?? undefined, + url, + provider: 'github', + providerIcon: 'github', + status: visibility, + statusColor: data.private ? 'gray' : 'green', + author: data.owner?.login, + authorAvatarUrl: data.owner?.avatar_url, + metadata: { + type: 'repo', + repo: `${owner}/${repo}`, + stars: data.stargazers_count, + forks: data.forks_count, + language: data.language, + defaultBranch: data.default_branch, + }, + }; + } + + async unfurlCommit( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + sha: string, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `${repoApiPath(owner, repo)}/commits/${sha}`, + ); + + const shortSha = data.sha?.slice(0, 7); + + const commitAuthor = data.author?.login ?? data.commit?.author?.name; + const commitDesc = [ + shortSha, + relativeTime(data.commit?.author?.date ?? data.commit?.committer?.date), + commitAuthor, + ].filter(Boolean).join(' · '); + + return { + title: data.commit?.message?.split('\n')[0] ?? shortSha, + description: commitDesc, + url, + provider: 'github', + providerIcon: 'github', + author: commitAuthor, + authorAvatarUrl: data.author?.avatar_url, + metadata: { + type: 'commit', + sha: data.sha, + shortSha, + repo: `${owner}/${repo}`, + stats: data.stats, + }, + }; + } + + unfurlFile( + owner: string, + repo: string, + ref: string, + path: string, + startLine: number | undefined, + endLine: number | undefined, + url: string, + ): UnfurlResult { + const fileName = path.split('/').pop() ?? path; + const lineRange = startLine + ? endLine + ? `L${startLine}-L${endLine}` + : `L${startLine}` + : undefined; + + return { + title: lineRange ? `${fileName}#${lineRange}` : fileName, + description: `${owner}/${repo} · ${ref.slice(0, 7)}`, + url, + provider: 'github', + providerIcon: 'github', + metadata: { + type: 'file', + repo: `${owner}/${repo}`, + ref, + path, + startLine, + endLine, + }, + }; + } + + async unfurlCollectionPage( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + collectionType: string, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + repoApiPath(owner, repo), + ); + + const labels: Record = { + 'pulls-list': 'Pull Requests', + 'issues-list': 'Issues', + 'releases-list': 'Releases', + }; + + return { + title: `${labels[collectionType] ?? collectionType} · ${data.full_name}`, + description: `${owner}/${repo}`, + url, + provider: 'github', + providerIcon: 'github', + author: data.owner?.login, + authorAvatarUrl: data.owner?.avatar_url, + metadata: { + type: collectionType, + repo: `${owner}/${repo}`, + }, + }; + } + + private formatPrStatus(pr: any): string { + if (pr.merged) return 'merged'; + if (pr.draft) return 'draft'; + return pr.state; + } + + private getPrStatusColor(pr: any): string { + if (pr.merged) return 'violet'; + if (pr.draft) return 'gray'; + if (pr.state === 'open') return 'green'; + return 'red'; + } + + private async apiGet( + accessToken: string, + apiBaseUrl: string, + path: string, + ): Promise { + const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'Docmost', + }, + }); + + return response.json(); + } +} diff --git a/apps/server/src/core/integration/providers/github/github.utils.ts b/apps/server/src/core/integration/providers/github/github.utils.ts new file mode 100644 index 00000000..5215feeb --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.utils.ts @@ -0,0 +1,9 @@ +import { UnfurlForbiddenError } from '../../registry/integration-provider.interface'; + +export function repoApiPath(owner: string, repo: string): string { + // encodeURIComponent keeps . and .., which the URL parser would resolve out of the path. + if ([owner, repo].some((name) => name === '.' || name === '..')) { + throw new UnfurlForbiddenError('GitHub repository path has a dot segment'); + } + return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; +} diff --git a/apps/server/src/core/integration/providers/linear/linear-patterns.spec.ts b/apps/server/src/core/integration/providers/linear/linear-patterns.spec.ts new file mode 100644 index 00000000..1841bc5c --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear-patterns.spec.ts @@ -0,0 +1,55 @@ +import { integrationLinkPatterns } from '@docmost/editor-ext'; +import { linearPatterns } from './linear-patterns'; + +function firstMatch(url: string) { + for (const pattern of linearPatterns) { + const match = url.match(pattern.regex); + if (match) return { type: pattern.type, groups: match.slice(1) }; + } + return null; +} + +const MATCHES: [string, string, (string | undefined)[]][] = [ + ['https://linear.app/acme/issue/ABC-1', 'linear-issue', ['acme', 'ABC-1', undefined]], + ['https://linear.app/acme/issue/ABC-1/fix-the-login', 'linear-issue', ['acme', 'ABC-1', 'fix-the-login']], + ['https://linear.app/acme/issue/ABC-1#comment-1/../..', 'linear-issue', ['acme', 'ABC-1', undefined]], + ['https://linear.app/acme/issue/ABC-1?q=../..', 'linear-issue', ['acme', 'ABC-1', undefined]], + ['https://linear.app/acme/project/mobile-app-1b9607f47174/overview', 'linear-project', ['acme', 'mobile-app-1b9607f47174']], + ['https://linear.app/acme/initiative/q4-goals-8a7b6c5d4e3f', 'linear-initiative', ['acme', 'q4-goals-8a7b6c5d4e3f']], + ['https://linear.app/acme/view/..x', 'linear-view', ['acme', '..x']], +]; + +// Each of these matched before the guard; the browser resolves them to a different path. +const NON_MATCHES = [ + 'https://linear.app/acme/issue/ABC-1/../../../evil/issue/XYZ-2', + 'https://linear.app/acme/issue/ABC-1/%2e%2e/%2E%2e/%2e./evil/issue/XYZ-2', + 'https://linear.app/acme/issue/ABC-1/slug/..', + 'https://linear.app/acme/project/p/../../../evil/project/q', + 'https://linear.app/acme/initiative/i\\..\\..\\evil', + 'https://linear.app/./view/v1', + 'https://linear.app/acme/view/v1/.', + 'https://linear.app/acme/view/v1/..?x=1', +]; + +describe('linearPatterns', () => { + it.each(MATCHES)('matches %s as %s', (url, type, groups) => { + expect(firstMatch(url)).toEqual({ type, groups }); + }); + + it.each(NON_MATCHES)('does not match %s', (url) => { + expect(firstMatch(url)).toBeNull(); + }); + + it('are the four patterns the editor matches pasted links with, in the same order', () => { + const shape = ({ type, regex }: { type: string; regex: RegExp }) => ({ + type, + source: regex.source, + flags: regex.flags, + }); + + expect(linearPatterns).toHaveLength(4); + expect( + integrationLinkPatterns.filter((pattern) => pattern.provider === 'linear').map(shape), + ).toEqual(linearPatterns.map(shape)); + }); +}); diff --git a/apps/server/src/core/integration/providers/linear/linear-patterns.ts b/apps/server/src/core/integration/providers/linear/linear-patterns.ts new file mode 100644 index 00000000..230a1186 --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear-patterns.ts @@ -0,0 +1,29 @@ +import { UnfurlPattern } from '../../registry/integration-provider.interface'; +import { CANONICAL_PATH } from '../../utils/canonical-path'; + +const LINEAR_APP = String.raw`^https?:\/\/${CANONICAL_PATH}linear\.app`; + +export const linearPatterns: UnfurlPattern[] = [ + // Issue: /:team/issue/:KEY-123(/:title-slug)?(#comment)? + { + regex: new RegExp( + String.raw`${LINEAR_APP}\/([^\/]+)\/issue\/([A-Z]+-\d+)(?:\/([^\/?#]+))?`, + ), + type: 'linear-issue', + }, + // Project: /:team/project/:slug(/:tab)? + { + regex: new RegExp(String.raw`${LINEAR_APP}\/([^\/]+)\/project\/([^\/]+)`), + type: 'linear-project', + }, + // Initiative: /:team/initiative/:slug(/:tab)? + { + regex: new RegExp(String.raw`${LINEAR_APP}\/([^\/]+)\/initiative\/([^\/]+)`), + type: 'linear-initiative', + }, + // View: /:team/view/:id(/:tab)? + { + regex: new RegExp(String.raw`${LINEAR_APP}\/([^\/]+)\/view\/([^\/]+)`), + type: 'linear-view', + }, +]; diff --git a/apps/server/src/core/integration/providers/linear/linear.module.ts b/apps/server/src/core/integration/providers/linear/linear.module.ts new file mode 100644 index 00000000..b93d9901 --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear.module.ts @@ -0,0 +1,21 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { LinearProvider } from './linear.provider'; +import { LinearService } from './linear.service'; +import { IntegrationRegistry } from '../../registry/integration-registry'; +import { IntegrationModule } from '../../integration.module'; + +@Module({ + imports: [IntegrationModule], + providers: [LinearProvider, LinearService], + exports: [LinearProvider], +}) +export class LinearModule implements OnModuleInit { + constructor( + private readonly registry: IntegrationRegistry, + private readonly linearProvider: LinearProvider, + ) {} + + onModuleInit() { + this.registry.register(this.linearProvider); + } +} diff --git a/apps/server/src/core/integration/providers/linear/linear.provider.ts b/apps/server/src/core/integration/providers/linear/linear.provider.ts new file mode 100644 index 00000000..fb7fa6b4 --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear.provider.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationProvider, + IntegrationDefinition, + LinkDescription, + ProviderAccount, + ResolveAccountOpts, + UnfurlOpts, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { LinearService } from './linear.service'; +import { linearPatterns } from './linear-patterns'; + +@Injectable() +export class LinearProvider extends IntegrationProvider { + definition: IntegrationDefinition = { + type: 'linear', + name: 'Linear', + description: 'Link previews for issues, projects, initiatives, and views', + icon: 'linear', + capabilities: ['oauth', 'unfurl'], + oauth: { + authUrl: 'https://linear.app/oauth/authorize', + tokenUrl: 'https://api.linear.app/oauth/token', + scopes: ['read'], + }, + unfurlPatterns: linearPatterns, + }; + + constructor(private readonly linearService: LinearService) { + super(); + } + + async resolveAccount(opts: ResolveAccountOpts): Promise { + return this.linearService.getAccount(opts.accessToken); + } + + async unfurl(opts: UnfurlOpts): Promise { + const { match, patternType, accessToken, url } = opts; + const teamSlug = match[1]; + + switch (patternType) { + case 'linear-issue': { + const issueId = match[2]; + return this.linearService.unfurlIssue(accessToken, issueId, url); + } + + case 'linear-project': { + const slugId = match[2]; + return this.linearService.unfurlProject(accessToken, slugId, teamSlug, url); + } + + case 'linear-initiative': { + const slugId = match[2]; + return this.linearService.unfurlInitiative(accessToken, slugId, teamSlug, url); + } + + case 'linear-view': { + const viewId = match[2]; + return this.linearService.unfurlView(teamSlug, viewId, url); + } + + default: + throw new Error(`Unknown Linear pattern type: ${patternType}`); + } + } + + describeLink( + patternType: string, + match: RegExpMatchArray, + ): LinkDescription | null { + const workspace = match[1]; + switch (patternType) { + case 'linear-issue': { + const titleSlug = match[3] ? this.humanizeSlug(match[3]) : null; + return { + title: `Issue ${match[2]}`, + description: titleSlug ?? workspace, + }; + } + case 'linear-project': + return { + title: this.humanizeSlug(match[2]) ?? 'Project', + description: 'Project', + }; + case 'linear-initiative': + return { + title: this.humanizeSlug(match[2]) ?? 'Initiative', + description: 'Initiative', + }; + case 'linear-view': + return { title: 'View', description: workspace }; + default: + return null; + } + } + + // "mobile-app-1b9607f47174" -> "Mobile app": slugs end in a hex id segment. + private humanizeSlug(slug: string): string | null { + const name = slug + .replace(/-[a-f0-9]{8,}$/, '') + .replace(/-/g, ' ') + .trim(); + if (!name || /^[a-f0-9]{8,}$/.test(name)) return null; + return name.charAt(0).toUpperCase() + name.slice(1); + } +} diff --git a/apps/server/src/core/integration/providers/linear/linear.service.spec.ts b/apps/server/src/core/integration/providers/linear/linear.service.spec.ts new file mode 100644 index 00000000..f2958bd2 --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear.service.spec.ts @@ -0,0 +1,58 @@ +import { proxyFetch } from '../../../../common/proxy-fetch'; +import { LinearProvider } from './linear.provider'; +import { LinearService } from './linear.service'; + +// Mock only proxyFetch so the real providerApiFetch handles statuses. +jest.mock('../../../../common/proxy-fetch', () => ({ + proxyFetch: jest.fn(), +})); + +const fetchMock = proxyFetch as jest.Mock; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('Linear connected account', () => { + const provider = new LinearProvider(new LinearService()); + + beforeEach(() => fetchMock.mockReset()); + + it('reads the viewer as the connected account', async () => { + fetchMock.mockResolvedValue( + json({ + data: { + viewer: { + id: '8f3e1b2a-4c5d-4e6f-9a0b-1c2d3e4f5a6b', + name: 'Philip Okugbe', + displayName: 'philip', + }, + }, + }), + ); + + await expect( + provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }), + ).resolves.toEqual({ + id: '8f3e1b2a-4c5d-4e6f-9a0b-1c2d3e4f5a6b', + displayName: 'Philip Okugbe', + username: 'philip', + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.linear.app/graphql'); + expect(JSON.parse(init.body).query).toContain( + 'viewer { id name displayName }', + ); + }); + + it('fails when Linear returns no viewer', async () => { + fetchMock.mockResolvedValue(json({ errors: [{ message: 'not authorized' }] })); + + await expect( + provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }), + ).rejects.toThrow('Provider profile has no account id'); + }); +}); diff --git a/apps/server/src/core/integration/providers/linear/linear.service.ts b/apps/server/src/core/integration/providers/linear/linear.service.ts new file mode 100644 index 00000000..7f25948e --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear.service.ts @@ -0,0 +1,275 @@ +import { Injectable } from '@nestjs/common'; +import { format } from 'date-fns'; +import { + ProviderAccount, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { providerApiFetch } from '../../utils/provider-fetch'; +import { toProviderAccount } from '../../utils/integration.utils'; + +const LINEAR_API = 'https://api.linear.app/graphql'; + +@Injectable() +export class LinearService { + async getAccount(accessToken: string): Promise { + const data = await this.graphqlRequest( + accessToken, + 'query Viewer { viewer { id name displayName } }', + {}, + ); + const viewer = data?.viewer; + return toProviderAccount({ + id: viewer?.id, + displayName: viewer?.name, + username: viewer?.displayName, + }); + } + + async unfurlIssue( + accessToken: string, + issueIdentifier: string, + url: string, + ): Promise { + const query = ` + query Issue($id: String!) { + issue(id: $id) { + title + identifier + priority + priorityLabel + createdAt + state { + name + color + } + assignee { + name + avatarUrl + } + team { + name + } + labels { + nodes { + name + color + } + } + } + } + `; + + const data = await this.graphqlRequest(accessToken, query, { + id: issueIdentifier, + }); + + const issue = data?.issue; + if (!issue) { + return { + title: issueIdentifier, + url, + provider: 'linear', + providerIcon: 'linear', + }; + } + + const descriptionParts = [ + issue.identifier, + issue.team?.name, + issue.assignee?.name ?? 'Unassigned', + this.formatDate(issue.createdAt), + ].filter(Boolean); + + return { + title: issue.title, + description: descriptionParts.join(' · '), + url, + provider: 'linear', + providerIcon: 'linear', + status: issue.state?.name, + statusColor: issue.state?.color, + author: issue.assignee?.name, + authorAvatarUrl: issue.assignee?.avatarUrl, + metadata: { + type: 'issue', + identifier: issue.identifier, + priority: issue.priorityLabel, + labels: + issue.labels?.nodes?.map((l: any) => ({ + name: l.name, + color: l.color, + })) ?? [], + }, + }; + } + + async unfurlProject( + accessToken: string, + slugId: string, + teamSlug: string, + url: string, + ): Promise { + const query = ` + query ProjectBySlug($slugId: String!) { + projects(filter: { slugId: { eq: $slugId } }, first: 1) { + nodes { + name + state + createdAt + lead { + name + avatarUrl + } + teams { + nodes { + name + } + } + } + } + } + `; + + const data = await this.graphqlRequest(accessToken, query, { slugId }); + const project = data?.projects?.nodes?.[0]; + + if (!project) { + return { + title: this.formatSlug(slugId), + description: teamSlug, + url, + provider: 'linear', + providerIcon: 'linear', + metadata: { type: 'project' }, + }; + } + + const teamName = project.teams?.nodes?.[0]?.name; + const descriptionParts = [ + 'Project', + this.formatDate(project.createdAt), + project.lead?.name, + teamName, + ].filter(Boolean); + + return { + title: project.name, + description: descriptionParts.join(' · '), + url, + provider: 'linear', + providerIcon: 'linear', + status: this.formatProjectState(project.state), + statusColor: this.getProjectStatusColor(project.state), + author: project.lead?.name, + authorAvatarUrl: project.lead?.avatarUrl, + metadata: { type: 'project' }, + }; + } + + async unfurlInitiative( + accessToken: string, + slugId: string, + teamSlug: string, + url: string, + ): Promise { + const query = ` + query InitiativeBySlug($slugId: String!) { + initiatives(filter: { slugId: { eq: $slugId } }, first: 1) { + nodes { + name + description + status + } + } + } + `; + + const data = await this.graphqlRequest(accessToken, query, { slugId }); + const initiative = data?.initiatives?.nodes?.[0]; + + if (!initiative) { + return { + title: this.formatSlug(slugId), + description: teamSlug, + url, + provider: 'linear', + providerIcon: 'linear', + metadata: { type: 'initiative' }, + }; + } + + return { + title: initiative.name, + description: initiative.description?.slice(0, 200) ?? undefined, + url, + provider: 'linear', + providerIcon: 'linear', + status: initiative.status, + metadata: { type: 'initiative' }, + }; + } + + unfurlView( + teamSlug: string, + viewId: string, + url: string, + ): UnfurlResult { + return { + title: `View · ${this.formatSlug(viewId)}`, + description: teamSlug, + url, + provider: 'linear', + providerIcon: 'linear', + metadata: { type: 'view' }, + }; + } + + private formatProjectState(state: string): string { + return state.charAt(0).toUpperCase() + state.slice(1); + } + + private formatDate(iso: string): string { + return format(new Date(iso), 'MMM d'); + } + + private formatSlug(slug: string): string { + return slug + .replace(/-[a-f0-9]+$/, '') + .replace(/-/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + + private getProjectStatusColor(state: string): string { + switch (state) { + case 'completed': + return 'green'; + case 'started': + return 'yellow'; + case 'planned': + return 'blue'; + case 'paused': + case 'canceled': + return 'gray'; + default: + return 'gray'; + } + } + + private async graphqlRequest( + accessToken: string, + query: string, + variables: Record, + ): Promise { + const response = await providerApiFetch('Linear', LINEAR_API, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }); + + const json = await response.json(); + return json.data; + } +} diff --git a/apps/server/src/core/integration/registry/integration-provider.interface.ts b/apps/server/src/core/integration/registry/integration-provider.interface.ts new file mode 100644 index 00000000..43d1aab4 --- /dev/null +++ b/apps/server/src/core/integration/registry/integration-provider.interface.ts @@ -0,0 +1,230 @@ +import { FeatureKey } from '../../../common/features'; +import { KyselyTransaction } from '@docmost/db/types/kysely.types'; + +export type IntegrationCapability = 'oauth' | 'unfurl' | 'actions'; + +// Proves which provider account a member holds, without granting API scopes. +export type IdentityOAuthConfig = { + authUrl: string; + tokenUrl: string; + scopes: string[]; + // Extra authorize-URL query params, e.g. Slack's team pin. + authParams?: Record; +}; + +export type OAuthConfig = { + authUrl: string; + tokenUrl: string; + scopes: string[]; + // 'workspace': one shared bot connection (Slack). 'user' (default): a token per Docmost user. + connectionScope?: 'workspace' | 'user'; + // Env credential prefix shared by products on one authorization server (atlassian, google). + credentialsKey?: string; + // Repeat the scopes on refresh, for token endpoints that pick the resource from them (Microsoft Entra). + scopeOnRefresh?: boolean; + // Extra authorize-URL query params the provider's docs require, e.g. Atlassian's audience. + authParams?: Record; + // Token refresh endpoint when the provider uses a separate one (Figma). + refreshUrl?: string; + // Send the client id and secret as HTTP Basic instead of in the body (Figma). + clientAuth?: 'basic'; + // Without it, workspace-scoped members can only link from inside the provider. + identity?: IdentityOAuthConfig; +}; + +// The provider account a connection authenticated as; id is also stored in provider_user_id. +export type ProviderAccount = { + id: string; + displayName?: string; + username?: string; +}; + +export type IdentityLinkEvent = { + integrationId: string; + workspaceId: string; + // The Docmost user linking their account. + userId: string; + tokenResponse: Record; + settings: Record; +}; + +export type ResolvedIdentity = { + account: ProviderAccount; + // Verified email; must match the Docmost user's for the link to succeed. + email?: string; + metadata?: Record; +}; + +export type ResolveAccountOpts = { + accessToken: string; + tokenResponse: Record; +}; + +export class IdentityEmailMismatchError extends Error { + constructor(message = 'Provider account email does not match your account') { + super(message); + this.name = 'IdentityEmailMismatchError'; + } +} + +// Thrown by resolveIdentity for an account outside the installed tenant. +export class IdentityTenantMismatchError extends Error { + constructor(message = 'Provider account belongs to a different tenant') { + super(message); + this.name = 'IdentityTenantMismatchError'; + } +} + +// Thrown by onConnected; inbound events must route to a single Docmost workspace. +export class IntegrationTenantInUseError extends Error { + constructor(message = 'Provider tenant is already connected to another workspace') { + super(message); + this.name = 'IntegrationTenantInUseError'; + } +} + +export type UnfurlPattern = { + regex: RegExp; + type: string; +}; + +export type UnfurlResult = { + title: string; + description?: string; + url: string; + provider: string; + providerIcon?: string; + status?: string; + statusColor?: string; + author?: string; + authorAvatarUrl?: string; + metadata?: Record; +}; + +export type IntegrationDefinition = { + type: string; + name: string; + description: string; + icon: string; + capabilities: IntegrationCapability[]; + oauth?: OAuthConfig; + unfurlPatterns?: UnfurlPattern[]; + // Not installable, but existing installations keep unfurling. + hidden?: boolean; + // License feature the install is gated on; unset = free. + requiresFeature?: FeatureKey; +}; + +export type ConnectedEvent = { + integrationId: string; + workspaceId: string; + accessToken: string; + refreshToken?: string; + // The Docmost user who completed the install flow. + userId: string; + metadata: Record; + // The installer's provider account, when it could be read. + account?: ProviderAccount; + // Provider writes must use it so a throw rolls back the install. + trx?: KyselyTransaction; +}; + +export type UnfurlOpts = { + url: string; + accessToken: string; + match: RegExpMatchArray; + patternType: string; + settings?: Record; + // Shared-connection providers must check this user can see the resource. + userId: string; + integrationId: string; +}; + +// UnfurlService shows no card for it instead of logging an error. +export class UnfurlForbiddenError extends Error { + constructor(message = 'Not authorized to unfurl this link') { + super(message); + this.name = 'UnfurlForbiddenError'; + } +} + +// The user has no linked provider identity yet (e.g. no Slack account link). +export class UnfurlNeedsConnectionError extends Error { + constructor(message = 'User has not connected this integration') { + super(message); + this.name = 'UnfurlNeedsConnectionError'; + } +} + +// The provider rejected the stored credential (API 401 or invalid_grant). +export class TokenInvalidError extends Error { + constructor(message = 'Integration credential is no longer valid') { + super(message); + this.name = 'TokenInvalidError'; + } +} + +// The access token expired and the refresh job has not renewed it yet. +export class TokenExpiredError extends Error { + constructor(message = 'Integration credential expired and awaits refresh') { + super(message); + this.name = 'TokenExpiredError'; + } +} + +export class ProviderApiError extends Error { + constructor( + readonly provider: string, + readonly status: number, + statusText = '', + ) { + super(`${provider} API error: ${status} ${statusText}`.trimEnd()); + this.name = 'ProviderApiError'; + } +} + +export type LinkDescription = { + title: string; + description?: string; +}; + +export type UnfurlNeedsConnection = { + needsConnection: true; + integrationId: string; + integrationType: string; + integrationName: string; + title: string; + description?: string; +}; + +export abstract class IntegrationProvider { + abstract definition: IntegrationDefinition; + + getOAuthConfig?( + workspaceSettings: Record, + ): OAuthConfig; + + getUnfurlPatterns?( + workspaceSettings: Record, + ): UnfurlPattern[]; + + // Lowercase hostnames the client offers cards on; "*.example.com" matches any subdomain. + getUnfurlHosts?(workspaceSettings: Record): string[]; + + onConnected?(opts: ConnectedEvent): Promise; + + // Maps an identity-flow token response to a provider account in the installed tenant. + resolveIdentity?(opts: IdentityLinkEvent): Promise; + + // Reads which account a fresh token belongs to; a throw only skips storing it. + resolveAccount?(opts: ResolveAccountOpts): Promise; + + unfurl?(opts: UnfurlOpts): Promise; + + // Tokenless summary for the connect prompt, e.g. "Pull Request #13337". + describeLink?( + patternType: string, + match: RegExpMatchArray, + url: string, + ): LinkDescription | null; +} diff --git a/apps/server/src/core/integration/registry/integration-registry.spec.ts b/apps/server/src/core/integration/registry/integration-registry.spec.ts new file mode 100644 index 00000000..48ad4f53 --- /dev/null +++ b/apps/server/src/core/integration/registry/integration-registry.spec.ts @@ -0,0 +1,25 @@ +import { IntegrationRegistry } from './integration-registry'; +import { IntegrationProvider } from './integration-provider.interface'; + +const provider = (type: string) => + ({ definition: { type, name: type } }) as unknown as IntegrationProvider; + +describe('IntegrationRegistry', () => { + it('registers providers by type', () => { + const registry = new IntegrationRegistry(); + const github = provider('github'); + + registry.register(github); + + expect(registry.getProvider('github')).toBe(github); + }); + + it('refuses a second provider with the same type', () => { + const registry = new IntegrationRegistry(); + registry.register(provider('linear')); + + expect(() => registry.register(provider('linear'))).toThrow( + 'Integration provider "linear" is already registered', + ); + }); +}); diff --git a/apps/server/src/core/integration/registry/integration-registry.ts b/apps/server/src/core/integration/registry/integration-registry.ts new file mode 100644 index 00000000..02edfd4f --- /dev/null +++ b/apps/server/src/core/integration/registry/integration-registry.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationDefinition, + IntegrationProvider, +} from './integration-provider.interface'; + +@Injectable() +export class IntegrationRegistry { + private providers = new Map(); + + register(provider: IntegrationProvider): void { + const { type } = provider.definition; + if (this.providers.has(type)) { + throw new Error(`Integration provider "${type}" is already registered`); + } + this.providers.set(type, provider); + } + + getProvider(type: string): IntegrationProvider | undefined { + return this.providers.get(type); + } + + getAllProviders(): IntegrationProvider[] { + return Array.from(this.providers.values()); + } + + getAvailableIntegrations(): IntegrationDefinition[] { + return this.getAllProviders() + .map((p) => p.definition) + .filter((definition) => !definition.hidden); + } + + findUnfurlProvider( + url: string, + ): { + provider: IntegrationProvider; + match: RegExpMatchArray; + patternType: string; + } | null { + for (const provider of this.providers.values()) { + if (!provider.definition.unfurlPatterns) continue; + for (const pattern of provider.definition.unfurlPatterns) { + const match = url.match(pattern.regex); + if (match) { + return { provider, match, patternType: pattern.type }; + } + } + } + return null; + } +} diff --git a/apps/server/src/core/integration/repos/integration-connection.repo.spec.ts b/apps/server/src/core/integration/repos/integration-connection.repo.spec.ts new file mode 100644 index 00000000..b66911dd --- /dev/null +++ b/apps/server/src/core/integration/repos/integration-connection.repo.spec.ts @@ -0,0 +1,236 @@ +import { + CamelCasePlugin, + CompiledQuery, + DummyDriver, + Kysely, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, +} from 'kysely'; +import { IntegrationConnectionRepo } from './integration-connection.repo'; + +function buildRepo() { + const queries: CompiledQuery[] = []; + const db = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (kysely) => new PostgresIntrospector(kysely), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + plugins: [new CamelCasePlugin()], + log: (event) => { + if (event.level === 'query') queries.push(event.query); + }, + }); + return { repo: new IntegrationConnectionRepo(db as any), queries }; +} + +describe('IntegrationConnectionRepo personal connections', () => { + it('states the partial index predicate when finding a personal connection', async () => { + const { repo, queries } = buildRepo(); + + await repo.findByIntegrationAndUser('integration-1', 'user-1'); + + expect(queries[0].sql).toBe( + 'select * from "integration_connections" where "integration_id" = $1 and "user_id" = $2 and "kind" = $3', + ); + expect(queries[0].parameters).toEqual(['integration-1', 'user-1', 'user']); + }); + + it('disconnects only the personal connection, never the workspace bot row', async () => { + const { repo, queries } = buildRepo(); + + await expect( + repo.deleteByIntegrationAndUser('integration-1', 'user-1'), + ).resolves.toBe(false); + + expect(queries[0].sql).toBe( + 'delete from "integration_connections" where "integration_id" = $1 and "user_id" = $2 and "kind" = $3', + ); + expect(queries[0].parameters).toEqual(['integration-1', 'user-1', 'user']); + }); +}); + +describe('IntegrationConnectionRepo offboarding', () => { + it('deletes only the personal connections of the user', async () => { + const { repo, queries } = buildRepo(); + + await repo.deleteUserConnections('user-1'); + + expect(queries).toHaveLength(1); + expect(queries[0].sql).toBe( + 'delete from "integration_connections" where "user_id" = $1 and "kind" = $2', + ); + expect(queries[0].parameters).toEqual(['user-1', 'user']); + }); + + it('skips tokens of deactivated or deleted users but keeps workspace bot rows', async () => { + const { repo, queries } = buildRepo(); + + await repo.findExpiringTokens(60_000, 500); + + const { sql, parameters } = queries[0]; + expect(sql).toContain( + 'inner join "users" on "users"."id" = "integration_connections"."user_id"', + ); + const kindParam = parameters.indexOf('workspace') + 1; + expect(kindParam).toBeGreaterThan(0); + expect(sql).toContain( + `("integration_connections"."kind" = $${kindParam} or ("users"."deactivated_at" is null and "users"."deleted_at" is null))`, + ); + }); +}); + +describe('IntegrationConnectionRepo expiring tokens', () => { + it('loads a capped batch of the soonest-expiring refreshable tokens', async () => { + const { repo, queries } = buildRepo(); + const before = Date.now(); + + await repo.findExpiringTokens(60_000, 500); + + const { sql, parameters } = queries[0]; + const n = parameters.length; + expect( + sql.slice(sql.indexOf('"integration_connections"."invalidated_at"')), + ).toBe( + `"integration_connections"."invalidated_at" is null and "integration_connections"."refresh_token" is not null and "integration_connections"."token_expires_at" is not null and "integration_connections"."token_expires_at" < $${n - 1} order by "integration_connections"."token_expires_at" asc limit $${n}`, + ); + expect(parameters[n - 1]).toBe(500); + const threshold = (parameters[n - 2] as Date).getTime(); + expect(threshold).toBeGreaterThanOrEqual(before + 60_000); + expect(threshold).toBeLessThanOrEqual(Date.now() + 60_000); + }); +}); + +describe('IntegrationConnectionRepo token writes', () => { + it('saves refreshed tokens only while the row still holds the refresh token that was sent', async () => { + const { repo, queries } = buildRepo(); + const tokenExpiresAt = new Date(); + + await repo.updateIfTokensMatch( + 'connection-1', + { refreshToken: 'sent-refresh-token' }, + { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + tokenExpiresAt, + invalidatedAt: null, + }, + ); + + expect(queries).toHaveLength(1); + expect(queries[0].sql).toBe( + 'update "integration_connections" set "access_token" = $1, "refresh_token" = $2, "token_expires_at" = $3, "invalidated_at" = $4, "updated_at" = $5 where "id" = $6 and "refresh_token" = $7', + ); + expect(queries[0].parameters).toEqual([ + 'new-access-token', + 'new-refresh-token', + tokenExpiresAt, + null, + expect.any(Date), + 'connection-1', + 'sent-refresh-token', + ]); + }); + + it('retires a connection only while it still holds the expected access token', async () => { + const { repo, queries } = buildRepo(); + + await repo.invalidate('connection-1', { accessToken: 'used-access-token' }); + + expect(queries[0].sql).toBe( + 'update "integration_connections" set "invalidated_at" = $1, "refresh_token" = $2, "token_expires_at" = $3, "updated_at" = $4 where "id" = $5 and "access_token" = $6', + ); + expect(queries[0].parameters.slice(1)).toEqual([ + null, + null, + expect.any(Date), + 'connection-1', + 'used-access-token', + ]); + }); + + it('retires a connection unconditionally when no token is expected', async () => { + const { repo, queries } = buildRepo(); + + await repo.invalidate('connection-1'); + + expect(queries[0].sql).toBe( + 'update "integration_connections" set "invalidated_at" = $1, "refresh_token" = $2, "token_expires_at" = $3, "updated_at" = $4 where "id" = $5', + ); + }); +}); + +describe('IntegrationConnectionRepo metadata on reconnect', () => { + const MERGE = + `"metadata" = (coalesce("integration_connections"."metadata", '{}'::jsonb) - 'account') || coalesce("excluded"."metadata", '{}'::jsonb)`; + + function conflictParam(sql: string, parameters: readonly unknown[], column: string) { + const setClause = sql.slice(sql.indexOf('do update set')); + const index = Number(setClause.match(new RegExp(`"${column}" = \\$(\\d+)`))![1]); + return parameters[index - 1]; + } + + it('swaps the account and keeps other keys when a token connection reconnects', async () => { + const { repo, queries } = buildRepo(); + + await repo + .upsert({ + integrationId: 'integration-1', + userId: 'user-1', + workspaceId: 'workspace-1', + accessToken: 'token', + refreshToken: null, + tokenExpiresAt: null, + scopes: null, + providerUserId: '583231', + metadata: { account: { id: '583231' } }, + }) + .catch(() => undefined); + + expect(queries[0].sql).toContain(MERGE); + expect( + conflictParam(queries[0].sql, queries[0].parameters, 'provider_user_id'), + ).toBe('583231'); + }); + + it('clears the stored account id when a reconnect could not read the account', async () => { + const { repo, queries } = buildRepo(); + + await repo + .upsert({ + integrationId: 'integration-1', + userId: 'user-1', + workspaceId: 'workspace-1', + accessToken: 'token', + refreshToken: null, + tokenExpiresAt: null, + scopes: null, + providerUserId: null, + metadata: null, + }) + .catch(() => undefined); + + expect(queries[0].sql).toContain(MERGE); + expect( + conflictParam(queries[0].sql, queries[0].parameters, 'provider_user_id'), + ).toBeNull(); + }); + + it('merges an identity link the same way, so a relink keeps notifyEnabled', async () => { + const { repo, queries } = buildRepo(); + + await repo + .upsertUserLink({ + integrationId: 'integration-1', + workspaceId: 'workspace-1', + userId: 'user-1', + providerUserId: 'U-1', + metadata: { slackTeamId: 'T-1', account: { id: 'U-1' } }, + }) + .catch(() => undefined); + + expect(queries[0].sql).toContain(MERGE); + }); +}); diff --git a/apps/server/src/core/integration/repos/integration-connection.repo.ts b/apps/server/src/core/integration/repos/integration-connection.repo.ts new file mode 100644 index 00000000..5b326a56 --- /dev/null +++ b/apps/server/src/core/integration/repos/integration-connection.repo.ts @@ -0,0 +1,371 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { sql } from 'kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { Json } from '@docmost/db/types/db'; +import { + IntegrationConnection, + InsertableIntegrationConnection, + UpdatableIntegrationConnection, +} from '@docmost/db/types/entity.types'; +import { dbOrTx } from '@docmost/db/utils'; + +type ExpectedTokens = Partial< + Pick +>; + +@Injectable() +export class IntegrationConnectionRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById( + connectionId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('id', '=', connectionId) + .executeTakeFirst(); + } + + async findByIntegrationAndUser( + integrationId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('integrationId', '=', integrationId) + .where('userId', '=', userId) + .where('kind', '=', 'user') + .executeTakeFirst(); + } + + async upsert( + connection: InsertableIntegrationConnection, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + // The unique index is partial on kind='user', so ON CONFLICT must repeat the predicate. + return db + .insertInto('integrationConnections') + .values(connection) + .onConflict((oc) => + oc + .columns(['integrationId', 'userId']) + .where(sql.ref('kind'), '=', 'user') + .doUpdateSet({ + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + tokenExpiresAt: connection.tokenExpiresAt, + invalidatedAt: null, + scopes: connection.scopes, + providerUserId: connection.providerUserId ?? null, + metadata: this.mergedMetadata(), + updatedAt: new Date(), + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async upsertWorkspaceConnection( + input: { + integrationId: string; + userId: string; + workspaceId: string; + accessToken: string; + refreshToken?: string | null; + tokenExpiresAt?: Date | null; + scopes?: string | null; + }, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + + const existing = await this.findWorkspaceConnection(input.integrationId, trx); + if (existing) { + return this.update( + existing.id, + { + accessToken: input.accessToken, + refreshToken: input.refreshToken ?? null, + tokenExpiresAt: input.tokenExpiresAt ?? null, + invalidatedAt: null, + scopes: input.scopes ?? null, + userId: input.userId, + }, + trx, + ); + } + + return db + .insertInto('integrationConnections') + .values({ + integrationId: input.integrationId, + userId: input.userId, + workspaceId: input.workspaceId, + accessToken: input.accessToken, + refreshToken: input.refreshToken ?? null, + tokenExpiresAt: input.tokenExpiresAt ?? null, + scopes: input.scopes ?? null, + kind: 'workspace', + }) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async update( + connectionId: string, + data: UpdatableIntegrationConnection, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .updateTable('integrationConnections') + .set({ ...data, updatedAt: new Date() }) + .where('id', '=', connectionId) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async deleteByIntegrationAndUser( + integrationId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + // The shared workspace row carries the installer's userId; a personal disconnect must not delete it. + const result = await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', integrationId) + .where('userId', '=', userId) + .where('kind', '=', 'user') + .executeTakeFirst(); + return Number(result.numDeletedRows) > 0; + } + + async findByUserAndWorkspace( + userId: string, + workspaceId: string, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .innerJoin( + 'integrations', + 'integrations.id', + 'integrationConnections.integrationId', + ) + .select([ + 'integrationConnections.integrationId', + 'integrations.type', + 'integrationConnections.providerUserId', + 'integrationConnections.metadata', + 'integrationConnections.createdAt', + 'integrationConnections.invalidatedAt', + ]) + .where('integrationConnections.userId', '=', userId) + // Skip the workspace bot row, which carries the installer's userId. + .where('integrationConnections.kind', '=', 'user') + .where('integrations.workspaceId', '=', workspaceId) + .where('integrations.deletedAt', 'is', null) + .execute(); + } + + async findExpiringTokens( + expiresBeforeMs: number, + limit: number, + ): Promise { + const threshold = new Date(Date.now() + expiresBeforeMs); + return this.db + .selectFrom('integrationConnections') + .innerJoin( + 'integrations', + 'integrations.id', + 'integrationConnections.integrationId', + ) + .innerJoin('users', 'users.id', 'integrationConnections.userId') + .selectAll('integrationConnections') + .where('integrations.deletedAt', 'is', null) + // The workspace bot row carries its installer's userId and must outlive them. + .where((eb) => + eb.or([ + eb('integrationConnections.kind', '=', 'workspace'), + eb.and([ + eb('users.deactivatedAt', 'is', null), + eb('users.deletedAt', 'is', null), + ]), + ]), + ) + .where('integrationConnections.invalidatedAt', 'is', null) + .where('integrationConnections.refreshToken', 'is not', null) + .where('integrationConnections.tokenExpiresAt', 'is not', null) + .where('integrationConnections.tokenExpiresAt', '<', threshold) + .orderBy('integrationConnections.tokenExpiresAt', 'asc') + .limit(limit) + .execute(); + } + + async updateIfTokensMatch( + connectionId: string, + expected: ExpectedTokens, + data: UpdatableIntegrationConnection, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + let query = db + .updateTable('integrationConnections') + .set({ ...data, updatedAt: new Date() }) + .where('id', '=', connectionId); + if (expected.accessToken !== undefined) { + query = query.where('accessToken', '=', expected.accessToken); + } + if (expected.refreshToken !== undefined) { + query = query.where('refreshToken', '=', expected.refreshToken); + } + const result = await query.executeTakeFirst(); + return Number(result.numUpdatedRows) > 0; + } + + async invalidate( + connectionId: string, + expected: ExpectedTokens = {}, + trx?: KyselyTransaction, + ): Promise { + await this.updateIfTokensMatch( + connectionId, + expected, + { invalidatedAt: new Date(), refreshToken: null, tokenExpiresAt: null }, + trx, + ); + } + + async deleteByIntegration( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', integrationId) + .execute(); + } + + // Leaves the workspace bot row a departing user installed. + async deleteUserConnections( + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationConnections') + .where('userId', '=', userId) + .where('kind', '=', 'user') + .execute(); + } + + async findWorkspaceConnection( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('integrationId', '=', integrationId) + .where('kind', '=', 'workspace') + .executeTakeFirst(); + } + + async findUserLink( + integrationId: string, + providerUserId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('integrationId', '=', integrationId) + .where('providerUserId', '=', providerUserId) + .where('kind', '=', 'user') + .executeTakeFirst(); + } + + async findUserLinkByUserId( + integrationId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('integrationId', '=', integrationId) + .where('userId', '=', userId) + .where('kind', '=', 'user') + .executeTakeFirst(); + } + + async deleteUserLink( + integrationId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', integrationId) + .where('userId', '=', userId) + .where('kind', '=', 'user') + .execute(); + } + + async upsertUserLink( + input: { + integrationId: string; + workspaceId: string; + userId: string; + providerUserId: string; + metadata: Record; + }, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + // The unique index is partial on kind='user', so ON CONFLICT must repeat the predicate. + return await db + .insertInto('integrationConnections') + .values({ + integrationId: input.integrationId, + workspaceId: input.workspaceId, + userId: input.userId, + providerUserId: input.providerUserId, + kind: 'user', + metadata: input.metadata as any, + accessToken: null, + }) + .onConflict((oc) => + oc + .columns(['integrationId', 'userId']) + .where(sql.ref('kind'), '=', 'user') + .doUpdateSet({ + providerUserId: input.providerUserId, + metadata: this.mergedMetadata(), + updatedAt: new Date(), + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + } + + // Swaps the account and keeps keys this write leaves out, such as Slack's notifyEnabled. + private mergedMetadata() { + return sql`(coalesce(${sql.ref('integrationConnections.metadata')}, '{}'::jsonb) - 'account') || coalesce(${sql.ref('excluded.metadata')}, '{}'::jsonb)`; + } +} diff --git a/apps/server/src/core/integration/repos/integration.repo.ts b/apps/server/src/core/integration/repos/integration.repo.ts new file mode 100644 index 00000000..b1ba687d --- /dev/null +++ b/apps/server/src/core/integration/repos/integration.repo.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { sql } from 'kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { + Integration, + InsertableIntegration, + UpdatableIntegration, +} from '@docmost/db/types/entity.types'; +import { dbOrTx } from '@docmost/db/utils'; + +@Injectable() +export class IntegrationRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrations') + .selectAll() + .where('id', '=', integrationId) + .where('deletedAt', 'is', null) + .executeTakeFirst(); + } + + async findByWorkspaceAndType( + workspaceId: string, + type: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrations') + .selectAll() + .where('workspaceId', '=', workspaceId) + .where('type', '=', type) + .where('deletedAt', 'is', null) + .executeTakeFirst(); + } + + async findAllByWorkspace( + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrations') + .selectAll() + .where('workspaceId', '=', workspaceId) + .where('deletedAt', 'is', null) + .execute(); + } + + async insertOrRestore( + integration: InsertableIntegration, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .insertInto('integrations') + .values(integration) + .onConflict((oc) => + oc.columns(['type', 'workspaceId']).doUpdateSet({ + deletedAt: null, + installedById: integration.installedById, + // Stale settings could re-claim a tenant (slackTeamId) another workspace now holds. + settings: null, + updatedAt: new Date(), + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async update( + integrationId: string, + data: UpdatableIntegration, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .updateTable('integrations') + .set({ ...data, updatedAt: new Date() }) + .where('id', '=', integrationId) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async softDelete( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .updateTable('integrations') + .set({ deletedAt: new Date() }) + .where('id', '=', integrationId) + .execute(); + } + + async findByTypeAndSettingsField( + type: string, + key: string, + value: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrations') + .selectAll() + .where('type', '=', type) + .where('deletedAt', 'is', null) + .where(sql`settings->>${sql.lit(key)}`, '=', value) + .executeTakeFirst(); + } +} diff --git a/apps/server/src/core/integration/unfurl/unfurl.controller.spec.ts b/apps/server/src/core/integration/unfurl/unfurl.controller.spec.ts new file mode 100644 index 00000000..8d7f3d4e --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.controller.spec.ts @@ -0,0 +1,59 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ThrottlerException, ThrottlerStorageService } from '@nestjs/throttler'; +import { UnfurlController } from './unfurl.controller'; +import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard'; +import { + AUTH_THROTTLER, + UNFURL_THROTTLER, +} from '../../../integrations/throttle/throttler-names'; + +describe('UnfurlController throttling', () => { + let storage: ThrottlerStorageService; + let guard: UserThrottlerGuard; + + beforeEach(async () => { + storage = new ThrottlerStorageService(); + guard = new UserThrottlerGuard( + { + throttlers: [ + { name: AUTH_THROTTLER, ttl: 60_000, limit: 10 }, + { name: UNFURL_THROTTLER, ttl: 60_000, limit: 120 }, + ], + }, + storage, + new Reflector(), + ); + await guard.onModuleInit(); + }); + + afterEach(() => storage.onApplicationShutdown()); + + function contextFor(userId: string): ExecutionContext { + const req = { ip: '10.0.0.1', headers: {}, user: { user: { id: userId } } }; + const res = { header: jest.fn() }; + return { + getHandler: () => UnfurlController.prototype.unfurl, + getClass: () => UnfurlController, + switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }), + } as unknown as ExecutionContext; + } + + async function hit(userId: string, times: number) { + for (let i = 0; i < times; i++) { + await guard.canActivate(contextFor(userId)); + } + } + + it('allows 120 requests per user per minute and rejects the next one', async () => { + await hit('user-a', 120); + await expect(guard.canActivate(contextFor('user-a'))).rejects.toThrow( + ThrottlerException, + ); + }); + + it('tracks users separately when they share an IP', async () => { + await hit('user-a', 120); + await expect(guard.canActivate(contextFor('user-b'))).resolves.toBe(true); + }); +}); diff --git a/apps/server/src/core/integration/unfurl/unfurl.controller.ts b/apps/server/src/core/integration/unfurl/unfurl.controller.ts new file mode 100644 index 00000000..7d458988 --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.controller.ts @@ -0,0 +1,43 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { UnfurlService } from './unfurl.service'; +import { UnfurlDto } from '../dto/integration.dto'; +import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard'; +import { + ALL_NAMED_THROTTLERS_SKIPPED, + UNFURL_THROTTLER, +} from '../../../integrations/throttle/throttler-names'; + +@Controller('integrations') +export class UnfurlController { + constructor(private readonly unfurlService: UnfurlService) {} + + @UseGuards(JwtAuthGuard, UserThrottlerGuard) + @SkipThrottle({ ...ALL_NAMED_THROTTLERS_SKIPPED, [UNFURL_THROTTLER]: false }) + @Throttle({ [UNFURL_THROTTLER]: { limit: 120, ttl: 60_000 } }) + @HttpCode(HttpStatus.OK) + @Post('unfurl') + async unfurl( + @Body() dto: UnfurlDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const result = await this.unfurlService.unfurl( + dto.url, + user.id, + workspace.id, + ); + return { data: result }; + } +} diff --git a/apps/server/src/core/integration/unfurl/unfurl.service.spec.ts b/apps/server/src/core/integration/unfurl/unfurl.service.spec.ts new file mode 100644 index 00000000..5621389d --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.service.spec.ts @@ -0,0 +1,324 @@ +import * as crypto from 'crypto'; +import { UnfurlService } from './unfurl.service'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import { GitHubProvider } from '../providers/github/github.provider'; +import { + IntegrationDefinition, + IntegrationProvider, + TokenExpiredError, + TokenInvalidError, +} from '../registry/integration-provider.interface'; + +const GHE_BASE = 'https://github.acme.com'; +const CONNECTED_AT = new Date('2026-10-01T09:00:00Z'); + +class WorkspaceScopedProvider extends IntegrationProvider { + definition: IntegrationDefinition = { + type: 'chat', + name: 'Chat', + description: '', + icon: '', + capabilities: ['unfurl'], + oauth: { authUrl: '', tokenUrl: '', scopes: [], connectionScope: 'workspace' }, + unfurlPatterns: [{ regex: /^https:\/\/chat\.example\/m\/(\w+)$/, type: 'message' }], + }; + unfurl = jest.fn().mockResolvedValue({ title: 'Message' }); +} + +function build() { + const unfurlPullRequest = jest.fn().mockResolvedValue({ title: 'PR' }); + const registry = new IntegrationRegistry(); + registry.register(new GitHubProvider({ unfurlPullRequest } as any)); + + const integration = { id: 'i1', type: 'github', settings: {} }; + const integrationRepo = { + findByWorkspaceAndType: jest.fn().mockResolvedValue(integration), + findAllByWorkspace: jest.fn().mockResolvedValue([integration]), + }; + const connectionRepo = { + findByIntegrationAndUser: jest + .fn() + .mockResolvedValue({ id: 'c1', invalidatedAt: null, updatedAt: CONNECTED_AT }), + findWorkspaceConnection: jest.fn().mockResolvedValue(undefined), + invalidate: jest.fn().mockResolvedValue(undefined), + }; + const oauthService = { + getValidAccessToken: jest.fn().mockResolvedValue('token'), + }; + const redis = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + }; + + const service = new UnfurlService( + registry, + integrationRepo as any, + connectionRepo as any, + oauthService as any, + { getOrThrow: () => redis } as any, + ); + return { + service, + registry, + unfurlPullRequest, + integrationRepo, + connectionRepo, + oauthService, + redis, + }; +} + +describe('UnfurlService provider resolution', () => { + const original = process.env.INTEGRATION_GITHUB_URL; + + afterEach(() => { + if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = original; + }); + + it('returns null for github.com links when GitHub Enterprise is configured', async () => { + process.env.INTEGRATION_GITHUB_URL = GHE_BASE; + const { service, unfurlPullRequest, connectionRepo } = build(); + + await expect( + service.unfurl('https://github.com/o/r/pull/1', 'u1', 'w1'), + ).resolves.toBeNull(); + expect(connectionRepo.findByIntegrationAndUser).not.toHaveBeenCalled(); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(unfurlPullRequest).not.toHaveBeenCalled(); + }); + + it.each([ + [undefined, 'https://github.com/o/r/pull/1', 'https://api.github.com'], + [GHE_BASE, `${GHE_BASE}/o/r/pull/1`, `${GHE_BASE}/api/v3`], + ])( + 'resolves links on the configured host (%s) through the installed integration', + async (configured, url, apiBaseUrl) => { + if (configured === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = configured; + const { service, unfurlPullRequest } = build(); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toEqual({ + title: 'PR', + }); + expect(unfurlPullRequest).toHaveBeenCalledWith( + 'token', apiBaseUrl, 'o', 'r', 1, url, + ); + }, + ); +}); + +describe('UnfurlService credential failures', () => { + const original = process.env.INTEGRATION_GITHUB_URL; + const url = 'https://github.com/o/r/pull/1'; + + beforeEach(() => { + delete process.env.INTEGRATION_GITHUB_URL; + }); + + afterEach(() => { + if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = original; + }); + + it('shows no card for a token awaiting refresh, without caching or retiring', async () => { + const { service, unfurlPullRequest, connectionRepo, oauthService, redis } = + build(); + oauthService.getValidAccessToken.mockRejectedValue(new TokenExpiredError()); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toBeNull(); + + expect(unfurlPullRequest).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + }); + + describe('when the provider rejects the token', () => { + function buildWithStoredRow() { + const built = build(); + const row: Record = { + id: 'c1', + invalidatedAt: null, + updatedAt: CONNECTED_AT, + accessToken: 'used-access-token', + }; + built.connectionRepo.findByIntegrationAndUser.mockImplementation( + async () => ({ ...row }), + ); + built.connectionRepo.invalidate.mockImplementation( + async (_id: string, expected: Record = {}) => { + const matches = Object.entries(expected).every( + ([column, value]) => row[column] === value, + ); + if (matches) row.invalidatedAt = new Date(); + }, + ); + return { ...built, row }; + } + + it('retires a connection that still holds the rejected token', async () => { + const { service, unfurlPullRequest, connectionRepo, row } = + buildWithStoredRow(); + unfurlPullRequest.mockRejectedValue(new TokenInvalidError()); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toMatchObject({ + needsConnection: true, + }); + + expect(connectionRepo.invalidate).toHaveBeenCalledWith('c1', { + accessToken: 'used-access-token', + }); + expect(row.invalidatedAt).toBeInstanceOf(Date); + }); + + it('keeps a connection whose token the refresh job replaced after it was read', async () => { + const { service, unfurlPullRequest, row } = buildWithStoredRow(); + unfurlPullRequest.mockImplementation(async () => { + row.accessToken = 'refreshed-access-token'; + throw new TokenInvalidError(); + }); + + await service.unfurl(url, 'u1', 'w1'); + + expect(unfurlPullRequest).toHaveBeenCalledTimes(1); + expect(row.invalidatedAt).toBeNull(); + }); + }); +}); + +describe('UnfurlService cache', () => { + const original = process.env.INTEGRATION_GITHUB_URL; + const url = 'https://github.com/o/r/pull/1'; + const urlDigest = crypto.createHash('sha256').update(url).digest('hex'); + const cachedCard = JSON.stringify({ title: 'Cached PR' }); + + beforeEach(() => { + delete process.env.INTEGRATION_GITHUB_URL; + }); + + afterEach(() => { + if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = original; + }); + + it('serves a cached card only after finding the connection', async () => { + const { service, unfurlPullRequest, connectionRepo, redis } = build(); + redis.get.mockResolvedValue(cachedCard); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toEqual({ + title: 'Cached PR', + }); + + expect(unfurlPullRequest).not.toHaveBeenCalled(); + expect( + connectionRepo.findByIntegrationAndUser.mock.invocationCallOrder[0], + ).toBeLessThan(redis.get.mock.invocationCallOrder[0]); + }); + + it('asks a disconnected member to connect instead of serving their cached card', async () => { + const { service, connectionRepo, redis } = build(); + redis.get.mockResolvedValue(cachedCard); + connectionRepo.findByIntegrationAndUser.mockResolvedValue(undefined); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toMatchObject({ + needsConnection: true, + }); + + expect(redis.get).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it('serves no cached card once the integration is uninstalled', async () => { + const { service, integrationRepo, connectionRepo, redis } = build(); + redis.get.mockResolvedValue(cachedCard); + integrationRepo.findByWorkspaceAndType.mockResolvedValue(undefined); + integrationRepo.findAllByWorkspace.mockResolvedValue([]); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toBeNull(); + + expect(connectionRepo.findByIntegrationAndUser).not.toHaveBeenCalled(); + expect(redis.get).not.toHaveBeenCalled(); + }); + + it('keys the card by connection, its last update and the full URL digest', async () => { + const { service, redis } = build(); + + await service.unfurl(url, 'u1', 'w1'); + + expect(urlDigest).toHaveLength(64); + expect(redis.get).toHaveBeenCalledWith( + `unfurl:w1:u1:c1:${CONNECTED_AT.getTime()}:${urlDigest}`, + ); + expect(redis.set).toHaveBeenCalledWith( + `unfurl:w1:u1:c1:${CONNECTED_AT.getTime()}:${urlDigest}`, + JSON.stringify({ title: 'PR' }), + 'EX', + 300, + ); + }); + + it('refetches once the connection is updated by a reconnect or refresh', async () => { + const { service, unfurlPullRequest, connectionRepo, redis } = build(); + const store = new Map(); + redis.get.mockImplementation(async (key: string) => store.get(key) ?? null); + redis.set.mockImplementation(async (key: string, value: string) => { + store.set(key, value); + return 'OK'; + }); + + await service.unfurl(url, 'u1', 'w1'); + await service.unfurl(url, 'u1', 'w1'); + expect(unfurlPullRequest).toHaveBeenCalledTimes(1); + + connectionRepo.findByIntegrationAndUser.mockResolvedValue({ + id: 'c1', + invalidatedAt: null, + updatedAt: new Date(CONNECTED_AT.getTime() + 1000), + }); + await service.unfurl(url, 'u1', 'w1'); + + expect(unfurlPullRequest).toHaveBeenCalledTimes(2); + }); + + describe('for a workspace-scoped provider', () => { + const messageUrl = 'https://chat.example/m/abc'; + + function buildWorkspaceScoped() { + const built = build(); + const provider = new WorkspaceScopedProvider(); + built.registry.register(provider); + const integration = { id: 'i2', type: 'chat', settings: {} }; + built.integrationRepo.findByWorkspaceAndType.mockResolvedValue(integration); + built.integrationRepo.findAllByWorkspace.mockResolvedValue([integration]); + return { ...built, provider }; + } + + it('serves no cached card once the workspace connection is gone', async () => { + const { service, provider, connectionRepo, redis } = + buildWorkspaceScoped(); + redis.get.mockResolvedValue(cachedCard); + + await expect(service.unfurl(messageUrl, 'u1', 'w1')).resolves.toBeNull(); + + expect(connectionRepo.findWorkspaceConnection).toHaveBeenCalledWith('i2'); + expect(redis.get).not.toHaveBeenCalled(); + expect(provider.unfurl).not.toHaveBeenCalled(); + }); + + it('keeps the viewer in the key of the shared connection', async () => { + const { service, connectionRepo, redis } = buildWorkspaceScoped(); + connectionRepo.findWorkspaceConnection.mockResolvedValue({ + id: 'wc1', + invalidatedAt: null, + updatedAt: CONNECTED_AT, + }); + + await service.unfurl(messageUrl, 'u1', 'w1'); + + const digest = crypto.createHash('sha256').update(messageUrl).digest('hex'); + expect(redis.get).toHaveBeenCalledWith( + `unfurl:w1:u1:wc1:${CONNECTED_AT.getTime()}:${digest}`, + ); + }); + }); +}); diff --git a/apps/server/src/core/integration/unfurl/unfurl.service.ts b/apps/server/src/core/integration/unfurl/unfurl.service.ts new file mode 100644 index 00000000..98071c35 --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.service.ts @@ -0,0 +1,251 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import { IntegrationConnectionRepo } from '../repos/integration-connection.repo'; +import { IntegrationRepo } from '../repos/integration.repo'; +import { OAuthService } from '../oauth/oauth.service'; +import { + UnfurlResult, + UnfurlNeedsConnection, + UnfurlForbiddenError, + UnfurlNeedsConnectionError, + TokenExpiredError, + TokenInvalidError, + ProviderApiError, + IntegrationProvider, +} from '../registry/integration-provider.interface'; +import { IntegrationConnection } from '@docmost/db/types/entity.types'; +import { RedisService } from '@nestjs-labs/nestjs-ioredis'; +import type { Redis } from 'ioredis'; +import * as crypto from 'crypto'; + +const UNFURL_CACHE_TTL = 300; // 5 minutes +// Short negative cache for transient failures; a 404 uses the normal TTL. +const UNFURL_ERROR_CACHE_TTL = 60; +const UNFURL_CACHE_PREFIX = 'unfurl:'; + +@Injectable() +export class UnfurlService { + private readonly logger = new Logger(UnfurlService.name); + private readonly redis: Redis; + + constructor( + private readonly registry: IntegrationRegistry, + private readonly integrationRepo: IntegrationRepo, + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly oauthService: OAuthService, + private readonly redisService: RedisService, + ) { + this.redis = this.redisService.getOrThrow(); + } + + async unfurl( + url: string, + userId: string, + workspaceId: string, + ): Promise { + const resolved = await this.resolveProvider(url, workspaceId); + + if (!resolved) { + return null; + } + + const { provider, match, patternType, integration } = resolved; + + if (!provider.unfurl) { + return null; + } + + // Workspace-scoped providers (Slack) unfurl through one shared bot connection. + const connectionScope = + provider.definition.oauth?.connectionScope ?? 'user'; + const connection = + connectionScope === 'workspace' + ? await this.connectionRepo.findWorkspaceConnection(integration.id) + : await this.connectionRepo.findByIntegrationAndUser( + integration.id, + userId, + ); + + if (!connection || connection.invalidatedAt) { + // Dead workspace connections need an admin re-install; members get no card. + if (connectionScope === 'workspace') { + return null; + } + // Not cached, so the card loads once the user connects. + return this.buildNeedsConnection( + provider, + integration.id, + patternType, + match, + url, + ); + } + + const cacheKey = this.buildCacheKey(workspaceId, userId, connection, url); + const cached = await this.redis.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + try { + const accessToken = + await this.oauthService.getValidAccessToken(connection); + + const unfurlResult = await provider.unfurl({ + url, + accessToken, + match, + patternType, + settings: (integration.settings as Record) ?? {}, + userId, + integrationId: integration.id, + }); + + await this.redis.set( + cacheKey, + JSON.stringify(unfurlResult), + 'EX', + UNFURL_CACHE_TTL, + ); + + return unfurlResult; + } catch (err) { + // Not cached, so the card loads once the user links their account. + if (err instanceof UnfurlNeedsConnectionError) { + return this.buildNeedsConnection( + provider, + integration.id, + patternType, + match, + url, + ); + } + if (err instanceof UnfurlForbiddenError) { + this.logger.debug( + `Unfurl not authorized for ${url}: ${(err as Error).message}`, + ); + await this.cacheNull(cacheKey, UNFURL_ERROR_CACHE_TTL); + return null; + } + // Not cached, so the card loads once the refresh job renews the token. + if (err instanceof TokenExpiredError) { + return null; + } + if (err instanceof TokenInvalidError) { + this.logger.warn( + `Retiring connection ${connection.id}: ${(err as Error).message}`, + ); + // Spares a connection whose token the refresh job replaced after it was read. + await this.connectionRepo + .invalidate(connection.id, { accessToken: connection.accessToken }) + .catch(() => undefined); + if (connectionScope === 'workspace') { + return null; + } + // Not cached, so the card loads once the user reconnects. + return this.buildNeedsConnection( + provider, + integration.id, + patternType, + match, + url, + ); + } + this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`); + const ttl = + err instanceof ProviderApiError && err.status === 404 + ? UNFURL_CACHE_TTL + : UNFURL_ERROR_CACHE_TTL; + await this.cacheNull(cacheKey, ttl); + return null; + } + } + + private async cacheNull(cacheKey: string, ttl: number): Promise { + await this.redis.set(cacheKey, 'null', 'EX', ttl); + } + + private buildNeedsConnection( + provider: IntegrationProvider, + integrationId: string, + patternType: string, + match: RegExpMatchArray, + url: string, + ): UnfurlNeedsConnection { + const described = + provider.describeLink?.(patternType, match, url) ?? null; + + let fallbackDescription: string | undefined; + try { + const parsed = new URL(url); + fallbackDescription = `${parsed.host}${parsed.pathname}`; + } catch { + fallbackDescription = undefined; + } + + return { + needsConnection: true, + integrationId, + integrationType: provider.definition.type, + integrationName: provider.definition.name, + title: described?.title ?? `${provider.definition.name} link`, + description: described?.description ?? fallbackDescription, + }; + } + + private async resolveProvider( + url: string, + workspaceId: string, + ): Promise<{ + provider: IntegrationProvider; + match: RegExpMatchArray; + patternType: string; + integration: { + id: string; + type: string; + settings: unknown; + }; + } | null> { + const staticResult = this.registry.findUnfurlProvider(url); + if (staticResult) { + const integration = await this.integrationRepo.findByWorkspaceAndType( + workspaceId, + staticResult.provider.definition.type, + ); + if (integration) { + return { ...staticResult, integration }; + } + } + + const integrations = + await this.integrationRepo.findAllByWorkspace(workspaceId); + + for (const integration of integrations) { + const provider = this.registry.getProvider(integration.type); + if (!provider?.getUnfurlPatterns || !provider.unfurl) continue; + + const settings = (integration.settings as Record) ?? {}; + const patterns = provider.getUnfurlPatterns(settings); + + for (const pattern of patterns) { + const match = url.match(pattern.regex); + if (match) { + return { provider, match, patternType: pattern.type, integration }; + } + } + } + + return null; + } + + // updatedAt moves on reconnect and token refresh, so another account's cards never match. + private buildCacheKey( + workspaceId: string, + userId: string, + connection: Pick, + url: string, + ): string { + const hash = crypto.createHash('sha256').update(url).digest('hex'); + return `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:${connection.id}:${connection.updatedAt.getTime()}:${hash}`; + } +} diff --git a/apps/server/src/core/integration/utils/canonical-path.ts b/apps/server/src/core/integration/utils/canonical-path.ts new file mode 100644 index 00000000..9329362d --- /dev/null +++ b/apps/server/src/core/integration/utils/canonical-path.ts @@ -0,0 +1,2 @@ +// Browsers resolve "." and ".." segments (also written %2e) and read "\" as "/", so such a link opens a different item. +export const CANONICAL_PATH = String.raw`(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))`; diff --git a/apps/server/src/core/integration/utils/integration.utils.spec.ts b/apps/server/src/core/integration/utils/integration.utils.spec.ts new file mode 100644 index 00000000..f048d759 --- /dev/null +++ b/apps/server/src/core/integration/utils/integration.utils.spec.ts @@ -0,0 +1,32 @@ +import { toProviderAccount } from './integration.utils'; + +describe('toProviderAccount', () => { + it('stringifies a numeric id and keeps the names', () => { + expect( + toProviderAccount({ + id: 583231, + displayName: 'Philip Okugbe', + username: 'Philipinho', + }), + ).toEqual({ + id: '583231', + displayName: 'Philip Okugbe', + username: 'Philipinho', + }); + }); + + it('leaves out names the provider did not return or left blank', () => { + expect( + toProviderAccount({ id: 'U-1', displayName: ' ', username: null }), + ).toEqual({ id: 'U-1' }); + }); + + it.each([undefined, null, '', {}])( + 'refuses a profile without an id (%p)', + (id) => { + expect(() => toProviderAccount({ id })).toThrow( + 'Provider profile has no account id', + ); + }, + ); +}); diff --git a/apps/server/src/core/integration/utils/integration.utils.ts b/apps/server/src/core/integration/utils/integration.utils.ts new file mode 100644 index 00000000..733de9a7 --- /dev/null +++ b/apps/server/src/core/integration/utils/integration.utils.ts @@ -0,0 +1,40 @@ +import { ProviderAccount } from '../registry/integration-provider.interface'; + +export function normalizeBaseUrl(value: string): string | null { + try { + const parsed = new URL(value); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`; + } catch { + return null; + } +} + +export function escapeForRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function stringOrUndefined(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function toProviderAccount(fields: { + id: unknown; + displayName?: unknown; + username?: unknown; +}): ProviderAccount { + const id = + typeof fields.id === 'number' + ? String(fields.id) + : stringOrUndefined(fields.id); + if (!id) { + throw new Error('Provider profile has no account id'); + } + const displayName = stringOrUndefined(fields.displayName)?.trim(); + const username = stringOrUndefined(fields.username)?.trim(); + return { + id, + ...(displayName ? { displayName } : {}), + ...(username ? { username } : {}), + }; +} diff --git a/apps/server/src/core/integration/utils/provider-fetch.spec.ts b/apps/server/src/core/integration/utils/provider-fetch.spec.ts new file mode 100644 index 00000000..4f4db920 --- /dev/null +++ b/apps/server/src/core/integration/utils/provider-fetch.spec.ts @@ -0,0 +1,181 @@ +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { + ProviderApiError, + TokenInvalidError, +} from '../registry/integration-provider.interface'; +import { MAX_PROVIDER_RESPONSE_BYTES, providerApiFetch } from './provider-fetch'; + +type TestServer = { server: http.Server; url: string; hits: number }; + +// Never ends the body, so the request settles only if the client stops reading. +function streamUntilClosed(res: http.ServerResponse, status = 200): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + const chunk = Buffer.from(`{"secret":"${'s'.repeat(64 * 1024)}"}`); + const pump = () => { + while (!res.destroyed && res.write(chunk)) {} + if (!res.destroyed) res.once('drain', pump); + }; + pump(); +} + +async function startServer( + respond: (res: http.ServerResponse) => void, +): Promise { + const testServer = { hits: 0 } as TestServer; + testServer.server = http.createServer((req, res) => { + testServer.hits += 1; + req.resume(); + req.on('end', () => respond(res)); + }); + await new Promise((resolve) => + testServer.server.listen(0, '127.0.0.1', resolve), + ); + const { port } = testServer.server.address() as AddressInfo; + testServer.url = `http://127.0.0.1:${port}`; + return testServer; +} + +function stopServer({ server }: TestServer): Promise { + server.closeAllConnections(); + return new Promise((resolve) => server.close(() => resolve())); +} + +describe('providerApiFetch', () => { + let sink: TestServer; + let provider: TestServer; + + beforeAll(async () => { + sink = await startServer((res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + provider = await startServer((res) => { + res.writeHead(307, { Location: `${sink.url}/internal` }); + res.end(); + }); + }); + + afterAll(async () => { + await Promise.all([stopServer(provider), stopServer(sink)]); + }); + + it('reports a redirecting provider endpoint as an unexpected redirect instead of following it', async () => { + const outcome = await providerApiFetch('acme', `${provider.url}/api/item`, { + headers: { Authorization: 'Bearer token' }, + }).catch((err) => err); + + expect(sink.hits).toBe(0); + expect(outcome).toBeInstanceOf(ProviderApiError); + expect(outcome.status).toBe(502); + expect(outcome.message).toBe('acme API error: 502 unexpected redirect'); + }); + + it('keeps the reason a 401 gives in its body and headers', async () => { + const refusing = await startServer((res) => { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': + 'Bearer authorization_uri=https://login.microsoftonline.com/tenant-1', + 'X-TFS-ServiceError': 'TF400813%3a+The+user+is+not+authorized', + }); + res.end('{"message":"Bad credentials"}'); + }); + + try { + const outcome = await providerApiFetch( + 'acme', + `${refusing.url}/api/item`, + ).catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenInvalidError); + expect(outcome.message).toBe( + 'acme API error: 401 Unauthorized {"message":"Bad credentials"} Bearer authorization_uri=https://login.microsoftonline.com/tenant-1 TF400813%3a+The+user+is+not+authorized', + ); + } finally { + await stopServer(refusing); + } + }); + + it('reports a bare 401 with the status alone', async () => { + const refusing = await startServer((res) => { + res.writeHead(401); + res.end(); + }); + + try { + const outcome = await providerApiFetch( + 'acme', + `${refusing.url}/api/item`, + ).catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenInvalidError); + expect(outcome.message).toBe('acme API error: 401 Unauthorized'); + } finally { + await stopServer(refusing); + } + }); + + describe('response size cap', () => { + async function fetchFrom(respond: (res: http.ServerResponse) => void) { + const testServer = await startServer(respond); + try { + return await providerApiFetch('acme', `${testServer.url}/api/item`).catch( + (err) => err, + ); + } finally { + await stopServer(testServer); + } + } + + it('hands back the status, headers and body of a response within the cap', async () => { + const body = `"${'a'.repeat(MAX_PROVIDER_RESPONSE_BYTES - 2)}"`; + + const response = await fetchFrom((res) => { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Request-Id': 'req-1', + }); + res.end(body); + }); + + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ); + expect(response.headers.get('x-request-id')).toBe('req-1'); + expect(await response.json()).toHaveLength(MAX_PROVIDER_RESPONSE_BYTES - 2); + }); + + it('refuses a declared length over the cap without waiting for the body', async () => { + const outcome = await fetchFrom((res) => { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': String(MAX_PROVIDER_RESPONSE_BYTES + 1), + }); + res.write('{"secret":"'); + }); + + expect(outcome).toBeInstanceOf(ProviderApiError); + expect(outcome.status).toBe(502); + expect(outcome.message).toBe('acme API error: 502 response too large'); + }, 2000); + + it('stops reading a body that never ends once it passes the cap', async () => { + const outcome = await fetchFrom((res) => streamUntilClosed(res)); + + expect(outcome).toBeInstanceOf(ProviderApiError); + expect(outcome.status).toBe(502); + expect(outcome.message).toBe('acme API error: 502 response too large'); + }, 5000); + + it('keeps the status of an error whose body is too large to quote', async () => { + const outcome = await fetchFrom((res) => streamUntilClosed(res, 500)); + + expect(outcome).toBeInstanceOf(ProviderApiError); + expect(outcome.status).toBe(500); + expect(outcome.message).toBe('acme API error: 500 Internal Server Error'); + }, 5000); + }); +}); diff --git a/apps/server/src/core/integration/utils/provider-fetch.ts b/apps/server/src/core/integration/utils/provider-fetch.ts new file mode 100644 index 00000000..de1be413 --- /dev/null +++ b/apps/server/src/core/integration/utils/provider-fetch.ts @@ -0,0 +1,118 @@ +import { + ProviderApiError, + TokenInvalidError, + UnfurlForbiddenError, +} from '../registry/integration-provider.interface'; +import { proxyFetch } from '../../../common/proxy-fetch'; + +export const INTEGRATION_HTTP_TIMEOUT_MS = 10_000; + +// Far above any unfurl or token response we read. +export const MAX_PROVIDER_RESPONSE_BYTES = 2 * 1024 * 1024; + +// Enough of the body to log why a provider refused ("insufficient scope", ...). +const MAX_ERROR_BODY_CHARS = 300; + +// Buffers the body into a new Response, giving up as soon as it passes the cap. +export async function readCappedResponse( + providerName: string, + response: Response, +): Promise { + const tooLarge = () => + new ProviderApiError(providerName, 502, 'response too large'); + + if ( + Number(response.headers.get('content-length')) > MAX_PROVIDER_RESPONSE_BYTES + ) { + void response.body?.cancel().catch(() => undefined); + throw tooLarge(); + } + + const chunks: Uint8Array[] = []; + let received = 0; + if (response.body) { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > MAX_PROVIDER_RESPONSE_BYTES) { + void reader.cancel().catch(() => undefined); + throw tooLarge(); + } + chunks.push(value); + } + } + + return new Response(received > 0 ? Buffer.concat(chunks) : null, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +async function readErrorBody( + providerName: string, + response: Response, +): Promise { + try { + const capped = await readCappedResponse(providerName, response); + const text = await capped.text(); + return text.replace(/\s+/g, ' ').trim().slice(0, MAX_ERROR_BODY_CHARS); + } catch { + return ''; + } +} + +export async function providerApiFetch( + providerName: string, + url: string, + init: RequestInit = {}, +): Promise { + const response = await proxyFetch(url, { + ...init, + redirect: 'manual', + signal: AbortSignal.timeout(INTEGRATION_HTTP_TIMEOUT_MS), + }); + + // Don't follow redirects: a 3xx could hop to an internal address. + if (response.status >= 300 && response.status < 400) { + throw new ProviderApiError(providerName, 502, 'unexpected redirect'); + } + + if (response.status === 401) { + // Azure DevOps puts its TF error code only in x-tfs-serviceerror. + const reasonHeaders = ['www-authenticate', 'x-tfs-serviceerror'] + .map((name) => response.headers.get(name)?.slice(0, MAX_ERROR_BODY_CHARS)) + .filter(Boolean); + throw new TokenInvalidError( + [ + `${providerName} API error: 401 Unauthorized`, + await readErrorBody(providerName, response), + ...reasonHeaders, + ] + .filter(Boolean) + .join(' '), + ); + } + if (!response.ok) { + const body = await readErrorBody(providerName, response); + + // GitHub also uses 403 for secondary rate limits, which stay real errors. + const rateLimited = + response.headers.get('retry-after') !== null || + response.headers.get('x-ratelimit-remaining') === '0'; + if (response.status === 403 && !rateLimited) { + throw new UnfurlForbiddenError( + `${providerName} API error: 403 ${body}`.trimEnd(), + ); + } + + throw new ProviderApiError( + providerName, + response.status, + `${response.statusText} ${body}`.trim(), + ); + } + return readCappedResponse(providerName, response); +} diff --git a/apps/server/src/core/integration/utils/refresh-lock.spec.ts b/apps/server/src/core/integration/utils/refresh-lock.spec.ts new file mode 100644 index 00000000..5cce06da --- /dev/null +++ b/apps/server/src/core/integration/utils/refresh-lock.spec.ts @@ -0,0 +1,129 @@ +import { Logger } from '@nestjs/common'; +import { RefreshLock } from './refresh-lock'; + +const KEY = 'integration:refresh:connection-1'; + +// Emulates SET NX, the compare-and-delete release script, DEL and EXISTS over a Map. +function fakeRedis() { + const keys = new Map(); + return { + keys, + set: jest.fn(async (key: string, value: string) => { + if (keys.has(key)) return null; + keys.set(key, value); + return 'OK'; + }), + eval: jest.fn(async (_script: string, _numKeys: number, key: string, token: string) => { + if (keys.get(key) !== token) return 0; + keys.delete(key); + return 1; + }), + del: jest.fn(async (key: string) => Number(keys.delete(key))), + exists: jest.fn(async (key: string) => (keys.has(key) ? 1 : 0)), + }; +} + +describe('RefreshLock', () => { + let redis: ReturnType; + let lock: RefreshLock; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + redis = fakeRedis(); + lock = new RefreshLock(redis as any); + }); + + it('takes the lock for 20 seconds only if nobody holds it', async () => { + const token = await lock.acquire('connection-1'); + + expect(token).toEqual(expect.any(String)); + expect(redis.set).toHaveBeenCalledWith(KEY, token, 'PX', 20_000, 'NX'); + expect(redis.keys.get(KEY)).toBe(token); + }); + + it('gives each holder its own token', async () => { + const first = await lock.acquire('connection-1'); + await lock.release('connection-1', first); + + const second = await lock.acquire('connection-1'); + + expect(second).not.toBe(first); + }); + + it('refuses the lock while someone else holds it', async () => { + redis.keys.set(KEY, 'other-holder'); + + await expect(lock.acquire('connection-1')).resolves.toBeNull(); + + expect(redis.keys.get(KEY)).toBe('other-holder'); + }); + + it('counts a Redis error as not acquired', async () => { + redis.set.mockRejectedValue(new Error('connection lost')); + + await expect(lock.acquire('connection-1')).resolves.toBeNull(); + }); + + it('releases a lock it holds', async () => { + const token = await lock.acquire('connection-1'); + + await lock.release('connection-1', token); + + expect(redis.keys.has(KEY)).toBe(false); + }); + + it('never deletes a lock another holder took after its own expired', async () => { + const stale = await lock.acquire('connection-1'); + redis.keys.delete(KEY); + const current = await lock.acquire('connection-1'); + + await lock.release('connection-1', stale); + + expect(redis.keys.get(KEY)).toBe(current); + expect(redis.del).not.toHaveBeenCalled(); + }); + + it('leaves the lock to expire when the release fails', async () => { + redis.eval.mockRejectedValue(new Error('connection lost')); + + await expect(lock.release('connection-1', 'token')).resolves.toBeUndefined(); + }); + + it('waits until the holder releases the lock', async () => { + redis.keys.set(KEY, 'other-holder'); + redis.exists.mockImplementationOnce(async () => { + redis.keys.delete(KEY); + return 1; + }); + + await lock.waitForRelease('connection-1', { timeoutMs: 1000, pollMs: 10 }); + + expect(redis.exists).toHaveBeenCalledTimes(2); + }); + + it('stops waiting at the timeout while the lock is still held', async () => { + redis.keys.set(KEY, 'other-holder'); + const started = Date.now(); + + await lock.waitForRelease('connection-1', { timeoutMs: 50, pollMs: 10 }); + + expect(Date.now() - started).toBeGreaterThanOrEqual(50); + expect(redis.exists.mock.calls.length).toBeGreaterThan(1); + expect(redis.keys.get(KEY)).toBe('other-holder'); + }); + + it('stops waiting when Redis fails', async () => { + redis.exists.mockRejectedValue(new Error('connection lost')); + + await lock.waitForRelease('connection-1', { timeoutMs: 1000, pollMs: 10 }); + + expect(redis.exists).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/server/src/core/integration/utils/refresh-lock.ts b/apps/server/src/core/integration/utils/refresh-lock.ts new file mode 100644 index 00000000..4563333c --- /dev/null +++ b/apps/server/src/core/integration/utils/refresh-lock.ts @@ -0,0 +1,72 @@ +import { Logger } from '@nestjs/common'; +import type { Redis } from 'ioredis'; +import * as crypto from 'crypto'; + +const REFRESH_LOCK_PREFIX = 'integration:refresh:'; +// Twice the token endpoint timeout, so the lock outlives its holder's provider call. +const REFRESH_LOCK_TTL_MS = 20_000; + +const RELEASE_SCRIPT = ` + if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) + else + return 0 + end +`; + +// Rotating providers revoke a refresh token on use, so one connection must never refresh twice at once. +export class RefreshLock { + private readonly logger = new Logger(RefreshLock.name); + + constructor(private readonly redis: Redis) {} + + // A Redis error counts as not acquired, so nobody refreshes without the lock. + async acquire(connectionId: string): Promise { + const token = crypto.randomUUID(); + try { + const ok = await this.redis.set( + REFRESH_LOCK_PREFIX + connectionId, + token, + 'PX', + REFRESH_LOCK_TTL_MS, + 'NX', + ); + return ok === 'OK' ? token : null; + } catch (err) { + this.logger.warn( + `Refresh lock unavailable for connection ${connectionId}: ${(err as Error).message}`, + ); + return null; + } + } + + async release(connectionId: string, token: string): Promise { + try { + await this.redis.eval( + RELEASE_SCRIPT, + 1, + REFRESH_LOCK_PREFIX + connectionId, + token, + ); + } catch (err) { + this.logger.warn( + `Refresh lock release failed for connection ${connectionId}: ${(err as Error).message}`, + ); + } + } + + // A Redis error ends the wait early; callers re-read the row either way. + async waitForRelease( + connectionId: string, + opts: { timeoutMs: number; pollMs: number }, + ): Promise { + const deadline = Date.now() + opts.timeoutMs; + while (Date.now() < deadline) { + const held = await this.redis + .exists(REFRESH_LOCK_PREFIX + connectionId) + .catch(() => 0); + if (!held) return; + await new Promise((resolve) => setTimeout(resolve, opts.pollMs)); + } + } +} diff --git a/apps/server/src/core/integration/utils/relative-time.ts b/apps/server/src/core/integration/utils/relative-time.ts new file mode 100644 index 00000000..250ce146 --- /dev/null +++ b/apps/server/src/core/integration/utils/relative-time.ts @@ -0,0 +1,5 @@ +import { formatDistanceStrict } from 'date-fns'; + +export function relativeTime(iso: string): string { + return formatDistanceStrict(new Date(iso), new Date(), { addSuffix: true }); +} diff --git a/apps/server/src/core/notification/notification.processor.ts b/apps/server/src/core/notification/notification.processor.ts index c477c398..6e178bb4 100644 --- a/apps/server/src/core/notification/notification.processor.ts +++ b/apps/server/src/core/notification/notification.processor.ts @@ -64,6 +64,12 @@ export class NotificationProcessor } const workspaceId = await this.resolveWorkspaceId(job); + if (!workspaceId) { + this.logger.debug( + `Skipping ${job.name} job ${job.id}: workspace not found`, + ); + return; + } const appUrl = await this.getWorkspaceUrl(workspaceId); switch (job.name) { @@ -162,7 +168,7 @@ export class NotificationProcessor } } - private async resolveWorkspaceId(job: Job): Promise { + private async resolveWorkspaceId(job: Job): Promise { if ( job.name === QueueJob.PAGE_VERIFICATION_EXPIRING || job.name === QueueJob.PAGE_VERIFICATION_EXPIRED @@ -173,9 +179,9 @@ export class NotificationProcessor .select('workspaceId') .where('id', '=', verificationId) .executeTakeFirst(); - return row?.workspaceId ?? ''; + return row?.workspaceId; } - return (job.data as { workspaceId: string }).workspaceId; + return (job.data as { workspaceId?: string }).workspaceId; } private async runVerificationReconcile(): Promise { diff --git a/apps/server/src/core/notification/notification.service.ts b/apps/server/src/core/notification/notification.service.ts index 1f88bf59..ab1b88e6 100644 --- a/apps/server/src/core/notification/notification.service.ts +++ b/apps/server/src/core/notification/notification.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectKysely } from 'nestjs-kysely'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo'; import { InsertableNotification } from '@docmost/db/types/entity.types'; @@ -8,6 +9,7 @@ import { WsGateway } from '../../ws/ws.gateway'; import { MailService } from '../../integrations/mail/mail.service'; import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; +import { EventName } from '../../common/events/event.contants'; @Injectable() export class NotificationService { @@ -18,6 +20,7 @@ export class NotificationService { private readonly pagePermissionRepo: PagePermissionRepo, private readonly wsGateway: WsGateway, private readonly mailService: MailService, + private readonly eventEmitter: EventEmitter2, @InjectKysely() private readonly db: KyselyDB, ) {} @@ -34,6 +37,8 @@ export class NotificationService { const notification = await this.notificationRepo.insert(data); + this.eventEmitter.emit(EventName.NOTIFICATION_CREATED, notification); + this.wsGateway.server .to(`user-${data.userId}`) .emit('notification', { id: notification.id, type: notification.type }); diff --git a/apps/server/src/core/page/page-access/page-access.service.ts b/apps/server/src/core/page/page-access/page-access.service.ts index 7978ac38..9c4584c2 100644 --- a/apps/server/src/core/page/page-access/page-access.service.ts +++ b/apps/server/src/core/page/page-access/page-access.service.ts @@ -112,6 +112,17 @@ export class PageAccessService { return { hasRestriction: hasAnyRestriction }; } + /** + * Validate user can create a root page in the space, throws if not. + * For non-HTTP callers (Slack, integrations) that skip the controller check. + */ + async validateCanCreate(spaceId: string, user: User): Promise { + const ability = await this.spaceAbility.createForUser(user, spaceId); + if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + } + async validateCanComment( page: Page, user: User, diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index eb5e44d2..9905da20 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -13,6 +13,7 @@ import { export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsOptional() @IsArray() + @IsString({ each: true }) emailDomains: string[]; @IsOptional() diff --git a/apps/server/src/core/workspace/services/workspace.service.spec.ts b/apps/server/src/core/workspace/services/workspace.service.spec.ts new file mode 100644 index 00000000..227489b2 --- /dev/null +++ b/apps/server/src/core/workspace/services/workspace.service.spec.ts @@ -0,0 +1,83 @@ +import { WorkspaceService } from './workspace.service'; +import { UserRole } from '../../../common/helpers/types/permission'; + +const workspaceId = 'workspace-1'; +const userId = 'user-1'; +const authUser = { id: 'admin-1', role: UserRole.ADMIN } as any; + +function buildService() { + const trx = { + deleteFrom: jest.fn(() => ({ + where: jest.fn(() => ({ execute: jest.fn() })), + })), + }; + const db = { + transaction: () => ({ execute: (cb: (t: any) => any) => cb(trx) }), + }; + const workspaceRepo = { + findById: jest.fn().mockResolvedValue({ id: workspaceId }), + }; + const userRepo = { + findById: jest.fn().mockResolvedValue({ + id: userId, + role: UserRole.MEMBER, + deactivatedAt: null, + deletedAt: null, + }), + updateUser: jest.fn(), + }; + const repoDeletingByUser = () => ({ + deleteByUserAndWorkspace: jest.fn(), + }); + const userSessionRepo = { revokeByUserId: jest.fn() }; + const integrationConnectionRepo = { deleteUserConnections: jest.fn() }; + + const service = new WorkspaceService( + workspaceRepo as any, + {} as any, + {} as any, + {} as any, + {} as any, + userRepo as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + repoDeletingByUser() as any, + repoDeletingByUser() as any, + db as any, + { add: jest.fn() } as any, + {} as any, + {} as any, + { log: jest.fn() } as any, + userSessionRepo as any, + integrationConnectionRepo as any, + ); + + return { service, trx, integrationConnectionRepo }; +} + +describe('WorkspaceService user offboarding', () => { + it('deletes personal integration connections on deactivation', async () => { + const { service, trx, integrationConnectionRepo } = buildService(); + + await service.deactivateUser(authUser, userId, workspaceId); + + expect(integrationConnectionRepo.deleteUserConnections).toHaveBeenCalledWith( + userId, + trx, + ); + }); + + it('deletes personal integration connections on deletion', async () => { + const { service, trx, integrationConnectionRepo } = buildService(); + + await service.deleteUser(authUser, userId, workspaceId); + + expect(integrationConnectionRepo.deleteUserConnections).toHaveBeenCalledWith( + userId, + trx, + ); + }); +}); diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 62b4a5e4..ea896eb2 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -50,6 +50,7 @@ import { AUDIT_SERVICE, IAuditService, } from '../../../integrations/audit/audit.service'; +import { IntegrationConnectionRepo } from '../../integration/repos/integration-connection.repo'; import { WsService } from '../../../ws/ws.service'; import { CollabAccessService } from '../../../collaboration/services/collab-access.service'; @@ -77,6 +78,7 @@ export class WorkspaceService { @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, private userSessionRepo: UserSessionRepo, + private integrationConnectionRepo: IntegrationConnectionRepo, private wsService: WsService, private readonly collabAccessService: CollabAccessService, ) {} @@ -313,11 +315,19 @@ export class WorkspaceService { if (updateWorkspaceDto.emailDomains) { const regex = - /(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/; - const emailDomains = updateWorkspaceDto.emailDomains || []; - updateWorkspaceDto.emailDomains = emailDomains - .map((domain) => regex.exec(domain)?.[0]) - .filter(Boolean); + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/; + const emailDomains = updateWorkspaceDto.emailDomains.map((domain) => + domain.trim().toLowerCase().replace(/^@/, ''), + ); + const invalidDomains = emailDomains.filter( + (domain) => !regex.test(domain), + ); + if (invalidDomains.length > 0) { + throw new BadRequestException( + `Invalid email domains: ${invalidDomains.join(', ')}`, + ); + } + updateWorkspaceDto.emailDomains = [...new Set(emailDomains)]; } if (updateWorkspaceDto.hostname) { @@ -908,6 +918,7 @@ export class WorkspaceService { trx, ); await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx); + await this.integrationConnectionRepo.deleteUserConnections(userId, trx); return user; }); @@ -1035,6 +1046,7 @@ export class WorkspaceService { }); await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx); + await this.integrationConnectionRepo.deleteUserConnections(userId, trx); return user; }); diff --git a/apps/server/src/core/workspace/workspace.module.ts b/apps/server/src/core/workspace/workspace.module.ts index b4c3481a..11cd6b02 100644 --- a/apps/server/src/core/workspace/workspace.module.ts +++ b/apps/server/src/core/workspace/workspace.module.ts @@ -4,9 +4,10 @@ import { WorkspaceController } from './controllers/workspace.controller'; import { SpaceModule } from '../space/space.module'; import { WorkspaceInvitationService } from './services/workspace-invitation.service'; import { TokenModule } from '../auth/token.module'; +import { IntegrationModule } from '../integration/integration.module'; @Module({ - imports: [SpaceModule, TokenModule], + imports: [SpaceModule, TokenModule, IntegrationModule], controllers: [WorkspaceController], providers: [WorkspaceService, WorkspaceInvitationService], exports: [WorkspaceService], diff --git a/apps/server/src/database/migrations/20260905T126412-integrations.ts b/apps/server/src/database/migrations/20260905T126412-integrations.ts new file mode 100644 index 00000000..faa6146d --- /dev/null +++ b/apps/server/src/database/migrations/20260905T126412-integrations.ts @@ -0,0 +1,124 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('integrations') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('type', 'text', (col) => col.notNull()) + .addColumn('settings', 'jsonb') + .addColumn('installed_by_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('deleted_at', 'timestamptz') + .addUniqueConstraint('uq_integrations_workspace_type', [ + 'workspace_id', + 'type', + ]) + .execute(); + + await db.schema + .createTable('integration_connections') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('integration_id', 'uuid', (col) => + col.references('integrations.id').onDelete('cascade').notNull(), + ) + .addColumn('user_id', 'uuid', (col) => + col.references('users.id').onDelete('cascade').notNull(), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('provider_user_id', 'text') + .addColumn('access_token', 'text') + .addColumn('refresh_token', 'text') + .addColumn('token_expires_at', 'timestamptz') + .addColumn('invalidated_at', 'timestamptz') + .addColumn('scopes', 'text') + .addColumn('metadata', 'jsonb') + // workspace: shared bot connection, user: per-user token + .addColumn('kind', 'text', (col) => col.notNull().defaultTo('user')) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await sql` + ALTER TABLE integration_connections + ADD CONSTRAINT integration_connections_kind_check + CHECK (kind IN ('workspace', 'user')) + `.execute(db); + + await db.schema + .createIndex('uq_integration_connections_workspace_per_integration') + .on('integration_connections') + .column('integration_id') + .where(sql.ref('kind'), '=', 'workspace') + .unique() + .execute(); + + await db.schema + .createIndex('uq_integration_connections_user_per_integration') + .on('integration_connections') + .columns(['integration_id', 'user_id']) + .where(sql.ref('kind'), '=', 'user') + .unique() + .execute(); + + await db.schema + .createIndex('uq_integration_connections_provider_user_per_integration') + .on('integration_connections') + .columns(['integration_id', 'provider_user_id']) + .where(sql.ref('kind'), '=', 'user') + .where(sql.ref('provider_user_id'), 'is not', null) + .unique() + .execute(); + + await db.schema + .createIndex('idx_integration_connections_user_id') + .on('integration_connections') + .column('user_id') + .execute(); + + await db.schema + .createIndex('idx_integration_connections_integration_id') + .on('integration_connections') + .column('integration_id') + .execute(); + + await db.schema + .createIndex('idx_integration_connections_workspace_id') + .on('integration_connections') + .column('workspace_id') + .execute(); + + await db.schema + .createIndex('idx_integration_connections_token_expires_at') + .on('integration_connections') + .column('token_expires_at') + .where(sql.ref('refresh_token'), 'is not', null) + .where(sql.ref('invalidated_at'), 'is', null) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('integration_connections').ifExists().execute(); + await db.schema.dropTable('integrations').ifExists().execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 0050e32d..d57e74ef 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -543,6 +543,34 @@ export interface Watchers { createdAt: Generated; } +export interface Integrations { + id: Generated; + workspaceId: string; + type: string; + settings: Json | null; + installedById: string | null; + createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; +} + +export interface IntegrationConnections { + id: Generated; + integrationId: string; + userId: string; + workspaceId: string; + providerUserId: string | null; + accessToken: string | null; + refreshToken: string | null; + tokenExpiresAt: Timestamp | null; + invalidatedAt: Timestamp | null; + scopes: string | null; + kind: string; + metadata: Json | null; + createdAt: Generated; + updatedAt: Generated; +} + export interface Labels { id: Generated; name: string; @@ -748,6 +776,8 @@ export interface DB { fileTasks: FileTasks; groups: Groups; groupUsers: GroupUsers; + integrationConnections: IntegrationConnections; + integrations: Integrations; labels: Labels; notifications: Notifications; pageAccess: PageAccess; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 1458eef5..0faf682e 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -8,6 +8,8 @@ import { BaseViews, Comments, Groups, + Integrations as _Integrations, + IntegrationConnections as _IntegrationConnections, Labels, Notifications, PageLabels, @@ -217,6 +219,19 @@ export type Watcher = Selectable; export type InsertableWatcher = Insertable; export type UpdatableWatcher = Updateable>; +// Integration +export type Integration = Selectable<_Integrations>; +export type InsertableIntegration = Insertable<_Integrations>; +export type UpdatableIntegration = Updateable>; + +// Integration Connection +export type IntegrationConnection = Selectable<_IntegrationConnections>; +export type InsertableIntegrationConnection = + Insertable<_IntegrationConnections>; +export type UpdatableIntegrationConnection = Updateable< + Omit<_IntegrationConnections, 'id'> +>; + // Label export type Label = Selectable; export type InsertableLabel = Insertable; diff --git a/apps/server/src/ee b/apps/server/src/ee index 1da2915b..7eb0ef1d 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 1da2915bf81a43f7f5169445cc2e096dd15c3ef2 +Subproject commit 7eb0ef1dfa2f5ed9e6331e8d068cbbe15be25d7d diff --git a/apps/server/src/integrations/encryption/a256cbc-hs512.spec.ts b/apps/server/src/integrations/encryption/a256cbc-hs512.spec.ts new file mode 100644 index 00000000..c0490cf9 --- /dev/null +++ b/apps/server/src/integrations/encryption/a256cbc-hs512.spec.ts @@ -0,0 +1,64 @@ +import * as crypto from 'node:crypto'; +import { decryptA256CbcHs512, encryptA256CbcHs512 } from './a256cbc-hs512'; + +// RFC 7518 Appendix B.3: independent known-answer values +const key = Buffer.from(Array.from({ length: 64 }, (_, i) => i)); +const iv = Buffer.from('1af38c2dc2b96ffdd86694092341bc04', 'hex'); +const plaintext = Buffer.from( + 'A cipher system must not be required to be secret, and it must be able to fall into the hands of the enemy without inconvenience', +); +const aad = Buffer.from('The second principle of Auguste Kerckhoffs'); +const cipherText = Buffer.from( + '4affaaadb78c31c5da4b1b590d10ffbd3dd8d5d302423526912da037ecbcc7bd' + + '822c301dd67c373bccb584ad3e9279c2e6d12a1374b77f077553df829410446b' + + '36ebd97066296ae6427ea75c2e0846a11a09ccf5370dc80bfecbad28c73f09b3' + + 'a3b75e662a2594410ae496b2e2e6609e31e6e02cc837f053d21f37ff4f51950b' + + 'be2638d09dd7a4930930806d0703b1f6', + 'hex', +); +const authTag = Buffer.from( + '4dd3b4c088a7f45c216839645b2012bf2e6269a8c56a816dbc1b267761955bc5', + 'hex', +); + +describe('A256CBC-HS512', () => { + it('matches the RFC 7518 encryption vector', () => { + expect(encryptA256CbcHs512(plaintext, key, iv, aad)).toEqual({ + iv, + cipherText, + authTag, + }); + }); + + it('decrypts the RFC 7518 ciphertext and tag', () => { + expect(decryptA256CbcHs512({ iv, cipherText, authTag }, key, aad)).toEqual( + plaintext, + ); + }); + + it('rejects bad authentication before invoking CBC decryption', () => { + const decrypt = jest.spyOn(crypto, 'createDecipheriv'); + try { + expect(() => + decryptA256CbcHs512( + { iv, cipherText, authTag: Buffer.alloc(32) }, + key, + aad, + ), + ).toThrow('Ciphertext authentication failed'); + expect(decrypt).not.toHaveBeenCalled(); + } finally { + decrypt.mockRestore(); + } + }); + + it('authenticates the additional data', () => { + expect(() => + decryptA256CbcHs512( + { iv, cipherText, authTag }, + key, + Buffer.from('other'), + ), + ).toThrow('Ciphertext authentication failed'); + }); +}); diff --git a/apps/server/src/integrations/encryption/a256cbc-hs512.ts b/apps/server/src/integrations/encryption/a256cbc-hs512.ts new file mode 100644 index 00000000..15b4c2d5 --- /dev/null +++ b/apps/server/src/integrations/encryption/a256cbc-hs512.ts @@ -0,0 +1,82 @@ +import { + createCipheriv, + createDecipheriv, + createHmac, + timingSafeEqual, +} from 'node:crypto'; + +export const V2_ALGORITHM = 'A256CBC-HS512'; +export const V2_KEY_LENGTH = 64; +export const V2_IV_LENGTH = 16; +export const V2_TAG_LENGTH = 32; + +type AuthenticatedCiphertext = { + iv: Buffer; + authTag: Buffer; + cipherText: Buffer; +}; + +function validateKeyAndIv(key: Buffer, iv: Buffer): void { + if (key.length !== V2_KEY_LENGTH || iv.length !== V2_IV_LENGTH) { + throw new Error('Invalid A256CBC-HS512 key or IV length.'); + } +} + +// RFC 7518 §5.2: MAC_KEY precedes ENC_KEY; AL is the AAD length in bits. +function authenticationTag( + key: Buffer, + aad: Buffer, + iv: Buffer, + cipherText: Buffer, +): Buffer { + const aadLength = Buffer.alloc(8); + aadLength.writeBigUInt64BE(BigInt(aad.length) * 8n); + return createHmac('sha512', key.subarray(0, 32)) + .update(aad) + .update(iv) + .update(cipherText) + .update(aadLength) + .digest() + .subarray(0, V2_TAG_LENGTH); +} + +export function encryptA256CbcHs512( + plaintext: Buffer, + key: Buffer, + iv: Buffer, + aad: Buffer, +): AuthenticatedCiphertext { + validateKeyAndIv(key, iv); + const cipher = createCipheriv('aes-256-cbc', key.subarray(32), iv); + const cipherText = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return { + iv, + cipherText, + authTag: authenticationTag(key, aad, iv, cipherText), + }; +} + +export function decryptA256CbcHs512( + payload: AuthenticatedCiphertext, + key: Buffer, + aad: Buffer, +): Buffer { + const { iv, authTag, cipherText } = payload; + validateKeyAndIv(key, iv); + if ( + authTag.length !== V2_TAG_LENGTH || + cipherText.length === 0 || + cipherText.length % V2_IV_LENGTH !== 0 + ) { + throw new Error('Invalid A256CBC-HS512 payload lengths.'); + } + + const expected = authenticationTag(key, aad, iv, cipherText); + if (!timingSafeEqual(authTag, expected)) { + throw new Error('Ciphertext authentication failed.'); + } + + // Authenticate before invoking CBC decryption or inspecting its padding. + const decipher = createDecipheriv('aes-256-cbc', key.subarray(32), iv); + return Buffer.concat([decipher.update(cipherText), decipher.final()]); +} diff --git a/apps/server/src/integrations/encryption/encryption.service.spec.ts b/apps/server/src/integrations/encryption/encryption.service.spec.ts index 64b1a8e0..7c6e2c9b 100644 --- a/apps/server/src/integrations/encryption/encryption.service.spec.ts +++ b/apps/server/src/integrations/encryption/encryption.service.spec.ts @@ -1,9 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { EncryptionService } from './encryption.service'; +import { EncryptionPurpose, EncryptionService } from './encryption.service'; import { UnableToDecrypt, UnableToInitialize } from './encryption.errors'; import { EnvironmentService } from '../environment/environment.service'; const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890'; +// Produced by the shipped v0.96.0 writer, with a fixed IV and a synthetic secret. +const LEGACY_SIEM_CIPHERTEXT = + 'eyJpdiI6IkFBRUNBd1FGQmdjSUNRb0wiLCJhdXRoVGFnIjoiZGZRbW1TVHdQck5uTzh3THFqMmhhUT09IiwiY2lwaGVyVGV4dCI6IjQxL0c1bVBlS0tVTkQ1ZW5vVk54M2V1eS9CNXluQVhJbWtCYmFLNlAifQ=='; const buildService = (appSecret: string | undefined) => { const env = { getAppSecret: () => appSecret } as EnvironmentService; @@ -12,6 +15,10 @@ const buildService = (appSecret: string | undefined) => { const decodeEnvelope = (encrypted: string) => JSON.parse(Buffer.from(encrypted, 'base64').toString()) as { + version?: number; + algorithm?: string; + keyId?: string; + purpose?: string; iv: string; authTag: string; cipherText: string; @@ -54,48 +61,77 @@ describe('EncryptionService', () => { describe('encrypt + decrypt round-trip', () => { it('decrypts back to the original plaintext', () => { const plaintext = 'hello world'; - const encrypted = service.encrypt(plaintext); - expect(service.decrypt(encrypted)).toBe(plaintext); + const encrypted = service.encrypt( + plaintext, + EncryptionPurpose.SIEM_CREDENTIALS, + ); + expect( + service.decrypt(encrypted, EncryptionPurpose.SIEM_CREDENTIALS), + ).toBe(plaintext); }); it('handles empty string', () => { - const encrypted = service.encrypt(''); - expect(service.decrypt(encrypted)).toBe(''); + const encrypted = service.encrypt('', EncryptionPurpose.SIEM_CREDENTIALS); + expect( + service.decrypt(encrypted, EncryptionPurpose.SIEM_CREDENTIALS), + ).toBe(''); }); it('handles unicode (multi-byte UTF-8)', () => { const plaintext = 'héllo 🔐 世界'; - const encrypted = service.encrypt(plaintext); - expect(service.decrypt(encrypted)).toBe(plaintext); + const encrypted = service.encrypt( + plaintext, + EncryptionPurpose.SIEM_CREDENTIALS, + ); + expect( + service.decrypt(encrypted, EncryptionPurpose.SIEM_CREDENTIALS), + ).toBe(plaintext); }); it('handles long plaintext (>1 block)', () => { const plaintext = 'a'.repeat(10_000); - const encrypted = service.encrypt(plaintext); - expect(service.decrypt(encrypted)).toBe(plaintext); + const encrypted = service.encrypt( + plaintext, + EncryptionPurpose.SIEM_CREDENTIALS, + ); + expect( + service.decrypt(encrypted, EncryptionPurpose.SIEM_CREDENTIALS), + ).toBe(plaintext); }); it('produces distinct ciphertexts for the same plaintext (random IV)', () => { const plaintext = 'same input'; - const a = service.encrypt(plaintext); - const b = service.encrypt(plaintext); + const a = service.encrypt(plaintext, EncryptionPurpose.SIEM_CREDENTIALS); + const b = service.encrypt(plaintext, EncryptionPurpose.SIEM_CREDENTIALS); expect(a).not.toBe(b); - expect(service.decrypt(a)).toBe(plaintext); - expect(service.decrypt(b)).toBe(plaintext); + expect(service.decrypt(a, EncryptionPurpose.SIEM_CREDENTIALS)).toBe( + plaintext, + ); + expect(service.decrypt(b, EncryptionPurpose.SIEM_CREDENTIALS)).toBe( + plaintext, + ); }); }); describe('cross-key isolation', () => { it('cannot decrypt ciphertext produced under a different APP_SECRET', () => { const other = buildService('totally-different-secret-value-9876543210'); - const encrypted = service.encrypt('secret'); - expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt); + const encrypted = service.encrypt( + 'secret', + EncryptionPurpose.SIEM_CREDENTIALS, + ); + expect(() => + other.decrypt(encrypted, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); }); describe('tamper detection', () => { it('rejects modified ciphertext', () => { - const encrypted = service.encrypt('hello'); + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); const tamperedCipher = Buffer.from(env.cipherText, 'base64'); tamperedCipher[0] ^= 0x01; @@ -103,11 +139,16 @@ describe('EncryptionService', () => { ...env, cipherText: tamperedCipher.toString('base64'), }); - expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(tampered, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); it('rejects modified auth tag', () => { - const encrypted = service.encrypt('hello'); + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); const tamperedTag = Buffer.from(env.authTag, 'base64'); tamperedTag[0] ^= 0x01; @@ -115,11 +156,16 @@ describe('EncryptionService', () => { ...env, authTag: tamperedTag.toString('base64'), }); - expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(tampered, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); it('rejects modified IV', () => { - const encrypted = service.encrypt('hello'); + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); const tamperedIV = Buffer.from(env.iv, 'base64'); tamperedIV[0] ^= 0x01; @@ -127,20 +173,27 @@ describe('EncryptionService', () => { ...env, iv: tamperedIV.toString('base64'), }); - expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(tampered, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); }); describe('malformed payloads', () => { it('rejects non-base64 garbage', () => { - expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow( - UnableToDecrypt, - ); + expect(() => + service.decrypt( + '!!!not-valid-base64!!!', + EncryptionPurpose.SIEM_CREDENTIALS, + ), + ).toThrow(UnableToDecrypt); }); it('rejects base64 of non-JSON', () => { const garbage = Buffer.from('not json at all').toString('base64'); - expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(garbage, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); it('rejects JSON missing required fields', () => { @@ -148,37 +201,161 @@ describe('EncryptionService', () => { iv: Buffer.alloc(12).toString('base64'), authTag: Buffer.alloc(16).toString('base64'), } as never); - expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(partial, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); it('rejects wrong-length IV', () => { - const encrypted = service.encrypt('hello'); + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); const bad = encodeEnvelope({ ...env, iv: Buffer.alloc(8).toString('base64'), }); - expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(bad, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); it('rejects wrong-length auth tag', () => { - const encrypted = service.encrypt('hello'); + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); const bad = encodeEnvelope({ ...env, authTag: Buffer.alloc(8).toString('base64'), }); - expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(bad, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); }); describe('envelope format', () => { - it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => { - const encrypted = service.encrypt('hello'); + it('writes an authenticated v2 envelope with a 16-byte IV and 32-byte tag', () => { + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); - expect(Buffer.from(env.iv, 'base64')).toHaveLength(12); - expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16); + expect(env).toMatchObject({ + version: 2, + algorithm: 'A256CBC-HS512', + keyId: 'app-secret', + purpose: 'siem-credentials', + }); + expect(Buffer.from(env.iv, 'base64')).toHaveLength(16); + expect(Buffer.from(env.authTag, 'base64')).toHaveLength(32); expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0); }); }); + + it('still decrypts credentials produced by the shipped SIEM writer', () => { + expect( + service.decrypt( + LEGACY_SIEM_CIPHERTEXT, + EncryptionPurpose.SIEM_CREDENTIALS, + ), + ).toBe('{"token":"shipped-siem-token"}'); + }); + + describe('purpose and version isolation', () => { + it.each(['encrypt', 'decrypt'] as const)( + '%s requires an explicit purpose', + (operation) => { + const input = + operation === 'encrypt' ? 'secret' : LEGACY_SIEM_CIPHERTEXT; + expect(() => + service[operation](input, undefined as unknown as EncryptionPurpose), + ).toThrow('Unsupported encryption purpose.'); + }, + ); + + it.each(Object.values(EncryptionPurpose))( + 'round-trips %s independently', + (purpose) => { + const encrypted = service.encrypt('secret', purpose); + expect(service.decrypt(encrypted, purpose)).toBe('secret'); + for (const other of Object.values(EncryptionPurpose)) { + if (other !== purpose) { + expect(() => service.decrypt(encrypted, other)).toThrow( + UnableToDecrypt, + ); + } + } + }, + ); + + it('does not accept legacy SIEM ciphertext as tokens or a completion ticket', () => { + expect(() => + service.decrypt( + LEGACY_SIEM_CIPHERTEXT, + EncryptionPurpose.INTEGRATION_TOKENS, + ), + ).toThrow(UnableToDecrypt); + expect(() => + service.decrypt( + LEGACY_SIEM_CIPHERTEXT, + EncryptionPurpose.OAUTH_COMPLETION, + ), + ).toThrow(UnableToDecrypt); + }); + + it.each([ + { version: 3 }, + { version: null }, + { algorithm: 'aes-256-gcm' }, + { keyId: 'unknown' }, + { purpose: EncryptionPurpose.INTEGRATION_TOKENS }, + ])('rejects changed authenticated metadata %j', (change) => { + const envelope = decodeEnvelope( + service.encrypt('secret', EncryptionPurpose.SIEM_CREDENTIALS), + ); + const modified = encodeEnvelope({ ...envelope, ...change }); + const purpose = change.purpose ?? EncryptionPurpose.SIEM_CREDENTIALS; + expect(() => service.decrypt(modified, purpose)).toThrow(UnableToDecrypt); + }); + + it('does not fall back to legacy decryption when the v2 header is stripped', () => { + const { iv, authTag, cipherText } = decodeEnvelope( + service.encrypt('secret', EncryptionPurpose.SIEM_CREDENTIALS), + ); + expect(() => + service.decrypt( + encodeEnvelope({ iv, authTag, cipherText }), + EncryptionPurpose.SIEM_CREDENTIALS, + ), + ).toThrow(UnableToDecrypt); + }); + + it.each([null, [], 'string', 1, { iv: 1, authTag: [], cipherText: null }])( + 'rejects malformed JSON envelopes %j', + (value) => { + expect(() => + service.decrypt( + Buffer.from(JSON.stringify(value)).toString('base64'), + EncryptionPurpose.SIEM_CREDENTIALS, + ), + ).toThrow(UnableToDecrypt); + }, + ); + + it('rejects noncanonical base64 fields instead of silently discarding characters', () => { + const envelope = decodeEnvelope( + service.encrypt('secret', EncryptionPurpose.SIEM_CREDENTIALS), + ); + expect(() => + service.decrypt( + encodeEnvelope({ ...envelope, iv: envelope.iv + '!' }), + EncryptionPurpose.SIEM_CREDENTIALS, + ), + ).toThrow(UnableToDecrypt); + }); + }); }); diff --git a/apps/server/src/integrations/encryption/encryption.service.ts b/apps/server/src/integrations/encryption/encryption.service.ts index 63dc4ce9..a0709f42 100644 --- a/apps/server/src/integrations/encryption/encryption.service.ts +++ b/apps/server/src/integrations/encryption/encryption.service.ts @@ -1,108 +1,210 @@ -// https://github.com/nhedger/nestjs-encryption - MIT import { Injectable } from '@nestjs/common'; import { - createCipheriv, createDecipheriv, createHash, + hkdfSync, randomBytes, } from 'node:crypto'; import { UnableToDecrypt, UnableToInitialize } from './encryption.errors'; import { EnvironmentService } from '../environment/environment.service'; +import { + decryptA256CbcHs512, + encryptA256CbcHs512, + V2_ALGORITHM, + V2_IV_LENGTH, + V2_KEY_LENGTH, +} from './a256cbc-hs512'; -const ALGORITHM = 'aes-256-gcm'; -const KEY_DOMAIN = 'docmost:encryption:v1'; -const IV_LENGTH = 12; -const AUTH_TAG_LENGTH = 16; +export enum EncryptionPurpose { + SIEM_CREDENTIALS = 'siem-credentials', + INTEGRATION_TOKENS = 'integration-tokens', + OAUTH_COMPLETION = 'oauth-completion', +} -type AEADPayload = { - iv: TFormat; - authTag: TFormat; - cipherText: TFormat; +const KEY_ID = 'app-secret'; +const V2_KEY_DOMAIN = 'docmost:encryption:v2'; +const LEGACY_KEY_DOMAIN = 'docmost:encryption:v1'; +const PAYLOAD_FIELDS = ['iv', 'authTag', 'cipherText']; +const HEADER_FIELDS = ['version', 'algorithm', 'keyId', 'purpose']; + +type BinaryPayload = { iv: Buffer; authTag: Buffer; cipherText: Buffer }; +type V2Header = { + version: 2; + algorithm: typeof V2_ALGORITHM; + keyId: typeof KEY_ID; + purpose: EncryptionPurpose; }; +type DecodedPayload = + | { version: 1; payload: BinaryPayload } + | { version: 2; payload: BinaryPayload; header: V2Header }; @Injectable() export class EncryptionService { - private readonly key: Buffer; + private readonly appSecret: string; + private readonly keys = new Map(); constructor(environmentService: EnvironmentService) { - const appSecret = environmentService.getAppSecret(); - if (!appSecret) { + this.appSecret = environmentService.getAppSecret(); + if (!this.appSecret) { throw new UnableToInitialize('APP_SECRET is not set.'); } - this.key = createHash('sha256') - .update(KEY_DOMAIN) - .update(appSecret) - .digest(); } - public encrypt(plaintext: string): string { - const iv = randomBytes(IV_LENGTH); - const cipher = createCipheriv(ALGORITHM, this.key, iv); - const cipherText = Buffer.concat([ - cipher.update(plaintext, 'utf8'), - cipher.final(), - ]); - const authTag = cipher.getAuthTag(); - - const aead: AEADPayload = { - iv: iv.toString('base64'), - authTag: authTag.toString('base64'), - cipherText: cipherText.toString('base64'), - }; - - return Buffer.from(JSON.stringify(aead)).toString('base64'); + // Every consumer must select its purpose; only SIEM can read the shipped + // unversioned format. + public encrypt(plaintext: string, purpose: EncryptionPurpose): string { + const header = this.header(purpose); + const { iv, authTag, cipherText } = encryptA256CbcHs512( + Buffer.from(plaintext, 'utf8'), + this.keyFor(purpose), + randomBytes(V2_IV_LENGTH), + this.aad(header), + ); + return Buffer.from( + JSON.stringify({ + ...header, + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + cipherText: cipherText.toString('base64'), + }), + ).toString('base64'); } - public decrypt(encrypted: string): string { + public decrypt(encrypted: string, purpose: EncryptionPurpose): string { try { - const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted); - const decipher = createDecipheriv(ALGORITHM, this.key, iv); - decipher.setAuthTag(authTag); - const decrypted = Buffer.concat([ - decipher.update(cipherText), - decipher.final(), - ]); - return decrypted.toString('utf8'); + this.assertPurpose(purpose); + const decoded = this.decode(encrypted); + if (decoded.version === 1) { + if (purpose !== EncryptionPurpose.SIEM_CREDENTIALS) { + throw new Error( + 'Legacy ciphertext is only supported for SIEM credentials.', + ); + } + return this.decryptLegacy(decoded.payload); + } + if (decoded.header.purpose !== purpose) { + throw new Error('Ciphertext purpose does not match.'); + } + return decryptA256CbcHs512( + decoded.payload, + this.keyFor(purpose), + this.aad(decoded.header), + ).toString('utf8'); } catch (e: unknown) { throw new UnableToDecrypt((e as Error).message); } } - private decodeAEADPayload(encodedPayload: string): AEADPayload { - const payload = Buffer.from(encodedPayload, 'base64'); - - let deserializedPkg: Record; - try { - deserializedPkg = JSON.parse(payload.toString()); - } catch { - throw new Error('The decoded AEAD payload is not a valid JSON string.'); - } - - for (const field of ['iv', 'authTag', 'cipherText']) { - if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) { - throw new Error(`The AEAD payload is missing the ${field} field.`); - } - } - - const iv = Buffer.from(deserializedPkg.iv as string, 'base64'); - if (iv.length !== IV_LENGTH) { - throw new Error( - `The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`, + private keyFor(purpose: EncryptionPurpose): Buffer { + this.assertPurpose(purpose); + let key = this.keys.get(purpose); + if (!key) { + // Derive 32-byte MAC_KEY || 32-byte ENC_KEY via the native provider. + key = Buffer.from( + hkdfSync( + 'sha256', + this.appSecret, + V2_KEY_DOMAIN, + `${V2_ALGORITHM}:${purpose}`, + V2_KEY_LENGTH, + ), ); + this.keys.set(purpose, key); } + return key; + } - const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64'); - if (authTag.length !== AUTH_TAG_LENGTH) { - throw new Error( - `The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`, - ); + private header(purpose: EncryptionPurpose): V2Header { + this.assertPurpose(purpose); + return { version: 2, algorithm: V2_ALGORITHM, keyId: KEY_ID, purpose }; + } + + private aad(header: V2Header): Buffer { + // Fixed field order makes authentication independent of JSON property order. + return Buffer.from(JSON.stringify(header), 'utf8'); + } + + private assertPurpose( + purpose: unknown, + ): asserts purpose is EncryptionPurpose { + if ( + !Object.values(EncryptionPurpose).includes(purpose as EncryptionPurpose) + ) { + throw new Error('Unsupported encryption purpose.'); } + } - const cipherText = Buffer.from( - deserializedPkg.cipherText as string, - 'base64', + private decode(encrypted: string): DecodedPayload { + const value: unknown = JSON.parse( + this.decodeBase64(encrypted).toString('utf8'), ); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('The ciphertext envelope must be an object.'); + } + const envelope = value as Record; + const hasVersion = Object.prototype.hasOwnProperty.call( + envelope, + 'version', + ); + const fields = hasVersion + ? [...HEADER_FIELDS, ...PAYLOAD_FIELDS] + : PAYLOAD_FIELDS; + if ( + Object.keys(envelope).length !== fields.length || + !fields.every((field) => + Object.prototype.hasOwnProperty.call(envelope, field), + ) + ) { + throw new Error('Invalid ciphertext envelope fields.'); + } + const payload = { + iv: this.decodeBase64(envelope.iv), + authTag: this.decodeBase64(envelope.authTag), + cipherText: this.decodeBase64(envelope.cipherText), + }; + if (!hasVersion) { + if (payload.iv.length !== 12 || payload.authTag.length !== 16) { + throw new Error('Invalid legacy ciphertext IV or tag length.'); + } + return { version: 1, payload }; + } + if ( + envelope.version !== 2 || + envelope.algorithm !== V2_ALGORITHM || + envelope.keyId !== KEY_ID + ) { + throw new Error( + 'Unsupported ciphertext version, algorithm or key identifier.', + ); + } + this.assertPurpose(envelope.purpose); + return { version: 2, payload, header: this.header(envelope.purpose) }; + } - return { iv, authTag, cipherText }; + private decodeBase64(value: unknown): Buffer { + if (typeof value !== 'string') { + throw new Error('Ciphertext fields must be base64 strings.'); + } + const decoded = Buffer.from(value, 'base64'); + if (decoded.toString('base64') !== value) { + throw new Error('Invalid base64 ciphertext encoding.'); + } + return decoded; + } + + private decryptLegacy({ iv, authTag, cipherText }: BinaryPayload): string { + // Preserve the shipped derivation exactly. It is never used for new writes. + const key = createHash('sha256') + .update(LEGACY_KEY_DOMAIN) + .update(this.appSecret) + .digest(); + const decipher = createDecipheriv('aes-256-gcm', key, iv, { + authTagLength: 16, + }); + decipher.setAuthTag(authTag); + return Buffer.concat([ + decipher.update(cipherText), + decipher.final(), + ]).toString('utf8'); } } diff --git a/apps/server/src/integrations/environment/domain.service.ts b/apps/server/src/integrations/environment/domain.service.ts index fa6b1778..e1b7de3e 100644 --- a/apps/server/src/integrations/environment/domain.service.ts +++ b/apps/server/src/integrations/environment/domain.service.ts @@ -18,4 +18,18 @@ export class DomainService { const protocol = this.environmentService.isHttps() ? 'https' : 'http'; return `${protocol}://${hostname}.${domain}`; } + + getWorkspaceUrl(workspace: { + hostname?: string | null; + customDomain?: string | null; + }): string { + if (!this.environmentService.isCloud()) { + return this.environmentService.getAppUrl(); + } + if (workspace.customDomain) { + const protocol = this.environmentService.isHttps() ? 'https' : 'http'; + return `${protocol}://${workspace.customDomain}`; + } + return this.getUrl(workspace.hostname ?? undefined); + } } diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index 96d17401..79b1ce60 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -393,6 +393,10 @@ export class EnvironmentService { .filter(Boolean); } + getSlackSigningSecret(): string | undefined { + return this.configService.get('INTEGRATION_SLACK_SIGNING_SECRET'); + } + getAllowedPrivateNetworks(): string { return this.configService.get('ALLOWED_PRIVATE_NETWORKS', 'none'); } diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index a03c492d..42132f4a 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -8,7 +8,13 @@ export enum QueueName { AI_QUEUE = '{ai-queue}', HISTORY_QUEUE = '{history-queue}', NOTIFICATION_QUEUE = '{notification-queue}', + INTEGRATION_QUEUE = '{integration-queue}', AUDIT_QUEUE = '{audit-queue}', + SLACK_INBOUND = '{slack-inbound}', + // /docmost ask takes seconds, so it stays off the inbound event queue + SLACK_ASK = '{slack-ask}', + // Outbound DMs, kept off the inbound event queue + SLACK_NOTIFY = '{slack-notify}', BASE_QUEUE = '{base-queue}', SIEM_QUEUE = '{siem-queue}', } @@ -90,6 +96,11 @@ export enum QueueJob { PDF_EXPORT_TASK = 'pdf-export-task', PDF_EXPORT_CLEANUP = 'pdf-export-cleanup', + INTEGRATION_TOKEN_REFRESH = 'integration-token-refresh', + SLACK_EVENT = 'slack-event', + SLACK_ASK = 'slack-ask', + SLACK_NOTIFICATION = 'slack-notification', + BASE_TYPE_CONVERSION = 'base-type-conversion', BASE_CELL_GC = 'base-cell-gc', BASE_FORMULA_RECOMPUTE = 'base-formula-recompute', diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index 77bdf2b4..3aa1ce8e 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -94,6 +94,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor'; attempts: 3, }, }), + BullModule.registerQueue({ + name: QueueName.INTEGRATION_QUEUE, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: { count: 50 }, + attempts: 3, + }, + }), BullModule.registerQueue({ name: QueueName.SIEM_QUEUE, defaultJobOptions: { diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts index 9fa3cb6b..e867f170 100644 --- a/apps/server/src/integrations/throttle/throttle.module.ts +++ b/apps/server/src/integrations/throttle/throttle.module.ts @@ -11,6 +11,7 @@ import { OAUTH_TOKEN_THROTTLER, OAUTH_AUTHORIZE_THROTTLER, SIEM_TEST_THROTTLER, + UNFURL_THROTTLER, } from './throttler-names'; import Redis from 'ioredis'; @@ -29,6 +30,7 @@ import Redis from 'ioredis'; { name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 }, { name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 }, { name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 }, + { name: UNFURL_THROTTLER, ttl: 60_000, limit: 120 }, ], errorMessage: 'Too many requests', storage: new ThrottlerStorageRedisService( diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts index e0c3b5af..db913f51 100644 --- a/apps/server/src/integrations/throttle/throttler-names.ts +++ b/apps/server/src/integrations/throttle/throttler-names.ts @@ -4,6 +4,7 @@ export const OAUTH_REGISTER_THROTTLER = 'oauth-register'; export const OAUTH_TOKEN_THROTTLER = 'oauth-token'; export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize'; export const SIEM_TEST_THROTTLER = 'siem-test'; +export const UNFURL_THROTTLER = 'integration-unfurl'; // Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false. export const ALL_NAMED_THROTTLERS_SKIPPED: Record = { @@ -13,4 +14,5 @@ export const ALL_NAMED_THROTTLERS_SKIPPED: Record = { [OAUTH_TOKEN_THROTTLER]: true, [OAUTH_AUTHORIZE_THROTTLER]: true, [SIEM_TEST_THROTTLER]: true, + [UNFURL_THROTTLER]: true, }; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 2696d4e1..8a9fff64 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -18,6 +18,10 @@ import { resolveFrameHeadersForPath, } from './common/helpers'; +// Provider redirects land on the central app host, outside any workspace. +const OAUTH_CALLBACK_PATH = + /^\/api\/integrations\/oauth\/[^/?#]+\/callback(?:[?#]|$)/; + async function bootstrap() { const adapter = new FastifyAdapter({ trustProxy: true, @@ -129,11 +133,15 @@ async function bootstrap() { '/api/workspace/create', '/api/workspace/joined', '/api/workspace/find-by-email', + '/api/integrations/slack/events', + '/api/integrations/slack/commands', + '/api/integrations/slack/interactivity', ]; if ( req.originalUrl.startsWith('/api') && - !excludedPaths.some((path) => req.originalUrl.startsWith(path)) + !excludedPaths.some((path) => req.originalUrl.startsWith(path)) && + !OAUTH_CALLBACK_PATH.test(req.originalUrl) ) { if (!req.raw?.['workspaceId'] && req.originalUrl !== '/api') { throw new NotFoundException('Workspace not found'); diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index cc1631b1..51f742eb 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -28,6 +28,7 @@ export * from "./lib/heading/heading"; export * from "./lib/unique-id"; export * from "./lib/shared-storage"; export * from "./lib/recreate-transform"; +export * from "./lib/integration-link"; export * from "./lib/columns"; export * from "./lib/status"; export * from "./lib/tabs"; diff --git a/packages/editor-ext/src/lib/integration-link/index.ts b/packages/editor-ext/src/lib/integration-link/index.ts new file mode 100644 index 00000000..9a07f8d5 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/index.ts @@ -0,0 +1,16 @@ +export { IntegrationCard } from "./integration-card"; +export type { + IntegrationLinkOptions, + IntegrationLinkAttributes, +} from "./integration-link"; +export { IntegrationMention } from "./integration-mention"; +export { + integrationLinkPatterns, + matchIntegrationLink, + describeIntegrationLink, +} from "./integration-link-patterns"; +export type { + IntegrationLinkPattern, + IntegrationLinkDescription, + IntegrationUnfurlHosts, +} from "./integration-link-patterns"; diff --git a/packages/editor-ext/src/lib/integration-link/integration-card.ts b/packages/editor-ext/src/lib/integration-link/integration-card.ts new file mode 100644 index 00000000..5d346662 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-card.ts @@ -0,0 +1,102 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { + IntegrationLinkAttributes, + IntegrationLinkOptions, + sanitizeIntegrationUrl, +} from "./integration-link"; + +declare module "@tiptap/core" { + interface Commands { + integrationCard: { + setIntegrationCard: ( + attributes: Partial, + ) => ReturnType; + }; + } +} + +export const IntegrationCard = Node.create({ + name: "integrationCard", + inline: false, + group: "block", + isolating: true, + atom: true, + defining: true, + draggable: true, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + addAttributes() { + return { + url: { + default: "", + parseHTML: (element: HTMLElement) => + sanitizeIntegrationUrl(element.getAttribute("data-url")), + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-url": sanitizeIntegrationUrl(attributes.url), + }), + }, + provider: { + default: "", + parseHTML: (element: HTMLElement) => + element.getAttribute("data-provider"), + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-provider": attributes.provider, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const safeUrl = sanitizeIntegrationUrl(HTMLAttributes["data-url"]); + + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl], + ]; + }, + + renderText({ node }) { + return node.attrs.url; + }, + + addCommands() { + return { + setIntegrationCard: + (attrs) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + ...attrs, + url: sanitizeIntegrationUrl(attrs.url), + }, + }); + }, + }; + }, + + addNodeView() { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, +}); diff --git a/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts b/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts new file mode 100644 index 00000000..0640ec30 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts @@ -0,0 +1,518 @@ +export type IntegrationLinkPattern = { + provider: string; + type: string; + regex: RegExp; + // Matches only on a host the provider reports serving. + requiresProviderHost?: boolean; +}; + +export const integrationLinkPatterns: IntegrationLinkPattern[] = [ + // Slack message permalink (must precede the host-agnostic GitHub repo pattern) + { + provider: "slack", + type: "slack-message", + regex: + /^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/p(\d+)(?:\?thread_ts=[\d.]+&cid=[A-Za-z\d]+)?$/, + }, + // Slack channel + { + provider: "slack", + type: "slack-channel", + regex: + /^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/?$/, + }, + // Azure DevOps captures org, project?, repo, id for a pull request and org, project?, id for a work item. + { + provider: "azure_devops", + type: "azure-devops-pr", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))dev\.azure\.com\/([A-Za-z0-9-]+)(?:\/((?!_)[^\/?#]+))?\/_git\/([^\/?#]+)\/pullrequest\/(\d{1,10})(?=[\/?#]|$)/, + }, + { + provider: "azure_devops", + type: "azure-devops-work-item", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))dev\.azure\.com\/([A-Za-z0-9-]+)(?:\/((?!_)[^\/?#]+))?\/_workitems\/edit\/(\d{1,10})(?=[\/?#]|$)/, + }, + // Azure DevOps board, backlog or sprint with an open work item + { + provider: "azure_devops", + type: "azure-devops-work-item", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))dev\.azure\.com\/([A-Za-z0-9-]+)\/((?!_)[^\/?#]+)\/_(?:boards|backlogs|sprints)\/[^?#]*\?(?:[^#]*&)?workitem=(\d{1,10})(?=[&#]|$)/, + }, + // Azure DevOps legacy host + { + provider: "azure_devops", + type: "azure-devops-pr", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))([A-Za-z0-9-]+)\.visualstudio\.com(?:\/DefaultCollection)?(?:\/((?!_)[^\/?#]+))?\/_git\/([^\/?#]+)\/pullrequest\/(\d{1,10})(?=[\/?#]|$)/, + }, + { + provider: "azure_devops", + type: "azure-devops-work-item", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))([A-Za-z0-9-]+)\.visualstudio\.com(?:\/DefaultCollection)?(?:\/((?!_)[^\/?#]+))?\/_workitems\/edit\/(\d{1,10})(?=[\/?#]|$)/, + }, + { + provider: "azure_devops", + type: "azure-devops-work-item", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))([A-Za-z0-9-]+)\.visualstudio\.com(?:\/DefaultCollection)?\/((?!_)[^\/?#]+)\/_(?:boards|backlogs|sprints)\/[^?#]*\?(?:[^#]*&)?workitem=(\d{1,10})(?=[&#]|$)/, + }, + // Jira issue (cloud + self-hosted): /browse/KEY-123 (must precede the GitHub repo pattern) + { + provider: "jira", + type: "jira-issue", + regex: /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/browse\/([A-Za-z0-9]+-\d+)/, + }, + // Jira legacy board (cloud + self-hosted): RapidBoard.jspa?…selectedIssue=KEY + { + provider: "jira", + type: "jira-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/secure\/RapidBoard\.jspa\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/, + }, + // Jira cloud board/backlog with a selected issue + { + provider: "jira", + type: "jira-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[A-Za-z0-9-]+\.[Aa][Tt][Ll][Aa][Ss][Ss][Ii][Aa][Nn]\.[Nn][Ee][Tt]\/jira\/software(?:\/c)?\/projects\/[\w-]+\/boards\/\d+(?:\/\w+)?\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/, + }, + // Jira Data Center under a context path, e.g. /jira/browse/KEY-123 + { + provider: "jira", + type: "jira-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+(?:\/[^\/?#]+)+?\/browse\/([A-Za-z0-9]+-\d+)/, + requiresProviderHost: true, + }, + { + provider: "jira", + type: "jira-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+(?:\/[^\/?#]+)+?\/secure\/RapidBoard\.jspa\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/, + requiresProviderHost: true, + }, + // GitHub PR commit (must be before generic PR pattern) + { + provider: "github", + type: "github-pr-commit", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/pull\/(\d+)\/commits\/([a-f0-9]+)/, + }, + // GitHub PR (with optional /checks, /commits, /files sub-pages) + { + provider: "github", + type: "github-pr", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/pull\/(\d+)/, + }, + // GitHub issue + { + provider: "github", + type: "github-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/issues\/(\d+)/, + }, + // GitHub commit + { + provider: "github", + type: "github-commit", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/commits?\/([a-f0-9]+)/, + }, + // GitHub file/blob + { + provider: "github", + type: "github-file", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/blob\/([^\/]+)\/(.+?)(?:#L(\d+)(?:-L(\d+))?)?$/, + }, + // GitHub pulls list + { + provider: "github", + type: "github-pulls-list", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/pulls(?:[\/?].*)?$/, + }, + // GitHub releases list + { + provider: "github", + type: "github-releases-list", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/releases(?:[\/?].*)?$/, + }, + // GitHub issues list + { + provider: "github", + type: "github-issues-list", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/issues(?:\/(?:created_by|assigned)\/[\w.\/-]+)?\/?(?:\?.*)?$/, + }, + // GitHub repo + { + provider: "github", + type: "github-repo", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/?$/, + }, + // GitLab commit in MR diff (must be before generic MR pattern) + { + provider: "gitlab", + type: "gitlab-commit-in-mr", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/merge_requests\/(\d+)\/diffs\?.*commit_id=([a-f0-9]+)/, + }, + // GitLab merge request + { + provider: "gitlab", + type: "gitlab-mr", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/merge_requests\/(\d+)/, + }, + // GitLab issue + { + provider: "gitlab", + type: "gitlab-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/issues\/(\d+)/, + }, + // GitLab work item, treated as an issue + { + provider: "gitlab", + type: "gitlab-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/work_items\/(\d+)/, + }, + // GitLab work item opened as a drawer over the list (?show=base64 payload) + { + provider: "gitlab", + type: "gitlab-work-item-drawer", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/work_items\/?\?(?:.*&)?show=/, + }, + // GitLab commit + { + provider: "gitlab", + type: "gitlab-commit", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/commits?\/([a-f0-9]+)/, + }, + // GitLab issues list + { + provider: "gitlab", + type: "gitlab-issues-list", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/issues\/?(?:\?.*)?$/, + }, + // GitLab merge requests list + { + provider: "gitlab", + type: "gitlab-merges-list", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/merge_requests\/?(?:\?.*)?$/, + }, + // GitLab project + { + provider: "gitlab", + type: "gitlab-project", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_]+)\/?$/, + }, + // Google Docs + { + provider: "google_docs", + type: "google-doc", + regex: /^https?:\/\/docs\.google\.com\/document\/d\/([\w-]+)/, + }, + // Google Sheets + { + provider: "google_docs", + type: "google-sheet", + regex: /^https?:\/\/docs\.google\.com\/spreadsheets\/d\/([\w-]+)/, + }, + // Google Slides + { + provider: "google_docs", + type: "google-slides", + regex: /^https?:\/\/docs\.google\.com\/presentation\/d\/([\w-]+)/, + }, + // Google Forms + { + provider: "google_docs", + type: "google-form", + regex: /^https?:\/\/docs\.google\.com\/forms\/d\/([\w-]+)/, + }, + // Google Drive file + { + provider: "google_docs", + type: "google-drive-file", + regex: /^https?:\/\/drive\.google\.com\/file\/d\/([\w-]+)/, + }, + // Figma file (design, file, proto, board) + { + provider: "figma", + type: "figma-file", + regex: + /^https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|board|design)\/([0-9a-zA-Z]{22,128})/, + }, + // Linear issue: /team/issue/KEY-123(/:title-slug)? + { + provider: "linear", + type: "linear-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))linear\.app\/([^\/]+)\/issue\/([A-Z]+-\d+)(?:\/([^\/?#]+))?/, + }, + // Linear project: /team/project/:slug(/:tab)? + { + provider: "linear", + type: "linear-project", + regex: /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))linear\.app\/([^\/]+)\/project\/([^\/]+)/, + }, + // Linear initiative: /team/initiative/:slug(/:tab)? + { + provider: "linear", + type: "linear-initiative", + regex: /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))linear\.app\/([^\/]+)\/initiative\/([^\/]+)/, + }, + // Linear view: /team/view/:id(/:tab)? + { + provider: "linear", + type: "linear-view", + regex: /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))linear\.app\/([^\/]+)\/view\/([^\/]+)/, + }, +]; + +const MAX_INTEGRATION_LINK_LENGTH = 8192; + +// Provider type -> hostnames it unfurls; "*.example.com" means any subdomain. +export type IntegrationUnfurlHosts = Record; + +function isUnfurlHost(hostname: string, hosts: string[]): boolean { + return hosts.some((host) => + host.startsWith("*.") ? hostname.endsWith(host.slice(1)) : hostname === host, + ); +} + +export function matchIntegrationLink( + url: string | null | undefined, + unfurlHosts?: IntegrationUnfurlHosts, +): { provider: string; type: string; match: RegExpMatchArray } | null { + // Real URLs carry no whitespace; the cap bounds regex backtracking on hostile input. + if ( + typeof url !== "string" || + url.length > MAX_INTEGRATION_LINK_LENGTH || + /\s/.test(url) + ) { + return null; + } + + let hostname = ""; + try { + hostname = new URL(url).hostname.toLowerCase(); + } catch { + return null; + } + + const providerHosts = unfurlHosts ?? {}; + const servesHost = (provider: string) => + !providerHosts[provider] || isUnfurlHost(hostname, providerHosts[provider]); + // A host a provider reports serving (e.g. GitHub Enterprise) is that provider's. + const hostProvider = Object.keys(providerHosts).find( + (provider) => providerHosts[provider] && servesHost(provider), + ); + const publicCodeHostProvider = + hostname === "github.com" || hostname === "www.github.com" + ? "github" + : hostname === "gitlab.com" || hostname === "www.gitlab.com" + ? "gitlab" + : null; + const preferredProvider = hostProvider ?? publicCodeHostProvider; + const candidatePatterns = integrationLinkPatterns.filter( + (pattern) => + (!preferredProvider || pattern.provider === preferredProvider) && + servesHost(pattern.provider) && + (!pattern.requiresProviderHost || pattern.provider === hostProvider), + ); + + for (const pattern of candidatePatterns) { + const match = url.match(pattern.regex); + if (!match) continue; + + const isAmbiguousProjectRoot = + pattern.type === "github-repo" || pattern.type === "gitlab-project"; + if (isAmbiguousProjectRoot && !preferredProvider) continue; + + return { provider: pattern.provider, type: pattern.type, match }; + } + return null; +} + +export type IntegrationLinkDescription = { + provider: string; + title: string; + description?: string; +}; + +// Static, offline description of an integration url +export function describeIntegrationLink( + url: string, +): IntegrationLinkDescription | null { + const matched = matchIntegrationLink(url); + if (!matched) return null; + const { provider, type, match } = matched; + + const describe = (title: string, description?: string) => ({ + provider, + title, + description, + }); + const repo = () => `${match[1]}/${match[2]}`; + + switch (type) { + case "jira-issue": + return describe(match[1], hostOf(url)); + + case "azure-devops-work-item": + return describe( + `Work Item #${match[3]}`, + [match[1], match[2] && safeDecode(match[2])].filter(Boolean).join("/"), + ); + case "azure-devops-pr": + return describe( + `Pull Request #${match[4]}`, + [match[1], match[2] && safeDecode(match[2]), safeDecode(match[3])] + .filter(Boolean) + .join("/"), + ); + + case "github-pr": + return describe(`Pull Request #${match[3]}`, repo()); + case "github-pr-commit": + return describe(`Commit ${match[4].slice(0, 7)}`, repo()); + case "github-issue": + return describe(`Issue #${match[3]}`, repo()); + case "github-commit": + return describe(`Commit ${match[3].slice(0, 7)}`, repo()); + case "github-file": + return describe(match[4], repo()); + case "github-pulls-list": + return describe("Pull Requests", repo()); + case "github-issues-list": + return describe("Issues", repo()); + case "github-releases-list": + return describe("Releases", repo()); + case "github-repo": + return describe(repo()); + + case "gitlab-mr": + return describe(`Merge Request !${match[2]}`, match[1]); + case "gitlab-issue": + return describe(`Issue #${match[2]}`, match[1]); + case "gitlab-work-item-drawer": { + const target = decodeWorkItemShowParam(url); + return target + ? describe(`Issue #${target.iid}`, target.fullPath) + : describe("Work item", match[1]); + } + case "gitlab-commit": + return describe(`Commit ${match[2].slice(0, 8)}`, match[1]); + case "gitlab-commit-in-mr": + return describe(`Commit ${match[3].slice(0, 8)}`, match[1]); + case "gitlab-issues-list": + return describe("Issues", match[1]); + case "gitlab-merges-list": + return describe("Merge Requests", match[1]); + case "gitlab-project": + return describe(repo()); + + case "linear-issue": + return describe( + `Issue ${match[2]}`, + (match[3] && humanizeSlug(match[3])) || match[1], + ); + case "linear-project": + return describe(humanizeSlug(match[2]) ?? "Project", "Project"); + case "linear-initiative": + return describe(humanizeSlug(match[2]) ?? "Initiative", "Initiative"); + case "linear-view": + return describe("View", match[1]); + + case "slack-message": + return describe("Slack message", hostOf(url)); + case "slack-channel": + return describe("Slack channel", hostOf(url)); + case "figma-file": + return describe("Figma file", hostOf(url)); + case "google-doc": + return describe("Google Doc"); + case "google-sheet": + return describe("Google Sheet"); + case "google-slides": + return describe("Google Slides"); + case "google-form": + return describe("Google Form"); + case "google-drive-file": + return describe("Google Drive file"); + + default: + return null; + } +} + +function hostOf(url: string): string | undefined { + try { + return new URL(url).host; + } catch { + return undefined; + } +} + +// Slashes and control, format (bidi, zero-width) or separator characters could disguise a url's display text. +const UNSAFE_DISPLAY_TEXT = /[\/\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu; + +function encodeUnsafe(text: string): string { + return text.replace(UNSAFE_DISPLAY_TEXT, encodeURIComponent); +} + +function safeDecode(value: string): string { + let decoded = value; + try { + decoded = decodeURIComponent(value); + } catch { + return encodeUnsafe(value); + } + return encodeUnsafe(decoded) === decoded ? decoded : encodeUnsafe(value); +} + +// "mobile-app-1b9607f47174" -> "Mobile app": slugs end in a hex id segment. +function humanizeSlug(slug: string): string | null { + const name = slug + .replace(/-[a-f0-9]{8,}$/, "") + .replace(/-/g, " ") + .trim(); + if (!name || /^[a-f0-9]{8,}$/.test(name)) return null; + return name.charAt(0).toUpperCase() + name.slice(1); +} + +// A GitLab work item drawer is encoded as ?show=base64({ iid, full_path, id }). +function decodeWorkItemShowParam( + url: string, +): { fullPath: string; iid: number } | null { + try { + const show = new URL(url).searchParams.get("show"); + if (!show) return null; + const payload = JSON.parse( + atob(show.replace(/-/g, "+").replace(/_/g, "/")), + ); + const iid = parseInt(payload.iid, 10); + if (typeof payload.full_path !== "string" || Number.isNaN(iid)) { + return null; + } + return { fullPath: payload.full_path, iid }; + } catch { + return null; + } +} diff --git a/packages/editor-ext/src/lib/integration-link/integration-link.ts b/packages/editor-ext/src/lib/integration-link/integration-link.ts new file mode 100644 index 00000000..e8b3eaa1 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-link.ts @@ -0,0 +1,17 @@ +import { isHttpUrl, sanitizeUrl } from "../utils"; + +// Only web urls survive, kept as given because sanitizeUrl decodes percent-escapes. +export function sanitizeIntegrationUrl(url: string | null | undefined): string { + const trimmed = url?.trim() ?? ""; + return isHttpUrl(trimmed) && isHttpUrl(sanitizeUrl(trimmed)) ? trimmed : ""; +} + +export interface IntegrationLinkOptions { + HTMLAttributes: Record; + view: any; +} + +export interface IntegrationLinkAttributes { + url: string; + provider: string; +} diff --git a/packages/editor-ext/src/lib/integration-link/integration-mention.ts b/packages/editor-ext/src/lib/integration-link/integration-mention.ts new file mode 100644 index 00000000..86f08cc9 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-mention.ts @@ -0,0 +1,100 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { + IntegrationLinkAttributes, + IntegrationLinkOptions, + sanitizeIntegrationUrl, +} from "./integration-link"; + +declare module "@tiptap/core" { + interface Commands { + integrationMention: { + setIntegrationMention: ( + attributes: Partial, + ) => ReturnType; + }; + } +} + +export const IntegrationMention = Node.create({ + name: "integrationMention", + inline: true, + group: "inline", + atom: true, + selectable: true, + draggable: false, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + addAttributes() { + return { + url: { + default: "", + parseHTML: (element: HTMLElement) => + sanitizeIntegrationUrl(element.getAttribute("data-url")), + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-url": sanitizeIntegrationUrl(attributes.url), + }), + }, + provider: { + default: "", + parseHTML: (element: HTMLElement) => + element.getAttribute("data-provider"), + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-provider": attributes.provider, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const safeUrl = sanitizeIntegrationUrl(HTMLAttributes["data-url"]); + + return [ + "span", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl], + ]; + }, + + renderText({ node }) { + return node.attrs.url; + }, + + addCommands() { + return { + setIntegrationMention: + (attrs) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + ...attrs, + url: sanitizeIntegrationUrl(attrs.url), + }, + }); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(this.options.view); + }, +}); diff --git a/packages/editor-ext/src/lib/link.ts b/packages/editor-ext/src/lib/link.ts index 38ff1da6..5fca354b 100644 --- a/packages/editor-ext/src/lib/link.ts +++ b/packages/editor-ext/src/lib/link.ts @@ -15,6 +15,15 @@ export const LinkExtension = TiptapLink.extend({ renderHTML: (attributes) => attributes.internal ? { 'data-internal': 'true' } : {}, }, + integrationProvider: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute('data-integration-provider'), + renderHTML: (attributes) => + attributes.integrationProvider + ? { 'data-integration-provider': attributes.integrationProvider } + : {}, + }, }; }, diff --git a/packages/editor-ext/src/lib/prosemirror-docx/schema.ts b/packages/editor-ext/src/lib/prosemirror-docx/schema.ts index c7b2d140..abd8da7f 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/schema.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/schema.ts @@ -1,5 +1,13 @@ -import { FootnoteReferenceRun, HeadingLevel, Paragraph, ShadingType } from 'docx'; +import { + ExternalHyperlink, + FootnoteReferenceRun, + HeadingLevel, + Paragraph, + ShadingType, + TextRun, +} from 'docx'; import { Node } from 'prosemirror-model'; +import { sanitizeIntegrationUrl } from '../integration-link/integration-link'; import { DocxSerializerAsync, MarkSerializer, @@ -64,6 +72,27 @@ const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => { state.closeBlock(node); }; +const renderIntegrationMention: NodeSerializerAsync[string] = (state, node) => { + const url = sanitizeIntegrationUrl(node.attrs?.url); + if (!url) return; + state.current.push( + new ExternalHyperlink({ + link: url, + children: [new TextRun({ text: url, style: 'Hyperlink' })], + }), + ); +}; + +const renderIntegrationCard: NodeSerializerAsync[string] = async ( + state, + node, + parent, + index, +) => { + await renderIntegrationMention(state, node, parent, index); + state.closeBlock(node); +}; + export const defaultAsyncNodes: NodeSerializerAsync = { text(state, node) { state.text(node.text ?? ''); @@ -137,6 +166,8 @@ export const defaultAsyncNodes: NodeSerializerAsync = { attachment: renderFileLine, embed: renderEmbedLine, youtube: renderEmbedLine, + integrationCard: renderIntegrationCard, + integrationMention: renderIntegrationMention, async callout(state, node) { await state.renderContent(node, { style: 'IntenseQuote' }); }, diff --git a/packages/editor-ext/src/lib/utils.ts b/packages/editor-ext/src/lib/utils.ts index 8d03577c..9aff4b87 100644 --- a/packages/editor-ext/src/lib/utils.ts +++ b/packages/editor-ext/src/lib/utils.ts @@ -391,6 +391,16 @@ export function sanitizeUrl(url: string | undefined): string { return sanitized === "about:blank" ? "" : sanitized; } +export function isHttpUrl(url: string | null | undefined): boolean { + if (!url) return false; + try { + const { protocol } = new URL(url); + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} + export function isInternalFileUrl(url: string | undefined): boolean { if (!url) return false; const normalized = url.trim(); From 744c82bdf86f454ca44a2e19dc150900e884a11e Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:53:55 +0100 Subject: [PATCH 18/18] New Crowdin updates (#2584) --- .../public/locales/de-DE/translation.json | 70 +++++++++++++++++++ .../public/locales/es-ES/translation.json | 70 +++++++++++++++++++ .../public/locales/fr-FR/translation.json | 70 +++++++++++++++++++ .../public/locales/it-IT/translation.json | 70 +++++++++++++++++++ .../public/locales/ja-JP/translation.json | 70 +++++++++++++++++++ .../public/locales/ko-KR/translation.json | 70 +++++++++++++++++++ .../public/locales/nl-NL/translation.json | 70 +++++++++++++++++++ .../public/locales/pt-BR/translation.json | 70 +++++++++++++++++++ .../public/locales/ru-RU/translation.json | 70 +++++++++++++++++++ .../public/locales/uk-UA/translation.json | 70 +++++++++++++++++++ .../public/locales/zh-CN/translation.json | 70 +++++++++++++++++++ 11 files changed, 770 insertions(+) diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 4ebb4022..91f79d69 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Sind Sie sicher, dass Sie diesen Benutzer aus der Gruppe entfernen möchten? Der Benutzer verliert den Zugang zu den Ressourcen, auf die diese Gruppe zugreifen kann.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Sind Sie sicher, dass Sie diesen Benutzer aus dem Bereich entfernen möchten? Der Benutzer verliert den gesamten Zugang zu diesem Bereich.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Sind Sie sicher, dass Sie diese Version wiederherstellen möchten? Alle nicht versionierten Änderungen gehen verloren.", + "Assigned to {{name}}": "Zugewiesen an {{name}}", "Can become members of groups and spaces in workspace": "Kann Mitglied von Gruppen und Bereichen im Arbeitsbereich werden", "Can create and edit pages in space.": "Kann Seiten im Bereich erstellen und bearbeiten.", "Can edit": "Kann bearbeiten", @@ -22,6 +23,8 @@ "Can view": "Kann anzeigen", "Can view pages in space but not edit.": "Kann Seiten im Bereich anzeigen, aber nicht bearbeiten.", "Cancel": "Abbrechen", + "Card": "Karte", + "Cards aren't available in this position": "Karten sind an dieser Position nicht verfügbar", "Change email": "E-Mail ändern", "Change password": "Passwort ändern", "Change photo": "Foto ändern", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.", "Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.", "Confirm": "Bestätigen", + "Connect to {{name}} to preview": "Mit {{name}} verbinden, um die Vorschau anzuzeigen", + "Connected as {{label}}": "Verbunden als {{label}}", "Copy as Markdown": "Als Markdown kopieren", "Copy link": "Link kopieren", "Create": "Erstellen", @@ -41,11 +46,25 @@ "Dark": "Dunkel", "Date": "Datum", "Delete": "Löschen", + "Initiative": "Initiative", + "Integration link actions": "Aktionen für Integrationslinks", + "Last modified by {{name}}": "Zuletzt geändert von {{name}}", + "Open in Slack": "In Slack öffnen", + "Open link": "Link öffnen", + "Paid": "Bezahlt", + "Paste as": "Einfügen als", + "Project": "Projekt", + "Uninstall {{name}}": "{{name}} deinstallieren", + "Disconnect {{name}}": "{{name}} trennen", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Dadurch wird die Verbindung zu deinem {{name}}-Konto getrennt. Links werden für dich nicht angereichert, bis du die Verbindung wiederherstellst.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Dadurch wird die {{name}}-Integration für den gesamten Workspace deaktiviert. Die Verbindungen der Mitglieder werden entfernt und Links werden nicht angereichert.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Dieser Slack-Workspace ist bereits mit einem anderen Docmost-Workspace verbunden. Trenne die Verbindung dort zuerst oder installiere Docmost in einem anderen Slack-Workspace.", "Remove from page": "Von Seite entfernen", "Base options": "Basisoptionen", "Delete group": "Gruppe löschen", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", "Description": "Beschreibung", + "Display as": "Anzeigen als", "Details": "Details", "e.g ACME": "z. B. ACME", "e.g ACME Inc": "z. B. ACME GmbH", @@ -151,6 +170,8 @@ "page": "Seite", "Page deleted successfully": "Seite erfolgreich gelöscht", "Page history": "Seitenverlauf", + "replies": "Antworten", + "reply": "Antwort", "Select version": "Version auswählen", "Highlight changes": "Änderungen hervorheben", "Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.", @@ -193,6 +214,8 @@ "Invitation sent": "Einladung gesendet", "Settings": "Einstellungen", "Setup workspace": "Workspace einrichten", + "show less": "weniger anzeigen", + "show more": "mehr anzeigen", "Sign In": "Anmelden", "Sign Up": "Registrieren", "Slug": "Slug", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "Um Ihre E-Mail-Adresse zu ändern, müssen Sie Ihr Passwort und Ihre neue E-Mail-Adresse eingeben.", "Toggle full page width": "Volle Seitenbreite umschalten", "Unable to import pages. Please try again.": "Seiten konnten nicht importiert werden. Bitte versuchen Sie es erneut.", + "Unassigned": "Nicht zugewiesen", "untitled": "ohne Titel", "Untitled": "Ohne Titel", "Updated successfully": "Erfolgreich aktualisiert", @@ -649,6 +673,8 @@ "Search in all your spaces": "In all Ihren Bereichen suchen", "Type": "Typ", "Enterprise": "Enterprise", + "Business": "Unternehmen", + "Standard": "Standard", "Download attachment": "Anhang herunterladen", "Allowed email domains": "Erlaubte E-Mail-Domains", "Only users with email addresses from these domains can signup via SSO.": "Nur Benutzer mit E-Mail-Adressen aus diesen Domains können sich per SSO registrieren.", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "Auf dieser Seite gibt es noch keine Anhänge.", "Uploaded by {{name}}": "Hochgeladen von {{name}}", "Download {{name}}": "{{name}} herunterladen", + "Delete attachment": "Anhang löschen", + "Delete {{name}}": "{{name}} löschen", + "Attachment deleted": "Anhang gelöscht", + "Failed to delete attachment": "Löschen des Anhangs fehlgeschlagen", "Access revoked": "Zugriff widerrufen", "Authorize application": "Anwendung autorisieren", "{{name}} wants to access {{workspace}}": "{{name}} möchte auf {{workspace}} zugreifen", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "Standardmäßig wird der Hostname dieser Instanz verwendet", "Delete destination": "Ziel löschen", "Deleted SIEM destination": "SIEM-Ziel gelöscht", + "Integrations": "Integrationen", + "Installed integration": "Integration installiert", + "Uninstalled integration": "Integration deinstalliert", + "Connected integration account": "Integrationskonto verbunden", + "Disconnected integration account": "Integrationskonto getrennt", "Destination created": "Ziel erstellt", "Destination deleted": "Ziel gelöscht", "Destination updated": "Ziel aktualisiert", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "SIEM-Ziel aktualisiert", "Verify TLS certificate": "TLS-Zertifikat verifizieren", "e.g. Splunk prod": "z. B. Splunk Prod", + "Manage workspace integrations.": "Workspace-Integrationen verwalten.", + "No integrations available.": "Keine Integrationen verfügbar.", + "Install": "Installieren", + "Uninstall": "Deinstallieren", + "Failed to start installation": "Installation konnte nicht gestartet werden", + "Integration uninstalled successfully": "Integration erfolgreich deinstalliert", + "Failed to uninstall integration": "Deinstallation der Integration fehlgeschlagen", + "Connections": "Verbindungen", + "Manage the apps you have connected to your account.": "Verwalte die Apps, die du mit deinem Konto verbunden hast.", + "Connect": "Verbinden", + "Connected": "Verbunden", + "Disconnect": "Trennen", + "Reconnect": "Erneut verbinden", + "Connection expired": "Verbindung abgelaufen", + "Integration disconnected": "Integration getrennt", + "Failed to disconnect integration": "Trennen der Integration fehlgeschlagen", + "Failed to start OAuth connection": "OAuth-Verbindung konnte nicht gestartet werden", + "OAuth connection failed. Please try again.": "OAuth-Verbindung fehlgeschlagen. Bitte versuche es erneut.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "Das Konto, mit dem du dich angemeldet hast, verwendet nicht deine Docmost-E-Mail-Adresse. Melde dich beim Anbieter mit dem Konto an, das mit deiner Docmost-E-Mail übereinstimmt, und versuche es erneut.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "Das Slack-Konto, mit dem du dich angemeldet hast, gehört nicht zum mit Docmost verbundenen Slack-Workspace. Melde dich bei Slack mit deinem Konto in diesem Workspace an und versuche es erneut.", + "Connect your {{name}} account so links you paste show rich previews.": "Verbinde dein {{name}}-Konto, damit eingefügte Links umfangreiche Vorschauen anzeigen.", + "Connect {{name}} to Docmost": "{{name}} mit Docmost verbinden", + "Link your Slack account to your Docmost account to:": "Verknüpfe dein Slack-Konto mit deinem Docmost-Konto, um:", + "Search and open Docmost pages from Slack": "Docmost-Seiten aus Slack suchen und öffnen", + "Create pages and save Slack messages to Docmost": "Seiten erstellen und Slack-Nachrichten in Docmost speichern", + "Ask AI questions about your workspace docs": "Der KI Fragen zu deinen Workspace-Dokumenten stellen", + "Get your Docmost notifications as Slack DMs": "Deine Docmost-Benachrichtigungen als Slack-DMs erhalten", + "See previews of Docmost links in Slack and Slack links in Docmost": "Vorschauen von Docmost-Links in Slack und von Slack-Links in Docmost anzeigen", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Du meldest dich mit Slack an, um zu bestätigen, welches Slack-Konto dir gehört. Es muss dieselbe E-Mail-Adresse wie dein Docmost-Konto verwenden.", + "Updated comment": "Kommentar aktualisiert", + "Resolved comment": "Kommentar gelöst", + "Reopened comment": "Kommentar wieder geöffnet", + "Something went wrong": "Etwas ist schiefgelaufen", + "This page failed to load. Reload to try again.": "Diese Seite konnte nicht geladen werden. Lade sie neu, um es erneut zu versuchen.", + "Reload": "Neu laden", "Tabs": "Registerkarten", "Insert a multi-tab content block.": "Einen Inhaltsblock mit mehreren Tabs einfügen.", "Add tab": "Tab hinzufügen", diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index f27b69ad..50ab7de9 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "¿Está seguro de que desea eliminar a este usuario del grupo? El usuario perderá acceso a los recursos a los que tiene acceso este grupo.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "¿Está seguro de que desea eliminar a este usuario del espacio? El usuario perderá todo acceso a este espacio.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "¿Está seguro de que desea restaurar esta versión? Cualquier cambio no versionado se perderá.", + "Assigned to {{name}}": "Asignado a {{name}}", "Can become members of groups and spaces in workspace": "Pueden convertirse en miembros de grupos y espacios en el espacio de trabajo", "Can create and edit pages in space.": "Puede crear y editar páginas en el espacio.", "Can edit": "Puede editar", @@ -22,6 +23,8 @@ "Can view": "Puede ver", "Can view pages in space but not edit.": "Puede ver páginas en el espacio pero no editarlas.", "Cancel": "Cancelar", + "Card": "Tarjeta", + "Cards aren't available in this position": "Las tarjetas no están disponibles en esta posición", "Change email": "Cambiar correo electrónico", "Change password": "Cambiar contraseña", "Change photo": "Cambiar foto", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.", "Choose your preferred page width.": "Elige el ancho de página que prefieras.", "Confirm": "Confirmar", + "Connect to {{name}} to preview": "Conéctate a {{name}} para obtener una vista previa", + "Connected as {{label}}": "Conectado como {{label}}", "Copy as Markdown": "Copiar como Markdown", "Copy link": "Copiar enlace", "Create": "Crear", @@ -41,11 +46,25 @@ "Dark": "Oscuro", "Date": "Fecha", "Delete": "Eliminar", + "Initiative": "Iniciativa", + "Integration link actions": "Acciones de enlaces de integración", + "Last modified by {{name}}": "Última modificación realizada por {{name}}", + "Open in Slack": "Abrir en Slack", + "Open link": "Abrir enlace", + "Paid": "De pago", + "Paste as": "Pegar como", + "Project": "Proyecto", + "Uninstall {{name}}": "Desinstalar {{name}}", + "Disconnect {{name}}": "Desconectar {{name}}", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Esto desconecta tu cuenta de {{name}}. Los enlaces no se enriquecerán para ti hasta que vuelvas a conectarte.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Esto desactiva la integración de {{name}} para todo el espacio de trabajo. Se eliminan las conexiones de los miembros y los enlaces no se enriquecen.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Este espacio de trabajo de Slack ya está conectado a otro espacio de trabajo de Docmost. Desconéctalo allí primero o instala Docmost en otro espacio de trabajo de Slack.", "Remove from page": "Quitar de la página", "Base options": "Opciones de la base", "Delete group": "Eliminar grupo", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "¿Está seguro de que desea eliminar esta página? Esto eliminará sus dependientes y el historial de la página. Esta acción es irreversible.", "Description": "Descripción", + "Display as": "Mostrar como", "Details": "Detalles", "e.g ACME": "p. ej., ACME", "e.g ACME Inc": "p. ej., ACME Inc", @@ -151,6 +170,8 @@ "page": "página", "Page deleted successfully": "Página eliminada con éxito", "Page history": "Historial de la página", + "replies": "respuestas", + "reply": "respuesta", "Select version": "Seleccionar versión", "Highlight changes": "Resaltar cambios", "Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.", @@ -193,6 +214,8 @@ "Invitation sent": "Invitación enviada", "Settings": "Configuración", "Setup workspace": "Configurar espacio de trabajo", + "show less": "mostrar menos", + "show more": "mostrar más", "Sign In": "Iniciar sesión", "Sign Up": "Registrarse", "Slug": "Slug", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "Para cambiar tu correo electrónico, debes ingresar tu contraseña y nuevo correo electrónico.", "Toggle full page width": "Alternar ancho completo de la página", "Unable to import pages. Please try again.": "No se pueden importar las páginas. Por favor, inténtelo de nuevo.", + "Unassigned": "Sin asignar", "untitled": "sin título", "Untitled": "Sin título", "Updated successfully": "Actualizado correctamente", @@ -649,6 +673,8 @@ "Search in all your spaces": "Buscar en todos tus espacios", "Type": "Tipo", "Enterprise": "Empresarial", + "Business": "Negocios", + "Standard": "Estándar", "Download attachment": "Descargar archivo adjunto", "Allowed email domains": "Dominios de correo electrónico permitidos", "Only users with email addresses from these domains can signup via SSO.": "Solo los usuarios con direcciones de correo electrónico de estos dominios pueden registrarse mediante SSO.", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "Todavía no hay archivos adjuntos en esta página.", "Uploaded by {{name}}": "Subido por {{name}}", "Download {{name}}": "Descargar {{name}}", + "Delete attachment": "Eliminar archivo adjunto", + "Delete {{name}}": "Eliminar {{name}}", + "Attachment deleted": "Archivo adjunto eliminado", + "Failed to delete attachment": "No se pudo eliminar el archivo adjunto", "Access revoked": "Acceso revocado", "Authorize application": "Autorizar aplicación", "{{name}} wants to access {{workspace}}": "{{name}} quiere acceder a {{workspace}}", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "Usa de forma predeterminada el nombre de host de esta instancia", "Delete destination": "Eliminar destino", "Deleted SIEM destination": "Destino SIEM eliminado", + "Integrations": "Integraciones", + "Installed integration": "Integración instalada", + "Uninstalled integration": "Integración desinstalada", + "Connected integration account": "Cuenta de integración conectada", + "Disconnected integration account": "Cuenta de integración desconectada", "Destination created": "Destino creado", "Destination deleted": "Destino eliminado", "Destination updated": "Destino actualizado", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "Destino SIEM actualizado", "Verify TLS certificate": "Verificar certificado TLS", "e.g. Splunk prod": "p. ej., Splunk producción", + "Manage workspace integrations.": "Gestiona las integraciones del espacio de trabajo.", + "No integrations available.": "No hay integraciones disponibles.", + "Install": "Instalar", + "Uninstall": "Desinstalar", + "Failed to start installation": "No se pudo iniciar la instalación", + "Integration uninstalled successfully": "La integración se desinstaló correctamente", + "Failed to uninstall integration": "No se pudo desinstalar la integración", + "Connections": "Conexiones", + "Manage the apps you have connected to your account.": "Gestiona las aplicaciones que has conectado a tu cuenta.", + "Connect": "Conectar", + "Connected": "Conectado", + "Disconnect": "Desconectar", + "Reconnect": "Volver a conectar", + "Connection expired": "La conexión ha caducado", + "Integration disconnected": "Integración desconectada", + "Failed to disconnect integration": "No se pudo desconectar la integración", + "Failed to start OAuth connection": "No se pudo iniciar la conexión OAuth", + "OAuth connection failed. Please try again.": "La conexión OAuth falló. Inténtalo de nuevo.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "La cuenta con la que iniciaste sesión no usa tu dirección de correo electrónico de Docmost. Inicia sesión en el proveedor con la cuenta que coincida con tu correo electrónico de Docmost y vuelve a intentarlo.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "La cuenta de Slack con la que iniciaste sesión no está en el espacio de trabajo de Slack conectado a Docmost. Inicia sesión en Slack con tu cuenta de ese espacio de trabajo y vuelve a intentarlo.", + "Connect your {{name}} account so links you paste show rich previews.": "Conecta tu cuenta de {{name}} para que los enlaces que pegues muestren vistas previas enriquecidas.", + "Connect {{name}} to Docmost": "Conectar {{name}} a Docmost", + "Link your Slack account to your Docmost account to:": "Vincula tu cuenta de Slack a tu cuenta de Docmost para:", + "Search and open Docmost pages from Slack": "Buscar y abrir páginas de Docmost desde Slack", + "Create pages and save Slack messages to Docmost": "Crear páginas y guardar mensajes de Slack en Docmost", + "Ask AI questions about your workspace docs": "Hacer preguntas a la IA sobre los documentos de tu espacio de trabajo", + "Get your Docmost notifications as Slack DMs": "Recibir tus notificaciones de Docmost como mensajes directos de Slack", + "See previews of Docmost links in Slack and Slack links in Docmost": "Ver vistas previas de enlaces de Docmost en Slack y de enlaces de Slack en Docmost", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Iniciarás sesión con Slack para confirmar cuál es tu cuenta de Slack. Debe usar el mismo correo electrónico que tu cuenta de Docmost.", + "Updated comment": "Comentario actualizado", + "Resolved comment": "Comentario resuelto", + "Reopened comment": "Comentario reabierto", + "Something went wrong": "Algo salió mal", + "This page failed to load. Reload to try again.": "No se pudo cargar esta página. Vuelve a cargarla para intentarlo de nuevo.", + "Reload": "Volver a cargar", "Tabs": "Pestañas", "Insert a multi-tab content block.": "Inserta un bloque de contenido con varias pestañas.", "Add tab": "Añadir pestaña", diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index c17bda7d..8a14001b 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Êtes-vous sûr de vouloir retirer cet utilisateur du groupe ? L'utilisateur perdra l'accès aux ressources auxquelles ce groupe a accès.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Êtes-vous sûr de vouloir retirer cet utilisateur de l'espace ? L'utilisateur perdra tout accès à cet espace.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Êtes-vous sûr de vouloir restaurer cette version ? Toutes les modifications non versionnées seront perdues.", + "Assigned to {{name}}": "Attribué à {{name}}", "Can become members of groups and spaces in workspace": "Peut devenir membre de groupes et d'espaces dans l'espace de travail", "Can create and edit pages in space.": "Peut créer et modifier des pages dans l'espace.", "Can edit": "Peut modifier", @@ -22,6 +23,8 @@ "Can view": "Peut voir", "Can view pages in space but not edit.": "Peut voir les pages dans l'espace mais ne peut pas les modifier.", "Cancel": "Annuler", + "Card": "Carte", + "Cards aren't available in this position": "Les cartes ne sont pas disponibles à cet emplacement", "Change email": "Changer le courriel", "Change password": "Changer le mot de passe", "Change photo": "Changer la photo", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.", "Choose your preferred page width.": "Choisissez votre largeur de page préférée.", "Confirm": "Confirmer", + "Connect to {{name}} to preview": "Connectez-vous à {{name}} pour voir l’aperçu", + "Connected as {{label}}": "Connecté en tant que {{label}}", "Copy as Markdown": "Copier comme Markdown", "Copy link": "Copier le lien", "Create": "Créer", @@ -41,11 +46,25 @@ "Dark": "Sombre", "Date": "Date", "Delete": "Supprimer", + "Initiative": "Initiative", + "Integration link actions": "Actions du lien d’intégration", + "Last modified by {{name}}": "Dernière modification par {{name}}", + "Open in Slack": "Ouvrir dans Slack", + "Open link": "Ouvrir le lien", + "Paid": "Payant", + "Paste as": "Coller en tant que", + "Project": "Projet", + "Uninstall {{name}}": "Désinstaller {{name}}", + "Disconnect {{name}}": "Déconnecter {{name}}", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Cela déconnecte votre compte {{name}}. Les liens ne seront plus enrichis pour vous tant que vous ne vous reconnecterez pas.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Cela désactive l’intégration {{name}} pour tout l’espace de travail. Les connexions des membres sont supprimées et les liens ne sont plus enrichis.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Cet espace de travail Slack est déjà connecté à un autre espace de travail Docmost. Déconnectez-le d’abord là-bas, ou installez Docmost dans un autre espace de travail Slack.", "Remove from page": "Supprimer de la page", "Base options": "Options de la base", "Delete group": "Supprimer groupe", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Êtes-vous sûr de vouloir supprimer cette page ? Cela supprimera ses enfants et l'historique de la page. Cette action est irréversible.", "Description": "Description", + "Display as": "Afficher comme", "Details": "Détails", "e.g ACME": "p. ex. ACME", "e.g ACME Inc": "p. ex. ACME Inc", @@ -151,6 +170,8 @@ "page": "page", "Page deleted successfully": "Page supprimée avec succès", "Page history": "Historique de la page", + "replies": "réponses", + "reply": "réponse", "Select version": "Sélectionner la version", "Highlight changes": "Mettre en évidence les changements", "Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.", @@ -193,6 +214,8 @@ "Invitation sent": "Invitation envoyée", "Settings": "Paramètres", "Setup workspace": "Configurer l’espace de travail", + "show less": "afficher moins", + "show more": "afficher plus", "Sign In": "Se connecter", "Sign Up": "S’inscrire", "Slug": "Slug", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "Pour changer votre email, vous devez entrer votre mot de passe et votre nouvel email.", "Toggle full page width": "Basculer la largeur complète de la page", "Unable to import pages. Please try again.": "Impossible d'importer les pages. Veuillez réessayer.", + "Unassigned": "Non attribué", "untitled": "sans titre", "Untitled": "Sans titre", "Updated successfully": "Mise à jour réussie", @@ -649,6 +673,8 @@ "Search in all your spaces": "Rechercher dans tous vos espaces", "Type": "Type", "Enterprise": "Entreprise", + "Business": "Entreprise", + "Standard": "Standard", "Download attachment": "Télécharger la pièce jointe", "Allowed email domains": "Domaines e-mail autorisés", "Only users with email addresses from these domains can signup via SSO.": "Seuls les utilisateurs disposant d’adresses e-mail provenant de ces domaines peuvent s’inscrire via l’authentification unique (SSO).", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "Aucune pièce jointe sur cette page pour le moment.", "Uploaded by {{name}}": "Téléversé par {{name}}", "Download {{name}}": "Télécharger {{name}}", + "Delete attachment": "Supprimer la pièce jointe", + "Delete {{name}}": "Supprimer {{name}}", + "Attachment deleted": "Pièce jointe supprimée", + "Failed to delete attachment": "Échec de la suppression de la pièce jointe", "Access revoked": "Accès révoqué", "Authorize application": "Autoriser l’application", "{{name}} wants to access {{workspace}}": "{{name}} souhaite accéder à {{workspace}}", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "Utilise par défaut le nom d’hôte de cette instance", "Delete destination": "Supprimer la destination", "Deleted SIEM destination": "Destination SIEM supprimée", + "Integrations": "Intégrations", + "Installed integration": "Intégration installée", + "Uninstalled integration": "Intégration désinstallée", + "Connected integration account": "Compte d’intégration connecté", + "Disconnected integration account": "Compte d’intégration déconnecté", "Destination created": "Destination créée", "Destination deleted": "Destination supprimée", "Destination updated": "Destination mise à jour", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "Destination SIEM mise à jour", "Verify TLS certificate": "Vérifier le certificat TLS", "e.g. Splunk prod": "p. ex. Splunk prod", + "Manage workspace integrations.": "Gérez les intégrations de l’espace de travail.", + "No integrations available.": "Aucune intégration disponible.", + "Install": "Installer", + "Uninstall": "Désinstaller", + "Failed to start installation": "Échec du démarrage de l’installation", + "Integration uninstalled successfully": "Intégration désinstallée avec succès", + "Failed to uninstall integration": "Échec de la désinstallation de l’intégration", + "Connections": "Connexions", + "Manage the apps you have connected to your account.": "Gérez les applications que vous avez connectées à votre compte.", + "Connect": "Connecter", + "Connected": "Connecté", + "Disconnect": "Déconnecter", + "Reconnect": "Reconnecter", + "Connection expired": "Connexion expirée", + "Integration disconnected": "Intégration déconnectée", + "Failed to disconnect integration": "Échec de la déconnexion de l’intégration", + "Failed to start OAuth connection": "Échec du démarrage de la connexion OAuth", + "OAuth connection failed. Please try again.": "La connexion OAuth a échoué. Veuillez réessayer.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "Le compte avec lequel vous vous êtes connecté n’utilise pas votre adresse e-mail Docmost. Connectez-vous au fournisseur avec le compte qui correspond à votre adresse e-mail Docmost, puis réessayez.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "Le compte Slack avec lequel vous vous êtes connecté ne fait pas partie de l’espace de travail Slack connecté à Docmost. Connectez-vous à Slack avec votre compte dans cet espace de travail, puis réessayez.", + "Connect your {{name}} account so links you paste show rich previews.": "Connectez votre compte {{name}} afin que les liens que vous collez affichent des aperçus enrichis.", + "Connect {{name}} to Docmost": "Connecter {{name}} à Docmost", + "Link your Slack account to your Docmost account to:": "Liez votre compte Slack à votre compte Docmost pour :", + "Search and open Docmost pages from Slack": "Rechercher et ouvrir des pages Docmost depuis Slack", + "Create pages and save Slack messages to Docmost": "Créer des pages et enregistrer des messages Slack dans Docmost", + "Ask AI questions about your workspace docs": "Poser des questions à l’IA sur les documents de votre espace de travail", + "Get your Docmost notifications as Slack DMs": "Recevoir vos notifications Docmost sous forme de messages privés Slack", + "See previews of Docmost links in Slack and Slack links in Docmost": "Voir les aperçus des liens Docmost dans Slack et des liens Slack dans Docmost", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Vous vous connecterez avec Slack pour confirmer quel compte Slack est le vôtre. Il doit utiliser la même adresse e-mail que votre compte Docmost.", + "Updated comment": "Commentaire mis à jour", + "Resolved comment": "Commentaire résolu", + "Reopened comment": "Commentaire rouvert", + "Something went wrong": "Une erreur s’est produite", + "This page failed to load. Reload to try again.": "Cette page n’a pas pu être chargée. Rechargez-la pour réessayer.", + "Reload": "Recharger", "Tabs": "Onglets", "Insert a multi-tab content block.": "Insérer un bloc de contenu à onglets multiples.", "Add tab": "Ajouter un onglet", diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index 7c7f6394..0374bf1f 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Sei sicuro di voler rimuovere questo utente dal gruppo? L'utente perderà l'accesso alle risorse accessibili da questo gruppo.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Sei sicuro di voler rimuovere questo utente dallo spazio? L'utente perderà tutti gli accessi a questo spazio.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Sei sicuro di voler ripristinare questa versione? Qualsiasi modifica non versionata verrà persa.", + "Assigned to {{name}}": "Assegnato a {{name}}", "Can become members of groups and spaces in workspace": "Può diventare membro di gruppi e spazi nell'area di lavoro", "Can create and edit pages in space.": "Può creare e modificare le pagine nello spazio.", "Can edit": "Può modificare", @@ -22,6 +23,8 @@ "Can view": "Può visualizzare", "Can view pages in space but not edit.": "Può visualizzare le pagine nello spazio ma non può modificarle.", "Cancel": "Annulla", + "Card": "Scheda", + "Cards aren't available in this position": "Le schede non sono disponibili in questa posizione", "Change email": "Cambia email", "Change password": "Cambia password", "Change photo": "Cambia foto", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.", "Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.", "Confirm": "Conferma", + "Connect to {{name}} to preview": "Connettiti a {{name}} per visualizzare l'anteprima", + "Connected as {{label}}": "Connesso come {{label}}", "Copy as Markdown": "Copia come Markdown", "Copy link": "Copia link", "Create": "Crea", @@ -41,11 +46,25 @@ "Dark": "Scuro", "Date": "Data", "Delete": "Elimina", + "Initiative": "Iniziativa", + "Integration link actions": "Azioni dei collegamenti di integrazione", + "Last modified by {{name}}": "Ultima modifica di {{name}}", + "Open in Slack": "Apri in Slack", + "Open link": "Apri collegamento", + "Paid": "A pagamento", + "Paste as": "Incolla come", + "Project": "Progetto", + "Uninstall {{name}}": "Disinstalla {{name}}", + "Disconnect {{name}}": "Disconnetti {{name}}", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Questo disconnette il tuo account {{name}}. I collegamenti non verranno arricchiti finché non ti riconnetti.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Questo disabilita l'integrazione {{name}} per l'intero spazio di lavoro. Le connessioni dei membri vengono rimosse e i collegamenti non vengono arricchiti.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Questo spazio di lavoro Slack è già connesso a un altro spazio di lavoro Docmost. Disconnettilo prima lì oppure installa Docmost in un altro spazio di lavoro Slack.", "Remove from page": "Rimuovi dalla pagina", "Base options": "Opzioni della base", "Delete group": "Elimina gruppo", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sei sicuro di voler eliminare questa pagina? Verranno cancellate anche le sue sottopagine e la cronologia. Questa azione è irreversibile.", "Description": "Descrizione", + "Display as": "Visualizza come", "Details": "Dettagli", "e.g ACME": "es. ACME", "e.g ACME Inc": "es. ACME Inc", @@ -151,6 +170,8 @@ "page": "pagina", "Page deleted successfully": "Pagina eliminata con successo", "Page history": "Cronologia della pagina", + "replies": "risposte", + "reply": "risposta", "Select version": "Seleziona versione", "Highlight changes": "Evidenzia modifiche", "Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.", @@ -193,6 +214,8 @@ "Invitation sent": "Invito inviato", "Settings": "Impostazioni", "Setup workspace": "Configura workspace", + "show less": "mostra meno", + "show more": "mostra di più", "Sign In": "Accedi", "Sign Up": "Registrati", "Slug": "Slug", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "Per cambiare la tua email, devi inserire la tua password e la nuova email.", "Toggle full page width": "Attiva/disattiva larghezza intera della pagina", "Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.", + "Unassigned": "Non assegnato", "untitled": "senza titolo", "Untitled": "Senza titolo", "Updated successfully": "Aggiornato con successo", @@ -649,6 +673,8 @@ "Search in all your spaces": "Cerca in tutti i tuoi spazi", "Type": "Tipo", "Enterprise": "Enterprise", + "Business": "Aziendale", + "Standard": "Standard", "Download attachment": "Scarica allegato", "Allowed email domains": "Domini email consentiti", "Only users with email addresses from these domains can signup via SSO.": "Solo gli utenti con indirizzi email di questi domini possono registrarsi tramite SSO.", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "Ancora nessun allegato in questa pagina.", "Uploaded by {{name}}": "Caricato da {{name}}", "Download {{name}}": "Scarica {{name}}", + "Delete attachment": "Elimina allegato", + "Delete {{name}}": "Elimina {{name}}", + "Attachment deleted": "Allegato eliminato", + "Failed to delete attachment": "Impossibile eliminare l'allegato", "Access revoked": "Accesso revocato", "Authorize application": "Autorizza applicazione", "{{name}} wants to access {{workspace}}": "{{name}} vuole accedere a {{workspace}}", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "Per impostazione predefinita usa il nome host di questa istanza", "Delete destination": "Elimina destinazione", "Deleted SIEM destination": "Destinazione SIEM eliminata", + "Integrations": "Integrazioni", + "Installed integration": "Integrazione installata", + "Uninstalled integration": "Integrazione disinstallata", + "Connected integration account": "Account di integrazione connesso", + "Disconnected integration account": "Account di integrazione disconnesso", "Destination created": "Destinazione creata", "Destination deleted": "Destinazione eliminata", "Destination updated": "Destinazione aggiornata", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "Destinazione SIEM aggiornata", "Verify TLS certificate": "Verifica certificato TLS", "e.g. Splunk prod": "ad es. Splunk produzione", + "Manage workspace integrations.": "Gestisci le integrazioni dello spazio di lavoro.", + "No integrations available.": "Nessuna integrazione disponibile.", + "Install": "Installa", + "Uninstall": "Disinstalla", + "Failed to start installation": "Impossibile avviare l'installazione", + "Integration uninstalled successfully": "Integrazione disinstallata con successo", + "Failed to uninstall integration": "Impossibile disinstallare l'integrazione", + "Connections": "Connessioni", + "Manage the apps you have connected to your account.": "Gestisci le app che hai connesso al tuo account.", + "Connect": "Connetti", + "Connected": "Connesso", + "Disconnect": "Disconnetti", + "Reconnect": "Riconnetti", + "Connection expired": "Connessione scaduta", + "Integration disconnected": "Integrazione disconnessa", + "Failed to disconnect integration": "Impossibile disconnettere l'integrazione", + "Failed to start OAuth connection": "Impossibile avviare la connessione OAuth", + "OAuth connection failed. Please try again.": "Connessione OAuth non riuscita. Riprova.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "L'account con cui hai effettuato l'accesso non usa il tuo indirizzo email di Docmost. Accedi al provider con l'account che corrisponde alla tua email di Docmost e riprova.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "L'account Slack con cui hai effettuato l'accesso non appartiene allo spazio di lavoro Slack connesso a Docmost. Accedi a Slack con il tuo account in quello spazio di lavoro e riprova.", + "Connect your {{name}} account so links you paste show rich previews.": "Connetti il tuo account {{name}} in modo che i collegamenti che incolli mostrino anteprime avanzate.", + "Connect {{name}} to Docmost": "Connetti {{name}} a Docmost", + "Link your Slack account to your Docmost account to:": "Collega il tuo account Slack al tuo account Docmost per:", + "Search and open Docmost pages from Slack": "Cercare e aprire pagine di Docmost da Slack", + "Create pages and save Slack messages to Docmost": "Creare pagine e salvare messaggi di Slack in Docmost", + "Ask AI questions about your workspace docs": "Fare domande all'IA sui documenti del tuo spazio di lavoro", + "Get your Docmost notifications as Slack DMs": "Ricevere le notifiche di Docmost come messaggi diretti di Slack", + "See previews of Docmost links in Slack and Slack links in Docmost": "Vedere anteprime dei collegamenti di Docmost in Slack e dei collegamenti di Slack in Docmost", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Accederai con Slack per confermare quale account Slack è il tuo. Deve usare la stessa email del tuo account Docmost.", + "Updated comment": "Commento aggiornato", + "Resolved comment": "Commento risolto", + "Reopened comment": "Commento riaperto", + "Something went wrong": "Qualcosa è andato storto", + "This page failed to load. Reload to try again.": "Impossibile caricare questa pagina. Ricaricala per riprovare.", + "Reload": "Ricarica", "Tabs": "Schede", "Insert a multi-tab content block.": "Inserisci un blocco di contenuto con più schede.", "Add tab": "Aggiungi scheda", diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index cb389457..33979763 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。", + "Assigned to {{name}}": "{{name}} に割り当て済み", "Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになれます", "Can create and edit pages in space.": "スペース内のページを作成・編集できます", "Can edit": "編集可能", @@ -22,6 +23,8 @@ "Can view": "閲覧可能", "Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません", "Cancel": "キャンセル", + "Card": "カード", + "Cards aren't available in this position": "この位置ではカードは利用できません", "Change email": "メールアドレスの変更", "Change password": "パスワードの変更", "Change photo": "画像の変更", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "お好みの言語を選択してください", "Choose your preferred page width.": "お好みのページ幅を選択してください", "Confirm": "確認", + "Connect to {{name}} to preview": "プレビューするには {{name}} に接続してください", + "Connected as {{label}}": "{{label}} として接続済み", "Copy as Markdown": "Markdownとしてコピー", "Copy link": "リンクをコピー", "Create": "新規作成", @@ -41,11 +46,25 @@ "Dark": "ダーク", "Date": "日付", "Delete": "削除", + "Initiative": "イニシアチブ", + "Integration link actions": "連携リンクのアクション", + "Last modified by {{name}}": "最終更新者: {{name}}", + "Open in Slack": "Slack で開く", + "Open link": "リンクを開く", + "Paid": "有料", + "Paste as": "次の形式で貼り付け", + "Project": "プロジェクト", + "Uninstall {{name}}": "{{name}} をアンインストール", + "Disconnect {{name}}": "{{name}} との接続を解除", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "これにより {{name}} アカウントとの接続が解除されます。再接続するまで、リンクはリッチ化されません。", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "これにより、ワークスペース全体で {{name}} 連携が無効になります。メンバーの接続は削除され、リンクはリッチ化されません。", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "この Slack ワークスペースは、すでに別の Docmost ワークスペースに接続されています。まずそちらで接続を解除するか、別の Slack ワークスペースに Docmost をインストールしてください。", "Remove from page": "ページから削除", "Base options": "ベースのオプション", "Delete group": "グループを削除", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?子ページとページ履歴も削除されます。この操作は取り消せません。", "Description": "説明", + "Display as": "表示形式", "Details": "詳細", "e.g ACME": "例: ACME", "e.g ACME Inc": "例: ACME Inc", @@ -151,6 +170,8 @@ "page": "ページ", "Page deleted successfully": "ページを削除しました", "Page history": "ページ履歴", + "replies": "返信", + "reply": "返信", "Select version": "バージョンを選択", "Highlight changes": "変更を強調表示", "Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください", @@ -193,6 +214,8 @@ "Invitation sent": "招待を送信しました", "Settings": "設定", "Setup workspace": "ワークスペースを設定", + "show less": "表示を減らす", + "show more": "さらに表示", "Sign In": "サインイン", "Sign Up": "サインアップ", "Slug": "スラッグ", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください", "Toggle full page width": "ページ全幅表示を切り替え", "Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください", + "Unassigned": "未割り当て", "untitled": "無題", "Untitled": "無題", "Updated successfully": "正常に更新されました", @@ -649,6 +673,8 @@ "Search in all your spaces": "すべてのスペースを検索", "Type": "タイプ", "Enterprise": "エンタープライズ", + "Business": "ビジネス", + "Standard": "スタンダード", "Download attachment": "添付ファイルをダウンロード", "Allowed email domains": "許可されたメールドメイン", "Only users with email addresses from these domains can signup via SSO.": "これらのドメインのメールアドレスを持つユーザーのみが SSO 経由でサインアップできます。", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "このページにはまだ添付ファイルがありません。", "Uploaded by {{name}}": "アップロード者: {{name}}", "Download {{name}}": "{{name}} をダウンロード", + "Delete attachment": "添付ファイルを削除", + "Delete {{name}}": "{{name}} を削除", + "Attachment deleted": "添付ファイルを削除しました", + "Failed to delete attachment": "添付ファイルの削除に失敗しました", "Access revoked": "アクセスが取り消されました", "Authorize application": "アプリケーションを認可", "{{name}} wants to access {{workspace}}": "{{name}} が {{workspace}} へのアクセスを求めています", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "デフォルトではこのインスタンスのホスト名が使用されます", "Delete destination": "宛先を削除", "Deleted SIEM destination": "SIEM 宛先を削除しました", + "Integrations": "連携", + "Installed integration": "インストール済みの連携", + "Uninstalled integration": "アンインストールされた連携", + "Connected integration account": "接続済みの連携アカウント", + "Disconnected integration account": "切断された連携アカウント", "Destination created": "宛先を作成しました", "Destination deleted": "宛先を削除しました", "Destination updated": "宛先を更新しました", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "SIEM 宛先を更新しました", "Verify TLS certificate": "TLS 証明書を検証", "e.g. Splunk prod": "例: Splunk 本番", + "Manage workspace integrations.": "ワークスペースの連携を管理します。", + "No integrations available.": "利用可能な連携はありません。", + "Install": "インストール", + "Uninstall": "アンインストール", + "Failed to start installation": "インストールの開始に失敗しました", + "Integration uninstalled successfully": "連携を正常にアンインストールしました", + "Failed to uninstall integration": "連携のアンインストールに失敗しました", + "Connections": "接続", + "Manage the apps you have connected to your account.": "アカウントに接続したアプリを管理します。", + "Connect": "接続", + "Connected": "接続済み", + "Disconnect": "切断", + "Reconnect": "再接続", + "Connection expired": "接続の有効期限が切れました", + "Integration disconnected": "連携が切断されました", + "Failed to disconnect integration": "連携の切断に失敗しました", + "Failed to start OAuth connection": "OAuth 接続の開始に失敗しました", + "OAuth connection failed. Please try again.": "OAuth 接続に失敗しました。もう一度お試しください。", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "サインインしたアカウントは、Docmost のメールアドレスを使用していません。Docmost のメールアドレスと一致するアカウントでプロバイダーにサインインして、もう一度お試しください。", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "サインインした Slack アカウントは、Docmost に接続されている Slack ワークスペースに属していません。そのワークスペース内のアカウントで Slack にサインインして、もう一度お試しください。", + "Connect your {{name}} account so links you paste show rich previews.": "貼り付けたリンクにリッチプレビューを表示するには、{{name}} アカウントを接続してください。", + "Connect {{name}} to Docmost": "{{name}} を Docmost に接続", + "Link your Slack account to your Docmost account to:": "Slack アカウントを Docmost アカウントにリンクすると、次のことができます:", + "Search and open Docmost pages from Slack": "Slack から Docmost ページを検索して開く", + "Create pages and save Slack messages to Docmost": "ページを作成し、Slack メッセージを Docmost に保存する", + "Ask AI questions about your workspace docs": "ワークスペースのドキュメントについて AI に質問する", + "Get your Docmost notifications as Slack DMs": "Docmost の通知を Slack のダイレクトメッセージで受け取る", + "See previews of Docmost links in Slack and Slack links in Docmost": "Slack で Docmost リンクのプレビューを表示し、Docmost で Slack リンクのプレビューを表示する", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "どの Slack アカウントが自分のものかを確認するために、Slack でサインインします。Docmost アカウントと同じメールアドレスを使用している必要があります。", + "Updated comment": "更新されたコメント", + "Resolved comment": "解決済みのコメント", + "Reopened comment": "再オープンされたコメント", + "Something went wrong": "問題が発生しました", + "This page failed to load. Reload to try again.": "このページの読み込みに失敗しました。再読み込みしてもう一度お試しください。", + "Reload": "再読み込み", "Tabs": "タブ", "Insert a multi-tab content block.": "複数タブのコンテンツブロックを挿入します。", "Add tab": "タブを追加", diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index fb220dc0..46d5ffe8 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "이 사용자를 그룹에서 제거하시겠습니까? 사용자는 이 그룹이 가진 모든 권한을 잃게 됩니다.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "이 사용자를 스페이스에서 제거하시겠습니까? 사용자는 이 스페이스에 대한 모든 접근 권한을 잃게 됩니다.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "이 버전으로 복원하시겠습니까? 저장되지 않은 모든 변경사항이 손실됩니다.", + "Assigned to {{name}}": "{{name}}에 할당됨", "Can become members of groups and spaces in workspace": "워크스페이스의 그룹 및 스페이스의 멤버가 될 수 있음", "Can create and edit pages in space.": "스페이스에서 페이지를 생성하고 편집할 수 있습니다.", "Can edit": "편집할 수 있음", @@ -22,6 +23,8 @@ "Can view": "볼 수 있음", "Can view pages in space but not edit.": "스페이스의 페이지를 볼 수 있지만, 편집은 할 수 없음.", "Cancel": "취소", + "Card": "카드", + "Cards aren't available in this position": "이 위치에서는 카드를 사용할 수 없습니다", "Change email": "이메일 변경", "Change password": "비밀번호 변경", "Change photo": "사진 변경", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.", "Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.", "Confirm": "확인", + "Connect to {{name}} to preview": "미리 보려면 {{name}}에 연결하세요", + "Connected as {{label}}": "{{label}}로 연결됨", "Copy as Markdown": "Markdown으로 복사", "Copy link": "링크 복사", "Create": "생성", @@ -41,11 +46,25 @@ "Dark": "어두운", "Date": "날짜", "Delete": "삭제", + "Initiative": "이니셔티브", + "Integration link actions": "통합 링크 작업", + "Last modified by {{name}}": "마지막 수정자: {{name}}", + "Open in Slack": "Slack에서 열기", + "Open link": "링크 열기", + "Paid": "유료", + "Paste as": "다음으로 붙여넣기", + "Project": "프로젝트", + "Uninstall {{name}}": "{{name}} 제거", + "Disconnect {{name}}": "{{name}} 연결 해제", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "이 작업을 수행하면 {{name}} 계정 연결이 해제됩니다. 다시 연결할 때까지 링크에 대한 풍부한 미리보기가 제공되지 않습니다.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "이 작업을 수행하면 전체 워크스페이스에서 {{name}} 통합이 비활성화됩니다. 멤버의 연결이 제거되고 링크에 대한 풍부한 미리보기가 제공되지 않습니다.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "이 Slack 워크스페이스는 이미 다른 Docmost 워크스페이스에 연결되어 있습니다. 먼저 այնտեղ서 연결을 해제하거나, 다른 Slack 워크스페이스에 Docmost를 설치하세요.", "Remove from page": "페이지에서 제거", "Base options": "베이스 옵션", "Delete group": "그룹 삭제", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.", "Description": "설명", + "Display as": "다음으로 표시", "Details": "세부사항", "e.g ACME": "예: ACME", "e.g ACME Inc": "예: ACME Inc", @@ -151,6 +170,8 @@ "page": "페이지", "Page deleted successfully": "페이지 삭제됨", "Page history": "페이지 기록", + "replies": "답글", + "reply": "답글", "Select version": "버전 선택", "Highlight changes": "변경 사항 강조", "Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.", @@ -193,6 +214,8 @@ "Invitation sent": "초대를 보냈습니다", "Settings": "설정", "Setup workspace": "워크스페이스 설정", + "show less": "간단히 보기", + "show more": "더 보기", "Sign In": "로그인", "Sign Up": "회원가입", "Slug": "슬러그", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "이메일을 변경하려면 기존 비밀번호와 새 이메일을 입력해야 합니다.", "Toggle full page width": "전체 페이지 너비 전환", "Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.", + "Unassigned": "할당되지 않음", "untitled": "제목 없음", "Untitled": "제목 없음", "Updated successfully": "업데이트 완료", @@ -649,6 +673,8 @@ "Search in all your spaces": "모든 스페이스에서 검색", "Type": "유형", "Enterprise": "엔터프라이즈", + "Business": "비즈니스", + "Standard": "스탠다드", "Download attachment": "첨부 파일 다운로드", "Allowed email domains": "허용된 이메일 도메인", "Only users with email addresses from these domains can signup via SSO.": "이 도메인의 이메일 주소를 가진 사용자만 SSO를 통해 가입할 수 있습니다.", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "이 페이지에는 아직 첨부 파일이 없습니다.", "Uploaded by {{name}}": "업로드한 사람: {{name}}", "Download {{name}}": "{{name}} 다운로드", + "Delete attachment": "첨부 파일 삭제", + "Delete {{name}}": "{{name}} 삭제", + "Attachment deleted": "첨부 파일이 삭제되었습니다", + "Failed to delete attachment": "첨부 파일 삭제 실패", "Access revoked": "액세스가 취소되었습니다", "Authorize application": "애플리케이션 승인", "{{name}} wants to access {{workspace}}": "{{name}}에서 {{workspace}}에 액세스하려고 합니다", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "기본값은 인스턴스의 호스트명입니다", "Delete destination": "대상 삭제", "Deleted SIEM destination": "SIEM 대상 삭제됨", + "Integrations": "통합", + "Installed integration": "설치된 통합", + "Uninstalled integration": "제거된 통합", + "Connected integration account": "연결된 통합 계정", + "Disconnected integration account": "연결 해제된 통합 계정", "Destination created": "대상 생성됨", "Destination deleted": "대상 삭제됨", "Destination updated": "대상 업데이트됨", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "SIEM 대상 업데이트됨", "Verify TLS certificate": "TLS 인증서 검증", "e.g. Splunk prod": "예: Splunk 운영", + "Manage workspace integrations.": "워크스페이스 통합을 관리합니다.", + "No integrations available.": "사용 가능한 통합이 없습니다.", + "Install": "설치", + "Uninstall": "제거", + "Failed to start installation": "설치를 시작하지 못했습니다", + "Integration uninstalled successfully": "통합이 성공적으로 제거되었습니다", + "Failed to uninstall integration": "통합 제거에 실패했습니다", + "Connections": "연결", + "Manage the apps you have connected to your account.": "계정에 연결한 앱을 관리합니다.", + "Connect": "연결", + "Connected": "연결됨", + "Disconnect": "연결 해제", + "Reconnect": "다시 연결", + "Connection expired": "연결 만료됨", + "Integration disconnected": "통합 연결이 해제되었습니다", + "Failed to disconnect integration": "통합 연결 해제에 실패했습니다", + "Failed to start OAuth connection": "OAuth 연결을 시작하지 못했습니다", + "OAuth connection failed. Please try again.": "OAuth 연결에 실패했습니다. 다시 시도해 주세요.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "로그인한 계정은 Docmost 이메일 주소를 사용하지 않습니다. Docmost 이메일과 일치하는 계정으로 제공업체에 로그인한 다음 다시 시도하세요.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "로그인한 Slack 계정은 Docmost에 연결된 Slack 워크스페이스에 속해 있지 않습니다. 해당 워크스페이스의 계정으로 Slack에 로그인한 다음 다시 시도하세요.", + "Connect your {{name}} account so links you paste show rich previews.": "붙여넣은 링크에 풍부한 미리보기가 표시되도록 {{name}} 계정을 연결하세요.", + "Connect {{name}} to Docmost": "{{name}}를 Docmost에 연결", + "Link your Slack account to your Docmost account to:": "다음을 위해 Slack 계정을 Docmost 계정에 연결하세요:", + "Search and open Docmost pages from Slack": "Slack에서 Docmost 페이지를 검색하고 열기", + "Create pages and save Slack messages to Docmost": "페이지를 만들고 Slack 메시지를 Docmost에 저장", + "Ask AI questions about your workspace docs": "워크스페이스 문서에 대해 AI에 질문하기", + "Get your Docmost notifications as Slack DMs": "Docmost 알림을 Slack DM으로 받기", + "See previews of Docmost links in Slack and Slack links in Docmost": "Slack에서 Docmost 링크 미리보기와 Docmost에서 Slack 링크 미리보기 보기", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "어떤 Slack 계정이 본인 것인지 확인하기 위해 Slack으로 로그인하게 됩니다. 이 계정은 Docmost 계정과 동일한 이메일을 사용해야 합니다.", + "Updated comment": "댓글이 업데이트됨", + "Resolved comment": "댓글이 해결됨", + "Reopened comment": "댓글이 다시 열림", + "Something went wrong": "문제가 발생했습니다", + "This page failed to load. Reload to try again.": "이 페이지를 불러오지 못했습니다. 다시 시도하려면 새로고침하세요.", + "Reload": "새로고침", "Tabs": "탭", "Insert a multi-tab content block.": "여러 탭이 있는 콘텐츠 블록을 삽입합니다.", "Add tab": "탭 추가", diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 7f12d485..4e6579a9 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Weet je zeker dat je deze groep wilt verwijderen? Leden verliezen toegang tot documenten waar deze groep toegang toe heeft.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Weet u zeker dat u deze gebruiker van de ruimte wilt verwijderen? De gebruiker zal alle toegang tot deze ruimte verliezen.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Weet u zeker dat u deze versie wilt herstellen? Wijzigingen die geen versie hebben zullen verloren gaan.", + "Assigned to {{name}}": "Toegewezen aan {{name}}", "Can become members of groups and spaces in workspace": "Kunnen lid worden van groepen en ruimtes in de werkruimte", "Can create and edit pages in space.": "Kan pagina's in de ruimte maken en bewerken.", "Can edit": "Kan bewerken", @@ -22,6 +23,8 @@ "Can view": "Kan bekijken", "Can view pages in space but not edit.": "Kan pagina's in de ruimte bekijken maar niet bewerken.", "Cancel": "Annuleren", + "Card": "Kaart", + "Cards aren't available in this position": "Kaarten zijn niet beschikbaar op deze positie", "Change email": "Wijzig e-mailadres", "Change password": "Wijzig wachtwoord", "Change photo": "Wijzig foto", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "Kies uw gewenste interfacetaal.", "Choose your preferred page width.": "Kies uw gewenste paginabreedte.", "Confirm": "Bevestig", + "Connect to {{name}} to preview": "Verbind met {{name}} om een voorbeeld te bekijken", + "Connected as {{label}}": "Verbonden als {{label}}", "Copy as Markdown": "Kopiëren als Markdown", "Copy link": "Link kopiëren", "Create": "Aanmaken", @@ -41,11 +46,25 @@ "Dark": "Donker", "Date": "Datum", "Delete": "Verwijderen", + "Initiative": "Initiatief", + "Integration link actions": "Acties voor integratiekoppelingen", + "Last modified by {{name}}": "Laatst gewijzigd door {{name}}", + "Open in Slack": "Openen in Slack", + "Open link": "Link openen", + "Paid": "Betaald", + "Paste as": "Plakken als", + "Project": "Project", + "Uninstall {{name}}": "{{name}} verwijderen", + "Disconnect {{name}}": "{{name}} ontkoppelen", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Hiermee wordt je {{name}}-account ontkoppeld. Links worden voor jou niet verrijkt totdat je opnieuw verbinding maakt.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Hiermee wordt de {{name}}-integratie uitgeschakeld voor de hele werkruimte. De verbindingen van leden worden verwijderd en links worden niet verrijkt.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Deze Slack-werkruimte is al verbonden met een andere Docmost-werkruimte. Ontkoppel die daar eerst, of installeer Docmost in een andere Slack-werkruimte.", "Remove from page": "Van pagina verwijderen", "Base options": "Basisopties", "Delete group": "Groep verwijderen", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Weet u zeker dat u deze pagina wilt verwijderen? Dit zal de subpagina's en paginageschiedenis verwijderen. Deze actie kan niet ongedaan gemaakt worden.", "Description": "Beschrijving", + "Display as": "Weergeven als", "Details": "Details", "e.g ACME": "bijv. ACME", "e.g ACME Inc": "bijv. ACME Inc", @@ -151,6 +170,8 @@ "page": "pagina", "Page deleted successfully": "Pagina succesvol verwijderd", "Page history": "Pagina geschiedenis", + "replies": "antwoorden", + "reply": "antwoord", "Select version": "Selecteer versie", "Highlight changes": "Wijzigingen markeren", "Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.", @@ -193,6 +214,8 @@ "Invitation sent": "Uitnodiging verzonden", "Settings": "Instellingen", "Setup workspace": "Werkruimte instellen", + "show less": "minder weergeven", + "show more": "meer weergeven", "Sign In": "Inloggen", "Sign Up": "Registreren", "Slug": "Slug", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "Om uw e-mailadres te wijzigen, moet u uw wachtwoord en nieuwe e-mail invullen.", "Toggle full page width": "Volledige paginabreedte in-/uitschakelen", "Unable to import pages. Please try again.": "Pagina's importeren is niet gelukt. Probeer het opnieuw.", + "Unassigned": "Niet toegewezen", "untitled": "zonder titel", "Untitled": "Zonder titel", "Updated successfully": "Succesvol bijgewerkt", @@ -649,6 +673,8 @@ "Search in all your spaces": "Zoek in al je ruimtes", "Type": "Type", "Enterprise": "Enterprise", + "Business": "Bedrijf", + "Standard": "Standaard", "Download attachment": "Bijlage downloaden", "Allowed email domains": "Toegestane e-maildomeinen", "Only users with email addresses from these domains can signup via SSO.": "Alleen gebruikers met e-mailadressen van deze domeinen kunnen zich via SSO registreren.", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "Er zijn nog geen bijlagen op deze pagina.", "Uploaded by {{name}}": "Geüpload door {{name}}", "Download {{name}}": "{{name}} downloaden", + "Delete attachment": "Bijlage verwijderen", + "Delete {{name}}": "{{name}} verwijderen", + "Attachment deleted": "Bijlage verwijderd", + "Failed to delete attachment": "Verwijderen van bijlage mislukt", "Access revoked": "Toegang ingetrokken", "Authorize application": "Applicatie autoriseren", "{{name}} wants to access {{workspace}}": "{{name}} wil toegang tot {{workspace}}", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "Standaard ingesteld op de hostnaam van deze instantie", "Delete destination": "Bestemming verwijderen", "Deleted SIEM destination": "SIEM-bestemming verwijderd", + "Integrations": "Integraties", + "Installed integration": "Geïnstalleerde integratie", + "Uninstalled integration": "Gedeïnstalleerde integratie", + "Connected integration account": "Verbonden integratieaccount", + "Disconnected integration account": "Ontkoppeld integratieaccount", "Destination created": "Bestemming aangemaakt", "Destination deleted": "Bestemming verwijderd", "Destination updated": "Bestemming bijgewerkt", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "SIEM-bestemming bijgewerkt", "Verify TLS certificate": "TLS-certificaat verifiëren", "e.g. Splunk prod": "bijv. Splunk prod", + "Manage workspace integrations.": "Beheer werkruimte-integraties.", + "No integrations available.": "Geen integraties beschikbaar.", + "Install": "Installeren", + "Uninstall": "Verwijderen", + "Failed to start installation": "Starten van installatie mislukt", + "Integration uninstalled successfully": "Integratie succesvol verwijderd", + "Failed to uninstall integration": "Verwijderen van integratie mislukt", + "Connections": "Verbindingen", + "Manage the apps you have connected to your account.": "Beheer de apps die je met je account hebt verbonden.", + "Connect": "Verbinden", + "Connected": "Verbonden", + "Disconnect": "Ontkoppelen", + "Reconnect": "Opnieuw verbinden", + "Connection expired": "Verbinding verlopen", + "Integration disconnected": "Integratie ontkoppeld", + "Failed to disconnect integration": "Ontkoppelen van integratie mislukt", + "Failed to start OAuth connection": "Starten van OAuth-verbinding mislukt", + "OAuth connection failed. Please try again.": "OAuth-verbinding mislukt. Probeer het opnieuw.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "Het account waarmee je bent ingelogd gebruikt niet je Docmost-e-mailadres. Log in bij de provider met het account dat overeenkomt met je Docmost-e-mailadres en probeer het opnieuw.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "Het Slack-account waarmee je bent ingelogd bevindt zich niet in de Slack-werkruimte die met Docmost is verbonden. Log in bij Slack met je account in die werkruimte en probeer het opnieuw.", + "Connect your {{name}} account so links you paste show rich previews.": "Verbind je {{name}}-account zodat links die je plakt rijke voorbeelden tonen.", + "Connect {{name}} to Docmost": "{{name}} verbinden met Docmost", + "Link your Slack account to your Docmost account to:": "Koppel je Slack-account aan je Docmost-account om:", + "Search and open Docmost pages from Slack": "Docmost-pagina's zoeken en openen vanuit Slack", + "Create pages and save Slack messages to Docmost": "Pagina's maken en Slack-berichten opslaan in Docmost", + "Ask AI questions about your workspace docs": "AI-vragen stellen over de documenten in je werkruimte", + "Get your Docmost notifications as Slack DMs": "Je Docmost-meldingen ontvangen als Slack-DM's", + "See previews of Docmost links in Slack and Slack links in Docmost": "Voorbeelden van Docmost-links in Slack en Slack-links in Docmost bekijken", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Je logt in met Slack om te bevestigen welk Slack-account van jou is. Het moet hetzelfde e-mailadres gebruiken als je Docmost-account.", + "Updated comment": "Reactie bijgewerkt", + "Resolved comment": "Reactie opgelost", + "Reopened comment": "Reactie heropend", + "Something went wrong": "Er is iets misgegaan", + "This page failed to load. Reload to try again.": "Deze pagina kon niet worden geladen. Laad opnieuw om het opnieuw te proberen.", + "Reload": "Opnieuw laden", "Tabs": "Tabbladen", "Insert a multi-tab content block.": "Voeg een inhoudsblok met meerdere tabbladen in.", "Add tab": "Tabblad toevoegen", diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 1f3234f3..d308dadf 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Tem certeza de que deseja remover este usuário do grupo? O usuário perderá acesso aos recursos que este grupo possui.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Tem certeza de que deseja remover este usuário do espaço? O usuário perderá todo acesso a este espaço.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Tem certeza de que deseja restaurar esta versão? Quaisquer alterações não versionadas serão perdidas.", + "Assigned to {{name}}": "Atribuído a {{name}}", "Can become members of groups and spaces in workspace": "Pode se tornar membro de grupos e espaços no workspace", "Can create and edit pages in space.": "Pode criar e editar páginas no espaço.", "Can edit": "Pode editar", @@ -22,6 +23,8 @@ "Can view": "Pode visualizar", "Can view pages in space but not edit.": "Pode visualizar páginas no espaço, mas não editar.", "Cancel": "Cancelar", + "Card": "Cartão", + "Cards aren't available in this position": "Os cartões não estão disponíveis nesta posição", "Change email": "Alterar email", "Change password": "Alterar senha", "Change photo": "Alterar foto", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "Escolha o idioma da interface.", "Choose your preferred page width.": "Escolha a largura preferida da página.", "Confirm": "Confirmar", + "Connect to {{name}} to preview": "Conecte-se a {{name}} para visualizar", + "Connected as {{label}}": "Conectado como {{label}}", "Copy as Markdown": "Copiar como Markdown", "Copy link": "Copiar link", "Create": "Criar", @@ -41,11 +46,25 @@ "Dark": "Escuro", "Date": "Data", "Delete": "Excluir", + "Initiative": "Iniciativa", + "Integration link actions": "Ações de link de integração", + "Last modified by {{name}}": "Última modificação por {{name}}", + "Open in Slack": "Abrir no Slack", + "Open link": "Abrir link", + "Paid": "Pago", + "Paste as": "Colar como", + "Project": "Projeto", + "Uninstall {{name}}": "Desinstalar {{name}}", + "Disconnect {{name}}": "Desconectar {{name}}", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Isso desconecta sua conta do {{name}}. Os links não serão enriquecidos para você até que você se reconecte.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Isso desativa a integração com {{name}} para todo o espaço de trabalho. As conexões dos membros são removidas e os links não são enriquecidos.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Este espaço de trabalho do Slack já está conectado a outro espaço de trabalho do Docmost. Desconecte-o lá primeiro ou instale o Docmost em um espaço de trabalho diferente do Slack.", "Remove from page": "Remover da página", "Base options": "Opções da base", "Delete group": "Excluir grupo", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Você tem certeza que quer deletar essa página? Isso irá deletar todas as páginas filhas e to o histórico. Esta ação é irreversível.", "Description": "Descrição", + "Display as": "Exibir como", "Details": "Detalhes", "e.g ACME": "ex.: ACME", "e.g ACME Inc": "ex.: ACME Inc", @@ -151,6 +170,8 @@ "page": "página", "Page deleted successfully": "Página excluída com sucesso", "Page history": "Histórico da página", + "replies": "respostas", + "reply": "resposta", "Select version": "Selecionar versão", "Highlight changes": "Destacar alterações", "Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.", @@ -193,6 +214,8 @@ "Invitation sent": "Convite enviado", "Settings": "Configurações", "Setup workspace": "Configurar workspace", + "show less": "mostrar menos", + "show more": "mostrar mais", "Sign In": "Entrar", "Sign Up": "Cadastrar-se", "Slug": "Slug", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "Para alterar seu email, você precisa inserir sua senha e o novo email.", "Toggle full page width": "Alternar largura total da página", "Unable to import pages. Please try again.": "Não foi possível importar as páginas. Por favor, tente novamente.", + "Unassigned": "Não atribuído", "untitled": "sem título", "Untitled": "Sem título", "Updated successfully": "Atualizado com sucesso", @@ -649,6 +673,8 @@ "Search in all your spaces": "Pesquisar em todos os seus espaços", "Type": "Tipo", "Enterprise": "Enterprise", + "Business": "Negócios", + "Standard": "Padrão", "Download attachment": "Baixar anexo", "Allowed email domains": "Domínios de e-mail permitidos", "Only users with email addresses from these domains can signup via SSO.": "Somente usuários com endereços de e-mail desses domínios podem se cadastrar via SSO.", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "Ainda não há anexos nesta página.", "Uploaded by {{name}}": "Enviado por {{name}}", "Download {{name}}": "Baixar {{name}}", + "Delete attachment": "Excluir anexo", + "Delete {{name}}": "Excluir {{name}}", + "Attachment deleted": "Anexo excluído", + "Failed to delete attachment": "Falha ao excluir anexo", "Access revoked": "Acesso revogado", "Authorize application": "Autorizar aplicativo", "{{name}} wants to access {{workspace}}": "{{name}} quer acessar {{workspace}}", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "O padrão é o nome do host desta instância", "Delete destination": "Excluir destino", "Deleted SIEM destination": "Destino SIEM excluído", + "Integrations": "Integrações", + "Installed integration": "Integração instalada", + "Uninstalled integration": "Integração desinstalada", + "Connected integration account": "Conta de integração conectada", + "Disconnected integration account": "Conta de integração desconectada", "Destination created": "Destino criado", "Destination deleted": "Destino excluído", "Destination updated": "Destino atualizado", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "Destino SIEM atualizado", "Verify TLS certificate": "Verificar certificado TLS", "e.g. Splunk prod": "ex.: Splunk produção", + "Manage workspace integrations.": "Gerencie as integrações do espaço de trabalho.", + "No integrations available.": "Nenhuma integração disponível.", + "Install": "Instalar", + "Uninstall": "Desinstalar", + "Failed to start installation": "Falha ao iniciar a instalação", + "Integration uninstalled successfully": "Integração desinstalada com sucesso", + "Failed to uninstall integration": "Falha ao desinstalar a integração", + "Connections": "Conexões", + "Manage the apps you have connected to your account.": "Gerencie os aplicativos que você conectou à sua conta.", + "Connect": "Conectar", + "Connected": "Conectado", + "Disconnect": "Desconectar", + "Reconnect": "Reconectar", + "Connection expired": "Conexão expirada", + "Integration disconnected": "Integração desconectada", + "Failed to disconnect integration": "Falha ao desconectar a integração", + "Failed to start OAuth connection": "Falha ao iniciar a conexão OAuth", + "OAuth connection failed. Please try again.": "Falha na conexão OAuth. Tente novamente.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "A conta com a qual você entrou não usa seu endereço de e-mail do Docmost. Entre no provedor com a conta que corresponde ao seu e-mail do Docmost e tente novamente.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "A conta do Slack com a qual você entrou não está no espaço de trabalho do Slack conectado ao Docmost. Entre no Slack com sua conta nesse espaço de trabalho e tente novamente.", + "Connect your {{name}} account so links you paste show rich previews.": "Conecte sua conta do {{name}} para que os links que você colar exibam visualizações avançadas.", + "Connect {{name}} to Docmost": "Conectar {{name}} ao Docmost", + "Link your Slack account to your Docmost account to:": "Vincule sua conta do Slack à sua conta do Docmost para:", + "Search and open Docmost pages from Slack": "Pesquisar e abrir páginas do Docmost a partir do Slack", + "Create pages and save Slack messages to Docmost": "Criar páginas e salvar mensagens do Slack no Docmost", + "Ask AI questions about your workspace docs": "Fazer perguntas à IA sobre os documentos do seu espaço de trabalho", + "Get your Docmost notifications as Slack DMs": "Receber suas notificações do Docmost como mensagens diretas no Slack", + "See previews of Docmost links in Slack and Slack links in Docmost": "Ver visualizações de links do Docmost no Slack e de links do Slack no Docmost", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Você entrará com o Slack para confirmar qual conta do Slack é sua. Ela deve usar o mesmo e-mail da sua conta do Docmost.", + "Updated comment": "Comentário atualizado", + "Resolved comment": "Comentário resolvido", + "Reopened comment": "Comentário reaberto", + "Something went wrong": "Algo deu errado", + "This page failed to load. Reload to try again.": "Falha ao carregar esta página. Recarregue para tentar novamente.", + "Reload": "Recarregar", "Tabs": "Abas", "Insert a multi-tab content block.": "Insira um bloco de conteúdo com várias abas.", "Add tab": "Adicionar aba", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 6ae63200..dff11b0d 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым есть доступ у этой группы.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.", + "Assigned to {{name}}": "Назначено: {{name}}", "Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области", "Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.", "Can edit": "Может изменять", @@ -22,6 +23,8 @@ "Can view": "Может просматривать", "Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.", "Cancel": "Отменить", + "Card": "Карточка", + "Cards aren't available in this position": "Карточки недоступны в этом месте", "Change email": "Изменить электронную почту", "Change password": "Изменить пароль", "Change photo": "Изменить фото", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.", "Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.", "Confirm": "Подтвердить", + "Connect to {{name}} to preview": "Подключитесь к {{name}}, чтобы просмотреть", + "Connected as {{label}}": "Подключено как {{label}}", "Copy as Markdown": "Копировать как Markdown", "Copy link": "Копировать ссылку", "Create": "Создать", @@ -41,11 +46,25 @@ "Dark": "Темная", "Date": "Дата", "Delete": "Удалить", + "Initiative": "Инициатива", + "Integration link actions": "Действия со ссылками интеграции", + "Last modified by {{name}}": "Последнее изменение: {{name}}", + "Open in Slack": "Открыть в Slack", + "Open link": "Открыть ссылку", + "Paid": "Платный", + "Paste as": "Вставить как", + "Project": "Проект", + "Uninstall {{name}}": "Удалить {{name}}", + "Disconnect {{name}}": "Отключить {{name}}", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Это отключит вашу учётную запись {{name}}. Ссылки не будут обогащаться, пока вы не подключитесь снова.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Это отключит интеграцию {{name}} для всего рабочего пространства. Подключения участников будут удалены, а ссылки не будут обогащаться.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Это рабочее пространство Slack уже подключено к другому рабочему пространству Docmost. Сначала отключите его там или установите Docmost в другое рабочее пространство Slack.", "Remove from page": "Удалить со страницы", "Base options": "Параметры базы", "Delete group": "Удалить группу", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.", "Description": "Описание", + "Display as": "Отображать как", "Details": "Подробности", "e.g ACME": "например, ACME", "e.g ACME Inc": "например, ACME Inc", @@ -151,6 +170,8 @@ "page": "страница", "Page deleted successfully": "Страница успешно удалена", "Page history": "История страницы", + "replies": "ответов", + "reply": "ответ", "Select version": "Выбрать версию", "Highlight changes": "Выделить изменения", "Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.", @@ -193,6 +214,8 @@ "Invitation sent": "Приглашение отправлено", "Settings": "Настройки", "Setup workspace": "Настроить рабочее пространство", + "show less": "показать меньше", + "show more": "показать больше", "Sign In": "Войти", "Sign Up": "Зарегистрироваться", "Slug": "Слаг", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.", "Toggle full page width": "Переключить полную ширину страницы", "Unable to import pages. Please try again.": "Не удалось импортировать страницы. Пожалуйста, попробуйте ещё раз.", + "Unassigned": "Не назначено", "untitled": "без названия", "Untitled": "Без названия", "Updated successfully": "Успешно обновлено", @@ -649,6 +673,8 @@ "Search in all your spaces": "Искать во всех ваших пространствах", "Type": "Тип", "Enterprise": "Корпоративный", + "Business": "Бизнес", + "Standard": "Стандарт", "Download attachment": "Скачать вложение", "Allowed email domains": "Разрешённые домены электронной почты", "Only users with email addresses from these domains can signup via SSO.": "Только пользователи с адресами электронной почты из этих доменов могут зарегистрироваться через SSO.", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "На этой странице пока нет вложений.", "Uploaded by {{name}}": "Загружено пользователем {{name}}", "Download {{name}}": "Скачать {{name}}", + "Delete attachment": "Удалить вложение", + "Delete {{name}}": "Удалить {{name}}", + "Attachment deleted": "Вложение удалено", + "Failed to delete attachment": "Не удалось удалить вложение", "Access revoked": "Доступ отозван", "Authorize application": "Авторизовать приложение", "{{name}} wants to access {{workspace}}": "{{name}} запрашивает доступ к {{workspace}}", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "По умолчанию используется имя хоста этого экземпляра", "Delete destination": "Удалить место назначения", "Deleted SIEM destination": "Удалено место назначения SIEM", + "Integrations": "Интеграции", + "Installed integration": "Установленная интеграция", + "Uninstalled integration": "Интеграция удалена", + "Connected integration account": "Подключена учётная запись интеграции", + "Disconnected integration account": "Отключена учётная запись интеграции", "Destination created": "Место назначения создано", "Destination deleted": "Место назначения удалено", "Destination updated": "Место назначения обновлено", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "Обновлено место назначения SIEM", "Verify TLS certificate": "Проверять сертификат TLS", "e.g. Splunk prod": "например, Splunk prod", + "Manage workspace integrations.": "Управляйте интеграциями рабочего пространства.", + "No integrations available.": "Нет доступных интеграций.", + "Install": "Установить", + "Uninstall": "Удалить", + "Failed to start installation": "Не удалось начать установку", + "Integration uninstalled successfully": "Интеграция успешно удалена", + "Failed to uninstall integration": "Не удалось удалить интеграцию", + "Connections": "Подключения", + "Manage the apps you have connected to your account.": "Управляйте приложениями, которые вы подключили к своей учётной записи.", + "Connect": "Подключить", + "Connected": "Подключено", + "Disconnect": "Отключить", + "Reconnect": "Подключить снова", + "Connection expired": "Срок действия подключения истёк", + "Integration disconnected": "Интеграция отключена", + "Failed to disconnect integration": "Не удалось отключить интеграцию", + "Failed to start OAuth connection": "Не удалось начать OAuth-подключение", + "OAuth connection failed. Please try again.": "Не удалось установить OAuth-подключение. Повторите попытку.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "Учётная запись, с которой вы вошли, не использует ваш адрес электронной почты Docmost. Войдите у провайдера с учётной записью, которая использует тот же адрес электронной почты, что и ваша учётная запись Docmost, и повторите попытку.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "Учётная запись Slack, с которой вы вошли, не состоит в рабочем пространстве Slack, подключённом к Docmost. Войдите в Slack с учётной записью из этого рабочего пространства и повторите попытку.", + "Connect your {{name}} account so links you paste show rich previews.": "Подключите свою учётную запись {{name}}, чтобы вставляемые вами ссылки отображались с расширенным предпросмотром.", + "Connect {{name}} to Docmost": "Подключить {{name}} к Docmost", + "Link your Slack account to your Docmost account to:": "Свяжите свою учётную запись Slack с учётной записью Docmost, чтобы:", + "Search and open Docmost pages from Slack": "Искать и открывать страницы Docmost из Slack", + "Create pages and save Slack messages to Docmost": "Создавать страницы и сохранять сообщения Slack в Docmost", + "Ask AI questions about your workspace docs": "Задавать ИИ вопросы о документах вашего рабочего пространства", + "Get your Docmost notifications as Slack DMs": "Получать уведомления Docmost в виде личных сообщений Slack", + "See previews of Docmost links in Slack and Slack links in Docmost": "Просматривать предпросмотр ссылок Docmost в Slack и ссылок Slack в Docmost", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Вы войдёте через Slack, чтобы подтвердить, какая учётная запись Slack принадлежит вам. Она должна использовать тот же адрес электронной почты, что и ваша учётная запись Docmost.", + "Updated comment": "Комментарий обновлён", + "Resolved comment": "Комментарий решён", + "Reopened comment": "Комментарий снова открыт", + "Something went wrong": "Что-то пошло не так", + "This page failed to load. Reload to try again.": "Не удалось загрузить эту страницу. Перезагрузите её и попробуйте снова.", + "Reload": "Перезагрузить", "Tabs": "Вкладки", "Insert a multi-tab content block.": "Вставить блок содержимого с несколькими вкладками.", "Add tab": "Добавить вкладку", diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index dc6779b4..340aa45c 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цього користувача з групи? Користувач втратить доступ до матеріалів, до яких ця група має доступ.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Ви впевнені, що хочете видалити цього користувача з простору? Користувач втратить весь доступ до цього простору.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Ви впевнені, що хочете відновити цю версію? Усі не збережені зміни будуть втрачені.", + "Assigned to {{name}}": "Призначено для {{name}}", "Can become members of groups and spaces in workspace": "Можуть ставати учасниками груп та просторів у робочій області", "Can create and edit pages in space.": "Може створювати та редагувати сторінки в просторі.", "Can edit": "Може редагувати", @@ -22,6 +23,8 @@ "Can view": "Може переглядати", "Can view pages in space but not edit.": "Може переглядати сторінки в просторі, але не може їх редагувати.", "Cancel": "Скасувати", + "Card": "Картка", + "Cards aren't available in this position": "Картки недоступні в цьому місці", "Change email": "Змінити електронну пошту", "Change password": "Змінити пароль", "Change photo": "Змінити фото", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.", "Choose your preferred page width.": "Оберіть бажану ширину сторінки.", "Confirm": "Підтвердити", + "Connect to {{name}} to preview": "Підключіться до {{name}}, щоб переглянути", + "Connected as {{label}}": "Підключено як {{label}}", "Copy as Markdown": "Скопіювати як Markdown", "Copy link": "Копіювати посилання", "Create": "Створити", @@ -41,11 +46,25 @@ "Dark": "Темна", "Date": "Дата", "Delete": "Видалити", + "Initiative": "Ініціатива", + "Integration link actions": "Дії посилань інтеграції", + "Last modified by {{name}}": "Останні зміни вніс {{name}}", + "Open in Slack": "Відкрити у Slack", + "Open link": "Відкрити посилання", + "Paid": "Платний", + "Paste as": "Вставити як", + "Project": "Проєкт", + "Uninstall {{name}}": "Видалити {{name}}", + "Disconnect {{name}}": "Відключити {{name}}", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Це відключить ваш обліковий запис {{name}}. Посилання не збагачуватимуться для вас, доки ви не підключитеся знову.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Це вимкне інтеграцію {{name}} для всього робочого простору. Підключення учасників буде видалено, а посилання не збагачуватимуться.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Цей робочий простір Slack уже підключено до іншого робочого простору Docmost. Спочатку відключіть його там або встановіть Docmost в іншому робочому просторі Slack.", "Remove from page": "Видалити зі сторінки", "Base options": "Параметри бази", "Delete group": "Видалити групу", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.", "Description": "Опис", + "Display as": "Відображати як", "Details": "Деталі", "e.g ACME": "напр., ACME", "e.g ACME Inc": "напр., ACME Inc", @@ -151,6 +170,8 @@ "page": "сторінка", "Page deleted successfully": "Сторінку успішно видалено", "Page history": "Історія сторінки", + "replies": "відповіді", + "reply": "відповідь", "Select version": "Вибрати версію", "Highlight changes": "Підсвітити зміни", "Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.", @@ -193,6 +214,8 @@ "Invitation sent": "Запрошення надіслано", "Settings": "Налаштування", "Setup workspace": "Налаштувати робочий простір", + "show less": "показати менше", + "show more": "показати більше", "Sign In": "Увійти", "Sign Up": "Зареєструватися", "Slug": "Slug", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "Щоб змінити електронну пошту, вам потрібно ввести пароль і нову адресу.", "Toggle full page width": "Перемкнути повну ширину сторінки", "Unable to import pages. Please try again.": "Не вдалося імпортувати сторінки. Будь ласка, спробуйте ще раз.", + "Unassigned": "Не призначено", "untitled": "без назви", "Untitled": "Без назви", "Updated successfully": "Успішно оновлено", @@ -649,6 +673,8 @@ "Search in all your spaces": "Шукати в усіх ваших просторах", "Type": "Тип", "Enterprise": "Enterprise", + "Business": "Бізнес", + "Standard": "Стандарт", "Download attachment": "Завантажити вкладення", "Allowed email domains": "Дозволені домени електронної пошти", "Only users with email addresses from these domains can signup via SSO.": "Лише користувачі з адресами електронної пошти з цих доменів можуть зареєструватися через SSO.", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "На цій сторінці ще немає вкладень.", "Uploaded by {{name}}": "Завантажено користувачем {{name}}", "Download {{name}}": "Завантажити {{name}}", + "Delete attachment": "Видалити вкладення", + "Delete {{name}}": "Видалити {{name}}", + "Attachment deleted": "Вкладення видалено", + "Failed to delete attachment": "Не вдалося видалити вкладення", "Access revoked": "Доступ відкликано", "Authorize application": "Авторизувати застосунок", "{{name}} wants to access {{workspace}}": "{{name}} хоче отримати доступ до {{workspace}}", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "За замовчуванням використовується ім’я хоста цього екземпляра", "Delete destination": "Видалити призначення", "Deleted SIEM destination": "Видалено призначення SIEM", + "Integrations": "Інтеграції", + "Installed integration": "Установлена інтеграція", + "Uninstalled integration": "Інтеграцію видалено", + "Connected integration account": "Обліковий запис інтеграції підключено", + "Disconnected integration account": "Обліковий запис інтеграції відключено", "Destination created": "Призначення створено", "Destination deleted": "Призначення видалено", "Destination updated": "Призначення оновлено", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "Оновлено призначення SIEM", "Verify TLS certificate": "Перевіряти сертифікат TLS", "e.g. Splunk prod": "напр., Splunk prod", + "Manage workspace integrations.": "Керуйте інтеграціями робочого простору.", + "No integrations available.": "Немає доступних інтеграцій.", + "Install": "Установити", + "Uninstall": "Видалити", + "Failed to start installation": "Не вдалося розпочати встановлення", + "Integration uninstalled successfully": "Інтеграцію успішно видалено", + "Failed to uninstall integration": "Не вдалося видалити інтеграцію", + "Connections": "Підключення", + "Manage the apps you have connected to your account.": "Керуйте програмами, які ви підключили до свого облікового запису.", + "Connect": "Підключити", + "Connected": "Підключено", + "Disconnect": "Відключити", + "Reconnect": "Підключити знову", + "Connection expired": "Термін дії підключення минув", + "Integration disconnected": "Інтеграцію відключено", + "Failed to disconnect integration": "Не вдалося відключити інтеграцію", + "Failed to start OAuth connection": "Не вдалося розпочати OAuth-підключення", + "OAuth connection failed. Please try again.": "Не вдалося виконати OAuth-підключення. Спробуйте ще раз.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "Обліковий запис, з яким ви ввійшли, не використовує вашу адресу електронної пошти Docmost. Увійдіть у сервіс через обліковий запис, який відповідає вашій адресі електронної пошти Docmost, і спробуйте ще раз.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "Обліковий запис Slack, з яким ви ввійшли, не входить до робочого простору Slack, підключеного до Docmost. Увійдіть у Slack через свій обліковий запис у цьому робочому просторі й спробуйте ще раз.", + "Connect your {{name}} account so links you paste show rich previews.": "Підключіть свій обліковий запис {{name}}, щоб вставлені вами посилання показували розгорнуті прев’ю.", + "Connect {{name}} to Docmost": "Підключити {{name}} до Docmost", + "Link your Slack account to your Docmost account to:": "Зв’яжіть свій обліковий запис Slack зі своїм обліковим записом Docmost, щоб:", + "Search and open Docmost pages from Slack": "Шукати та відкривати сторінки Docmost зі Slack", + "Create pages and save Slack messages to Docmost": "Створювати сторінки та зберігати повідомлення Slack у Docmost", + "Ask AI questions about your workspace docs": "Ставити ШІ запитання про документи вашого робочого простору", + "Get your Docmost notifications as Slack DMs": "Отримувати сповіщення Docmost як особисті повідомлення Slack", + "See previews of Docmost links in Slack and Slack links in Docmost": "Переглядати прев’ю посилань Docmost у Slack і посилань Slack у Docmost", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Ви ввійдете через Slack, щоб підтвердити, який обліковий запис Slack належить вам. Він має використовувати ту саму адресу електронної пошти, що й ваш обліковий запис Docmost.", + "Updated comment": "Коментар оновлено", + "Resolved comment": "Коментар позначено як вирішений", + "Reopened comment": "Коментар знову відкрито", + "Something went wrong": "Щось пішло не так", + "This page failed to load. Reload to try again.": "Не вдалося завантажити цю сторінку. Перезавантажте її, щоб спробувати ще раз.", + "Reload": "Перезавантажити", "Tabs": "Вкладки", "Insert a multi-tab content block.": "Вставити блок вмісту з кількома вкладками.", "Add tab": "Додати вкладку", diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 4da58631..374d747b 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "您确定要从群组中移除这个用户吗?该用户将失去对该群组可访问资源的访问权限。", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "您确定要从空间中移除这个用户吗?该用户将失去对这个空间的所有访问权限。", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "您确定要恢复此版本吗?任何未版本化的更改将会丢失。", + "Assigned to {{name}}": "已分配给 {{name}}", "Can become members of groups and spaces in workspace": "可以成为工作区中群组和空间的成员", "Can create and edit pages in space.": "能够在空间中创建和编辑页面", "Can edit": "可以编辑", @@ -22,6 +23,8 @@ "Can view": "可以查看", "Can view pages in space but not edit.": "能够在空间中读取页面,但不允许编辑", "Cancel": "取消", + "Card": "卡片", + "Cards aren't available in this position": "此位置无法使用卡片", "Change email": "更改电子邮箱", "Change password": "更改密码", "Change photo": "更改照片", @@ -30,6 +33,8 @@ "Choose your preferred interface language.": "选择您喜欢的界面语言。", "Choose your preferred page width.": "选择您喜欢的页面宽度。", "Confirm": "确认", + "Connect to {{name}} to preview": "连接到 {{name}} 以预览", + "Connected as {{label}}": "已连接为 {{label}}", "Copy as Markdown": "复制为Markdown", "Copy link": "复制链接", "Create": "创建", @@ -41,11 +46,25 @@ "Dark": "深色", "Date": "日期", "Delete": "删除", + "Initiative": "计划", + "Integration link actions": "集成链接操作", + "Last modified by {{name}}": "最后修改者:{{name}}", + "Open in Slack": "在 Slack 中打开", + "Open link": "打开链接", + "Paid": "付费", + "Paste as": "粘贴为", + "Project": "项目", + "Uninstall {{name}}": "卸载 {{name}}", + "Disconnect {{name}}": "断开 {{name}}", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "这将断开你的 {{name}} 账户。在你重新连接之前,链接将不会为你显示丰富预览。", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "这将为整个工作区禁用 {{name}} 集成。成员的连接将被移除,链接也不会显示丰富预览。", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "此 Slack 工作区已连接到另一个 Docmost 工作区。请先在那边断开连接,或者将 Docmost 安装到其他 Slack 工作区。", "Remove from page": "从页面中移除", "Base options": "数据库选项", "Delete group": "删除群组", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "您确定要删除这个页面吗?这将删除其子页面和页面历史记录。此操作不可逆。", "Description": "描述", + "Display as": "显示为", "Details": "详情", "e.g ACME": "例如 ACME", "e.g ACME Inc": "例如 ACME Inc", @@ -151,6 +170,8 @@ "page": "个页面", "Page deleted successfully": "页面已成功删除", "Page history": "页面历史", + "replies": "回复", + "reply": "回复", "Select version": "选择版本", "Highlight changes": "突出显示更改", "Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。", @@ -193,6 +214,8 @@ "Invitation sent": "邀请已发送", "Settings": "设置", "Setup workspace": "设置工作区", + "show less": "收起", + "show more": "显示更多", "Sign In": "登录", "Sign Up": "注册", "Slug": "标识符", @@ -216,6 +239,7 @@ "To change your email, you have to enter your password and new email.": "要更改您的电子邮箱,您需要输入密码和新的电子邮箱地址。", "Toggle full page width": "切换整页宽度", "Unable to import pages. Please try again.": "无法导入页面。请重试。", + "Unassigned": "未分配", "untitled": "未命名", "Untitled": "未命名", "Updated successfully": "更新成功", @@ -649,6 +673,8 @@ "Search in all your spaces": "在您的所有空间中搜索", "Type": "类型", "Enterprise": "企业版", + "Business": "商业版", + "Standard": "标准版", "Download attachment": "下载附件", "Allowed email domains": "允许的邮箱域名", "Only users with email addresses from these domains can signup via SSO.": "只有使用这些域名邮箱地址的用户才能通过 SSO 注册。", @@ -1331,6 +1357,10 @@ "No attachments on this page yet.": "此页面上还没有附件。", "Uploaded by {{name}}": "由 {{name}} 上传", "Download {{name}}": "下载 {{name}}", + "Delete attachment": "删除附件", + "Delete {{name}}": "删除 {{name}}", + "Attachment deleted": "附件已删除", + "Failed to delete attachment": "删除附件失败", "Access revoked": "访问权限已撤销", "Authorize application": "授权应用程序", "{{name}} wants to access {{workspace}}": "{{name}} 想要访问 {{workspace}}", @@ -1453,6 +1483,11 @@ "Defaults to this instance's hostname": "默认为此实例的主机名", "Delete destination": "删除目标", "Deleted SIEM destination": "已删除 SIEM 目标", + "Integrations": "集成", + "Installed integration": "已安装的集成", + "Uninstalled integration": "已卸载的集成", + "Connected integration account": "已连接集成账户", + "Disconnected integration account": "已断开集成账户", "Destination created": "目标已创建", "Destination deleted": "目标已删除", "Destination updated": "目标已更新", @@ -1495,6 +1530,41 @@ "Updated SIEM destination": "已更新 SIEM 目标", "Verify TLS certificate": "验证 TLS 证书", "e.g. Splunk prod": "例如:Splunk 生产环境", + "Manage workspace integrations.": "管理工作区集成。", + "No integrations available.": "没有可用的集成。", + "Install": "安装", + "Uninstall": "卸载", + "Failed to start installation": "启动安装失败", + "Integration uninstalled successfully": "集成卸载成功", + "Failed to uninstall integration": "卸载集成失败", + "Connections": "连接", + "Manage the apps you have connected to your account.": "管理你已连接到账户的应用。", + "Connect": "连接", + "Connected": "已连接", + "Disconnect": "断开连接", + "Reconnect": "重新连接", + "Connection expired": "连接已过期", + "Integration disconnected": "集成已断开", + "Failed to disconnect integration": "断开集成失败", + "Failed to start OAuth connection": "启动 OAuth 连接失败", + "OAuth connection failed. Please try again.": "OAuth 连接失败。请重试。", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "你登录使用的账户未使用你的 Docmost 电子邮箱地址。请使用与 Docmost 电子邮箱匹配的账户登录该服务提供商后重试。", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "你登录使用的 Slack 账户不属于连接到 Docmost 的 Slack 工作区。请使用该工作区中的账户登录 Slack 后重试。", + "Connect your {{name}} account so links you paste show rich previews.": "连接你的 {{name}} 账户,这样你粘贴的链接就会显示丰富预览。", + "Connect {{name}} to Docmost": "将 {{name}} 连接到 Docmost", + "Link your Slack account to your Docmost account to:": "将你的 Slack 账户链接到你的 Docmost 账户,以便:", + "Search and open Docmost pages from Slack": "从 Slack 搜索并打开 Docmost 页面", + "Create pages and save Slack messages to Docmost": "创建页面并将 Slack 消息保存到 Docmost", + "Ask AI questions about your workspace docs": "就你的工作区文档向 AI 提问", + "Get your Docmost notifications as Slack DMs": "通过 Slack 私信接收你的 Docmost 通知", + "See previews of Docmost links in Slack and Slack links in Docmost": "在 Slack 中查看 Docmost 链接预览,也可在 Docmost 中查看 Slack 链接预览", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "你将使用 Slack 登录,以确认哪个 Slack 账户是你的账户。它必须使用与你的 Docmost 账户相同的电子邮箱。", + "Updated comment": "已更新评论", + "Resolved comment": "已解决评论", + "Reopened comment": "已重新打开评论", + "Something went wrong": "出现了一些问题", + "This page failed to load. Reload to try again.": "此页面加载失败。请重新加载后重试。", + "Reload": "重新加载", "Tabs": "标签页", "Insert a multi-tab content block.": "插入一个多标签页内容区块。", "Add tab": "添加标签页",