Merge pull request 'feat(comments): attribute MCP agent comments as AI (unspoofable provenance)' (#143) from feat/mcp-comments-ai-attribution into develop
Reviewed-on: #143
This commit was merged in pull request #143.
This commit is contained in:
@@ -123,6 +123,14 @@ MCP_DOCMOST_PASSWORD=
|
||||
# expose the port publicly).
|
||||
# MCP_TOKEN=
|
||||
# MCP_SESSION_IDLE_MS=1800000
|
||||
#
|
||||
# AI-AGENT ATTRIBUTION (comments/pages written via MCP are badged as "AI"):
|
||||
# attribution is driven by a per-user `is_agent` flag on the users row. There is
|
||||
# NO admin UI/API for it — set it out-of-band with SQL. Use a DEDICATED service
|
||||
# account for the MCP fallback above and flag ONLY that account, e.g.:
|
||||
# UPDATE users SET is_agent = true WHERE email = 'mcp-bot@your-domain';
|
||||
# NEVER set is_agent on a human or shared account — every action by that account
|
||||
# (including normal human edits) would then be mis-attributed as AI.
|
||||
|
||||
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
||||
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **AI-agent attribution for MCP writes.** Comments (and pages) created through
|
||||
the MCP endpoint by a dedicated agent account are now badged as "AI", with
|
||||
unspoofable provenance derived from a per-user `is_agent` flag (not from the
|
||||
request body). **Operator setup:** use a *dedicated* service account for the
|
||||
MCP fallback and set the flag with SQL —
|
||||
`UPDATE users SET is_agent = true WHERE email = '<mcp-account>'`. Never flag a
|
||||
human or shared account, or its normal edits get mis-attributed as AI. See the
|
||||
AI-agent block in `.env.example`. (#143)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Public share AI: default per-workspace hourly assistant cap lowered
|
||||
|
||||
96
apps/client/src/components/ui/ai-agent-badge.test.tsx
Normal file
96
apps/client/src/components/ui/ai-agent-badge.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { AiAgentBadge } from "./ai-agent-badge";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<AiAgentBadge {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Render a clickable badge inside an explicit jotai store, with a leftover draft
|
||||
// and an onActivate + parent-click spy, so the deep-link side effects are
|
||||
// assertable. Returns the store and spies.
|
||||
function setupClickable() {
|
||||
const store = createStore();
|
||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||
const onActivate = vi.fn();
|
||||
const onParentClick = vi.fn();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MantineProvider>
|
||||
<div onClick={onParentClick}>
|
||||
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</Provider>,
|
||||
);
|
||||
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
|
||||
}
|
||||
|
||||
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
|
||||
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
|
||||
}
|
||||
|
||||
describe("AiAgentBadge", () => {
|
||||
it("renders the AI-agent label", () => {
|
||||
renderBadge({ authorName: "Bot" });
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
});
|
||||
|
||||
it("is clickable (accessible button) when aiChatId is present", () => {
|
||||
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
|
||||
const badge = screen.getByRole("button");
|
||||
expect(badge).toBeDefined();
|
||||
expect(badge.textContent).toContain("AI-agent");
|
||||
});
|
||||
|
||||
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
|
||||
const { store, onActivate, onParentClick, badge } = setupClickable();
|
||||
fireEvent.click(badge);
|
||||
expectDeepLinked(store, onActivate);
|
||||
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
|
||||
});
|
||||
|
||||
it.each(["Enter", " "])(
|
||||
"keyboard %j activates the deep-link (same side effects as click)",
|
||||
(key) => {
|
||||
const { store, onActivate, badge } = setupClickable();
|
||||
fireEvent.keyDown(badge, { key });
|
||||
expectDeepLinked(store, onActivate);
|
||||
},
|
||||
);
|
||||
|
||||
it("an unrelated key does NOT activate the badge", () => {
|
||||
const { store, onActivate, badge } = setupClickable();
|
||||
fireEvent.keyDown(badge, { key: "Tab" });
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
|
||||
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
|
||||
expect(onActivate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ aiChatId: null }, {}])(
|
||||
"is a plain non-clickable label without a chat target (%o)",
|
||||
(props) => {
|
||||
renderBadge({ authorName: "Bot", ...props });
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
// No interactive role is exposed when there is no chat to deep-link into.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
99
apps/client/src/components/ui/ai-agent-badge.tsx
Normal file
99
apps/client/src/components/ui/ai-agent-badge.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
interface AiAgentBadgeProps {
|
||||
authorName?: string;
|
||||
aiChatId?: string | null;
|
||||
// Fired after the badge deep-links into its chat. The caller handles its own
|
||||
// context (e.g. the page-history row closes the history modal) so this generic
|
||||
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
|
||||
onActivate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
|
||||
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
|
||||
* page-history list and the comments sidebar.
|
||||
*
|
||||
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
|
||||
* badge deep-links into that chat: it sets the active-chat atom and opens the
|
||||
* floating AI-chat window, then invokes `onActivate` so the caller can react
|
||||
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
|
||||
* external MCP write with no internal ai_chats row), the badge is a plain
|
||||
* non-clickable label. The click is contained (stopPropagation) so it does not
|
||||
* also trigger an enclosing row's click handler.
|
||||
*/
|
||||
export function AiAgentBadge({
|
||||
authorName,
|
||||
aiChatId,
|
||||
onActivate,
|
||||
}: AiAgentBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
|
||||
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
const openChat = useCallback(
|
||||
(event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!aiChatId) return;
|
||||
setActiveChatId(aiChatId);
|
||||
// Switching to another chat must start with a clean composer — clear any
|
||||
// unsent draft so it does not leak from the previously open chat.
|
||||
setDraft("");
|
||||
setAiChatWindowOpen(true);
|
||||
onActivate?.();
|
||||
},
|
||||
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
|
||||
);
|
||||
|
||||
const badge = (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="violet"
|
||||
radius="sm"
|
||||
leftSection={<IconSparkles size={12} stroke={2} />}
|
||||
style={aiChatId ? { cursor: "pointer" } : undefined}
|
||||
{...(aiChatId
|
||||
? {
|
||||
// Keep the default Badge root element (not a <button>) to avoid an
|
||||
// invalid <button>-in-<button> nesting inside a row's
|
||||
// UnstyledButton; expose it as an accessible button via
|
||||
// role/keyboard.
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: openChat,
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openChat(event);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{t("AI-agent")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
{badge}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default AiAgentBadge;
|
||||
@@ -1,26 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import RoleCards from "./role-cards";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom
|
||||
// does not implement. Provide a minimal stub so the provider can render.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
const roles: IAiRole[] = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
}));
|
||||
|
||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
default: () => <div data-testid="comment-editor" />,
|
||||
}));
|
||||
|
||||
import CommentListItem from "./comment-list-item";
|
||||
|
||||
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||
({
|
||||
id: "c-1",
|
||||
content: JSON.stringify({ type: "doc", content: [] }),
|
||||
creatorId: "user-1",
|
||||
pageId: "page-1",
|
||||
workspaceId: "ws-1",
|
||||
createdAt: new Date(),
|
||||
creator: { id: "user-1", name: "Service Bot", avatarUrl: null } as any,
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
function renderItem(comment: IComment) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CommentListItem — AI badge", () => {
|
||||
it('renders the AI-agent badge when createdSource === "agent"', () => {
|
||||
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
|
||||
renderItem(baseComment({ createdSource: "user" }));
|
||||
expect(screen.queryByText("AI-agent")).toBeNull();
|
||||
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||
});
|
||||
|
||||
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
|
||||
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
|
||||
// the insertion gate (agent → badge, user → no badge) above (#143 review).
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
||||
import { Group, Text, Box } from "@mantine/core";
|
||||
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
@@ -126,10 +127,19 @@ function CommentListItem({
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
|
||||
{comment.createdSource === "agent" && (
|
||||
<AiAgentBadge
|
||||
authorName={comment.creator?.name}
|
||||
aiChatId={comment.aiChatId}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
||||
{!comment.parentCommentId && canComment && (
|
||||
<ResolveComment
|
||||
|
||||
@@ -17,6 +17,13 @@ export interface IComment {
|
||||
deletedAt?: Date;
|
||||
creator: IUser;
|
||||
resolvedBy?: IUser;
|
||||
// Agent-edit provenance (returned by the backend via selectAll('comments')).
|
||||
// createdSource === "agent" marks a comment authored via an AI agent (MCP /
|
||||
// internal AI chat); aiChatId deep-links to the internal chat when present
|
||||
// (null for an external MCP agent); resolvedSource marks an AI-resolved thread.
|
||||
createdSource?: string;
|
||||
aiChatId?: string | null;
|
||||
resolvedSource?: string | null;
|
||||
yjsSelection?: {
|
||||
anchor: any;
|
||||
head: any;
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip, Badge } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { memo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
@@ -26,87 +20,6 @@ interface HistoryItemProps {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking a version written by the AI agent (provenance C3 / §7.4). It is
|
||||
* ADDITIVE — shown next to the human author, never replacing them. When the
|
||||
* version carries an `aiChatId`, clicking the badge deep-links into that chat:
|
||||
* it sets the active-chat atom, opens the floating AI-chat window, and closes
|
||||
* the history modal. The click is contained (stopPropagation) so it does not
|
||||
* also trigger the row's version-select.
|
||||
*/
|
||||
function AiAgentBadge({
|
||||
authorName,
|
||||
aiChatId,
|
||||
}: {
|
||||
authorName?: string;
|
||||
aiChatId?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||
|
||||
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
const openChat = useCallback(
|
||||
(event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!aiChatId) return;
|
||||
setActiveChatId(aiChatId);
|
||||
// Switching to another chat must start with a clean composer — clear any
|
||||
// unsent draft so it does not leak from the previously open chat.
|
||||
setDraft("");
|
||||
setAiChatWindowOpen(true);
|
||||
setHistoryModalOpen(false);
|
||||
},
|
||||
[
|
||||
aiChatId,
|
||||
setActiveChatId,
|
||||
setDraft,
|
||||
setAiChatWindowOpen,
|
||||
setHistoryModalOpen,
|
||||
],
|
||||
);
|
||||
|
||||
const badge = (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="violet"
|
||||
radius="sm"
|
||||
leftSection={<IconSparkles size={12} stroke={2} />}
|
||||
style={aiChatId ? { cursor: "pointer" } : undefined}
|
||||
{...(aiChatId
|
||||
? {
|
||||
// Keep the default Badge root element (not a <button>) to avoid an
|
||||
// invalid <button>-in-<button> nesting inside the history row's
|
||||
// UnstyledButton; expose it as an accessible button via role/keyboard.
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: openChat,
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openChat(event);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{t("AI-agent")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
{badge}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const HistoryItem = memo(function HistoryItem({
|
||||
historyItem,
|
||||
index,
|
||||
@@ -115,6 +28,8 @@ const HistoryItem = memo(function HistoryItem({
|
||||
onHoverEnd,
|
||||
isActive,
|
||||
}: HistoryItemProps) {
|
||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(historyItem.id, index);
|
||||
}, [onSelect, historyItem.id, index]);
|
||||
@@ -188,6 +103,9 @@ const HistoryItem = memo(function HistoryItem({
|
||||
<AiAgentBadge
|
||||
authorName={historyItem.lastUpdatedBy?.name}
|
||||
aiChatId={historyItem.lastUpdatedAiChatId}
|
||||
// The history row owns the modal: close it when the badge deep-links
|
||||
// into the chat (the badge no longer reaches into page-history).
|
||||
onActivate={() => setHistoryModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -49,3 +49,17 @@ function createStorage(): Storage {
|
||||
// `window.localStorage` resolve to the same working stub.
|
||||
vi.stubGlobal("localStorage", createStorage());
|
||||
vi.stubGlobal("sessionStorage", createStorage());
|
||||
|
||||
// MantineProvider (and other components) read `window.matchMedia` on mount, which
|
||||
// jsdom does not implement. Provide a minimal stub here so any test rendering
|
||||
// Mantine works without re-stubbing matchMedia in every file.
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -207,5 +207,28 @@ describe('AuthenticationExtension.onAuthenticate', () => {
|
||||
|
||||
expect(ctx.actor).toBe('user');
|
||||
expect(ctx.aiChatId).toBeNull();
|
||||
// Wiring guard (#143): the collab seam MUST opt into the isAgent flag —
|
||||
// it is not in baseFields, so without this option findById omits it and a
|
||||
// flagged service account's collab edits would silently persist as 'user'.
|
||||
expect(userRepo.findById).toHaveBeenCalledWith(
|
||||
USER_ID,
|
||||
WORKSPACE_ID,
|
||||
expect.objectContaining({ includeIsAgent: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('is_agent user with NO claim → actor=agent (collab seam consults the signed identity)', async () => {
|
||||
// Arch A regression guard: a flagged service account editing page CONTENT
|
||||
// over the collab websocket carries a plain COLLAB token (no actor claim).
|
||||
// Before the shared resolveProvenance() wiring this seam derived actor from
|
||||
// the claim alone, so such edits persisted as lastUpdatedSource='user' —
|
||||
// drifting from the REST seam. The seam must now stamp 'agent' from the
|
||||
// is_agent flag, matching jwt.strategy.
|
||||
userRepo.findById.mockResolvedValue(buildUser({ isAgent: true }));
|
||||
const ctx = await ext.onAuthenticate(buildData() as any);
|
||||
|
||||
expect(ctx.actor).toBe('agent');
|
||||
// No internal ai_chats row for an MCP/service-account collab edit → null.
|
||||
expect(ctx.aiChatId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SpaceRole } from '../../common/helpers/types/permission';
|
||||
import { isUserDisabled } from '../../common/helpers';
|
||||
import { getPageId } from '../collaboration.util';
|
||||
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticationExtension implements Extension {
|
||||
@@ -43,7 +44,9 @@ export class AuthenticationExtension implements Extension {
|
||||
const userId = jwtPayload.sub;
|
||||
const workspaceId = jwtPayload.workspaceId;
|
||||
|
||||
const user = await this.userRepo.findById(userId, workspaceId);
|
||||
const user = await this.userRepo.findById(userId, workspaceId, {
|
||||
includeIsAgent: true,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
@@ -103,13 +106,17 @@ export class AuthenticationExtension implements Extension {
|
||||
|
||||
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
|
||||
|
||||
// Carry the signed agent-edit provenance claim into the hocuspocus
|
||||
// connection context (§6.6 / §15 C2). The human collab path omits these
|
||||
// claims, so it resolves to actor='user' / aiChatId=null.
|
||||
// Carry the agent-edit provenance into the hocuspocus connection context
|
||||
// (§6.6 / §15 C2), derived via the SAME resolver as the REST seam so the two
|
||||
// can't drift. An is_agent service account (e.g. the MCP bot) is attributed
|
||||
// 'agent' here too, so its page-content edits over collab persist as
|
||||
// lastUpdatedSource='agent' (#143 review Arch A) — not just its REST writes.
|
||||
// The human collab path carries no claim and is not flagged → actor='user'.
|
||||
const provenance = resolveProvenance(user, jwtPayload);
|
||||
return {
|
||||
user,
|
||||
actor: jwtPayload.actor ?? 'user',
|
||||
aiChatId: jwtPayload.aiChatId ?? null,
|
||||
actor: provenance.actor,
|
||||
aiChatId: provenance.aiChatId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { ProvenanceSource } from '../../core/auth/dto/jwt-payload';
|
||||
import { Queue } from 'bullmq';
|
||||
import {
|
||||
extractMentions,
|
||||
@@ -50,7 +51,7 @@ import { TransclusionService } from '../../core/page/transclusion/transclusion.s
|
||||
export function resolveSource(
|
||||
stickyTouched: boolean,
|
||||
contextActor?: string,
|
||||
): 'agent' | 'user' {
|
||||
): ProvenanceSource {
|
||||
return stickyTouched || contextActor === 'agent' ? 'agent' : 'user';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
resolveProvenance,
|
||||
agentSourceFields,
|
||||
} from './auth-provenance.decorator';
|
||||
|
||||
/**
|
||||
* Unit tests for the shared provenance helpers (#143 review, Arch A & follow-up
|
||||
* 5). resolveProvenance is the single source of truth wired into BOTH transport
|
||||
* seams (REST jwt.strategy + collab authentication.extension) — testing it here
|
||||
* pins the derivation matrix so the seams can't silently drift. agentSourceFields
|
||||
* is the one-place write-stamp idiom reused at every insert/update site.
|
||||
*/
|
||||
describe('resolveProvenance', () => {
|
||||
it("flags an is_agent user as 'agent' even with no claim (the closed collab gap)", () => {
|
||||
expect(resolveProvenance({ isAgent: true }, undefined)).toEqual({
|
||||
actor: 'agent',
|
||||
aiChatId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("an is_agent user keeps the claim's aiChatId when present", () => {
|
||||
expect(
|
||||
resolveProvenance({ isAgent: true }, { aiChatId: 'chat-1' }),
|
||||
).toEqual({ actor: 'agent', aiChatId: 'chat-1' });
|
||||
});
|
||||
|
||||
it("honors a signed actor='agent' claim on a non-agent user (internal AI-chat token)", () => {
|
||||
expect(
|
||||
resolveProvenance(
|
||||
{ isAgent: false },
|
||||
{ actor: 'agent', aiChatId: 'chat-2' },
|
||||
),
|
||||
).toEqual({ actor: 'agent', aiChatId: 'chat-2' });
|
||||
});
|
||||
|
||||
it("a plain user with no claim resolves to 'user' with null chat", () => {
|
||||
expect(resolveProvenance({ isAgent: false }, undefined)).toEqual({
|
||||
actor: 'user',
|
||||
aiChatId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a null/undefined user (defaults to the claim, else user)', () => {
|
||||
expect(resolveProvenance(null, null)).toEqual({
|
||||
actor: 'user',
|
||||
aiChatId: null,
|
||||
});
|
||||
expect(resolveProvenance(undefined, { actor: 'agent' })).toEqual({
|
||||
actor: 'agent',
|
||||
aiChatId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentSourceFields', () => {
|
||||
it('stamps the configured source + chat columns for an agent write', () => {
|
||||
expect(
|
||||
agentSourceFields(
|
||||
{ actor: 'agent', aiChatId: 'chat-1' },
|
||||
'createdSource',
|
||||
'aiChatId',
|
||||
),
|
||||
).toEqual({ createdSource: 'agent', aiChatId: 'chat-1' });
|
||||
});
|
||||
|
||||
it('uses the per-table column names passed in (page update variant)', () => {
|
||||
expect(
|
||||
agentSourceFields(
|
||||
{ actor: 'agent', aiChatId: null },
|
||||
'lastUpdatedSource',
|
||||
'lastUpdatedAiChatId',
|
||||
),
|
||||
).toEqual({ lastUpdatedSource: 'agent', lastUpdatedAiChatId: null });
|
||||
});
|
||||
|
||||
it('returns {} for a user write so the column keeps its default', () => {
|
||||
expect(
|
||||
agentSourceFields(
|
||||
{ actor: 'user', aiChatId: null },
|
||||
'createdSource',
|
||||
'aiChatId',
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when provenance is undefined', () => {
|
||||
expect(
|
||||
agentSourceFields(undefined, 'createdSource', 'aiChatId'),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { ProvenanceSource } from '../../core/auth/dto/jwt-payload';
|
||||
|
||||
/**
|
||||
* The agent-edit provenance carried by the request, read from the SIGNED access
|
||||
@@ -8,10 +9,64 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
* cannot fake an 'agent' marker.
|
||||
*/
|
||||
export interface AuthProvenanceData {
|
||||
actor: 'user' | 'agent';
|
||||
actor: ProvenanceSource;
|
||||
aiChatId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for deriving a write's provenance from the SIGNED
|
||||
* server-side identity (#143 review, Arch A). Used by BOTH transport seams — the
|
||||
* REST access-token strategy and the collab websocket auth — so they can't drift:
|
||||
*
|
||||
* - A `user.isAgent` service account (e.g. the MCP bot) stamps 'agent' on every
|
||||
* write. It has no internal ai_chats row, so aiChatId comes from the claim
|
||||
* (usually null).
|
||||
* - Otherwise honor the actor claim minted into the internal AI agent's token
|
||||
* (actor='agent' + aiChatId); a normal user token carries no claim → 'user'.
|
||||
*
|
||||
* Provenance is NEVER read from a client body field, so a normal user cannot fake
|
||||
* an 'agent' marker.
|
||||
*/
|
||||
export function resolveProvenance(
|
||||
user: { isAgent?: boolean | null } | null | undefined,
|
||||
claim: { actor?: ProvenanceSource; aiChatId?: string | null } | null | undefined,
|
||||
): AuthProvenanceData {
|
||||
const actor: ProvenanceSource = user?.isAgent
|
||||
? 'agent'
|
||||
: (claim?.actor ?? 'user');
|
||||
return { actor, aiChatId: claim?.aiChatId ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-edit write-stamp fields for a repository insert/update (#143 review).
|
||||
* Spread into the row being written: for an agent it stamps the `*Source`
|
||||
* column 'agent' and the AI-chat id; for a normal user it returns `{}` — on an
|
||||
* INSERT the omitted column falls back to its DB default ('user'); on an UPDATE
|
||||
* the column simply keeps its existing stored value (Kysely only writes the keys
|
||||
* present). The only per-table variation is the column names, passed as
|
||||
* `sourceKey`/`chatKey`, so the agent-stamp idiom lives in ONE place instead of
|
||||
* being hand-reimplemented at every write site (where a wrong literal or a
|
||||
* forgotten `aiChatId` could drift).
|
||||
*
|
||||
* insertComment({ ..., ...agentSourceFields(p, 'createdSource', 'aiChatId') })
|
||||
* updatePage({ ..., ...agentSourceFields(p, 'lastUpdatedSource', 'lastUpdatedAiChatId') })
|
||||
*
|
||||
* Does NOT cover sites that must CLEAR the source on a non-agent action (e.g.
|
||||
* comment un-resolve, which writes an explicit null) — those keep their own
|
||||
* conditional; nor the collab persistence path (its own sticky-window logic).
|
||||
*/
|
||||
export function agentSourceFields<S extends string, C extends string>(
|
||||
provenance: AuthProvenanceData | undefined,
|
||||
sourceKey: S,
|
||||
chatKey: C,
|
||||
): Partial<Record<S, ProvenanceSource> & Record<C, string | null>> {
|
||||
if (provenance?.actor !== 'agent') return {};
|
||||
return {
|
||||
[sourceKey]: 'agent',
|
||||
[chatKey]: provenance.aiChatId,
|
||||
} as Partial<Record<S, ProvenanceSource> & Record<C, string | null>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the request's provenance. Defaults to a 'user' actor when the claim
|
||||
* is absent (e.g. an endpoint reached without going through the access-token
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Provenance actor for a write: who the action is attributed to. Derived only
|
||||
* from the SIGNED token claim (never a request body), so 'agent' is unspoofable.
|
||||
* Single source of truth so a typo like 'agnet' can't slip through as a bare
|
||||
* string (#143 review). Distinct from `ActorType` (auth principal kind).
|
||||
*/
|
||||
export type ProvenanceSource = 'user' | 'agent';
|
||||
|
||||
export enum JwtType {
|
||||
ACCESS = 'access',
|
||||
COLLAB = 'collab',
|
||||
@@ -19,8 +27,10 @@ export type JwtPayload = {
|
||||
// mints a provenance access token so REST writes (create/rename/move page,
|
||||
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
|
||||
// C3 / §14 N2).
|
||||
actor?: 'user' | 'agent';
|
||||
aiChatId?: string;
|
||||
actor?: ProvenanceSource;
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
aiChatId?: string | null;
|
||||
};
|
||||
|
||||
export type JwtCollabPayload = {
|
||||
@@ -30,8 +40,10 @@ export type JwtCollabPayload = {
|
||||
// Optional agent-edit provenance, signed into the collab token. Absent for
|
||||
// the human collab path (treated as 'user'); set only when the internal agent
|
||||
// mints a provenance collab token (§6.6 / §15 C2).
|
||||
actor?: 'user' | 'agent';
|
||||
aiChatId?: string;
|
||||
actor?: ProvenanceSource;
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
aiChatId?: string | null;
|
||||
};
|
||||
|
||||
export type JwtExchangePayload = {
|
||||
|
||||
@@ -34,7 +34,9 @@ export class TokenService {
|
||||
// token carries no actor/aiChatId and is treated as 'user' downstream. The
|
||||
// internal agent passes { actor:'agent', aiChatId } so REST writes record a
|
||||
// non-spoofable 'agent' marker off the signed claim (§6.5 / §15 C3 / §14 N2).
|
||||
provenance?: { actor: 'agent'; aiChatId: string },
|
||||
// aiChatId is nullable: an external MCP agent has no internal ai_chats row,
|
||||
// so it stamps 'agent' with a null aiChatId.
|
||||
provenance?: { actor: 'agent'; aiChatId: string | null },
|
||||
): Promise<string> {
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
@@ -58,7 +60,8 @@ export class TokenService {
|
||||
workspaceId: string,
|
||||
// Optional agent-edit provenance. When omitted (the human collab path), the
|
||||
// token carries no actor/aiChatId and is treated as 'user' downstream.
|
||||
provenance?: { actor: 'agent'; aiChatId: string },
|
||||
// aiChatId is nullable for an external agent with no internal ai_chats row.
|
||||
provenance?: { actor: 'agent'; aiChatId: string | null },
|
||||
): Promise<string> {
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
|
||||
122
apps/server/src/core/auth/strategies/jwt.strategy.spec.ts
Normal file
122
apps/server/src/core/auth/strategies/jwt.strategy.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { JwtType } from '../dto/jwt-payload';
|
||||
|
||||
/**
|
||||
* Provenance derivation in JwtStrategy.validate (jwt.strategy.ts).
|
||||
*
|
||||
* The strategy must derive the agent-edit provenance from the SIGNED server-side
|
||||
* identity, never from a client-controlled field. The security invariant under
|
||||
* test: a user flagged is_agent stamps 'agent'; an ordinary user resolves to
|
||||
* 'user'; and an `actor` claim in the token CANNOT escalate a non-agent user
|
||||
* past the existing internal-AI-chat claim semantics (anti-spoof — a plain user
|
||||
* cannot obtain created_source='agent').
|
||||
*
|
||||
* The strategy is constructed directly with stub deps. The PassportStrategy base
|
||||
* only needs a secret at construction time; validate() is exercised on its own.
|
||||
*/
|
||||
describe('JwtStrategy — provenance derivation', () => {
|
||||
function makeStrategy(user: any) {
|
||||
const userRepo: any = { findById: jest.fn(async () => user) };
|
||||
const workspaceRepo: any = { findById: jest.fn(async () => ({ id: 'ws-1' })) };
|
||||
const userSessionRepo: any = { findActiveById: jest.fn() };
|
||||
const sessionActivityService: any = { trackActivity: jest.fn() };
|
||||
const environmentService: any = { getAppSecret: () => 'test-secret' };
|
||||
const moduleRef: any = {};
|
||||
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
workspaceRepo,
|
||||
userSessionRepo,
|
||||
sessionActivityService,
|
||||
environmentService,
|
||||
moduleRef,
|
||||
);
|
||||
return { strategy, userRepo };
|
||||
}
|
||||
|
||||
// A bare request whose `raw` collects the provenance the strategy stamps.
|
||||
const makeReq = () => ({ raw: {} as Record<string, any> });
|
||||
|
||||
const accessPayload = (over?: Record<string, any>) => ({
|
||||
sub: 'user-1',
|
||||
email: 'u@test.local',
|
||||
workspaceId: 'ws-1',
|
||||
type: JwtType.ACCESS,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("stamps actor='agent' for an is_agent user (derived from the signed identity)", async () => {
|
||||
const { strategy, userRepo } = makeStrategy({
|
||||
id: 'user-1',
|
||||
isAgent: true,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
});
|
||||
const req = makeReq();
|
||||
|
||||
await strategy.validate(req, accessPayload() as any);
|
||||
|
||||
expect(req.raw.actor).toBe('agent');
|
||||
// External MCP agent: no internal ai_chats row → null.
|
||||
expect(req.raw.aiChatId).toBeNull();
|
||||
// Wiring guard (#143): the seam MUST opt into the isAgent flag, otherwise
|
||||
// findById omits it (it is not in baseFields) and provenance silently
|
||||
// degrades to 'user'.
|
||||
expect(userRepo.findById).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ includeIsAgent: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stamps actor='user' for an ordinary user", async () => {
|
||||
const { strategy } = makeStrategy({
|
||||
id: 'user-1',
|
||||
isAgent: false,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
});
|
||||
const req = makeReq();
|
||||
|
||||
await strategy.validate(req, accessPayload() as any);
|
||||
|
||||
expect(req.raw.actor).toBe('user');
|
||||
expect(req.raw.aiChatId).toBeNull();
|
||||
});
|
||||
|
||||
it("honors a SIGNED actor='agent' claim on a non-agent user's token (the internal AI-chat path)", async () => {
|
||||
// A non-agent user (the plain no-claim → 'user' case is covered above). A
|
||||
// token that DOES carry actor='agent' resolves to 'agent' — BY DESIGN: that
|
||||
// claim can only exist on a SERVER-MINTED provenance token (the internal AI
|
||||
// chat), never on a plain login token, because the token is signed with the
|
||||
// app secret. The guarantee is that a client cannot FORGE this signed claim,
|
||||
// not that the strategy ignores it. (A plain user still cannot obtain
|
||||
// 'agent' — they have no way to get such a token.)
|
||||
const { strategy } = makeStrategy({
|
||||
id: 'user-1',
|
||||
isAgent: false,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
});
|
||||
const req2 = makeReq();
|
||||
await strategy.validate(req2, accessPayload({ actor: 'agent', aiChatId: 'chat-1' }) as any);
|
||||
expect(req2.raw.actor).toBe('agent');
|
||||
expect(req2.raw.aiChatId).toBe('chat-1');
|
||||
});
|
||||
|
||||
it('rejects a disabled is_agent user (Unauthorized) before stamping provenance', async () => {
|
||||
const { strategy } = makeStrategy({
|
||||
id: 'user-1',
|
||||
isAgent: true,
|
||||
deactivatedAt: new Date('2026-01-01'),
|
||||
deletedAt: null,
|
||||
});
|
||||
const req = makeReq();
|
||||
|
||||
await expect(strategy.validate(req, accessPayload() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(req.raw.actor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { SessionActivityService } from '../../session/session-activity.service';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { resolveProvenance } from '../../../common/decorators/auth-provenance.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
@@ -55,7 +56,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
if (!workspace) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
|
||||
const user = await this.userRepo.findById(payload.sub, payload.workspaceId, {
|
||||
includeIsAgent: true,
|
||||
});
|
||||
|
||||
if (!user || isUserDisabled(user)) {
|
||||
throw new UnauthorizedException();
|
||||
@@ -71,14 +74,15 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId);
|
||||
}
|
||||
|
||||
// Propagate the signed agent-edit provenance claim onto the request so REST
|
||||
// services/controllers can set the 'agent' marker off it. A normal user
|
||||
// token carries no actor claim and resolves to 'user' (unchanged behaviour);
|
||||
// only the internal agent's minted token sets actor='agent' + aiChatId. This
|
||||
// is read server-side from the SIGNED token, never from a client body field,
|
||||
// so a normal user cannot fake an 'agent' badge.
|
||||
req.raw.actor = (payload as JwtPayload).actor ?? 'user';
|
||||
req.raw.aiChatId = (payload as JwtPayload).aiChatId ?? null;
|
||||
// Propagate the agent-edit provenance onto the request so REST
|
||||
// services/controllers can set the 'agent' marker off it. Derived from the
|
||||
// SIGNED server-side identity via the shared resolver (also used by the
|
||||
// collab seam, so the two never drift), never from a client body field — so
|
||||
// an is_agent service account stamps every REST write made with an access
|
||||
// token, and a normal user cannot fake an 'agent' badge.
|
||||
const provenance = resolveProvenance(user, payload as JwtPayload);
|
||||
req.raw.actor = provenance.actor;
|
||||
req.raw.aiChatId = provenance.aiChatId;
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
@@ -147,6 +147,24 @@ describe('CommentService — behavior', () => {
|
||||
expect(insertArg.creatorId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('stamps createdSource:"agent" with a null aiChatId (external MCP agent) without breaking insert', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
|
||||
// An external MCP agent is flagged is_agent server-side but has no
|
||||
// internal ai_chats row, so provenance carries actor='agent' + a null
|
||||
// aiChatId. The insert must still record the agent marker.
|
||||
await service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user() },
|
||||
{ content: JSON.stringify(docMentioning()) } as any,
|
||||
{ actor: 'agent', aiChatId: null },
|
||||
);
|
||||
|
||||
const insertArg = commentRepo.insertComment.mock.calls[0][0];
|
||||
expect(insertArg.createdSource).toBe('agent');
|
||||
expect(insertArg.aiChatId).toBeNull();
|
||||
expect(insertArg.creatorId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves source default (no agent stamp) for a normal user', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
ICommentResolvedNotificationJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { WsService } from '../../ws/ws.service';
|
||||
import { AuthProvenanceData } from '../../common/decorators/auth-provenance.decorator';
|
||||
import {
|
||||
AuthProvenanceData,
|
||||
agentSourceFields,
|
||||
} from '../../common/decorators/auth-provenance.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
@@ -60,7 +63,6 @@ export class CommentService {
|
||||
) {
|
||||
const { page, workspaceId, user } = opts;
|
||||
const commentContent = JSON.parse(createCommentDto.content);
|
||||
const isAgent = provenance?.actor === 'agent';
|
||||
|
||||
if (createCommentDto.parentCommentId) {
|
||||
const parentComment = await this.commentRepo.findById(
|
||||
@@ -87,9 +89,7 @@ export class CommentService {
|
||||
spaceId: page.spaceId,
|
||||
// Agent-edit provenance: the user stays creatorId; this only annotates the
|
||||
// source. Normal user requests leave the column default ('user').
|
||||
...(isAgent
|
||||
? { createdSource: 'agent', aiChatId: provenance.aiChatId }
|
||||
: {}),
|
||||
...agentSourceFields(provenance, 'createdSource', 'aiChatId'),
|
||||
});
|
||||
|
||||
if (createCommentDto.yjsSelection) {
|
||||
|
||||
@@ -147,4 +147,246 @@ describe('PageService', () => {
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent provenance stamping (#143)', () => {
|
||||
// Provenance handed to the four write sites. The agent case must surface the
|
||||
// signed source marker + chat id on the persisted payload; the user case must
|
||||
// leave both keys absent so the column keeps its INSERT default / existing
|
||||
// UPDATE value (agentSourceFields returns {} for a non-agent).
|
||||
const AGENT = { actor: 'agent', aiChatId: 'chat-7' } as any;
|
||||
const USER = { actor: 'user', aiChatId: null } as any;
|
||||
|
||||
// A general-queue stub whose `.add(...)` returns a `{ catch }` thenable —
|
||||
// the service does `generalQueue.add(...).catch(...)` and never awaits it.
|
||||
const makeGeneralQueue = () =>
|
||||
({ add: jest.fn().mockReturnValue({ catch: jest.fn() }) }) as any;
|
||||
|
||||
// Build a PageService where only the deps a given site touches are real
|
||||
// stubs; everything else stays a bare object. db is supplied per-test.
|
||||
const makeSvc = (overrides: {
|
||||
pageRepo?: any;
|
||||
generalQueue?: any;
|
||||
db?: any;
|
||||
}) =>
|
||||
new PageService(
|
||||
(overrides.pageRepo ?? {}) as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
(overrides.db ?? {}) as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
(overrides.generalQueue ?? makeGeneralQueue()) as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
describe('create() → insertPage', () => {
|
||||
const run = async (provenance: any) => {
|
||||
const pageRepo = {
|
||||
insertPage: jest.fn().mockResolvedValue({ id: 'p1' }),
|
||||
};
|
||||
const svc = makeSvc({ pageRepo, generalQueue: makeGeneralQueue() });
|
||||
// nextPagePosition runs a real db query; stub it out.
|
||||
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0' as any);
|
||||
// No content/format → the prosemirror parse branch is skipped. No
|
||||
// parentPageId → no parent lookup.
|
||||
await svc.create(
|
||||
'u1',
|
||||
'w1',
|
||||
{ title: 't', spaceId: 's1' } as any,
|
||||
provenance,
|
||||
);
|
||||
return pageRepo.insertPage.mock.calls[0][0];
|
||||
};
|
||||
|
||||
it('stamps lastUpdatedSource/lastUpdatedAiChatId for an agent', async () => {
|
||||
const payload = await run(AGENT);
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: 'chat-7',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the source columns for a normal user', async () => {
|
||||
const payload = await run(USER);
|
||||
expect(payload).not.toHaveProperty('lastUpdatedSource');
|
||||
expect(payload).not.toHaveProperty('lastUpdatedAiChatId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update() → updatePage', () => {
|
||||
const run = async (provenance: any) => {
|
||||
const pageRepo = {
|
||||
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||
findById: jest.fn().mockResolvedValue({ id: 'p1' }),
|
||||
};
|
||||
const svc = makeSvc({ pageRepo, generalQueue: makeGeneralQueue() });
|
||||
const page = {
|
||||
id: 'p1',
|
||||
contributorIds: [],
|
||||
spaceId: 's1',
|
||||
workspaceId: 'w1',
|
||||
slugId: 'sl1',
|
||||
title: 't',
|
||||
parentPageId: null,
|
||||
} as any;
|
||||
// dto carries no content/operation/format → updatePageContent skipped.
|
||||
await svc.update(page, {} as any, { id: 'u1' } as any, provenance);
|
||||
return pageRepo.updatePage.mock.calls[0][0];
|
||||
};
|
||||
|
||||
it('stamps lastUpdatedSource/lastUpdatedAiChatId for an agent', async () => {
|
||||
const payload = await run(AGENT);
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: 'chat-7',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the source columns for a normal user', async () => {
|
||||
const payload = await run(USER);
|
||||
expect(payload).not.toHaveProperty('lastUpdatedSource');
|
||||
expect(payload).not.toHaveProperty('lastUpdatedAiChatId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePage() → updatePage', () => {
|
||||
const VALID_POSITION = 'a0';
|
||||
const run = async (provenance: any) => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: 'dest-parent',
|
||||
deletedAt: null,
|
||||
spaceId: 'space-1',
|
||||
}),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const svc = makeSvc({
|
||||
pageRepo,
|
||||
db: {} as any,
|
||||
});
|
||||
// Legitimate move: destination ancestors do NOT include the moved page.
|
||||
jest
|
||||
.spyOn(svc, 'getPageBreadCrumbs')
|
||||
.mockResolvedValue([{ id: 'dest-parent' }, { id: 'root' }] as any);
|
||||
// eventEmitter is a bare {} stub; movePage emits PAGE_MOVED, so give it
|
||||
// an emit. Re-wire via the private field to avoid threading it through.
|
||||
(svc as any).eventEmitter = { emit: jest.fn() };
|
||||
const movedPage = {
|
||||
id: 'page-1',
|
||||
parentPageId: 'old-parent',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
slugId: 'slug-1',
|
||||
title: 'Page 1',
|
||||
icon: null,
|
||||
} as any;
|
||||
const dto = {
|
||||
pageId: 'page-1',
|
||||
position: VALID_POSITION,
|
||||
parentPageId: 'dest-parent',
|
||||
} as any;
|
||||
await svc.movePage(dto, movedPage, provenance);
|
||||
return pageRepo.updatePage.mock.calls[0][0];
|
||||
};
|
||||
|
||||
it('stamps lastUpdatedSource/lastUpdatedAiChatId for an agent', async () => {
|
||||
const payload = await run(AGENT);
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: 'chat-7',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the source columns for a normal user', async () => {
|
||||
const payload = await run(USER);
|
||||
expect(payload).not.toHaveProperty('lastUpdatedSource');
|
||||
expect(payload).not.toHaveProperty('lastUpdatedAiChatId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePageToSpace() → root-page updatePage', () => {
|
||||
// movePageToSpace runs its writes inside executeTx(this.db, cb), which
|
||||
// calls this.db.transaction().execute(fn => fn(trx)). A permissive
|
||||
// chainable Proxy stands in for the Kysely trx so arbitrary chains resolve.
|
||||
const makeChain = () => {
|
||||
const c: any = new Proxy(function () {}, {
|
||||
get: (_t, p) =>
|
||||
p === 'then'
|
||||
? undefined
|
||||
: p === 'execute' || p === 'executeTakeFirst'
|
||||
? () => Promise.resolve([])
|
||||
: () => c,
|
||||
});
|
||||
return c;
|
||||
};
|
||||
|
||||
const run = async (provenance: any) => {
|
||||
const trxStub = makeChain();
|
||||
const db = {
|
||||
transaction: () => ({ execute: (fn: any) => fn(trxStub) }),
|
||||
} as any;
|
||||
const rootPage = {
|
||||
id: 'root',
|
||||
spaceId: 'src-space',
|
||||
parentPageId: null,
|
||||
workspaceId: 'ws-1',
|
||||
} as any;
|
||||
const pageRepo = {
|
||||
getPageAndDescendants: jest.fn().mockResolvedValue([rootPage]),
|
||||
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||
updatePages: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const svc = makeSvc({ pageRepo, db });
|
||||
// The single-accessible-page path still runs the bulk side-effect writes
|
||||
// (attachments/watchers/ai-queue) AFTER the root updatePage we assert on;
|
||||
// stub them so the transaction completes without throwing.
|
||||
(svc as any).attachmentRepo = {
|
||||
updateAttachmentsByPageId: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
(svc as any).watcherService = {
|
||||
movePageWatchersToSpace: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
(svc as any).aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
// Single accessible page (the root) → pagesToOrphan is empty, so the
|
||||
// root updatePage is the first/only provenance-carrying updatePage call.
|
||||
// filterAccessibleTreePages is private; spy via an `any` cast.
|
||||
jest
|
||||
.spyOn(svc as any, 'filterAccessibleTreePages')
|
||||
.mockResolvedValue([rootPage] as any);
|
||||
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0' as any);
|
||||
await svc.movePageToSpace(rootPage, 'dst-space', 'u1', provenance);
|
||||
return pageRepo.updatePage.mock.calls[0][0];
|
||||
};
|
||||
|
||||
it('stamps the moved root with the agent source + chat id', async () => {
|
||||
const payload = await run(AGENT);
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
spaceId: 'dst-space',
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: 'chat-7',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the source columns on the moved root for a normal user', async () => {
|
||||
const payload = await run(USER);
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({ spaceId: 'dst-space' }),
|
||||
);
|
||||
expect(payload).not.toHaveProperty('lastUpdatedSource');
|
||||
expect(payload).not.toHaveProperty('lastUpdatedAiChatId');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,10 @@ import { WatcherService } from '../../watcher/watcher.service';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||
import { remapPageEmbedSourceId } from '../transclusion/utils/transclusion-prosemirror.util';
|
||||
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
|
||||
import {
|
||||
AuthProvenanceData,
|
||||
agentSourceFields,
|
||||
} from '../../../common/decorators/auth-provenance.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -135,8 +138,6 @@ export class PageService {
|
||||
ydoc = createYdocFromJson(prosemirrorJson);
|
||||
}
|
||||
|
||||
const isAgent = provenance?.actor === 'agent';
|
||||
|
||||
const page = await this.pageRepo.insertPage({
|
||||
slugId: generateSlugId(),
|
||||
title: createPageDto.title,
|
||||
@@ -153,12 +154,7 @@ export class PageService {
|
||||
// Agent-edit provenance. The human stays the responsible author
|
||||
// (creatorId/lastUpdatedById); these only annotate the source. A normal
|
||||
// user request leaves the column default ('user').
|
||||
...(isAgent
|
||||
? {
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: provenance.aiChatId,
|
||||
}
|
||||
: {}),
|
||||
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
@@ -231,8 +227,6 @@ export class PageService {
|
||||
contributors.add(user.id);
|
||||
const contributorIds = Array.from(contributors);
|
||||
|
||||
const isAgent = provenance?.actor === 'agent';
|
||||
|
||||
// Detect a real title/icon change so the WS tree listener can broadcast an
|
||||
// `updateOne` to the space (rename / icon swap) WITHOUT re-broadcasting on a
|
||||
// content-only save. Only treat a field as changed when the DTO actually
|
||||
@@ -250,13 +244,9 @@ export class PageService {
|
||||
icon: updatePageDto.icon,
|
||||
lastUpdatedById: user.id,
|
||||
// Agent-edit provenance: annotate the source without changing the
|
||||
// responsible author. A normal user request leaves the column default.
|
||||
...(isAgent
|
||||
? {
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: provenance.aiChatId,
|
||||
}
|
||||
: {}),
|
||||
// responsible author. A normal user request leaves the existing source
|
||||
// value unchanged.
|
||||
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
|
||||
updatedAt: new Date(),
|
||||
contributorIds: contributorIds,
|
||||
},
|
||||
@@ -443,7 +433,6 @@ export class PageService {
|
||||
provenance?: AuthProvenanceData,
|
||||
) {
|
||||
let childPageIds: string[] = [];
|
||||
const isAgent = provenance?.actor === 'agent';
|
||||
|
||||
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||
includeContent: false,
|
||||
@@ -490,12 +479,7 @@ export class PageService {
|
||||
// Agent-edit provenance on the moved root page. Child pages are bulk
|
||||
// re-parented to the new space (no content change), so the marker is
|
||||
// stamped on the root the agent acted on. Normal user: no change.
|
||||
...(isAgent
|
||||
? {
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: provenance.aiChatId,
|
||||
}
|
||||
: {}),
|
||||
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
|
||||
},
|
||||
rootPage.id,
|
||||
trx,
|
||||
@@ -949,20 +933,13 @@ export class PageService {
|
||||
}
|
||||
}
|
||||
|
||||
const isAgent = provenance?.actor === 'agent';
|
||||
|
||||
const updateResult = await this.pageRepo.updatePage(
|
||||
{
|
||||
position: dto.position,
|
||||
parentPageId: parentPageId,
|
||||
// Agent-edit provenance: annotate the source on an agent move. A normal
|
||||
// user request leaves the column default ('user').
|
||||
...(isAgent
|
||||
? {
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: provenance.aiChatId,
|
||||
}
|
||||
: {}),
|
||||
// user request leaves the existing source value unchanged.
|
||||
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
|
||||
},
|
||||
dto.pageId,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type Kysely } from 'kysely';
|
||||
|
||||
/**
|
||||
* Agent identity flag on users (MCP comment/page AI attribution).
|
||||
*
|
||||
* Additive boolean marking a service account as an AI agent. When set, the JWT
|
||||
* strategy derives provenance ('agent') from this SIGNED server-side identity —
|
||||
* never from a client-supplied field — so every write by the account is
|
||||
* attributed to AI in a non-spoofable way. Defaults to false; ordinary users
|
||||
* are unaffected. Kept as a dedicated column (not `role`, which has
|
||||
* authorization semantics, and not buried in `settings`) for a cheap filter and
|
||||
* explicitness.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('users')
|
||||
.addColumn('is_agent', 'boolean', (col) => col.notNull().defaultTo(false))
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('users').dropColumn('is_agent').execute();
|
||||
}
|
||||
@@ -45,6 +45,12 @@ export class UserRepo {
|
||||
includePassword?: boolean;
|
||||
includeUserMfa?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
// Opt-in: `isAgent` is internal provenance state, not part of the generic
|
||||
// user payload. Keeping it out of `baseFields` stops it from leaking into
|
||||
// the workspace member list / `/users/me` (an enumeration leak). Only the
|
||||
// JWT + collab auth seams opt in, because they derive a non-spoofable
|
||||
// 'agent' provenance from the signed server-side identity.
|
||||
includeIsAgent?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<User> {
|
||||
@@ -55,6 +61,7 @@ export class UserRepo {
|
||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.$if(opts?.includeIsAgent, (qb) => qb.select('isAgent'))
|
||||
.where('id', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
1
apps/server/src/database/types/db.d.ts
vendored
1
apps/server/src/database/types/db.d.ts
vendored
@@ -368,6 +368,7 @@ export interface Users {
|
||||
emailVerifiedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
invitedById: string | null;
|
||||
isAgent: Generated<boolean>;
|
||||
lastActiveAt: Timestamp | null;
|
||||
lastLoginAt: Timestamp | null;
|
||||
locale: string | null;
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
# Атрибуция комментариев (и записей) от MCP как «AI», а не как пользователь
|
||||
|
||||
Статус: **открыто (дизайн).** Сейчас комментарии, созданные через MCP-инструмент,
|
||||
показываются как комментарии обычного пользователя (сервис-аккаунта, под которым
|
||||
залогинен MCP). Нужно, чтобы они показывались как комментарии от AI. Инфраструктура
|
||||
agent-провенанса (`§15 C3`) в проекте уже наполовину построена — задача переиспользует
|
||||
её, а не строит заново.
|
||||
|
||||
## Цель
|
||||
|
||||
Комментарий, созданный/зарезолвленный через MCP, на фронтенде помечается AI-бейджем
|
||||
(как версии страниц в истории), а не выглядит как комментарий обычного участника.
|
||||
Пометка должна быть **неподделываемой** (выводиться сервером из идентичности, а не из
|
||||
тела запроса) и **аддитивной** (человек/сервис-аккаунт-автор остаётся, бейдж добавляется
|
||||
рядом).
|
||||
|
||||
## Текущее состояние (почему сейчас «от пользователя»)
|
||||
|
||||
1. **Сервер умеет ставить маркер.** `apps/server/src/core/comment/comment.service.ts`
|
||||
(~стр. 88–92) при `provenance.actor === 'agent'` пишет в комментарий
|
||||
`createdSource: 'agent'` + `aiChatId`; иначе колонка остаётся в дефолте `'user'`.
|
||||
Аналогично `resolveComment` (~стр. 235–244) ставит `resolved_source = 'agent'`.
|
||||
2. **`provenance.actor` берётся только из подписанного JWT.** Декоратор
|
||||
`apps/server/src/common/decorators/auth-provenance.decorator.ts` читает
|
||||
`request.raw.actor`, который выставляется в
|
||||
`apps/server/src/core/auth/strategies/jwt.strategy.ts` (~стр. 80–81) из claim
|
||||
`actor` токена. Сделано намеренно, чтобы обычный пользователь не подделал бейдж.
|
||||
3. **MCP логинится как обычный сервис-аккаунт.** stdio-вариант
|
||||
(`packages/mcp/src/stdio.ts:38-39`) создаёт `DocmostClient` по `email`/`password`
|
||||
(`packages/mcp/src/client.ts:99-106`) → обычный `POST /auth/login` → access-токен
|
||||
**без** claim `actor`. Ветка API-ключа в `jwt.strategy.ts` (~стр. 45–47, 86–110)
|
||||
тоже не выставляет `actor`. Итог: `provenance.actor = 'user'` →
|
||||
`created_source = 'user'` → комментарий выглядит как от пользователя.
|
||||
4. **В сайдбаре комментариев бейдж не рисуется.** Репозиторий уже отдаёт `createdSource`
|
||||
на фронт (`selectAll('comments')` в
|
||||
`apps/server/src/database/repos/comment/comment.repo.ts:34-49`), но клиентский тип
|
||||
`IComment` (`apps/client/src/features/comment/types/comment.types.ts`) его не описывает,
|
||||
а `apps/client/src/features/comment/components/comment-list-item.tsx` (~стр. 127–162)
|
||||
показывает только `comment.creator.name`. AI-бейдж сейчас рендерится **только** в
|
||||
истории страниц — `apps/client/src/features/page-history/components/history-item.tsx`
|
||||
(компонент `AiAgentBadge`, иконка `IconSparkles`, метка «AI-agent»,
|
||||
`lastUpdatedSource === "agent"`).
|
||||
|
||||
Колонки БД для этого уже существуют (миграция
|
||||
`apps/server/src/database/migrations/20260616T130000-agent-provenance.ts`:
|
||||
`comments.created_source` дефолт `'user'`, `comments.ai_chat_id` nullable,
|
||||
`comments.resolved_source` nullable). Новых колонок на стороне комментариев не нужно.
|
||||
|
||||
## Дизайн
|
||||
|
||||
Два независимых куска: бэкенд (проставить провенанс для MCP-идентичности) и фронтенд
|
||||
(отрисовать бейдж). Они стыкуются через уже отдаваемое поле `createdSource`.
|
||||
|
||||
### B1. Бэкенд — пометить MCP-идентичность как «agent» (неподделываемо)
|
||||
|
||||
Принцип: пометка выводится из идентичности на сервере, а не передаётся клиентом.
|
||||
Помечаем сам сервис-аккаунт MCP как агентский — тогда **все** его записи (комментарии,
|
||||
а также страницы через уже существующий provenance в `page.service.ts`) автоматически
|
||||
атрибутируются AI, без правок в теле запроса.
|
||||
|
||||
1. **Флаг агентской идентичности на пользователе.** Добавить булеву колонку (например
|
||||
`users.is_agent`, дефолт `false`) отдельной аддитивной миграцией. Не переиспользовать
|
||||
`role` (у него семантика авторизации) и не прятать флаг в `settings` (нужен дешёвый
|
||||
фильтр и явность). Обновить тип `Users` в
|
||||
`apps/server/src/database/types/db.d.ts` и сущность `User`.
|
||||
- Эксплуатация: для MCP завести **отдельный** сервис-аккаунт и выставить ему
|
||||
`is_agent = true`. Не помечать обычных людей.
|
||||
2. **Проставление `actor` в JWT-стратегии.** В
|
||||
`apps/server/src/core/auth/strategies/jwt.strategy.ts` после загрузки `user`
|
||||
(в ACCESS-ветке `validate`, и зеркально в `validateApiKey`, если MCP когда-то
|
||||
перейдёт на API-ключ) выставлять:
|
||||
```ts
|
||||
// Derive provenance from the SIGNED identity, never from a client field:
|
||||
// an account flagged is_agent stamps every write as 'agent'.
|
||||
req.raw.actor = user.isAgent ? 'agent' : ((payload as JwtPayload).actor ?? 'user');
|
||||
req.raw.aiChatId = (payload as JwtPayload).aiChatId ?? null; // null for external MCP
|
||||
```
|
||||
Внешний MCP не связан с внутренним `ai_chats`, поэтому `aiChatId` остаётся `null` —
|
||||
колонка `comments.ai_chat_id` nullable, FK `ON DELETE SET NULL`, это валидно.
|
||||
3. **Ослабить тип provenance, где он требует `aiChatId: string`.** Сейчас
|
||||
`apps/server/src/core/auth/services/token.service.ts` (~стр. 37, 61) и спред в
|
||||
`comment.service.ts` исходят из непустого `aiChatId`. Для внешнего MCP нужен
|
||||
`aiChatId: string | null`. Декоратор уже возвращает `aiChatId: ... ?? null`, так что
|
||||
правка — это только смягчение типа в цепочке `provenance` (тип-уровень), а не логики.
|
||||
Запись `createdSource: 'agent', aiChatId: null` в БД корректна.
|
||||
|
||||
Почему именно идентичность, а не per-request флаг: (а) неподделываемо «по построению» —
|
||||
обычный пользователь не сможет получить токен агентской учётки; (б) одной точкой
|
||||
покрывает и комментарии, и страницы (`page.service.ts` уже читает provenance для
|
||||
create/rename/move — стр. ~138/234/446/952), то есть MCP-страницы начнут показывать
|
||||
AI-бейдж в истории **без** доп. фронтенд-работы.
|
||||
|
||||
Альтернатива (отклонена): заставить MCP чеканить provenance-токены, как это делает
|
||||
внутренний AI-чат (`token.service.generateAccessToken(..., {actor:'agent', aiChatId})`,
|
||||
см. `apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts:73`). Для внешнего MCP
|
||||
это тяжелее: он ходит через `performLogin`, у него нет подписывающего секрета сервера, и
|
||||
provenance всё равно пришлось бы привязать к идентичности. Идентичность-флаг проще и
|
||||
покрывает оба транспорта.
|
||||
|
||||
### B2. Фронтенд — показать AI-бейдж в сайдбаре комментариев
|
||||
|
||||
1. **Расширить тип.** Добавить в `IComment`
|
||||
(`apps/client/src/features/comment/types/comment.types.ts`) поля
|
||||
`createdSource?: string`, `aiChatId?: string | null`, `resolvedSource?: string | null`
|
||||
(бэкенд их уже отдаёт через `selectAll`).
|
||||
2. **Вынести общий бейдж.** Сейчас `AiAgentBadge` локальный внутри `history-item.tsx`.
|
||||
Вынести его в переиспользуемый компонент (например
|
||||
`apps/client/src/components/ui/ai-agent-badge.tsx`) с опциональным `aiChatId`:
|
||||
когда `aiChatId` есть — кликабельный deep-link в чат (поведение истории), когда `null`
|
||||
(внешний MCP) — просто метка. Существующая реализация уже корректно ведёт себя при
|
||||
`aiChatId == null` (нет курсора/клика).
|
||||
3. **Отрисовать в `comment-list-item.tsx`** рядом с `comment.creator.name`
|
||||
(~стр. 129–131):
|
||||
```tsx
|
||||
{comment.createdSource === "agent" && (
|
||||
<AiAgentBadge authorName={comment.creator?.name} aiChatId={comment.aiChatId} />
|
||||
)}
|
||||
```
|
||||
4. **(Опционально, в том же объёме) «Resolved by AI».** Поскольку `resolved_source` уже
|
||||
пишется, аналогичный маркер можно показать у строки «resolved» в
|
||||
`resolve-comment.tsx` / шапке треда. Вынести в отдельный подпункт, если объём растёт.
|
||||
|
||||
## Краевые случаи и тонкие места
|
||||
|
||||
- **`aiChatId = null` у внешнего MCP** — бейдж некликабелен, FK nullable; проверить, что
|
||||
ни сервер (спред в `comment.service`), ни фронт (deep-link) не падают на null.
|
||||
- **Неподделываемость** — инвариант «`actor` только из серверной идентичности/подписанного
|
||||
claim, никогда из тела запроса» обязан сохраниться; покрыть тестом, что обычный
|
||||
пользователь не получает `created_source='agent'`.
|
||||
- **Живое обновление** — WS-событие `commentCreated` несёт весь объект комментария
|
||||
(с `createdSource`), значит бейдж появится без перезагрузки. Проверить, что поле не
|
||||
теряется на пути WS → стор.
|
||||
- **Уведомления/watchers** — автор остаётся сервис-аккаунтом (`creatorId`), нотификации
|
||||
работают как раньше; решить, нужно ли вообще слать уведомления о комментариях от AI
|
||||
(по умолчанию — оставить как есть).
|
||||
- **Резолв человеком комментария от AI и наоборот** — `resolved_source` независим от
|
||||
`created_source`; UI не должен их путать.
|
||||
- **Смешанная учётка** — если один и тот же аккаунт используется и людьми, и MCP, флаг
|
||||
пометит человеческие действия тоже. Поэтому требование: для MCP — отдельный аккаунт.
|
||||
|
||||
## Тесты
|
||||
|
||||
- `comment.service` (юнит): `provenance.actor='agent'` → `createdSource='agent'`,
|
||||
`aiChatId=null` не ломает вставку; `actor='user'` → дефолт.
|
||||
- `jwt.strategy` (юнит/инт): `user.isAgent=true` → `req.raw.actor='agent'`; обычный
|
||||
пользователь → `'user'`; claim из тела не влияет (анти-spoof).
|
||||
- Фронтенд (компонентный): `comment-list-item` рендерит бейдж при
|
||||
`createdSource==='agent'` и не рендерит при `'user'`; бейдж некликабелен при
|
||||
`aiChatId==null`.
|
||||
- Регрессия: существующие тесты комментариев (`comment.service.spec`,
|
||||
`comment.service.behavior.spec`) остаются зелёными.
|
||||
|
||||
## Объём и решения, которые надо зафиксировать перед реализацией
|
||||
|
||||
- **Охват:** помечать как AI только комментарии или все MCP-записи. Рекомендуется все
|
||||
(флаг идентичности это и даёт «бесплатно»; страницы уже поддержаны на бэке и в истории).
|
||||
- **«Resolved by AI»:** включать в первый заход или отдельным пунктом.
|
||||
- **Имя/аватар сервис-аккаунта:** независимо от бейджа, разумно назвать учётку «AI» и
|
||||
дать аватар-робота — бейдж и имя усиливают друг друга.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Комментарий, созданный через MCP под агентским сервис-аккаунтом, имеет
|
||||
`created_source = 'agent'` в БД.
|
||||
2. В сайдбаре комментариев у такого комментария виден AI-бейдж рядом с именем автора;
|
||||
у обычного — нет.
|
||||
3. Обычный пользователь никаким способом (включая поле в теле запроса) не может получить
|
||||
`created_source = 'agent'`.
|
||||
4. Страницы, созданные через MCP, показывают AI-бейдж в истории (следствие B1, без
|
||||
доп. фронтенд-работы).
|
||||
5. Существующие тесты зелёные; добавлены тесты из раздела «Тесты».
|
||||
|
||||
## Связанные места (быстрые ссылки)
|
||||
|
||||
- Бэкенд-маркер: `apps/server/src/core/comment/comment.service.ts` (create ~88–92,
|
||||
resolve ~235–244).
|
||||
- Провенанс из JWT: `apps/server/src/common/decorators/auth-provenance.decorator.ts`,
|
||||
`apps/server/src/core/auth/strategies/jwt.strategy.ts` (~80–81; API-key ~86–110).
|
||||
- Минтинг provenance-токена (образец внутреннего агента):
|
||||
`apps/server/src/core/auth/services/token.service.ts` (~30–77),
|
||||
`apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts` (~53–84).
|
||||
- Колонки БД: `apps/server/src/database/migrations/20260616T130000-agent-provenance.ts`.
|
||||
- MCP-аутентификация: `packages/mcp/src/stdio.ts:38-39`,
|
||||
`packages/mcp/src/client.ts:99-106`.
|
||||
- Фронтенд: `apps/client/src/features/comment/types/comment.types.ts`,
|
||||
`apps/client/src/features/comment/components/comment-list-item.tsx`,
|
||||
образец бейджа `apps/client/src/features/page-history/components/history-item.tsx`
|
||||
(`AiAgentBadge`).
|
||||
Reference in New Issue
Block a user