feat(comments): attribute MCP agent comments as AI (unspoofable provenance) #143
60
apps/client/src/components/ui/ai-agent-badge.test.tsx
Normal file
60
apps/client/src/components/ui/ai-agent-badge.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { AiAgentBadge } from "./ai-agent-badge";
|
||||
|
||||
// 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(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<AiAgentBadge {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
// Clicking does not throw — the deep-link handler runs against the default
|
||||
// jotai store. (Asserting the badge exposes an interactive role is the
|
||||
// observable contract; the atom side-effects are covered by the history UI.)
|
||||
fireEvent.click(badge);
|
||||
});
|
||||
|
||||
it("is a plain non-clickable label when aiChatId is null (external MCP agent)", () => {
|
||||
renderBadge({ authorName: "Bot", aiChatId: null });
|
||||
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();
|
||||
});
|
||||
|
||||
it("is non-clickable when aiChatId is absent", () => {
|
||||
renderBadge({ authorName: "Bot" });
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
});
|
||||
92
apps/client/src/components/ui/ai-agent-badge.tsx
Normal file
92
apps/client/src/components/ui/ai-agent-badge.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
|
||||
interface AiAgentBadgeProps {
|
||||
authorName?: string;
|
||||
aiChatId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, opens the
|
||||
* floating AI-chat window, and closes the history modal. 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 }: AiAgentBadgeProps) {
|
||||
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 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;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
// MantineProvider reads window.matchMedia on mount, which jsdom lacks.
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
it("renders a non-clickable badge when aiChatId is null (external MCP agent)", () => {
|
||||
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
// No deep-link target → no interactive button role.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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,9 +127,18 @@ function CommentListItem({
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
<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 && (
|
||||
|
||||
@@ -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,19 +1,11 @@
|
||||
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 +18,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,
|
||||
|
||||
@@ -20,7 +20,9 @@ export type JwtPayload = {
|
||||
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
|
||||
// C3 / §14 N2).
|
||||
actor?: 'user' | 'agent';
|
||||
aiChatId?: string;
|
||||
// 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 = {
|
||||
@@ -31,7 +33,9 @@ export type JwtCollabPayload = {
|
||||
// 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;
|
||||
// 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();
|
||||
|
||||
125
apps/server/src/core/auth/strategies/jwt.strategy.spec.ts
Normal file
125
apps/server/src/core/auth/strategies/jwt.strategy.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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 } = 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();
|
||||
});
|
||||
|
||||
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("does NOT let an 'actor' claim escalate a non-agent user beyond the existing claim semantics", async () => {
|
||||
// A non-agent user. The only way the token carries actor='agent' is the
|
||||
// internal AI-chat's server-minted token (the claim cannot be set by a
|
||||
// client on a plain login). We assert the derivation falls back to the
|
||||
// claim ONLY when is_agent is false — i.e. an is_agent=false user is never
|
||||
// forced to 'agent' by anything other than that signed claim, and a plain
|
||||
// user (no claim) stays 'user'.
|
||||
const { strategy } = makeStrategy({
|
||||
id: 'user-1',
|
||||
isAgent: false,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
});
|
||||
const req = makeReq();
|
||||
|
||||
// No actor claim (the plain-user login case): stays 'user'.
|
||||
await strategy.validate(req, accessPayload() as any);
|
||||
expect(req.raw.actor).toBe('user');
|
||||
|
||||
// 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 security guarantee is that a client cannot forge this
|
||||
// signed claim, NOT that the strategy ignores it. (A plain user therefore
|
||||
// still cannot obtain 'agent' — they have no way to get such a token.)
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -71,13 +71,18 @@ 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';
|
||||
// Propagate the agent-edit provenance onto the request so REST
|
||||
// services/controllers can set the 'agent' marker off it. Provenance is
|
||||
// derived from the SIGNED server-side identity, never from a client body
|
||||
// field, so a normal user cannot fake an 'agent' badge:
|
||||
// - An account flagged is_agent (an MCP service account) stamps EVERY write
|
||||
// as 'agent'. It has no internal ai_chats row, so aiChatId stays null.
|
||||
// - Otherwise fall back to the actor claim minted into the internal AI
|
||||
// agent's token (actor='agent' + aiChatId); a normal user token carries
|
||||
// no claim and resolves to 'user' (unchanged behaviour).
|
||||
req.raw.actor = user.isAgent
|
||||
? 'agent'
|
||||
: ((payload as JwtPayload).actor ?? 'user');
|
||||
req.raw.aiChatId = (payload as JwtPayload).aiChatId ?? null;
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -36,6 +36,9 @@ export class UserRepo {
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
'hasGeneratedPassword',
|
||||
// AI agent identity flag — needed by the JWT strategy to derive a
|
||||
// non-spoofable 'agent' provenance from the signed server-side identity.
|
||||
'isAgent',
|
||||
];
|
||||
|
||||
async findById(
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user