Compare commits
2 Commits
feat/205-s
...
feat/189-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88199703fe | ||
|
|
d88fe4cde7 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -12,16 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
|
||||
any publicly shared page a short, memorable, workspace-scoped vanity address
|
||||
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
|
||||
(never `301`, since the target is retargetable) to the canonical
|
||||
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
|
||||
serves the plain SPA index so that the existence of a name never leaks. An
|
||||
alias can be moved to another page (with a confirm-reassign guard) and the
|
||||
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
|
||||
alias any workspace member can reclaim. (#205)
|
||||
|
||||
- **Persistent AI-chat history as the source of truth + server-side export.**
|
||||
An assistant turn is now persisted to the database step by step: the row is
|
||||
inserted upfront as `streaming` and updated as each agent step finishes, then
|
||||
@@ -53,6 +43,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
OpenRouter, etc.; `openai` uses the official provider (real-OpenAI
|
||||
reasoning-model request shaping). Chosen explicitly rather than inferred from
|
||||
the base URL, since a custom URL can front real OpenAI too. (#175, #177)
|
||||
- **AI chat "Context window (tokens)" setting (`chatContextWindow`).** A new
|
||||
admin field in AI settings that records the chat model's context-window size.
|
||||
When set (> 0) it becomes the denominator of the header context-badge, which
|
||||
now reads "used / max"; `0`/empty clears the limit and the badge shows only
|
||||
the current context as before. There is no provider-independent way to read a
|
||||
model's window automatically, so it is an explicit workspace-level value.
|
||||
(#189)
|
||||
- **Per-MCP-server instructions in the agent prompt.** Each external MCP server
|
||||
now has an admin-authored `instructions` field ("how/when to use this server's
|
||||
tools") that is injected into the agent's system prompt next to that server's
|
||||
@@ -71,6 +68,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
model's reasoning out of the box. An endpoint that is real OpenAI behind a
|
||||
custom base URL should set the new `chatApiStyle` "Protocol" to `openai`. (#177)
|
||||
|
||||
- **AI chat header context-badge now shows "used / max".** When an admin sets
|
||||
the new `chatContextWindow`, the badge displays the current context size over
|
||||
the configured window (e.g. `120k / 200k`) instead of switching to a live
|
||||
per-turn token counter during streaming. With no window configured the badge
|
||||
keeps showing just the current context. (#189)
|
||||
|
||||
- **Footnotes now reuse (Pandoc semantics).** Multiple `[^a]` references to the
|
||||
same id are ONE footnote — one number, one definition, several back-references
|
||||
— instead of being renamed to `a__2`, `a__3`. Duplicate `[^a]:` definitions are
|
||||
|
||||
@@ -1168,7 +1168,10 @@
|
||||
"Built-in assistant persona": "Built-in assistant persona",
|
||||
"Minimize": "Minimize",
|
||||
"Current context size": "Current context size",
|
||||
"Tokens generated this turn": "Tokens generated this turn",
|
||||
"Context size / model limit": "Context size / model limit",
|
||||
"Context window (tokens)": "Context window (tokens)",
|
||||
"Shows used / total in the chat header badge; empty hides the total.": "Shows used / total in the chat header badge; empty hides the total.",
|
||||
"e.g. 200000": "e.g. 200000",
|
||||
"AI agent": "AI agent",
|
||||
"Take a look at the current document": "Take a look at the current document",
|
||||
"AI agent is typing…": "AI agent is typing…",
|
||||
@@ -1315,15 +1318,5 @@
|
||||
"Protocol": "Protocol",
|
||||
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
|
||||
"OpenAI (official)": "OpenAI (official)",
|
||||
"Custom address": "Custom address",
|
||||
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
|
||||
"This address is already in use": "This address is already in use",
|
||||
"Move custom address?": "Move custom address?",
|
||||
"Move here": "Move here",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
|
||||
"Failed to set custom address": "Failed to set custom address",
|
||||
"Failed to remove custom address": "Failed to remove custom address"
|
||||
"OpenAI (official)": "OpenAI (official)"
|
||||
}
|
||||
|
||||
@@ -705,7 +705,10 @@
|
||||
"Copy chat": "Копировать чат",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Current context size": "Текущий размер контекста",
|
||||
"Tokens generated this turn": "Токенов сгенерировано за ход",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Размер окна контекста (токены)",
|
||||
"Shows used / total in the chat header badge; empty hides the total.": "Показывает использовано/всего в шапке чата; пусто — скрыть лимит.",
|
||||
"e.g. 200000": "напр. 200000",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
@@ -1169,15 +1172,5 @@
|
||||
"Protocol": "Протокол",
|
||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||
"OpenAI (official)": "OpenAI (официальный)",
|
||||
"Custom address": "Пользовательский адрес",
|
||||
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
|
||||
"This address is already in use": "Этот адрес уже занят",
|
||||
"Move custom address?": "Переместить пользовательский адрес?",
|
||||
"Move here": "Переместить сюда",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
|
||||
"Failed to set custom address": "Не удалось задать пользовательский адрес",
|
||||
"Failed to remove custom address": "Не удалось удалить пользовательский адрес"
|
||||
"OpenAI (official)": "OpenAI (официальный)"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Group, Loader, Tooltip } from "@mantine/core";
|
||||
import { Group, Loader } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsDiagonal,
|
||||
IconCheck,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
import { ContextBadge } from "@/features/ai-chat/components/context-badge.tsx";
|
||||
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||
import {
|
||||
@@ -60,13 +61,6 @@ const MIN_HEIGHT = 400;
|
||||
// Margin kept between the window and the viewport edges while dragging.
|
||||
const EDGE_MARGIN = 8;
|
||||
|
||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// Compute the initial top-right placement at the default size, fitted to the
|
||||
// current viewport. Reads `window` only when called (inside an effect).
|
||||
function computeInitialGeom() {
|
||||
@@ -161,12 +155,6 @@ export default function AiChatWindow() {
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
|
||||
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
|
||||
// `null` means no turn is in flight -> the badge falls back to the persisted
|
||||
// context size below.
|
||||
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
@@ -306,6 +294,21 @@ export default function AiChatWindow() {
|
||||
return 0;
|
||||
}, [activeChatId, messageRows]);
|
||||
|
||||
// The model's context-window size (badge denominator), read from the most
|
||||
// recent assistant row that carries it. Admin-configured in AI settings and
|
||||
// stamped onto the turn server-side, so it travels with the message metadata —
|
||||
// no client-side model resolution, and it survives public shares / per-role
|
||||
// models automatically. 0 (no limit configured, or older rows) → the badge
|
||||
// hides the denominator and shows only the current context size.
|
||||
const maxContextTokens = useMemo(() => {
|
||||
if (!activeChatId || !messageRows) return 0;
|
||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
||||
const max = messageRows[i].metadata?.maxContextTokens;
|
||||
if (typeof max === "number" && max > 0) return max;
|
||||
}
|
||||
return 0;
|
||||
}, [activeChatId, messageRows]);
|
||||
|
||||
// On (re)open, settle the geometry before paint (useLayoutEffect → no
|
||||
// first-frame jump): compute an initial top-right placement the first time,
|
||||
// and re-clamp an existing geometry to the current viewport on later opens
|
||||
@@ -495,23 +498,14 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
|
||||
once it finishes, fall back to the persisted context size. Require
|
||||
> 0 so the very first emit (an empty tail message, count 0) does not
|
||||
flash a "0" badge before any token streams in (#151 review). */}
|
||||
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
|
||||
<Tooltip label={t("Tokens generated this turn")} withArrow>
|
||||
<span className={classes.badge}>
|
||||
{formatTokens(liveTurnTokens)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : contextTokens > 0 ? (
|
||||
<Tooltip label={t("Current context size")} withArrow>
|
||||
<span className={classes.badge}>
|
||||
{formatTokens(contextTokens)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{/* Context badge: always "current / max" context size (or just current
|
||||
when no model limit is configured). It no longer flips to a live
|
||||
per-turn generation counter mid-stream — that live feedback lives in
|
||||
the chat body's "Thinking · N tokens" block. */}
|
||||
<ContextBadge
|
||||
contextTokens={contextTokens}
|
||||
maxContextTokens={maxContextTokens}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
@@ -634,7 +628,6 @@ export default function AiChatWindow() {
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
onServerChatId={onServerChatId}
|
||||
onLiveTurnTokens={setLiveTurnTokens}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
@@ -67,12 +66,6 @@ interface ChatThreadProps {
|
||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||
* which fires only at the terminal outcome. */
|
||||
onServerChatId?: (serverChatId?: string) => void;
|
||||
/** Reports the live turn-token total (reasoning + output) for the in-flight
|
||||
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
|
||||
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
|
||||
* every streamed delta. Called with `null` when no turn is in flight (the
|
||||
* parent then reverts the badge to the persisted context size). */
|
||||
onLiveTurnTokens?: (tokens: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +110,6 @@ export default function ChatThread({
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
onLiveTurnTokens,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -328,53 +320,6 @@ export default function ChatThread({
|
||||
// the SAME on-screen banner text can be mirrored into the export (issue #160).
|
||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||
|
||||
// Report the live turn-token total to the parent header badge, THROTTLED to
|
||||
// ~8 Hz so the parent re-renders a few times a second instead of on every
|
||||
// streamed delta. The tail assistant message's reasoning+output (estimate while
|
||||
// streaming, authoritative once a step reports usage) is the live figure. When
|
||||
// the turn ends we emit a final exact value, then `null` so the parent reverts
|
||||
// the badge to the persisted context size.
|
||||
const lastEmitRef = useRef(0);
|
||||
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!onLiveTurnTokens) return;
|
||||
if (!isStreaming) {
|
||||
// Turn ended (or never started): clear any pending throttle and revert.
|
||||
if (emitTimerRef.current) {
|
||||
clearTimeout(emitTimerRef.current);
|
||||
emitTimerRef.current = null;
|
||||
}
|
||||
lastEmitRef.current = 0;
|
||||
onLiveTurnTokens(null);
|
||||
return;
|
||||
}
|
||||
const tail = messages[messages.length - 1];
|
||||
const live = tail?.role === "assistant" ? liveTurnTokens(tail) : null;
|
||||
const total = live ? live.reasoning + live.output : 0;
|
||||
const now = Date.now();
|
||||
const MIN_INTERVAL = 120; // ms (~8 Hz)
|
||||
const elapsed = now - lastEmitRef.current;
|
||||
if (elapsed >= MIN_INTERVAL) {
|
||||
lastEmitRef.current = now;
|
||||
onLiveTurnTokens(total);
|
||||
} else if (!emitTimerRef.current) {
|
||||
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
|
||||
emitTimerRef.current = setTimeout(() => {
|
||||
emitTimerRef.current = null;
|
||||
lastEmitRef.current = Date.now();
|
||||
onLiveTurnTokens(total);
|
||||
}, MIN_INTERVAL - elapsed);
|
||||
}
|
||||
}, [messages, isStreaming, onLiveTurnTokens]);
|
||||
|
||||
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
|
||||
// trailing emit can't fire into a torn-down thread's parent.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A role was picked with autoStart=false: the role is bound but NOTHING was
|
||||
// sent, so chatId stays null and the empty state would keep showing the cards.
|
||||
// This flag hides the cards and reveals the composer (with the role indicated)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { ContextBadge, formatTokens } from "./context-badge";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
// Without an I18nextProvider, `t(key)` returns the key verbatim, so tooltip
|
||||
// labels assert against their English source strings.
|
||||
|
||||
function renderBadge(props: {
|
||||
contextTokens: number;
|
||||
maxContextTokens?: number;
|
||||
}) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<ContextBadge {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("formatTokens", () => {
|
||||
it("formats with k / M suffixes", () => {
|
||||
expect(formatTokens(572)).toBe("572");
|
||||
expect(formatTokens(200_000)).toBe("200.0k");
|
||||
expect(formatTokens(1_500_000)).toBe("1.5M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextBadge", () => {
|
||||
it("shows `current / max` when a limit is configured", () => {
|
||||
renderBadge({ contextTokens: 572, maxContextTokens: 200_000 });
|
||||
expect(screen.getByText("572 / 200.0k")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows only the current size when no limit is configured", () => {
|
||||
renderBadge({ contextTokens: 572, maxContextTokens: 0 });
|
||||
expect(screen.getByText("572")).toBeDefined();
|
||||
// No denominator rendered.
|
||||
expect(screen.queryByText(/\//)).toBeNull();
|
||||
});
|
||||
|
||||
it("treats an undefined limit as no limit", () => {
|
||||
renderBadge({ contextTokens: 1234 });
|
||||
expect(screen.getByText("1.2k")).toBeDefined();
|
||||
expect(screen.queryByText(/\//)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing until there is a current context size", () => {
|
||||
const { container } = renderBadge({
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 200_000,
|
||||
});
|
||||
expect(container.querySelector("span")).toBeNull();
|
||||
});
|
||||
|
||||
it("never flips to a live per-turn counter (no live mode); shows context as-is even above max", () => {
|
||||
// `current > max` (estimate drift / smaller-model role) is shown unclamped.
|
||||
renderBadge({ contextTokens: 210_000, maxContextTokens: 200_000 });
|
||||
expect(screen.getByText("210.0k / 200.0k")).toBeDefined();
|
||||
});
|
||||
|
||||
it("exposes the limit tooltip label on hover", async () => {
|
||||
renderBadge({ contextTokens: 572, maxContextTokens: 200_000 });
|
||||
fireEvent.mouseEnter(screen.getByText("572 / 200.0k"));
|
||||
expect(
|
||||
await screen.findByText("Context size / model limit"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
||||
|
||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||
export function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
interface ContextBadgeProps {
|
||||
// Current context size for the active chat (tokens occupied in the model's
|
||||
// window). 0 = unknown → nothing is rendered.
|
||||
contextTokens: number;
|
||||
// The model's context-window size (tokens), from AI settings. 0/undefined =
|
||||
// no limit known → only the current size is shown (no denominator).
|
||||
maxContextTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header badge that ALWAYS shows the current context size, and — when the model's
|
||||
* context-window size is configured — appends "/ max" so the badge reads
|
||||
* "current / max" (e.g. `572 / 200k`). This is a single, stable meaning: unlike
|
||||
* the previous design it never flips to a live per-turn generation counter while
|
||||
* streaming (that live feedback lives in the chat body's "Thinking · N tokens").
|
||||
*
|
||||
* No limit configured (or older history rows without it) → the denominator is
|
||||
* hidden and the badge shows the current size only, matching the prior at-rest
|
||||
* behaviour. `context > max` (estimate drift, or a role on a smaller model) is
|
||||
* shown as-is, without clamping.
|
||||
*/
|
||||
export function ContextBadge({
|
||||
contextTokens,
|
||||
maxContextTokens,
|
||||
}: ContextBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Nothing to show until the first persisted context figure exists.
|
||||
if (!(contextTokens > 0)) return null;
|
||||
|
||||
const hasMax = typeof maxContextTokens === "number" && maxContextTokens > 0;
|
||||
const label = hasMax
|
||||
? `${formatTokens(contextTokens)} / ${formatTokens(maxContextTokens)}`
|
||||
: formatTokens(contextTokens);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
hasMax
|
||||
? t("Context size / model limit")
|
||||
: t("Current context size")
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<span className={classes.badge}>{label}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContextBadge;
|
||||
@@ -113,9 +113,14 @@ export interface IAiChatMessageRow {
|
||||
};
|
||||
// Current context size for the turn = final-step (input+output) tokens, i.e.
|
||||
// how much the conversation occupies in the model's context window after this
|
||||
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
|
||||
// floating window's header badge.
|
||||
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown as the
|
||||
// numerator of the floating window's "current / max" header badge.
|
||||
contextTokens?: number;
|
||||
// The model's context-window size (tokens), admin-configured in AI settings
|
||||
// and stamped onto the turn server-side. The denominator of the header badge.
|
||||
// Absent/0 (older rows, or no limit configured) → the badge hides the
|
||||
// denominator and shows only the current context size (`contextTokens`).
|
||||
maxContextTokens?: number;
|
||||
// Set on an assistant row whose turn ended in a provider/stream error; the
|
||||
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
||||
error?: string;
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import {
|
||||
estimateTokens,
|
||||
liveTurnTokens,
|
||||
} from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
const msg = (parts: unknown[], metadata?: unknown): UIMessage =>
|
||||
({
|
||||
id: Math.random().toString(),
|
||||
role: "assistant",
|
||||
parts,
|
||||
metadata,
|
||||
}) as UIMessage;
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
describe("estimateTokens", () => {
|
||||
it("returns 0 for the empty string", () => {
|
||||
@@ -25,147 +13,3 @@ describe("estimateTokens", () => {
|
||||
expect(estimateTokens("12345678")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — estimate path", () => {
|
||||
it("is all zeros for an undefined message", () => {
|
||||
expect(liveTurnTokens(undefined)).toEqual({
|
||||
reasoning: 0,
|
||||
output: 0,
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("is all zeros for a parts-less message", () => {
|
||||
expect(liveTurnTokens({ id: "x", role: "assistant" } as UIMessage)).toEqual({
|
||||
reasoning: 0,
|
||||
output: 0,
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("estimates output from text parts", () => {
|
||||
// 8 chars -> 2 tokens.
|
||||
const r = liveTurnTokens(msg([{ type: "text", text: "12345678" }]));
|
||||
expect(r).toEqual({ reasoning: 0, output: 2, authoritative: false });
|
||||
});
|
||||
|
||||
it("estimates reasoning from reasoning parts (kept separate from output)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "reasoning", text: "12345678" },
|
||||
{ type: "text", text: "abcd" },
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 2, output: 1, authoritative: false });
|
||||
});
|
||||
|
||||
it("accumulates across multiple text + reasoning parts (multi-step)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "reasoning", text: "abcd" }, // 1
|
||||
{ type: "text", text: "abcd" }, // 1
|
||||
{ type: "tool-getPage", state: "output-available" }, // ignored
|
||||
{ type: "reasoning", text: "abcd" }, // 1
|
||||
{ type: "text", text: "abcdefgh" }, // 2
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 2, output: 3, authoritative: false });
|
||||
});
|
||||
|
||||
it("ignores non text/reasoning parts (tools, step-start)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "step-start" },
|
||||
{ type: "tool-getPage", state: "input-available" },
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 0, authoritative: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — authoritative path", () => {
|
||||
it("returns authoritative usage verbatim, splitting reasoning out of output", () => {
|
||||
// outputTokens INCLUDES reasoning in the AI SDK shape -> answer = 100 - 30.
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "estimate would be tiny" }], {
|
||||
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
|
||||
});
|
||||
|
||||
it("treats missing reasoningTokens as 0 and keeps full output", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "x" }], {
|
||||
usage: { inputTokens: 10, outputTokens: 42 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 42, authoritative: true });
|
||||
});
|
||||
|
||||
it("never returns a negative output when reasoning exceeds reported output", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([], { usage: { outputTokens: 10, reasoningTokens: 40 } }),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 40, output: 0, authoritative: true });
|
||||
});
|
||||
|
||||
it("falls back to the estimate when metadata has no usage object", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "abcd" }], { chatId: "c1" }),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 1, authoritative: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — combined authoritative + estimate (#163)", () => {
|
||||
it("ticks the in-flight step above the completed-steps authoritative base", () => {
|
||||
// The authoritative usage is the sum over COMPLETED steps (step 1). The
|
||||
// CURRENT step is streaming and its text is NOT in `usage` yet, but it IS in
|
||||
// the parts -> the running estimate must push the live figure above the base
|
||||
// so the badge keeps growing between step boundaries.
|
||||
const longText = "x".repeat(800); // 800 chars -> 200 est output tokens
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: longText }], {
|
||||
usage: { inputTokens: 500, outputTokens: 40 }, // step-1 base: 40 output
|
||||
}),
|
||||
);
|
||||
// max(authOutput=40, estOutput=200) = 200 -> the counter ticks, not frozen.
|
||||
expect(r.output).toBe(200);
|
||||
expect(r.authoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("ticks reasoning of the in-flight step above the authoritative reasoning base", () => {
|
||||
const longReasoning = "r".repeat(400); // 400 chars -> 100 est reasoning
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "reasoning", text: longReasoning }], {
|
||||
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 20 },
|
||||
}),
|
||||
);
|
||||
// reasoning: max(20, 100) = 100 ; output: max(max(0,20-20)=0, 0) = 0.
|
||||
expect(r.reasoning).toBe(100);
|
||||
expect(r.output).toBe(0);
|
||||
expect(r.authoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("snaps to the authoritative figure once it exceeds the rough estimate", () => {
|
||||
// Short on-screen text (estimate tiny) but a large authoritative output:
|
||||
// the exact figure wins at the boundary (the counter never under-reports).
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "abcd" }], {
|
||||
usage: { inputTokens: 10, outputTokens: 5000 },
|
||||
}),
|
||||
);
|
||||
expect(r.output).toBe(5000);
|
||||
});
|
||||
|
||||
it("is monotonic: max never drops below the authoritative base when the estimate is smaller", () => {
|
||||
// Mirrors the legacy 'verbatim' tests: estimate < authoritative -> unchanged.
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "tiny" }], {
|
||||
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
/**
|
||||
* Live token counting for a streaming AI-chat turn — split into REASONING
|
||||
* (thinking) and OUTPUT (answer) tokens, mirroring how Claude Code shows
|
||||
* `Thinking… · 60 tokens` next to its thinking indicator.
|
||||
* Live token ESTIMATION for a streaming AI-chat turn.
|
||||
*
|
||||
* No provider streams exact per-token usage mid-stream, so the live number is a
|
||||
* CLIENT ESTIMATE (chars/≈4 heuristic) that is reconciled to AUTHORITATIVE usage
|
||||
* once the server attaches it on a step/turn boundary (see the server's
|
||||
* `chatStreamMetadata` + the client's read of `message.metadata.usage`). When
|
||||
* authoritative usage is present we return it verbatim (the number "jumps to
|
||||
* exact"); otherwise we return the running estimate. Pure + unit-testable: it
|
||||
* never runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the
|
||||
* CLIENT ESTIMATE (chars/≈4 heuristic). It powers the chat body's
|
||||
* `Thinking… · N tokens` indicator (see `ReasoningBlock`), which reconciles to
|
||||
* the authoritative server usage once it lands. Pure + unit-testable: it never
|
||||
* runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the
|
||||
* bundle, and be wrong for Gemini/Ollama anyway).
|
||||
*
|
||||
* The former header-badge `liveTurnTokens()` split was removed with #189 (the
|
||||
* header badge now shows the stable "current / max" context size, not a live
|
||||
* per-turn counter); the live feedback remains in `ReasoningBlock`.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -24,90 +22,3 @@ export function estimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/** Authoritative per-step/turn usage the server attaches to message metadata. */
|
||||
export interface AuthoritativeUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}
|
||||
|
||||
/** Live token split for a turn's tail (streaming) assistant message. */
|
||||
export interface LiveTurnTokens {
|
||||
/** Thinking/reasoning tokens (estimate, or authoritative when available). */
|
||||
reasoning: number;
|
||||
/** Answer/output tokens (estimate, or authoritative when available). */
|
||||
output: number;
|
||||
/** True when the numbers come from authoritative server usage, not estimate. */
|
||||
authoritative: boolean;
|
||||
}
|
||||
|
||||
/** Read the authoritative usage off a UIMessage's metadata, if the server set it. */
|
||||
function metadataUsage(message: UIMessage): AuthoritativeUsage | undefined {
|
||||
const meta = message?.metadata as
|
||||
| { usage?: AuthoritativeUsage }
|
||||
| undefined;
|
||||
const usage = meta?.usage;
|
||||
if (!usage || typeof usage !== "object") return undefined;
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token split for the given (streaming) assistant message.
|
||||
*
|
||||
* COMBINES the authoritative server usage with the running text estimate so the
|
||||
* counter ticks in real time AND lands exact. The server only attaches
|
||||
* `metadata.usage` at a step/turn boundary (`finish-step`/`finish`) and it is
|
||||
* CUMULATIVE over COMPLETED steps — it does NOT yet include the in-flight step.
|
||||
* So a multi-step turn that returned the authoritative figure verbatim would
|
||||
* FREEZE between boundaries and jump in steps (issue #163).
|
||||
*
|
||||
* Instead we always compute the running ESTIMATE (chars/≈4 over the message's
|
||||
* `reasoning`/`text` parts, which grows on every streamed delta) and take the
|
||||
* per-component MAX of the authoritative base and the estimate:
|
||||
* - between boundaries the estimate of the in-flight step ticks the number up;
|
||||
* - at a boundary the authoritative figure snaps it to exact;
|
||||
* - because the server's usage is cumulative and we only ever take the max, the
|
||||
* number is MONOTONIC — it never drops.
|
||||
*
|
||||
* Providers that don't stream reasoning text still surface a reasoning count once
|
||||
* the authoritative usage arrives (`max(reasoningTokens, 0)`); on the pure
|
||||
* estimate path (no usage yet) such a turn shows `reasoning: 0` until then.
|
||||
*/
|
||||
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
|
||||
if (!message) return { reasoning: 0, output: 0, authoritative: false };
|
||||
|
||||
// Running ESTIMATE over every reasoning/text part — grows on each delta. This
|
||||
// includes the IN-FLIGHT step, which the authoritative usage does not cover yet.
|
||||
let estReasoning = 0;
|
||||
let estOutput = 0;
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === "reasoning") {
|
||||
estReasoning += estimateTokens((part as { text?: string }).text ?? "");
|
||||
} else if (part.type === "text") {
|
||||
estOutput += estimateTokens((part as { text?: string }).text ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
const usage = metadataUsage(message);
|
||||
if (!usage) {
|
||||
// No authoritative usage streamed yet: the estimate IS the live figure.
|
||||
return { reasoning: estReasoning, output: estOutput, authoritative: false };
|
||||
}
|
||||
|
||||
// Authoritative sum over COMPLETED steps. `outputTokens` already INCLUDES
|
||||
// reasoning in the AI SDK usage shape, so subtract it out for the "answer"
|
||||
// figure (never go negative if a provider reports them inconsistently).
|
||||
const authReasoning = usage.reasoningTokens ?? 0;
|
||||
const authOutput = Math.max(0, (usage.outputTokens ?? 0) - authReasoning);
|
||||
|
||||
// Per-component max: the in-flight step's estimate ticks above the completed-
|
||||
// steps base between boundaries, and the authoritative figure wins once it
|
||||
// exceeds the (rough) estimate at the next boundary. Monotonic by construction.
|
||||
return {
|
||||
reasoning: Math.max(authReasoning, estReasoning),
|
||||
output: Math.max(authOutput, estOutput),
|
||||
authoritative: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import {
|
||||
useRemoveShareAliasMutation,
|
||||
useSetShareAliasMutation,
|
||||
useShareAliasForPageQuery,
|
||||
} from "@/features/share/queries/share-query.ts";
|
||||
import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts";
|
||||
import {
|
||||
isValidShareAlias,
|
||||
normalizeShareAlias,
|
||||
} from "@/features/share/share-alias.util.ts";
|
||||
|
||||
interface ShareAliasSectionProps {
|
||||
pageId: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
// The prefix label shown next to the slug input, e.g. "docs.example.com/l/".
|
||||
function aliasPrefixLabel(): string {
|
||||
const url = getAppUrl();
|
||||
const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
return `${host}/l/`;
|
||||
}
|
||||
|
||||
export default function ShareAliasSection({
|
||||
pageId,
|
||||
readOnly,
|
||||
}: ShareAliasSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: currentAlias } = useShareAliasForPageQuery(pageId);
|
||||
const setAliasMutation = useSetShareAliasMutation();
|
||||
const removeAliasMutation = useRemoveShareAliasMutation();
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
const [availability, setAvailability] = useState<{
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} | null>(null);
|
||||
const [reassign, setReassign] = useState<{
|
||||
alias: string;
|
||||
currentPageTitle: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Seed the input from the page's current alias (if any).
|
||||
useEffect(() => {
|
||||
setValue(currentAlias?.alias ?? "");
|
||||
}, [currentAlias?.alias, pageId]);
|
||||
|
||||
const normalized = useMemo(() => normalizeShareAlias(value), [value]);
|
||||
const isValid = isValidShareAlias(normalized);
|
||||
const unchanged = currentAlias?.alias === normalized;
|
||||
|
||||
// Debounced availability probe (skips when invalid or unchanged).
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
setAvailability(null);
|
||||
if (!isValid || unchanged) return;
|
||||
debounceRef.current && clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await checkShareAliasAvailability(normalized);
|
||||
setAvailability({
|
||||
valid: res.valid,
|
||||
available: res.available,
|
||||
currentPageId: res.currentPageId,
|
||||
});
|
||||
} catch {
|
||||
setAvailability(null);
|
||||
}
|
||||
}, 400);
|
||||
return () => {
|
||||
debounceRef.current && clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [normalized, isValid, unchanged]);
|
||||
|
||||
const prettyLink = currentAlias?.alias
|
||||
? `${getAppUrl()}/l/${currentAlias.alias}`
|
||||
: null;
|
||||
|
||||
const handleSave = async (confirmReassign = false) => {
|
||||
try {
|
||||
await setAliasMutation.mutateAsync({
|
||||
pageId,
|
||||
alias: normalized,
|
||||
confirmReassign,
|
||||
});
|
||||
setReassign(null);
|
||||
} catch (error: any) {
|
||||
// The address already points at another page: prompt to move it here.
|
||||
if (error?.status === 409 || error?.response?.status === 409) {
|
||||
const data = error?.response?.data;
|
||||
if (data?.code === "ALIAS_REASSIGN_REQUIRED") {
|
||||
setReassign({
|
||||
alias: normalized,
|
||||
currentPageTitle: data?.currentPageTitle ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!currentAlias?.id) return;
|
||||
await removeAliasMutation.mutateAsync(currentAlias.id);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
const showInvalid = normalized.length > 0 && !isValid;
|
||||
const showTaken =
|
||||
isValid && !unchanged && availability && !availability.available;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" fw={500} mt="md">
|
||||
{t("Custom address")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("A short, memorable link you can point at any shared page.")}
|
||||
</Text>
|
||||
|
||||
{prettyLink && (
|
||||
<Group my="xs" gap={4} wrap="nowrap">
|
||||
<TextInput
|
||||
variant="filled"
|
||||
value={prettyLink}
|
||||
readOnly
|
||||
rightSection={<CopyTextButton text={prettyLink} />}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
variant="default"
|
||||
target="_blank"
|
||||
href={prettyLink}
|
||||
size="sm"
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
// Show the canonical form once the user pauses so what they type maps
|
||||
// visibly to what gets stored.
|
||||
onBlur={() => setValue(normalized)}
|
||||
leftSection={
|
||||
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
|
||||
{aliasPrefixLabel()}
|
||||
</Text>
|
||||
}
|
||||
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
|
||||
placeholder={t("my-page")}
|
||||
disabled={readOnly}
|
||||
error={
|
||||
showInvalid
|
||||
? t("Use 2-60 lowercase letters, digits and hyphens")
|
||||
: showTaken
|
||||
? t("This address is already in use")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Group mt="xs" gap="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
onClick={() => handleSave(false)}
|
||||
loading={setAliasMutation.isPending}
|
||||
disabled={readOnly || !isValid || unchanged}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
{currentAlias?.id && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
color="red"
|
||||
onClick={handleRemove}
|
||||
loading={removeAliasMutation.isPending}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={!!reassign}
|
||||
onClose={() => setReassign(null)}
|
||||
title={t("Move custom address?")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text size="sm">
|
||||
{reassign?.currentPageTitle
|
||||
? t(
|
||||
'The address "{{alias}}" currently points to "{{title}}". Move it to this page?',
|
||||
{
|
||||
alias: reassign?.alias,
|
||||
title: reassign?.currentPageTitle,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
'The address "{{alias}}" is already in use. Move it to this page?',
|
||||
{ alias: reassign?.alias },
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={() => setReassign(null)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => handleSave(true)}
|
||||
loading={setAliasMutation.isPending}
|
||||
>
|
||||
{t("Move here")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import classes from "@/features/share/components/share.module.css";
|
||||
import ShareAliasSection from "@/features/share/components/share-alias-section.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -254,9 +253,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Group>
|
||||
{pageId && (
|
||||
<ShareAliasSection pageId={pageId} readOnly={readOnly} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -10,8 +10,6 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ICreateShare,
|
||||
IShare,
|
||||
IShareAlias,
|
||||
ISetShareAlias,
|
||||
ISharedItem,
|
||||
ISharedPage,
|
||||
ISharedPageTree,
|
||||
@@ -22,14 +20,11 @@ import {
|
||||
import {
|
||||
createShare,
|
||||
deleteShare,
|
||||
getShareAliasForPage,
|
||||
getSharedPageTree,
|
||||
getShareForPage,
|
||||
getShareInfo,
|
||||
getSharePageInfo,
|
||||
getShares,
|
||||
removeShareAlias,
|
||||
setShareAlias,
|
||||
updateShare,
|
||||
} from "@/features/share/services/share-service.ts";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
@@ -175,72 +170,6 @@ export function useDeleteShareMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useShareAliasForPageQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<IShareAlias | null, Error> {
|
||||
return useQuery({
|
||||
// The endpoint resolves to null when the page has no alias; normalize the
|
||||
// absence so React Query never sees `undefined`.
|
||||
queryKey: ["share-alias-for-page", pageId],
|
||||
queryFn: async () => (await getShareAliasForPage(pageId)) ?? null,
|
||||
enabled: !!pageId,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetShareAliasMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IShareAlias, Error, ISetShareAlias>({
|
||||
mutationFn: (data) => setShareAlias(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["share-alias-for-page", "share-list"].includes(
|
||||
item.queryKey[0] as string,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
// A 409 reassign-required is handled inline by the modal (it shows the
|
||||
// "move address here?" confirmation), so don't surface a generic toast.
|
||||
if (error?.["status"] === 409) return;
|
||||
notifications.show({
|
||||
message:
|
||||
error?.["response"]?.data?.message || t("Failed to set custom address"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveShareAliasMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (aliasId) => removeShareAlias(aliasId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["share-alias-for-page", "share-list"].includes(
|
||||
item.queryKey[0] as string,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
message:
|
||||
error?.["response"]?.data?.message ||
|
||||
t("Failed to remove custom address"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSharedPageTreeQuery(
|
||||
shareId: string,
|
||||
): UseQueryResult<ISharedPageTree, Error> {
|
||||
|
||||
@@ -4,9 +4,6 @@ import { IPage } from "@/features/page/types/page.types";
|
||||
import {
|
||||
ICreateShare,
|
||||
IShare,
|
||||
IShareAlias,
|
||||
IShareAliasAvailability,
|
||||
ISetShareAlias,
|
||||
ISharedItem,
|
||||
ISharedPage,
|
||||
ISharedPageTree,
|
||||
@@ -60,33 +57,3 @@ export async function getSharedPageTree(
|
||||
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getShareAliasForPage(
|
||||
pageId: string,
|
||||
): Promise<IShareAlias | null> {
|
||||
const req = await api.post<IShareAlias | null>("/share-aliases/for-page", {
|
||||
pageId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setShareAlias(
|
||||
data: ISetShareAlias,
|
||||
): Promise<IShareAlias> {
|
||||
const req = await api.post<IShareAlias>("/share-aliases/set", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function removeShareAlias(aliasId: string): Promise<void> {
|
||||
await api.post("/share-aliases/remove", { aliasId });
|
||||
}
|
||||
|
||||
export async function checkShareAliasAvailability(
|
||||
alias: string,
|
||||
): Promise<IShareAliasAvailability> {
|
||||
const req = await api.post<IShareAliasAvailability>(
|
||||
"/share-aliases/availability",
|
||||
{ alias },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isValidShareAlias,
|
||||
normalizeShareAlias,
|
||||
} from "@/features/share/share-alias.util.ts";
|
||||
|
||||
// Mirrors the server-side util so the modal's live feedback matches what the
|
||||
// server will accept/store.
|
||||
describe("normalizeShareAlias", () => {
|
||||
it("lowercases, trims and maps separators to single hyphens", () => {
|
||||
expect(normalizeShareAlias(" My Cool_Page ")).toBe("my-cool-page");
|
||||
});
|
||||
|
||||
it("collapses repeated hyphens and trims edges", () => {
|
||||
expect(normalizeShareAlias("--a---b--")).toBe("a-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidShareAlias", () => {
|
||||
it("accepts ascii hyphen-separated slugs of length 2..60", () => {
|
||||
expect(isValidShareAlias("hello-world")).toBe(true);
|
||||
expect(isValidShareAlias("a".repeat(60))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects too short, edge/double hyphens, uppercase and non-ascii", () => {
|
||||
expect(isValidShareAlias("a")).toBe(false);
|
||||
expect(isValidShareAlias("-a")).toBe(false);
|
||||
expect(isValidShareAlias("a--b")).toBe(false);
|
||||
expect(isValidShareAlias("Hello")).toBe(false);
|
||||
expect(isValidShareAlias("привет")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Client copy of the vanity share-alias helpers. Kept in sync with the server
|
||||
* (`apps/server/src/core/share/share-alias.util.ts`) so live input feedback
|
||||
* matches what the server will store/accept. ASCII-only, lowercase, hyphen
|
||||
* separated, length 2..60.
|
||||
*/
|
||||
|
||||
// Normalize a user-provided vanity alias into canonical ASCII storage form.
|
||||
export function normalizeShareAlias(raw: string): string {
|
||||
return (raw ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
export function isValidShareAlias(alias: string): boolean {
|
||||
return (
|
||||
typeof alias === "string" &&
|
||||
alias.length >= 2 &&
|
||||
alias.length <= 60 &&
|
||||
ALIAS_RE.test(alias)
|
||||
);
|
||||
}
|
||||
@@ -75,30 +75,6 @@ export interface IShareInfoInput {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
// Vanity /l/:alias pointer.
|
||||
export interface IShareAlias {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
alias: string;
|
||||
pageId: string | null;
|
||||
creatorId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ISetShareAlias {
|
||||
pageId: string;
|
||||
alias: string;
|
||||
confirmReassign?: boolean;
|
||||
}
|
||||
|
||||
export interface IShareAliasAvailability {
|
||||
alias: string;
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
}
|
||||
|
||||
export interface ISharedPageTree {
|
||||
share: IShare;
|
||||
pageTree: Partial<IPage[]>;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Select,
|
||||
@@ -85,6 +86,9 @@ const formSchema = z.object({
|
||||
chatModel: z.string(),
|
||||
// Chat provider implementation (reasoning surfacing). Default openai-compatible.
|
||||
chatApiStyle: z.enum(["openai-compatible", "openai"]),
|
||||
// Model context-window size (tokens) shown as the chat header badge's "max".
|
||||
// Empty string = no limit (NumberInput emits "" when cleared).
|
||||
chatContextWindow: z.union([z.number(), z.literal("")]),
|
||||
// Cheap model id for the anonymous public-share assistant; empty = use chatModel.
|
||||
publicShareChatModel: z.string(),
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
@@ -312,6 +316,7 @@ export default function AiProviderSettings() {
|
||||
initialValues: {
|
||||
chatModel: "",
|
||||
chatApiStyle: "openai-compatible" as ChatApiStyle,
|
||||
chatContextWindow: "" as number | "",
|
||||
publicShareChatModel: "",
|
||||
publicShareAssistantRoleId: "",
|
||||
embeddingModel: "",
|
||||
@@ -335,6 +340,10 @@ export default function AiProviderSettings() {
|
||||
form.setValues({
|
||||
chatModel: settings.chatModel ?? "",
|
||||
chatApiStyle: settings.chatApiStyle ?? "openai-compatible",
|
||||
// 0/unset = no limit → show an empty field (not a literal "0").
|
||||
chatContextWindow: settings.chatContextWindow
|
||||
? settings.chatContextWindow
|
||||
: "",
|
||||
publicShareChatModel: settings.publicShareChatModel ?? "",
|
||||
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
|
||||
embeddingModel: settings.embeddingModel ?? "",
|
||||
@@ -365,6 +374,11 @@ export default function AiProviderSettings() {
|
||||
driver: "openai",
|
||||
chatModel: values.chatModel,
|
||||
chatApiStyle: values.chatApiStyle,
|
||||
// Empty → 0, which clears the limit server-side (badge shows current only).
|
||||
chatContextWindow:
|
||||
typeof values.chatContextWindow === "number"
|
||||
? values.chatContextWindow
|
||||
: 0,
|
||||
// Cheap model id for the anonymous public-share assistant; empty falls
|
||||
// back to chatModel server-side.
|
||||
publicShareChatModel: values.publicShareChatModel,
|
||||
@@ -785,6 +799,22 @@ export default function AiProviderSettings() {
|
||||
{...form.getInputProps("chatApiStyle")}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
mt="sm"
|
||||
label={t("Context window (tokens)")}
|
||||
description={t(
|
||||
"Shows used / total in the chat header badge; empty hides the total.",
|
||||
)}
|
||||
placeholder={t("e.g. 200000")}
|
||||
min={0}
|
||||
step={1000}
|
||||
allowDecimal={false}
|
||||
allowNegative={false}
|
||||
thousandSeparator=" "
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("chatContextWindow")}
|
||||
/>
|
||||
|
||||
{/* Anonymous public-share assistant: a single master toggle + an
|
||||
optional cheaper model id. Reuses this card's driver/URL/key. */}
|
||||
<Group justify="space-between" align="center" wrap="nowrap" mt="md">
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface IAiSettings {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
chatApiStyle?: ChatApiStyle;
|
||||
// Chat model context-window size (tokens); shown as the "max" in the chat
|
||||
// header context badge. 0/unset = no limit (badge shows the current size only).
|
||||
chatContextWindow?: number;
|
||||
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
|
||||
publicShareChatModel?: string;
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
@@ -57,6 +60,8 @@ export interface IAiSettingsUpdate {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
chatApiStyle?: ChatApiStyle;
|
||||
// Chat model context-window size (tokens); 0 clears the limit.
|
||||
chatContextWindow?: number;
|
||||
publicShareChatModel?: string;
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
// built-in locked persona.
|
||||
|
||||
@@ -292,6 +292,26 @@ describe('flushAssistant', () => {
|
||||
expect(f.metadata.contextTokens).toBe(15);
|
||||
});
|
||||
|
||||
it('completed: writes maxContextTokens when the model limit is > 0', () => {
|
||||
const f = flushAssistant([toolStep], '', 'completed', {
|
||||
contextTokens: 15,
|
||||
maxContextTokens: 200_000,
|
||||
});
|
||||
expect(f.metadata.maxContextTokens).toBe(200_000);
|
||||
});
|
||||
|
||||
it('omits maxContextTokens when the limit is unset or 0', () => {
|
||||
const unset = flushAssistant([toolStep], '', 'completed', {
|
||||
contextTokens: 15,
|
||||
});
|
||||
expect('maxContextTokens' in unset.metadata).toBe(false);
|
||||
const zero = flushAssistant([toolStep], '', 'completed', {
|
||||
contextTokens: 15,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
expect('maxContextTokens' in zero.metadata).toBe(false);
|
||||
});
|
||||
|
||||
it('error: records the error and a derived finishReason', () => {
|
||||
const f = flushAssistant([], 'partial answer', 'error', { error: 'boom' });
|
||||
expect(f.status).toBe('error');
|
||||
|
||||
@@ -616,6 +616,9 @@ export class AiChatService implements OnModuleInit {
|
||||
contextTokens:
|
||||
(usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) ||
|
||||
undefined,
|
||||
// Admin-configured context-window size for this model (badge max).
|
||||
// Resolved once per turn above; written to metadata only when > 0.
|
||||
maxContextTokens: resolved?.chatContextWindow,
|
||||
}),
|
||||
);
|
||||
// Lifecycle: release the external MCP clients leased for this turn.
|
||||
@@ -1223,6 +1226,10 @@ export function flushAssistant(
|
||||
finishReason?: string;
|
||||
usage?: ChatStreamUsage | StreamUsage | undefined;
|
||||
contextTokens?: number;
|
||||
// Admin-configured context-window size (tokens) for this turn's model; the
|
||||
// denominator of the client's "current / max" header badge. Written only
|
||||
// when > 0 (0/unset = no limit known → the badge shows current only).
|
||||
maxContextTokens?: number;
|
||||
error?: string;
|
||||
},
|
||||
): AssistantFlush {
|
||||
@@ -1253,6 +1260,9 @@ export function flushAssistant(
|
||||
normalizeStreamUsage(extra.usage as StreamUsage) ?? extra.usage;
|
||||
}
|
||||
if (extra?.contextTokens) metadata.contextTokens = extra.contextTokens;
|
||||
if (extra?.maxContextTokens && extra.maxContextTokens > 0) {
|
||||
metadata.maxContextTokens = extra.maxContextTokens;
|
||||
}
|
||||
if (extra?.error) metadata.error = extra.error;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Create/retarget a vanity alias for a page. `confirmReassign` is the
|
||||
* two-step guard for the "address already points at another page" case: the
|
||||
* first call without it gets a 409 carrying the current target, the client
|
||||
* confirms, and retries with `confirmReassign: true`.
|
||||
*/
|
||||
export class SetShareAliasDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
alias: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
confirmReassign?: boolean;
|
||||
}
|
||||
|
||||
export class RemoveShareAliasDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
aliasId: string;
|
||||
}
|
||||
|
||||
export class ShareAliasAvailabilityDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export class ShareAliasForPageDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
// `@sindresorhus/slugify` is ESM-only and not in jest's transformIgnorePatterns,
|
||||
// so the real module fails to parse under ts-jest. Stub it with a minimal,
|
||||
// deterministic slugifier — this spec asserts the controller's slug *assembly*
|
||||
// (`<title-slug>-<slugId>`, 70-char clamp, `untitled` fallback), not the upstream
|
||||
// slug algorithm. The factory keeps the real ESM module from ever being loaded.
|
||||
jest.mock('@sindresorhus/slugify', () => ({
|
||||
__esModule: true,
|
||||
default: (input: string) =>
|
||||
String(input)
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, ''),
|
||||
}));
|
||||
|
||||
import { ShareAliasRedirectController } from './share-alias-redirect.controller';
|
||||
|
||||
/**
|
||||
* Routing/leak guard for the PUBLIC `GET /l/:alias` resolver.
|
||||
*
|
||||
* This is the most security-sensitive surface of the alias feature: an
|
||||
* unauthenticated route that MUST serve the plain SPA index (exactly like any
|
||||
* unknown path) for an unknown / dangling / no-longer-readable alias so that the
|
||||
* existence of a name never leaks. Only a resolvable, still-readable alias may
|
||||
* 302 to the canonical `/share/<key>/p/<title-slug>-<slugId>` page (302 — never
|
||||
* 301 — because the target is retargetable). These tests pin that routing and
|
||||
* the defensive percent-decoding, mirroring `share-seo.controller.routing.spec`.
|
||||
*/
|
||||
|
||||
const STREAM_SENTINEL = { __isStream: true } as unknown as fs.ReadStream;
|
||||
|
||||
// Stub fs at CALL time (jest.spyOn), NOT module load (jest.mock): the controller
|
||||
// transitively pulls bcrypt, whose native module is located by node-gyp-build
|
||||
// reading the filesystem at import time — a module-level fs mock breaks that.
|
||||
beforeEach(() => {
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.spyOn(fs, 'createReadStream').mockReturnValue(STREAM_SENTINEL);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
function makeRes() {
|
||||
const res: any = {
|
||||
sent: undefined as unknown,
|
||||
statusCode: undefined as number | undefined,
|
||||
redirectUrl: undefined as string | undefined,
|
||||
type: jest.fn(() => res),
|
||||
status: jest.fn((code: number) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
send: jest.fn((v: unknown) => {
|
||||
res.sent = v;
|
||||
return res;
|
||||
}),
|
||||
redirect: jest.fn((url: string, code: number) => {
|
||||
res.redirectUrl = url;
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
function makeController(opts: {
|
||||
resolved?: { share: any; page: any } | null;
|
||||
selfHosted?: boolean;
|
||||
}) {
|
||||
const shareAliasService = {
|
||||
resolveReadableTarget: jest.fn(async () => opts.resolved ?? null),
|
||||
};
|
||||
const workspaceRepo = {
|
||||
findFirst: jest.fn(async () => ({ id: 'ws-self' })),
|
||||
findByHostname: jest.fn(async (sub: string) =>
|
||||
sub === 'acme' ? { id: 'ws-acme' } : null,
|
||||
),
|
||||
};
|
||||
const environmentService = {
|
||||
isSelfHosted: jest.fn(() => opts.selfHosted ?? true),
|
||||
};
|
||||
const controller = new ShareAliasRedirectController(
|
||||
shareAliasService as any,
|
||||
workspaceRepo as any,
|
||||
environmentService as any,
|
||||
);
|
||||
return { controller, shareAliasService, workspaceRepo, environmentService };
|
||||
}
|
||||
|
||||
const selfReq: any = { raw: { headers: { host: 'self' } } };
|
||||
|
||||
describe('ShareAliasRedirectController.resolve', () => {
|
||||
it('302-redirects a resolvable alias to the canonical share page', async () => {
|
||||
const { controller, shareAliasService } = makeController({
|
||||
resolved: {
|
||||
share: { key: 'SHAREKEY' },
|
||||
page: { slugId: 'abc123', title: 'Quarterly Report' },
|
||||
},
|
||||
});
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('promo', selfReq, res);
|
||||
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
|
||||
'promo',
|
||||
'ws-self',
|
||||
);
|
||||
expect(res.redirect).toHaveBeenCalledWith(
|
||||
'/share/SHAREKEY/p/quarterly-report-abc123',
|
||||
302,
|
||||
);
|
||||
// No index stream was served on a hit.
|
||||
expect(res.sent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to "untitled" in the slug when the target has no title', async () => {
|
||||
const { controller } = makeController({
|
||||
resolved: { share: { key: 'K' }, page: { slugId: 'sid', title: '' } },
|
||||
});
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('promo', selfReq, res);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith('/share/K/p/untitled-sid', 302);
|
||||
});
|
||||
|
||||
it('clamps the title-slug to the first 70 characters of the page title', async () => {
|
||||
// 119-char title; only the first 70 chars must reach the slug. The 70-char
|
||||
// boundary deliberately falls mid-word ("Entire" -> "entir") so the clamp is
|
||||
// unambiguous: anything past char 70 ("...e Fiscal Year...") must be dropped.
|
||||
const longTitle =
|
||||
'The Comprehensive Quarterly Financial Performance Report For The Entire Fiscal Year Two Thousand Twenty Five And Beyond';
|
||||
const { controller } = makeController({
|
||||
resolved: {
|
||||
share: { key: 'K' },
|
||||
page: { slugId: 'sid', title: longTitle },
|
||||
},
|
||||
});
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('promo', selfReq, res);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith(
|
||||
'/share/K/p/the-comprehensive-quarterly-financial-performance-report-for-the-entir-sid',
|
||||
302,
|
||||
);
|
||||
});
|
||||
|
||||
it('streams the SPA index WITHOUT a 302 for an unknown/dangling/unreadable alias (no leak)', async () => {
|
||||
const { controller, shareAliasService } = makeController({ resolved: null });
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('does-not-exist', selfReq, res);
|
||||
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalled();
|
||||
// The plain index stream was served and no redirect leaked alias existence.
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.sent).toBe(STREAM_SENTINEL);
|
||||
expect(res.type).toHaveBeenCalledWith('text/html');
|
||||
});
|
||||
|
||||
it('streams the SPA index without even resolving when the workspace is null', async () => {
|
||||
// Subdomain host that maps to no workspace => workspace === null.
|
||||
const { controller, shareAliasService, workspaceRepo } = makeController({
|
||||
selfHosted: false,
|
||||
});
|
||||
const res = makeRes();
|
||||
const req: any = { raw: { headers: { host: 'unknown.example.com' } } };
|
||||
|
||||
await controller.resolve('promo', req, res);
|
||||
|
||||
expect(workspaceRepo.findByHostname).toHaveBeenCalledWith('unknown');
|
||||
// Never even attempts to resolve (alias existence cannot leak per-host).
|
||||
expect(shareAliasService.resolveReadableTarget).not.toHaveBeenCalled();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.sent).toBe(STREAM_SENTINEL);
|
||||
});
|
||||
|
||||
it('defensively decodes broken percent-encoding and treats it as unknown', async () => {
|
||||
const { controller, shareAliasService } = makeController({ resolved: null });
|
||||
const res = makeRes();
|
||||
|
||||
// '%E0%A4%A' is invalid -> decodeURIComponent throws -> raw value is used,
|
||||
// and the alias resolves to nothing (no crash, served as index).
|
||||
await controller.resolve('%E0%A4%A', selfReq, res);
|
||||
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
|
||||
'%E0%A4%A',
|
||||
'ws-self',
|
||||
);
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.sent).toBe(STREAM_SENTINEL);
|
||||
});
|
||||
|
||||
it('decodes a valid percent-encoded alias before resolving', async () => {
|
||||
const { controller, shareAliasService } = makeController({ resolved: null });
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('my%2Dlink', selfReq, res);
|
||||
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
|
||||
'my-link',
|
||||
'ws-self',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the workspace via findFirst on the self-hosted path', async () => {
|
||||
const { controller, workspaceRepo, shareAliasService } = makeController({
|
||||
selfHosted: true,
|
||||
resolved: null,
|
||||
});
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('promo', selfReq, res);
|
||||
|
||||
expect(workspaceRepo.findFirst).toHaveBeenCalled();
|
||||
expect(workspaceRepo.findByHostname).not.toHaveBeenCalled();
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
|
||||
'promo',
|
||||
'ws-self',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the workspace via findByHostname (subdomain) on the cloud path', async () => {
|
||||
const { controller, workspaceRepo, shareAliasService } = makeController({
|
||||
selfHosted: false,
|
||||
resolved: null,
|
||||
});
|
||||
const res = makeRes();
|
||||
const req: any = { raw: { headers: { host: 'acme.example.com' } } };
|
||||
|
||||
await controller.resolve('promo', req, res);
|
||||
|
||||
expect(workspaceRepo.findByHostname).toHaveBeenCalledWith('acme');
|
||||
expect(workspaceRepo.findFirst).not.toHaveBeenCalled();
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
|
||||
'promo',
|
||||
'ws-acme',
|
||||
);
|
||||
});
|
||||
|
||||
it('serves a 404 when no built client index exists', async () => {
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
const { controller } = makeController({ resolved: null });
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('promo', selfReq, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { ShareAliasService } from './share-alias.service';
|
||||
|
||||
/**
|
||||
* Public resolver for vanity links `GET /l/:alias`. Excluded from the global
|
||||
* `/api` prefix (see main.ts) and parallel to ShareSeoController.
|
||||
*
|
||||
* On a hit it issues a 302 (NEVER 301) to the canonical
|
||||
* `/share/:key/p/:slug` page, so:
|
||||
* - the existing share render + SSR meta is reused verbatim (crawlers follow
|
||||
* the 302 and get the correct preview);
|
||||
* - because the alias target is mutable, a temporary redirect is always
|
||||
* re-resolved — a cached 301 would pin clients to the pre-swap page.
|
||||
*
|
||||
* Any unknown / dangling / no-longer-readable alias serves the plain SPA index
|
||||
* (same as any unknown path) so the existence of a name never leaks.
|
||||
*/
|
||||
@Controller('l')
|
||||
export class ShareAliasRedirectController {
|
||||
constructor(
|
||||
private readonly shareAliasService: ShareAliasService,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
@Get(':alias')
|
||||
async resolve(
|
||||
@Param('alias') rawAlias: string,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res({ passthrough: false }) res: FastifyReply,
|
||||
) {
|
||||
// NestJS does not apply middlewares to paths excluded from the global /api
|
||||
// prefix, so the DomainMiddleware workspace resolution is duplicated here
|
||||
// (same workaround as ShareSeoController).
|
||||
let workspace: Workspace = null;
|
||||
if (this.environmentService.isSelfHosted()) {
|
||||
workspace = await this.workspaceRepo.findFirst();
|
||||
} else {
|
||||
const header = req.raw.headers.host;
|
||||
const subdomain = header?.split('.')[0];
|
||||
workspace = subdomain
|
||||
? await this.workspaceRepo.findByHostname(subdomain)
|
||||
: null;
|
||||
}
|
||||
|
||||
const clientDistPath = join(__dirname, '..', '..', '..', '..', 'client/dist');
|
||||
const indexFilePath = join(clientDistPath, 'index.html');
|
||||
|
||||
let decoded = rawAlias;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawAlias);
|
||||
} catch {
|
||||
// Malformed percent-encoding -> treat as unknown alias.
|
||||
}
|
||||
|
||||
const resolved = workspace
|
||||
? await this.shareAliasService.resolveReadableTarget(
|
||||
decoded,
|
||||
workspace.id,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!resolved) {
|
||||
return this.sendIndex(indexFilePath, res);
|
||||
}
|
||||
|
||||
const slug = buildPageSlug(resolved.page.slugId, resolved.page.title);
|
||||
// 302, NOT 301: the alias is retargetable, so the redirect must always be
|
||||
// re-resolved by clients/crawlers.
|
||||
return res.redirect(`/share/${resolved.share.key}/p/${slug}`, 302);
|
||||
}
|
||||
|
||||
private sendIndex(indexFilePath: string, res: FastifyReply) {
|
||||
if (!fs.existsSync(indexFilePath)) {
|
||||
// No built client (e.g. API-only dev): nothing to serve.
|
||||
res.status(404).send('Not found');
|
||||
return;
|
||||
}
|
||||
const stream = fs.createReadStream(indexFilePath);
|
||||
res.type('text/html').send(stream);
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical share page slug: `<title-slug>-<slugId>` (mirrors the client). */
|
||||
function buildPageSlug(slugId: string, title?: string): string {
|
||||
const titleSlug = slugify(title?.substring(0, 70) || 'untitled');
|
||||
return `${titleSlug}-${slugId}`;
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ShareAliasController } from './share-alias.controller';
|
||||
|
||||
/**
|
||||
* Authz-gate tests for the authenticated alias management controller. The access
|
||||
* decisions for creating/retargeting/removing an alias live in THIS controller
|
||||
* (the service spec delegates authorization to the caller), so each gate is
|
||||
* pinned here against mocked PageRepo / ShareService / ShareAliasService /
|
||||
* PageAccessService. A regression that drops any gate must fail here.
|
||||
*/
|
||||
describe('ShareAliasController authz gates', () => {
|
||||
function makeController() {
|
||||
const shareAliasService = {
|
||||
setAlias: jest.fn(async () => ({ id: 'alias-1' })),
|
||||
removeAlias: jest.fn(async () => undefined),
|
||||
getAliasById: jest.fn(),
|
||||
getAliasForPage: jest.fn(),
|
||||
checkAvailability: jest.fn(),
|
||||
};
|
||||
const shareService = {
|
||||
resolveReadableSharePage: jest.fn(),
|
||||
isSharingAllowed: jest.fn(),
|
||||
};
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
const pageAccessService = {
|
||||
validateCanEdit: jest.fn(async () => undefined),
|
||||
validateCanView: jest.fn(async () => undefined),
|
||||
};
|
||||
const controller = new ShareAliasController(
|
||||
shareAliasService as any,
|
||||
shareService as any,
|
||||
pageRepo as any,
|
||||
pageAccessService as any,
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
shareAliasService,
|
||||
shareService,
|
||||
pageRepo,
|
||||
pageAccessService,
|
||||
};
|
||||
}
|
||||
|
||||
const user: any = { id: 'u-1' };
|
||||
const workspace: any = { id: 'ws-1' };
|
||||
|
||||
describe('set', () => {
|
||||
it('throws NotFoundException for a nonexistent page', async () => {
|
||||
const { controller, pageRepo, pageAccessService } = makeController();
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-x', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException for a page in another workspace', async () => {
|
||||
const { controller, pageRepo } = makeController();
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p-1',
|
||||
workspaceId: 'ws-OTHER',
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('enforces validateCanEdit before setting the alias', async () => {
|
||||
const { controller, pageRepo, pageAccessService, shareService } =
|
||||
makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
pageAccessService.validateCanEdit.mockRejectedValue(
|
||||
new ForbiddenException('no edit'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
// Gate short-circuits before any share resolution.
|
||||
expect(shareService.resolveReadableSharePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestException when the page is not publicly shared', async () => {
|
||||
const { controller, pageRepo, shareService } = makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
shareService.resolveReadableSharePage.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toThrow('Page is not publicly shared');
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when public sharing is disabled', async () => {
|
||||
const { controller, pageRepo, shareService } = makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
shareService.resolveReadableSharePage.mockResolvedValue({
|
||||
share: { spaceId: 'sp-1' },
|
||||
});
|
||||
shareService.isSharingAllowed.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('delegates to setAlias on the happy path with all gates passed', async () => {
|
||||
const { controller, pageRepo, shareService, shareAliasService } =
|
||||
makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
shareService.resolveReadableSharePage.mockResolvedValue({
|
||||
share: { spaceId: 'sp-1' },
|
||||
});
|
||||
shareService.isSharingAllowed.mockResolvedValue(true);
|
||||
|
||||
const result = await controller.set(
|
||||
{ pageId: 'p-1', alias: 'promo', confirmReassign: true } as any,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
|
||||
expect(shareAliasService.setAlias).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'promo',
|
||||
confirmReassign: true,
|
||||
});
|
||||
expect(result).toEqual({ id: 'alias-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('throws NotFoundException for an unknown alias', async () => {
|
||||
const { controller, shareAliasService } = makeController();
|
||||
shareAliasService.getAliasById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.remove({ aliasId: 'a-x' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(shareAliasService.removeAlias).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires validateCanEdit on the current target before removing', async () => {
|
||||
const { controller, shareAliasService, pageRepo, pageAccessService } =
|
||||
makeController();
|
||||
shareAliasService.getAliasById.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
pageAccessService.validateCanEdit.mockRejectedValue(
|
||||
new ForbiddenException('no edit'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.remove({ aliasId: 'a-1' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(shareAliasService.removeAlias).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes a dangling alias (pageId null) WITHOUT an edit check', async () => {
|
||||
const { controller, shareAliasService, pageRepo, pageAccessService } =
|
||||
makeController();
|
||||
shareAliasService.getAliasById.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: null,
|
||||
});
|
||||
|
||||
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
|
||||
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
|
||||
});
|
||||
|
||||
it('removes when the editor can edit the current target', async () => {
|
||||
const { controller, shareAliasService, pageRepo, pageAccessService } =
|
||||
makeController();
|
||||
shareAliasService.getAliasById.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
|
||||
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
|
||||
|
||||
expect(pageAccessService.validateCanEdit).toHaveBeenCalled();
|
||||
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
|
||||
});
|
||||
|
||||
it('removes even if the recorded target page no longer exists', async () => {
|
||||
const { controller, shareAliasService, pageRepo, pageAccessService } =
|
||||
makeController();
|
||||
shareAliasService.getAliasById.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-gone',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
|
||||
|
||||
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forPage', () => {
|
||||
it('throws NotFoundException for a cross-workspace/nonexistent page', async () => {
|
||||
const { controller, pageRepo, pageAccessService } = makeController();
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p-1',
|
||||
workspaceId: 'ws-OTHER',
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.forPage({ pageId: 'p-1' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(pageAccessService.validateCanView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires validateCanView and returns the alias (or null)', async () => {
|
||||
const { controller, pageRepo, pageAccessService, shareAliasService } =
|
||||
makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
shareAliasService.getAliasForPage.mockResolvedValue({ id: 'a-1' });
|
||||
|
||||
const result = await controller.forPage(
|
||||
{ pageId: 'p-1' } as any,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
|
||||
expect(pageAccessService.validateCanView).toHaveBeenCalled();
|
||||
expect(result).toEqual({ id: 'a-1' });
|
||||
});
|
||||
|
||||
it('returns null when the page has no alias', async () => {
|
||||
const { controller, pageRepo, shareAliasService } = makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
shareAliasService.getAliasForPage.mockResolvedValue(undefined);
|
||||
|
||||
const result = await controller.forPage(
|
||||
{ pageId: 'p-1' } as any,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
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 { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { ShareService } from './share.service';
|
||||
import { ShareAliasService } from './share-alias.service';
|
||||
import {
|
||||
RemoveShareAliasDto,
|
||||
SetShareAliasDto,
|
||||
ShareAliasAvailabilityDto,
|
||||
ShareAliasForPageDto,
|
||||
} from './dto/share-alias.dto';
|
||||
|
||||
/**
|
||||
* Authenticated management of vanity `/l/:alias` links. The PUBLIC resolve path
|
||||
* lives in `ShareAliasRedirectController` (`/l/:alias`); this controller only
|
||||
* creates/retargets/removes/looks-up aliases for editors.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('share-aliases')
|
||||
export class ShareAliasController {
|
||||
constructor(
|
||||
private readonly shareAliasService: ShareAliasService,
|
||||
private readonly shareService: ShareService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('set')
|
||||
async set(
|
||||
@Body() dto: SetShareAliasDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.workspaceId !== workspace.id) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// Editing the page is required to point an address at it.
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
// The page must currently be publicly readable through the share graph; an
|
||||
// alias to a non-shared page would only ever 404.
|
||||
const resolved = await this.shareService.resolveReadableSharePage(
|
||||
undefined,
|
||||
page.id,
|
||||
workspace.id,
|
||||
);
|
||||
if (!resolved) {
|
||||
throw new BadRequestException('Page is not publicly shared');
|
||||
}
|
||||
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
workspace.id,
|
||||
resolved.share.spaceId,
|
||||
);
|
||||
if (!sharingAllowed) {
|
||||
throw new ForbiddenException('Public sharing is disabled');
|
||||
}
|
||||
|
||||
return this.shareAliasService.setAlias({
|
||||
workspaceId: workspace.id,
|
||||
pageId: page.id,
|
||||
creatorId: user.id,
|
||||
alias: dto.alias,
|
||||
confirmReassign: dto.confirmReassign,
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('remove')
|
||||
async remove(
|
||||
@Body() dto: RemoveShareAliasDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const alias = await this.shareAliasService.getAliasById(
|
||||
dto.aliasId,
|
||||
workspace.id,
|
||||
);
|
||||
if (!alias) {
|
||||
throw new NotFoundException('Alias not found');
|
||||
}
|
||||
|
||||
// Only someone who can edit the (current) target page may free the address.
|
||||
// A dangling alias (page deleted) can be removed by any workspace member.
|
||||
if (alias.pageId) {
|
||||
const page = await this.pageRepo.findById(alias.pageId);
|
||||
if (page) {
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
}
|
||||
}
|
||||
|
||||
await this.shareAliasService.removeAlias(alias.id, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('availability')
|
||||
async availability(
|
||||
@Body() dto: ShareAliasAvailabilityDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.shareAliasService.checkAvailability(dto.alias, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('for-page')
|
||||
async forPage(
|
||||
@Body() dto: ShareAliasForPageDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.workspaceId !== workspace.id) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return (
|
||||
(await this.shareAliasService.getAliasForPage(page.id, workspace.id)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { ShareAliasService } from './share-alias.service';
|
||||
|
||||
/**
|
||||
* Behaviour tests for the alias write/resolve semantics: create vs no-op vs the
|
||||
* 409 reassign guard, uniqueness-race handling, availability probe, and the
|
||||
* request-time readable-target resolution (which re-runs the share boundary).
|
||||
*/
|
||||
describe('ShareAliasService', () => {
|
||||
function makeService() {
|
||||
const shareAliasRepo = {
|
||||
findByAliasAndWorkspace: jest.fn(),
|
||||
findByPageId: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
updatePageId: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
const shareService = {
|
||||
resolveReadableSharePage: jest.fn(),
|
||||
isSharingAllowed: jest.fn(),
|
||||
};
|
||||
const service = new ShareAliasService(
|
||||
shareAliasRepo as any,
|
||||
pageRepo as any,
|
||||
shareService as any,
|
||||
);
|
||||
return { service, shareAliasRepo, pageRepo, shareService };
|
||||
}
|
||||
|
||||
describe('setAlias', () => {
|
||||
it('rejects an invalid alias before touching the db', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
await expect(
|
||||
service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'A', // too short + uppercase
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes then inserts a brand-new alias', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' });
|
||||
|
||||
const res = await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: ' My Page ',
|
||||
});
|
||||
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
|
||||
'my-page',
|
||||
'ws-1',
|
||||
);
|
||||
expect(shareAliasRepo.insert).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'my-page',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
});
|
||||
expect(res).toMatchObject({ id: 'a-1' });
|
||||
});
|
||||
|
||||
it('is a no-op when the alias already points at the same page', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' };
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing);
|
||||
|
||||
const res = await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
});
|
||||
|
||||
expect(res).toBe(existing);
|
||||
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
|
||||
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 409 with current target when name is taken and not confirmed', async () => {
|
||||
const { service, shareAliasRepo, pageRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-other',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-other', title: 'Other' });
|
||||
|
||||
try {
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
});
|
||||
fail('expected ConflictException');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
expect((err as ConflictException).getResponse()).toMatchObject({
|
||||
code: 'ALIAS_REASSIGN_REQUIRED',
|
||||
currentPageId: 'p-other',
|
||||
currentPageTitle: 'Other',
|
||||
});
|
||||
}
|
||||
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retargets (UPDATE page_id) when confirmReassign is set', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-other',
|
||||
});
|
||||
shareAliasRepo.updatePageId.mockResolvedValue({ id: 'a-1', pageId: 'p-1' });
|
||||
|
||||
const res = await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
confirmReassign: true,
|
||||
});
|
||||
|
||||
expect(shareAliasRepo.updatePageId).toHaveBeenCalledWith(
|
||||
'a-1',
|
||||
'p-1',
|
||||
'ws-1',
|
||||
);
|
||||
expect(res).toMatchObject({ pageId: 'p-1' });
|
||||
});
|
||||
|
||||
it('maps a unique-violation race to 409', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.insert.mockRejectedValue({ code: '23505' });
|
||||
|
||||
await expect(
|
||||
service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAvailability', () => {
|
||||
it('reports invalid for a bad slug without a db hit', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
const res = await service.checkAvailability('Bad Slug!', 'ws-1');
|
||||
expect(res).toMatchObject({ valid: false, available: false });
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports available when no row exists', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
const res = await service.checkAvailability('free-name', 'ws-1');
|
||||
expect(res).toMatchObject({
|
||||
alias: 'free-name',
|
||||
valid: true,
|
||||
available: true,
|
||||
currentPageId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports taken with the current target page', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-9',
|
||||
});
|
||||
const res = await service.checkAvailability('taken', 'ws-1');
|
||||
expect(res).toMatchObject({ available: false, currentPageId: 'p-9' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveReadableTarget', () => {
|
||||
it('returns null for an invalid alias', async () => {
|
||||
const { service } = makeService();
|
||||
expect(await service.resolveReadableTarget('!!', 'ws-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an unknown or dangling alias', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValueOnce(undefined);
|
||||
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
||||
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValueOnce({
|
||||
id: 'a-1',
|
||||
pageId: null,
|
||||
});
|
||||
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the page is no longer publicly readable', async () => {
|
||||
const { service, shareAliasRepo, shareService } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
shareService.resolveReadableSharePage.mockResolvedValue(null);
|
||||
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when sharing is disabled for the space', async () => {
|
||||
const { service, shareAliasRepo, shareService } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
shareService.resolveReadableSharePage.mockResolvedValue({
|
||||
share: { key: 'k', spaceId: 's-1' },
|
||||
page: { slugId: 'sid', title: 'T' },
|
||||
});
|
||||
shareService.isSharingAllowed.mockResolvedValue(false);
|
||||
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the resolved share+page on success', async () => {
|
||||
const { service, shareAliasRepo, shareService } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
const resolved = {
|
||||
share: { key: 'k', spaceId: 's-1' },
|
||||
page: { slugId: 'sid', title: 'T' },
|
||||
};
|
||||
shareService.resolveReadableSharePage.mockResolvedValue(resolved);
|
||||
shareService.isSharingAllowed.mockResolvedValue(true);
|
||||
|
||||
const res = await service.resolveReadableTarget('FOO', 'ws-1');
|
||||
expect(res).toBe(resolved);
|
||||
// alias was normalized to lowercase before lookup
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
|
||||
'foo',
|
||||
'ws-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { ShareService } from './share.service';
|
||||
import { Page, ShareAlias } from '@docmost/db/types/entity.types';
|
||||
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
|
||||
|
||||
/** Postgres unique_violation; the (workspace_id, alias) constraint races here. */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
|
||||
export interface ResolvedAliasTarget {
|
||||
share: NonNullable<
|
||||
Awaited<ReturnType<ShareService['resolveReadableSharePage']>>
|
||||
>['share'];
|
||||
page: Page;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShareAliasService {
|
||||
private readonly logger = new Logger(ShareAliasService.name);
|
||||
|
||||
constructor(
|
||||
private readonly shareAliasRepo: ShareAliasRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly shareService: ShareService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create or retarget a vanity alias. The alias is workspace-scoped:
|
||||
* - no row for this name -> INSERT a new pointer
|
||||
* - row already points at pageId -> no-op (idempotent)
|
||||
* - row points elsewhere -> the "swap". Without confirmReassign we
|
||||
* throw 409 carrying the current target so the client can confirm; with
|
||||
* it we UPDATE the single row's page_id (every /l/<alias> link follows the
|
||||
* 302 to the new page instantly — no stale 301 cache).
|
||||
*
|
||||
* Caller is responsible for authorizing the page (edit rights + public
|
||||
* readability); this method owns only the alias-name semantics.
|
||||
*/
|
||||
async setAlias(opts: {
|
||||
workspaceId: string;
|
||||
pageId: string;
|
||||
creatorId: string;
|
||||
alias: string;
|
||||
confirmReassign?: boolean;
|
||||
}): Promise<ShareAlias> {
|
||||
const { workspaceId, pageId, creatorId, confirmReassign } = opts;
|
||||
const alias = normalizeShareAlias(opts.alias);
|
||||
if (!isValidShareAlias(alias)) {
|
||||
throw new BadRequestException(
|
||||
'Invalid alias. Use 2-60 lowercase letters, digits and hyphens.',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
|
||||
alias,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
return await this.shareAliasRepo.insert({
|
||||
workspaceId,
|
||||
alias,
|
||||
pageId,
|
||||
creatorId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Lost a uniqueness race: another request claimed the name first.
|
||||
if (err?.code === PG_UNIQUE_VIOLATION) {
|
||||
throw new ConflictException({ message: 'Alias already taken' });
|
||||
}
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to set alias');
|
||||
}
|
||||
}
|
||||
|
||||
// Already points at this page -> nothing to do.
|
||||
if (existing.pageId === pageId) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Name occupied by a different (or dangling) target: require confirmation.
|
||||
if (!confirmReassign) {
|
||||
const currentPage = existing.pageId
|
||||
? await this.pageRepo.findById(existing.pageId)
|
||||
: null;
|
||||
throw new ConflictException({
|
||||
message: 'Alias already in use',
|
||||
code: 'ALIAS_REASSIGN_REQUIRED',
|
||||
currentPageId: existing.pageId,
|
||||
currentPageTitle: currentPage?.title ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.shareAliasRepo.updatePageId(existing.id, pageId, workspaceId);
|
||||
}
|
||||
|
||||
/** Free a vanity name (no history kept). */
|
||||
async removeAlias(aliasId: string, workspaceId: string): Promise<void> {
|
||||
await this.shareAliasRepo.delete(aliasId, workspaceId);
|
||||
}
|
||||
|
||||
/** Debounced availability probe for the modal. */
|
||||
async checkAvailability(
|
||||
rawAlias: string,
|
||||
workspaceId: string,
|
||||
): Promise<{
|
||||
alias: string;
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
}> {
|
||||
const alias = normalizeShareAlias(rawAlias);
|
||||
if (!isValidShareAlias(alias)) {
|
||||
return { alias, valid: false, available: false, currentPageId: null };
|
||||
}
|
||||
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
|
||||
alias,
|
||||
workspaceId,
|
||||
);
|
||||
return {
|
||||
alias,
|
||||
valid: true,
|
||||
available: !existing,
|
||||
currentPageId: existing?.pageId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A single alias row scoped to the workspace, or undefined. */
|
||||
getAliasById(
|
||||
aliasId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ShareAlias | undefined> {
|
||||
return this.shareAliasRepo.findById(aliasId, workspaceId);
|
||||
}
|
||||
|
||||
/** The alias currently targeting a page (modal display), or undefined. */
|
||||
getAliasForPage(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ShareAlias | undefined> {
|
||||
return this.shareAliasRepo.findByPageId(pageId, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a vanity alias to the canonical, publicly-READABLE share page, or
|
||||
* null. This re-runs the authoritative share boundary at request time (so a
|
||||
* later-unshared / restricted / sharing-disabled target collapses to null and
|
||||
* the caller serves the generic SPA 404 — no existence leak). The alias row
|
||||
* itself is just a pointer; this is where access is actually decided.
|
||||
*/
|
||||
async resolveReadableTarget(
|
||||
rawAlias: string,
|
||||
workspaceId: string,
|
||||
): Promise<ResolvedAliasTarget | null> {
|
||||
const alias = normalizeShareAlias(rawAlias);
|
||||
if (!isValidShareAlias(alias)) return null;
|
||||
|
||||
const aliasRow = await this.shareAliasRepo.findByAliasAndWorkspace(
|
||||
alias,
|
||||
workspaceId,
|
||||
);
|
||||
// Unknown name or a dangling alias (target page deleted) -> not resolvable.
|
||||
if (!aliasRow?.pageId) return null;
|
||||
|
||||
const resolved = await this.shareService.resolveReadableSharePage(
|
||||
undefined,
|
||||
aliasRow.pageId,
|
||||
workspaceId,
|
||||
);
|
||||
if (!resolved) return null;
|
||||
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
workspaceId,
|
||||
resolved.share.spaceId,
|
||||
);
|
||||
if (!sharingAllowed) return null;
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
|
||||
|
||||
describe('normalizeShareAlias', () => {
|
||||
it('lowercases and trims', () => {
|
||||
expect(normalizeShareAlias(' HelloWorld ')).toBe('helloworld');
|
||||
});
|
||||
|
||||
it('converts spaces and underscores to single hyphens', () => {
|
||||
expect(normalizeShareAlias('my cool page')).toBe('my-cool-page');
|
||||
expect(normalizeShareAlias('my_cool_page')).toBe('my-cool-page');
|
||||
});
|
||||
|
||||
it('collapses repeated hyphens and trims edge hyphens', () => {
|
||||
expect(normalizeShareAlias('--a---b--')).toBe('a-b');
|
||||
});
|
||||
|
||||
it('handles null/undefined defensively', () => {
|
||||
expect(normalizeShareAlias(undefined as unknown as string)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidShareAlias', () => {
|
||||
it('accepts ascii lowercase hyphen-separated slugs', () => {
|
||||
expect(isValidShareAlias('hello')).toBe(true);
|
||||
expect(isValidShareAlias('hello-world-2')).toBe(true);
|
||||
expect(isValidShareAlias('a1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects too short / too long', () => {
|
||||
expect(isValidShareAlias('a')).toBe(false);
|
||||
expect(isValidShareAlias('a'.repeat(61))).toBe(false);
|
||||
expect(isValidShareAlias('a'.repeat(60))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects leading/trailing/double hyphens', () => {
|
||||
expect(isValidShareAlias('-abc')).toBe(false);
|
||||
expect(isValidShareAlias('abc-')).toBe(false);
|
||||
expect(isValidShareAlias('a--b')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects uppercase, cyrillic and other non-ascii', () => {
|
||||
expect(isValidShareAlias('Hello')).toBe(false);
|
||||
expect(isValidShareAlias('привет')).toBe(false);
|
||||
expect(isValidShareAlias('a b')).toBe(false);
|
||||
expect(isValidShareAlias('a_b')).toBe(false);
|
||||
expect(isValidShareAlias('a.b')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalize + validate round-trips a messy input to a valid slug', () => {
|
||||
const alias = normalizeShareAlias(' My Cool_Page!! ');
|
||||
// "!!" is not stripped by normalize (only case/separators), so the result
|
||||
// still fails validation — the charset gate is intentionally separate.
|
||||
expect(alias).toBe('my-cool-page!!');
|
||||
expect(isValidShareAlias(alias)).toBe(false);
|
||||
|
||||
const ok = normalizeShareAlias(' My Cool Page ');
|
||||
expect(ok).toBe('my-cool-page');
|
||||
expect(isValidShareAlias(ok)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Vanity share-alias helpers shared by the write path (set/availability) and the
|
||||
* `/l/:alias` resolve path. Aliases are ASCII-only, lowercase, hyphen-separated
|
||||
* slugs — deliberately no Cyrillic / transliteration: the user types the exact
|
||||
* canonical form. Keep this in sync with the client copy in
|
||||
* `apps/client/src/features/share/share-alias.util.ts`.
|
||||
*/
|
||||
|
||||
// Normalize a user-provided vanity alias into canonical ASCII storage form.
|
||||
// This only canonicalizes shape (case, separators); it does NOT enforce the
|
||||
// charset — call isValidShareAlias afterwards to reject anything illegal.
|
||||
export function normalizeShareAlias(raw: string): string {
|
||||
return (raw ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, '-') // spaces/underscores -> single hyphen
|
||||
.replace(/-{2,}/g, '-') // collapse repeated hyphens
|
||||
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
||||
}
|
||||
|
||||
// ASCII only: lowercase letters/digits in hyphen-separated groups, length 2..60.
|
||||
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
export function isValidShareAlias(alias: string): boolean {
|
||||
return (
|
||||
typeof alias === 'string' &&
|
||||
alias.length >= 2 &&
|
||||
alias.length <= 60 &&
|
||||
ALIAS_RE.test(alias)
|
||||
);
|
||||
}
|
||||
@@ -5,22 +5,13 @@ import { TokenModule } from '../auth/token.module';
|
||||
import { ShareSeoController } from './share-seo.controller';
|
||||
import { TransclusionModule } from '../page/transclusion/transclusion.module';
|
||||
import { AiModule } from '../../integrations/ai/ai.module';
|
||||
import { ShareAliasService } from './share-alias.service';
|
||||
import { ShareAliasController } from './share-alias.controller';
|
||||
import { ShareAliasRedirectController } from './share-alias-redirect.controller';
|
||||
|
||||
@Module({
|
||||
// AiModule (AiSettingsService) is used by the page-info route to surface
|
||||
// whether the anonymous public-share assistant is enabled for the workspace.
|
||||
imports: [TokenModule, TransclusionModule, AiModule],
|
||||
controllers: [
|
||||
ShareController,
|
||||
ShareSeoController,
|
||||
// Vanity /l/:alias: authenticated management + public 302 resolver.
|
||||
ShareAliasController,
|
||||
ShareAliasRedirectController,
|
||||
],
|
||||
providers: [ShareService, ShareAliasService],
|
||||
exports: [ShareService, ShareAliasService],
|
||||
controllers: [ShareController, ShareSeoController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
})
|
||||
export class ShareModule {}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
|
||||
@@ -97,7 +96,6 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
ShareAliasRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
@@ -130,7 +128,6 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
ShareAliasRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* Vanity share aliases: a retargetable, human-readable pointer (`/l/<alias>`)
|
||||
* that lives independently of any single `shares` row. The alias belongs to the
|
||||
* WORKSPACE (stable address), and `page_id` is nullable with ON DELETE SET NULL
|
||||
* so the address survives deletion of its current target (it 404s until
|
||||
* retargeted) rather than disappearing with the page.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('share_aliases')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
// Normalized ASCII, lowercase. Uniqueness is enforced per-workspace below.
|
||||
.addColumn('alias', 'varchar', (col) => col.notNull())
|
||||
// Nullable + SET NULL: the address outlives its target page.
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('creator_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()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// The vanity name is unique within a workspace (mirrors shares.key scoping).
|
||||
await db.schema
|
||||
.createIndex('share_aliases_workspace_id_alias_unique')
|
||||
.on('share_aliases')
|
||||
.columns(['workspace_id', 'alias'])
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
// "Which alias targets this page?" lookup for the share modal.
|
||||
await db.schema
|
||||
.createIndex('share_aliases_page_id_idx')
|
||||
.on('share_aliases')
|
||||
.column('page_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('share_aliases').execute();
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import * as migration from './20260626T130000-share-aliases';
|
||||
import type {
|
||||
InsertableShareAlias,
|
||||
ShareAlias,
|
||||
UpdatableShareAlias,
|
||||
} from '../types/entity.types';
|
||||
|
||||
/**
|
||||
* Sanity checks for the share_aliases migration + entity types. We don't run a
|
||||
* live Postgres here (that's the integration suite); instead we assert the
|
||||
* migration exposes the expected up/down contract and creates the table with
|
||||
* the unique (workspace_id, alias) constraint and the page_id index, and that
|
||||
* the generated entity types line up with the column set.
|
||||
*/
|
||||
describe('share-aliases migration', () => {
|
||||
it('up creates the table, the unique index and the page_id index', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const tableBuilder: any = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop: string) {
|
||||
if (prop === 'execute') return async () => undefined;
|
||||
// addColumn/addConstraint/etc. are chainable no-ops.
|
||||
return () => tableBuilder;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const indexBuilder: any = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop: string) {
|
||||
if (prop === 'execute') return async () => undefined;
|
||||
return () => indexBuilder;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const schema = {
|
||||
createTable: (name: string) => {
|
||||
calls.push(`createTable:${name}`);
|
||||
return tableBuilder;
|
||||
},
|
||||
createIndex: (name: string) => {
|
||||
calls.push(`createIndex:${name}`);
|
||||
return indexBuilder;
|
||||
},
|
||||
};
|
||||
|
||||
await migration.up({ schema } as any);
|
||||
|
||||
expect(calls).toContain('createTable:share_aliases');
|
||||
expect(calls).toContain(
|
||||
'createIndex:share_aliases_workspace_id_alias_unique',
|
||||
);
|
||||
expect(calls).toContain('createIndex:share_aliases_page_id_idx');
|
||||
});
|
||||
|
||||
it('down drops the table', async () => {
|
||||
const calls: string[] = [];
|
||||
const dropBuilder: any = { execute: async () => undefined };
|
||||
const schema = {
|
||||
dropTable: (name: string) => {
|
||||
calls.push(`dropTable:${name}`);
|
||||
return dropBuilder;
|
||||
},
|
||||
};
|
||||
await migration.down({ schema } as any);
|
||||
expect(calls).toContain('dropTable:share_aliases');
|
||||
});
|
||||
|
||||
it('entity types expose the alias columns', () => {
|
||||
// Compile-time only: these typed declarations fail `tsc` if the entity types
|
||||
// drift (missing/renamed columns, wrong nullability). The runtime assertions
|
||||
// would be tautological, so the value is purely in the type-check.
|
||||
const row: ShareAlias = {
|
||||
id: 'a-1',
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const insert: InsertableShareAlias = {
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'foo',
|
||||
};
|
||||
const update: UpdatableShareAlias = { pageId: null };
|
||||
|
||||
expect([row, insert, update]).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { ShareAliasRepo } from './share-alias.repo';
|
||||
import type { KyselyDB } from '../../types/kysely.types';
|
||||
|
||||
/**
|
||||
* SQL-shape unit tests for ShareAliasRepo. A live Postgres is out of scope;
|
||||
* instead we spy on the Kysely builder to assert each method pins the
|
||||
* workspace scope (so a name in one workspace can never resolve another's
|
||||
* page) and threads the right columns.
|
||||
*/
|
||||
describe('ShareAliasRepo', () => {
|
||||
function makeSelectRepo(result: unknown) {
|
||||
const where = jest.fn();
|
||||
const builder: any = {
|
||||
select: jest.fn(() => builder),
|
||||
where: jest.fn((...args: unknown[]) => {
|
||||
where(...args);
|
||||
return builder;
|
||||
}),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue(result),
|
||||
};
|
||||
const db = { selectFrom: jest.fn(() => builder) } as unknown as KyselyDB;
|
||||
return { repo: new ShareAliasRepo(db), db, where, builder };
|
||||
}
|
||||
|
||||
it('findByAliasAndWorkspace scopes by alias AND workspace', async () => {
|
||||
const row = { id: 'a-1', alias: 'foo', workspaceId: 'ws-1' };
|
||||
const { repo, db, where } = makeSelectRepo(row);
|
||||
|
||||
const res = await repo.findByAliasAndWorkspace('foo', 'ws-1');
|
||||
|
||||
expect(res).toBe(row);
|
||||
expect(db.selectFrom).toHaveBeenCalledWith('shareAliases');
|
||||
expect(where).toHaveBeenCalledWith('alias', '=', 'foo');
|
||||
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
||||
});
|
||||
|
||||
it('findByPageId scopes by page AND workspace', async () => {
|
||||
const { repo, where } = makeSelectRepo(undefined);
|
||||
await repo.findByPageId('p-1', 'ws-1');
|
||||
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
|
||||
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
||||
});
|
||||
|
||||
it('insert writes the provided columns and returns the row', async () => {
|
||||
const values = jest.fn();
|
||||
const inserted = { id: 'a-1' };
|
||||
const builder: any = {
|
||||
values: jest.fn((v: unknown) => {
|
||||
values(v);
|
||||
return builder;
|
||||
}),
|
||||
returning: jest.fn(() => builder),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue(inserted),
|
||||
};
|
||||
const db = { insertInto: jest.fn(() => builder) } as unknown as KyselyDB;
|
||||
const repo = new ShareAliasRepo(db);
|
||||
|
||||
const res = await repo.insert({
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
});
|
||||
|
||||
expect(db.insertInto).toHaveBeenCalledWith('shareAliases');
|
||||
expect(values).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
});
|
||||
expect(res).toBe(inserted);
|
||||
});
|
||||
|
||||
it('updatePageId retargets a single row scoped by id + workspace', async () => {
|
||||
const set = jest.fn();
|
||||
const where = jest.fn();
|
||||
const builder: any = {
|
||||
set: jest.fn((s: unknown) => {
|
||||
set(s);
|
||||
return builder;
|
||||
}),
|
||||
where: jest.fn((...args: unknown[]) => {
|
||||
where(...args);
|
||||
return builder;
|
||||
}),
|
||||
returning: jest.fn(() => builder),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue({ id: 'a-1' }),
|
||||
};
|
||||
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
|
||||
const repo = new ShareAliasRepo(db);
|
||||
|
||||
await repo.updatePageId('a-1', 'p-2', 'ws-1');
|
||||
|
||||
expect(db.updateTable).toHaveBeenCalledWith('shareAliases');
|
||||
expect(set.mock.calls[0][0].pageId).toBe('p-2');
|
||||
expect(set.mock.calls[0][0].updatedAt).toBeInstanceOf(Date);
|
||||
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
|
||||
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
||||
});
|
||||
|
||||
it('delete scopes by id + workspace', async () => {
|
||||
const where = jest.fn();
|
||||
const builder: any = {
|
||||
where: jest.fn((...args: unknown[]) => {
|
||||
where(...args);
|
||||
return builder;
|
||||
}),
|
||||
execute: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const db = { deleteFrom: jest.fn(() => builder) } as unknown as KyselyDB;
|
||||
const repo = new ShareAliasRepo(db);
|
||||
|
||||
await repo.delete('a-1', 'ws-1');
|
||||
|
||||
expect(db.deleteFrom).toHaveBeenCalledWith('shareAliases');
|
||||
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
|
||||
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import {
|
||||
InsertableShareAlias,
|
||||
ShareAlias,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Repository for vanity share aliases (`/l/:alias`). An alias is a long-lived,
|
||||
* workspace-scoped pointer to a page; retargeting is a single UPDATE of
|
||||
* `page_id`. All lookups are workspace-scoped so a name in one workspace can
|
||||
* never resolve a page in another.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShareAliasRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
private baseFields: Array<keyof ShareAlias> = [
|
||||
'id',
|
||||
'workspaceId',
|
||||
'alias',
|
||||
'pageId',
|
||||
'creatorId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
/** Resolve a (normalized) alias within a workspace, or undefined. */
|
||||
async findByAliasAndWorkspace(
|
||||
alias: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ShareAlias | undefined> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('shareAliases')
|
||||
.select(this.baseFields)
|
||||
.where('alias', '=', alias)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** The alias currently pointing at a page (for the share modal). */
|
||||
async findByPageId(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ShareAlias | undefined> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('shareAliases')
|
||||
.select(this.baseFields)
|
||||
.where('pageId', '=', pageId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ShareAlias | undefined> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('shareAliases')
|
||||
.select(this.baseFields)
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async insert(
|
||||
insertable: InsertableShareAlias,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ShareAlias> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.insertInto('shareAliases')
|
||||
.values(insertable)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** Retarget an existing alias to a new page (the "swap" operation). */
|
||||
async updatePageId(
|
||||
id: string,
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ShareAlias> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.updateTable('shareAliases')
|
||||
.set({ pageId, updatedAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async delete(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await dbOrTx(this.db, trx)
|
||||
.deleteFrom('shareAliases')
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const AI_PROVIDER_SETTINGS_ALLOWED: readonly string[] = [
|
||||
'driver',
|
||||
'chatModel',
|
||||
'chatApiStyle',
|
||||
'chatContextWindow',
|
||||
'embeddingModel',
|
||||
'baseUrl',
|
||||
'embeddingBaseUrl',
|
||||
@@ -255,11 +256,17 @@ export class WorkspaceRepo {
|
||||
): Promise<Workspace> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
// Assemble the provider object IN SQL. Keys are fixed provider field names
|
||||
// (sql.lit -> inlined literals, no injection); values are bound params cast
|
||||
// to ::text — postgres.js sends bound params untyped, and jsonb_build_object's
|
||||
// value args are polymorphic ("any"), so without the explicit ::text cast
|
||||
// Postgres throws "could not determine data type of parameter $1". The result
|
||||
// is a real jsonb object, never a double-encoded string. The CASE self-heals
|
||||
// (sql.lit -> inlined literals, no injection); values are bound params with
|
||||
// an explicit cast — postgres.js sends bound params untyped, and
|
||||
// jsonb_build_object's value args are polymorphic ("any"), so without the
|
||||
// cast Postgres throws "could not determine data type of parameter $1". The
|
||||
// cast is branched by the JS runtime type so the value lands in jsonb with
|
||||
// the matching JSON type: a number stays a JSON number (e.g.
|
||||
// chatContextWindow → `{"chatContextWindow":200000}`, jsonb_typeof 'number'),
|
||||
// a boolean a JSON boolean, everything else a JSON string. A plain `::text`
|
||||
// for all would store a numeric field as the JSON STRING `"200000"`, which
|
||||
// the client's `typeof === "number"` guards reject. The result is a real
|
||||
// jsonb object, never a double-encoded string. The CASE self-heals
|
||||
// workspaces whose settings.ai.provider was previously corrupted into an
|
||||
// array/string.
|
||||
const entries = Object.entries(provider).filter(
|
||||
@@ -267,7 +274,14 @@ export class WorkspaceRepo {
|
||||
);
|
||||
const patch = entries.length
|
||||
? sql`jsonb_build_object(${sql.join(
|
||||
entries.flatMap(([k, v]) => [sql.lit(k), sql`${v}::text`]),
|
||||
entries.flatMap(([k, v]) => [
|
||||
sql.lit(k),
|
||||
typeof v === 'number'
|
||||
? sql`${v}::numeric`
|
||||
: typeof v === 'boolean'
|
||||
? sql`${v}::boolean`
|
||||
: sql`${v}::text`,
|
||||
]),
|
||||
)})`
|
||||
: sql`'{}'::jsonb`;
|
||||
return db
|
||||
|
||||
11
apps/server/src/database/types/db.d.ts
vendored
11
apps/server/src/database/types/db.d.ts
vendored
@@ -305,16 +305,6 @@ export interface Pages {
|
||||
ydoc: Buffer | null;
|
||||
}
|
||||
|
||||
export interface ShareAliases {
|
||||
alias: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
id: Generated<string>;
|
||||
pageId: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Shares {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
@@ -684,7 +674,6 @@ export interface DB {
|
||||
pageVerifiers: PageVerifiers;
|
||||
pages: Pages;
|
||||
scimTokens: ScimTokens;
|
||||
shareAliases: ShareAliases;
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
AuthProviders,
|
||||
AuthAccounts,
|
||||
Shares,
|
||||
ShareAliases,
|
||||
Favorites,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
@@ -173,11 +172,6 @@ export type Share = Selectable<Shares>;
|
||||
export type InsertableShare = Insertable<Shares>;
|
||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
||||
|
||||
// Share alias (vanity /l/:alias pointer)
|
||||
export type ShareAlias = Selectable<ShareAliases>;
|
||||
export type InsertableShareAlias = Insertable<ShareAliases>;
|
||||
export type UpdatableShareAlias = Updateable<Omit<ShareAliases, 'id'>>;
|
||||
|
||||
// Favorite
|
||||
export type Favorite = Selectable<Favorites>;
|
||||
export type InsertableFavorite = Insertable<Favorites>;
|
||||
|
||||
@@ -41,3 +41,35 @@ describe('UpdateAiSettingsDto.chatApiStyle', () => {
|
||||
expect(errs.find((e) => e.property === 'chatApiStyle')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/** DTO validation for chatContextWindow (@IsOptional @IsInt @Min(0)). */
|
||||
describe('UpdateAiSettingsDto.chatContextWindow', () => {
|
||||
const errorsFor = async (chatContextWindow: unknown) =>
|
||||
validate(plainToInstance(UpdateAiSettingsDto, { chatContextWindow }));
|
||||
|
||||
it('accepts a non-negative integer (incl. 0 = clear the limit)', async () => {
|
||||
for (const v of [0, 200000]) {
|
||||
const errs = await errorsFor(v);
|
||||
expect(
|
||||
errs.find((e) => e.property === 'chatContextWindow'),
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a negative value', async () => {
|
||||
const errs = await errorsFor(-1);
|
||||
expect(errs.find((e) => e.property === 'chatContextWindow')).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a non-integer value', async () => {
|
||||
const errs = await errorsFor(1.5);
|
||||
expect(errs.find((e) => e.property === 'chatContextWindow')).toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts the field being omitted (optional)', async () => {
|
||||
const errs = await validate(plainToInstance(UpdateAiSettingsDto, {}));
|
||||
expect(
|
||||
errs.find((e) => e.property === 'chatContextWindow'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface UpdateAiSettingsInput {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
chatApiStyle?: ChatApiStyle;
|
||||
// Chat context-window size (tokens); 0/empty clears the limit.
|
||||
chatContextWindow?: number;
|
||||
embeddingModel?: string;
|
||||
baseUrl?: string;
|
||||
embeddingBaseUrl?: string;
|
||||
@@ -162,6 +164,8 @@ export class AiSettingsService {
|
||||
chatModel: provider.chatModel,
|
||||
// Plain passthrough; getChatModel defaults unset to 'openai-compatible'.
|
||||
chatApiStyle: provider.chatApiStyle,
|
||||
// Admin-configured context-window size; 0/unset = no limit (badge denominator).
|
||||
chatContextWindow: provider.chatContextWindow,
|
||||
// Cheap model id for the anonymous public-share assistant; reuses the chat
|
||||
// driver/baseUrl/apiKey. Empty/unset → callers fall back to chatModel.
|
||||
publicShareChatModel: provider.publicShareChatModel,
|
||||
@@ -244,6 +248,7 @@ export class AiSettingsService {
|
||||
driver: provider.driver,
|
||||
chatModel: provider.chatModel,
|
||||
chatApiStyle: provider.chatApiStyle,
|
||||
chatContextWindow: provider.chatContextWindow,
|
||||
embeddingModel: provider.embeddingModel,
|
||||
baseUrl: provider.baseUrl,
|
||||
embeddingBaseUrl: provider.embeddingBaseUrl,
|
||||
|
||||
@@ -35,6 +35,13 @@ export interface AiProviderSettings {
|
||||
// Chat provider implementation for the `openai` driver. Unset → defaults to
|
||||
// 'openai-compatible' (so reasoning is surfaced by default). See ChatApiStyle.
|
||||
chatApiStyle?: ChatApiStyle;
|
||||
// Admin-configured chat model context-window size, in tokens. There is no
|
||||
// provider-independent way to discover this (OpenAI's /v1/models usually omits
|
||||
// it, Gemini/Ollama/OpenRouter each expose it differently), so it is entered
|
||||
// manually. Surfaced to the chat client (via assistant message metadata) as the
|
||||
// denominator of the header "current / max" context badge. Empty/0 = no limit
|
||||
// known → the badge shows only the current context size.
|
||||
chatContextWindow?: number;
|
||||
embeddingModel?: string;
|
||||
baseUrl?: string;
|
||||
// Embedding-specific base URL. Falls back to `baseUrl` when empty/unset.
|
||||
@@ -73,6 +80,7 @@ export const PROVIDER_SETTINGS_KEYS = [
|
||||
'driver',
|
||||
'chatModel',
|
||||
'chatApiStyle',
|
||||
'chatContextWindow',
|
||||
'embeddingModel',
|
||||
'baseUrl',
|
||||
'embeddingBaseUrl',
|
||||
@@ -98,6 +106,10 @@ export const PROVIDER_SETTINGS_KEYS = [
|
||||
export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
// Admin-configured chat context-window size (tokens); 0/unset = no limit. Used
|
||||
// as the header context-badge denominator. Re-declared for parity with the
|
||||
// explicit fields above.
|
||||
chatContextWindow?: number;
|
||||
// Cheap model id for the public-share assistant; reuses the chat creds.
|
||||
publicShareChatModel?: string;
|
||||
// Agent-role id whose persona the public-share assistant adopts (empty/unset
|
||||
@@ -117,6 +129,8 @@ export interface MaskedAiSettings {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
chatApiStyle?: ChatApiStyle;
|
||||
// Admin-configured chat context-window size (tokens); 0/unset = no limit.
|
||||
chatContextWindow?: number;
|
||||
embeddingModel?: string;
|
||||
baseUrl?: string;
|
||||
embeddingBaseUrl?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
||||
import {
|
||||
AI_DRIVERS,
|
||||
AiDriver,
|
||||
@@ -29,6 +29,13 @@ export class UpdateAiSettingsDto {
|
||||
@IsIn(CHAT_API_STYLES)
|
||||
chatApiStyle?: ChatApiStyle;
|
||||
|
||||
// Chat model context-window size in tokens (header context-badge denominator).
|
||||
// 0 (or empty) clears the limit so the badge shows only the current context.
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
chatContextWindow?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
embeddingModel?: string;
|
||||
|
||||
@@ -40,14 +40,7 @@ async function bootstrap() {
|
||||
app.useLogger(app.get(PinoLogger));
|
||||
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [
|
||||
'robots.txt',
|
||||
'share/:shareId/p/:pageSlug',
|
||||
// Vanity link resolver lives outside /api so /l/<alias> is a clean
|
||||
// public URL that 302s to the canonical share page.
|
||||
'l/:alias',
|
||||
'mcp',
|
||||
],
|
||||
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug', 'mcp'],
|
||||
});
|
||||
|
||||
const reflector = app.get(Reflector);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { getTestDb, destroyTestDb, createWorkspace } from './db';
|
||||
|
||||
/**
|
||||
* WorkspaceRepo.updateAiProviderSettings numeric round-trip (#189, #213).
|
||||
*
|
||||
* `chatContextWindow` is the first NUMERIC provider field routed through this
|
||||
* generic SQL layer. The patch builder must cast a JS number so it lands in
|
||||
* jsonb as a JSON NUMBER, not the JSON STRING `"200000"` — the client guards
|
||||
* (`typeof === "number"`) reject a string, silently killing the `/ max` badge
|
||||
* denominator. A plain `::text` cast (the prior code) regressed exactly this.
|
||||
* These specs are real SQL and assert both the JS value type and the on-disk
|
||||
* `jsonb_typeof`.
|
||||
*/
|
||||
describe('WorkspaceRepo.updateAiProviderSettings (numeric round-trip) [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let repo: WorkspaceRepo;
|
||||
|
||||
beforeAll(() => {
|
||||
db = getTestDb();
|
||||
repo = new WorkspaceRepo(db as any);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
it('stores chatContextWindow as a JSON number (not a "200000" string)', async () => {
|
||||
const ws = await createWorkspace(db, { settings: undefined });
|
||||
|
||||
const updated = await repo.updateAiProviderSettings(ws.id, {
|
||||
driver: 'openai',
|
||||
chatModel: 'gpt-4o',
|
||||
chatContextWindow: 200000,
|
||||
});
|
||||
|
||||
// Returned row: the number survives as a real JS number, alongside the
|
||||
// string fields which stay strings.
|
||||
const provider = (updated.settings as any)?.ai?.provider;
|
||||
expect(provider.chatContextWindow).toBe(200000);
|
||||
expect(typeof provider.chatContextWindow).toBe('number');
|
||||
expect(provider.driver).toBe('openai');
|
||||
expect(provider.chatModel).toBe('gpt-4o');
|
||||
|
||||
// On disk: the jsonb value is typed 'number' (the must-fix assertion), and
|
||||
// sibling string fields are typed 'string'.
|
||||
const typed = await db
|
||||
.selectFrom('workspaces')
|
||||
.select([
|
||||
sql<string>`jsonb_typeof(settings->'ai'->'provider'->'chatContextWindow')`.as(
|
||||
'windowType',
|
||||
),
|
||||
sql<string>`jsonb_typeof(settings->'ai'->'provider'->'chatModel')`.as(
|
||||
'modelType',
|
||||
),
|
||||
])
|
||||
.where('id', '=', ws.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(typed.windowType).toBe('number');
|
||||
expect(typed.modelType).toBe('string');
|
||||
});
|
||||
|
||||
it('re-reads chatContextWindow as a number after a partial-merge update', async () => {
|
||||
const ws = await createWorkspace(db, {
|
||||
settings: { ai: { provider: { driver: 'openai', chatModel: 'x' } } },
|
||||
});
|
||||
|
||||
// Merge in only the numeric field; siblings must be preserved and the value
|
||||
// must still be a JSON number, not a string.
|
||||
await repo.updateAiProviderSettings(ws.id, { chatContextWindow: 128000 });
|
||||
|
||||
const row = await db
|
||||
.selectFrom('workspaces')
|
||||
.select([
|
||||
'settings',
|
||||
sql<string>`jsonb_typeof(settings->'ai'->'provider'->'chatContextWindow')`.as(
|
||||
'windowType',
|
||||
),
|
||||
])
|
||||
.where('id', '=', ws.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.windowType).toBe('number');
|
||||
const provider = (row.settings as any)?.ai?.provider;
|
||||
expect(provider.chatContextWindow).toBe(128000);
|
||||
expect(provider.driver).toBe('openai');
|
||||
expect(provider.chatModel).toBe('x');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user