@desplega.ai/agent-swarm 1.83.1 → 1.83.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.json +139 -8
- package/package.json +1 -1
- package/src/artifact-sdk/server.ts +23 -1
- package/src/be/budget-admission.ts +28 -4
- package/src/be/budget-refusal-notify.ts +19 -3
- package/src/be/db-queries/oauth.ts +43 -0
- package/src/be/db.ts +35 -2
- package/src/be/migrations/074_user_budget_scope.sql +85 -0
- package/src/commands/resume-session.ts +118 -0
- package/src/commands/runner.ts +137 -67
- package/src/http/core.ts +4 -1
- package/src/http/index.ts +16 -0
- package/src/http/integrations.ts +26 -0
- package/src/http/mcp-user.ts +111 -0
- package/src/http/poll.ts +19 -5
- package/src/http/schedules.ts +1 -1
- package/src/http/users.ts +107 -2
- package/src/jira/client.ts +3 -5
- package/src/jira/oauth.ts +1 -0
- package/src/jira/sync.ts +2 -2
- package/src/oauth/ensure-token.ts +1 -0
- package/src/oauth/wrapper.ts +38 -7
- package/src/providers/claude-adapter.ts +7 -2
- package/src/providers/claude-managed-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +30 -0
- package/src/providers/opencode-adapter.ts +149 -14
- package/src/providers/pi-mono-adapter.ts +41 -1
- package/src/providers/types.ts +1 -1
- package/src/server-user.ts +117 -0
- package/src/tests/artifact-sdk.test.ts +23 -19
- package/src/tests/budget-user-scope.test.ts +376 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +192 -0
- package/src/tests/codex-rate-limit-parse.test.ts +256 -0
- package/src/tests/db-queries-oauth.test.ts +43 -0
- package/src/tests/ensure-token.test.ts +93 -0
- package/src/tests/error-tracker.test.ts +52 -0
- package/src/tests/fetch-resolved-env.test.ts +33 -20
- package/src/tests/http-users.test.ts +29 -1
- package/src/tests/mcp-user-route.test.ts +325 -0
- package/src/tests/opencode-adapter.test.ts +75 -0
- package/src/tests/pi-mono-adapter.test.ts +21 -1
- package/src/tests/rate-limit-event.test.ts +69 -6
- package/src/tests/resume-session.test.ts +93 -0
- package/src/tests/task-tools-ctx.test.ts +100 -0
- package/src/tests/task-tools-ownership.test.ts +167 -0
- package/src/tests/user-token-routes.test.ts +221 -0
- package/src/tools/cancel-task.ts +137 -83
- package/src/tools/get-task-details.ts +73 -59
- package/src/tools/get-tasks.ts +134 -126
- package/src/tools/send-task.ts +312 -312
- package/src/tools/task-action.ts +464 -367
- package/src/tools/task-tool-ctx.ts +43 -0
- package/src/types.ts +6 -2
- package/src/utils/error-tracker.ts +122 -9
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, createTaskExtended, createUser, getTaskById, initDb } from "../be/db";
|
|
4
|
+
import { getTasksHandler } from "../tools/get-tasks";
|
|
5
|
+
import { sendTaskHandler } from "../tools/send-task";
|
|
6
|
+
import { assertOwnsTask, ownerCtx, userCtx } from "../tools/task-tool-ctx";
|
|
7
|
+
|
|
8
|
+
const TEST_DB_PATH = "./test-task-tools-ctx.sqlite";
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
12
|
+
try {
|
|
13
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
14
|
+
} catch {}
|
|
15
|
+
}
|
|
16
|
+
initDb(TEST_DB_PATH);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
closeDb();
|
|
21
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
22
|
+
try {
|
|
23
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("task tool ctx", () => {
|
|
29
|
+
test("sendTaskHandler with user ctx writes requestedByUserId", async () => {
|
|
30
|
+
const user = createUser({ name: "MCP User" });
|
|
31
|
+
|
|
32
|
+
const result = await sendTaskHandler(userCtx(user), {
|
|
33
|
+
task: "user requested task",
|
|
34
|
+
offerMode: false,
|
|
35
|
+
allowDuplicate: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const structured = result.structuredContent as {
|
|
39
|
+
success: boolean;
|
|
40
|
+
task: { id: string; requestedByUserId?: string };
|
|
41
|
+
};
|
|
42
|
+
expect(structured.success).toBe(true);
|
|
43
|
+
expect(structured.task.requestedByUserId).toBe(user.id);
|
|
44
|
+
|
|
45
|
+
const stored = getTaskById(structured.task.id);
|
|
46
|
+
expect(stored?.creatorAgentId).toBeUndefined();
|
|
47
|
+
expect(stored?.requestedByUserId).toBe(user.id);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("getTasksHandler with user ctx only returns that user's tasks", async () => {
|
|
51
|
+
const userA = createUser({ name: "List User A" });
|
|
52
|
+
const userB = createUser({ name: "List User B" });
|
|
53
|
+
|
|
54
|
+
const a1 = createTaskExtended("owned task one", { requestedByUserId: userA.id });
|
|
55
|
+
const a2 = createTaskExtended("owned task two", { requestedByUserId: userA.id });
|
|
56
|
+
const b1 = createTaskExtended("foreign task", { requestedByUserId: userB.id });
|
|
57
|
+
createTaskExtended("owner-only task");
|
|
58
|
+
|
|
59
|
+
const result = await getTasksHandler(userCtx(userA), {
|
|
60
|
+
includeFull: true,
|
|
61
|
+
includeHeartbeat: true,
|
|
62
|
+
limit: 50,
|
|
63
|
+
mineOnly: true,
|
|
64
|
+
offeredToMe: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const structured = result.structuredContent as {
|
|
68
|
+
tasks: Array<{ id: string; task?: string }>;
|
|
69
|
+
};
|
|
70
|
+
const ids = structured.tasks.map((task) => task.id);
|
|
71
|
+
expect(ids).toContain(a1.id);
|
|
72
|
+
expect(ids).toContain(a2.id);
|
|
73
|
+
expect(ids).not.toContain(b1.id);
|
|
74
|
+
expect(structured.tasks.every((task) => task.task?.startsWith("owned task"))).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("assertOwnsTask gates user tasks and allows owned or owner ctx", () => {
|
|
78
|
+
const owner = createUser({ name: "Task Owner" });
|
|
79
|
+
const foreignUser = createUser({ name: "Foreign User" });
|
|
80
|
+
const ownedTask = createTaskExtended("owned", { requestedByUserId: owner.id });
|
|
81
|
+
|
|
82
|
+
expect(assertOwnsTask(userCtx(owner), ownedTask)).toBeNull();
|
|
83
|
+
expect(
|
|
84
|
+
assertOwnsTask(
|
|
85
|
+
ownerCtx({
|
|
86
|
+
agentId: "00000000-0000-4000-8000-000000000001",
|
|
87
|
+
sourceTaskId: undefined,
|
|
88
|
+
sessionId: "session-1",
|
|
89
|
+
}),
|
|
90
|
+
ownedTask,
|
|
91
|
+
),
|
|
92
|
+
).toBeNull();
|
|
93
|
+
|
|
94
|
+
const forbidden = assertOwnsTask(userCtx(foreignUser), ownedTask);
|
|
95
|
+
expect(forbidden?.isError).toBe(true);
|
|
96
|
+
expect(forbidden?.content[0]?.type).toBe("text");
|
|
97
|
+
expect(forbidden?.content[0]?.text).toContain("this task is not yours");
|
|
98
|
+
expect((forbidden?.structuredContent as { code?: string })?.code).toBe("forbidden");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createAgent,
|
|
6
|
+
createTaskExtended,
|
|
7
|
+
createUser,
|
|
8
|
+
getDb,
|
|
9
|
+
getTaskById,
|
|
10
|
+
initDb,
|
|
11
|
+
} from "../be/db";
|
|
12
|
+
import { cancelTaskHandler } from "../tools/cancel-task";
|
|
13
|
+
import { getTaskDetailsHandler } from "../tools/get-task-details";
|
|
14
|
+
import { taskActionHandler } from "../tools/task-action";
|
|
15
|
+
import { ownerCtx, userCtx } from "../tools/task-tool-ctx";
|
|
16
|
+
|
|
17
|
+
const TEST_DB_PATH = "./test-task-tools-ownership.sqlite";
|
|
18
|
+
|
|
19
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
20
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
21
|
+
try {
|
|
22
|
+
await unlink(path + suffix);
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
29
|
+
initDb(TEST_DB_PATH);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
closeDb();
|
|
34
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
const db = getDb();
|
|
39
|
+
db.prepare("DELETE FROM agent_tasks").run();
|
|
40
|
+
db.prepare("DELETE FROM agents").run();
|
|
41
|
+
db.prepare("DELETE FROM users").run();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function expectForbidden(result: Awaited<ReturnType<typeof getTaskDetailsHandler>>): void {
|
|
45
|
+
expect(result.isError).toBe(true);
|
|
46
|
+
expect(result.content[0]?.type).toBe("text");
|
|
47
|
+
expect(result.content[0]?.text).toContain("this task is not yours");
|
|
48
|
+
expect((result.structuredContent as { code?: string })?.code).toBe("forbidden");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("ownership-gated task tools", () => {
|
|
52
|
+
test("getTaskDetailsHandler gates user ctx and leaves owner ctx visible", async () => {
|
|
53
|
+
const owner = createUser({ name: "Task Owner" });
|
|
54
|
+
const foreignUser = createUser({ name: "Foreign User" });
|
|
55
|
+
const task = createTaskExtended("owned details", { requestedByUserId: owner.id });
|
|
56
|
+
|
|
57
|
+
expectForbidden(await getTaskDetailsHandler(userCtx(foreignUser), { taskId: task.id }));
|
|
58
|
+
|
|
59
|
+
const userResult = await getTaskDetailsHandler(userCtx(owner), { taskId: task.id });
|
|
60
|
+
expect(
|
|
61
|
+
(userResult.structuredContent as { success: boolean; task?: { id: string } }).success,
|
|
62
|
+
).toBe(true);
|
|
63
|
+
expect((userResult.structuredContent as { task?: { id: string } }).task?.id).toBe(task.id);
|
|
64
|
+
|
|
65
|
+
const ownerResult = await getTaskDetailsHandler(
|
|
66
|
+
ownerCtx({
|
|
67
|
+
agentId: "00000000-0000-4000-8000-000000000001",
|
|
68
|
+
}),
|
|
69
|
+
{ taskId: task.id },
|
|
70
|
+
);
|
|
71
|
+
expect((ownerResult.structuredContent as { success: boolean }).success).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("cancelTaskHandler gates user ctx and preserves owner lead permission", async () => {
|
|
75
|
+
const owner = createUser({ name: "Cancel Owner" });
|
|
76
|
+
const foreignUser = createUser({ name: "Cancel Foreign" });
|
|
77
|
+
const task = createTaskExtended("owned cancellation", { requestedByUserId: owner.id });
|
|
78
|
+
|
|
79
|
+
expectForbidden(
|
|
80
|
+
await cancelTaskHandler(userCtx(foreignUser), {
|
|
81
|
+
taskId: task.id,
|
|
82
|
+
reason: "foreign attempt",
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
expect(getTaskById(task.id)?.status).toBe("unassigned");
|
|
86
|
+
|
|
87
|
+
const userResult = await cancelTaskHandler(userCtx(owner), {
|
|
88
|
+
taskId: task.id,
|
|
89
|
+
reason: "owned cancel",
|
|
90
|
+
});
|
|
91
|
+
expect(
|
|
92
|
+
(userResult.structuredContent as { success: boolean; task?: { status: string } }).success,
|
|
93
|
+
).toBe(true);
|
|
94
|
+
expect((userResult.structuredContent as { task?: { status: string } }).task?.status).toBe(
|
|
95
|
+
"cancelled",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const lead = createAgent({ name: "lead", isLead: true, status: "idle", maxTasks: 1 });
|
|
99
|
+
const leadTask = createTaskExtended("lead cancellation");
|
|
100
|
+
const ownerResult = await cancelTaskHandler(ownerCtx({ agentId: lead.id }), {
|
|
101
|
+
taskId: leadTask.id,
|
|
102
|
+
reason: "lead cancel",
|
|
103
|
+
});
|
|
104
|
+
expect((ownerResult.structuredContent as { success: boolean }).success).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("taskActionHandler gates user backlog moves and rejects agent-only actions", async () => {
|
|
108
|
+
const owner = createUser({ name: "Backlog Owner" });
|
|
109
|
+
const foreignUser = createUser({ name: "Backlog Foreign" });
|
|
110
|
+
const task = createTaskExtended("owned backlog move", { requestedByUserId: owner.id });
|
|
111
|
+
|
|
112
|
+
expectForbidden(
|
|
113
|
+
await taskActionHandler(userCtx(foreignUser), {
|
|
114
|
+
action: "to_backlog",
|
|
115
|
+
taskId: task.id,
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
expect(getTaskById(task.id)?.status).toBe("unassigned");
|
|
119
|
+
|
|
120
|
+
const toBacklog = await taskActionHandler(userCtx(owner), {
|
|
121
|
+
action: "to_backlog",
|
|
122
|
+
taskId: task.id,
|
|
123
|
+
});
|
|
124
|
+
expect(
|
|
125
|
+
(toBacklog.structuredContent as { success: boolean; task?: { status: string } }).success,
|
|
126
|
+
).toBe(true);
|
|
127
|
+
expect((toBacklog.structuredContent as { task?: { status: string } }).task?.status).toBe(
|
|
128
|
+
"backlog",
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const fromBacklog = await taskActionHandler(userCtx(owner), {
|
|
132
|
+
action: "from_backlog",
|
|
133
|
+
taskId: task.id,
|
|
134
|
+
});
|
|
135
|
+
expect(
|
|
136
|
+
(fromBacklog.structuredContent as { success: boolean; task?: { status: string } }).success,
|
|
137
|
+
).toBe(true);
|
|
138
|
+
expect((fromBacklog.structuredContent as { task?: { status: string } }).task?.status).toBe(
|
|
139
|
+
"unassigned",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const rejected = await taskActionHandler(userCtx(owner), {
|
|
143
|
+
action: "create",
|
|
144
|
+
task: "duplicate create path",
|
|
145
|
+
});
|
|
146
|
+
expect(rejected.isError).toBe(true);
|
|
147
|
+
expect(rejected.content[0]?.type).toBe("text");
|
|
148
|
+
expect(rejected.content[0]?.text).toContain("only available to worker agents");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("taskActionHandler owner ctx preserves worker release behavior", async () => {
|
|
152
|
+
const worker = createAgent({ name: "worker", isLead: false, status: "idle", maxTasks: 1 });
|
|
153
|
+
const task = createTaskExtended("assigned task", { agentId: worker.id });
|
|
154
|
+
|
|
155
|
+
const result = await taskActionHandler(ownerCtx({ agentId: worker.id }), {
|
|
156
|
+
action: "release",
|
|
157
|
+
taskId: task.id,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(
|
|
161
|
+
(result.structuredContent as { success: boolean; task?: { status: string } }).success,
|
|
162
|
+
).toBe(true);
|
|
163
|
+
expect((result.structuredContent as { task?: { status: string } }).task?.status).toBe(
|
|
164
|
+
"unassigned",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
createServer as createHttpServer,
|
|
5
|
+
type IncomingMessage,
|
|
6
|
+
type Server,
|
|
7
|
+
type ServerResponse,
|
|
8
|
+
} from "node:http";
|
|
9
|
+
import { closeDb, createUser, getDb, initDb } from "../be/db";
|
|
10
|
+
import { fingerprintApiKey } from "../be/users";
|
|
11
|
+
import { handleCore } from "../http/core";
|
|
12
|
+
import { handleUsers } from "../http/users";
|
|
13
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
14
|
+
|
|
15
|
+
const TEST_DB_PATH = "./test-user-token-routes.sqlite";
|
|
16
|
+
const API_KEY = "test-user-token-key";
|
|
17
|
+
const ORIGINAL_API_KEY = process.env.AGENT_SWARM_API_KEY;
|
|
18
|
+
|
|
19
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
20
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
21
|
+
try {
|
|
22
|
+
await unlink(path + suffix);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function listen(server: Server): Promise<number> {
|
|
30
|
+
const port = 15174;
|
|
31
|
+
await new Promise<void>((resolve, reject) => {
|
|
32
|
+
server.once("error", reject);
|
|
33
|
+
server.listen(port, "127.0.0.1", () => {
|
|
34
|
+
server.off("error", reject);
|
|
35
|
+
resolve();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
const addr = server.address();
|
|
39
|
+
if (!addr || typeof addr === "string") throw new Error("no port");
|
|
40
|
+
return addr.port;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createTestServer(apiKey: string): Server {
|
|
44
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
45
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
46
|
+
const handled = await handleCore(req, res, myAgentId, apiKey);
|
|
47
|
+
if (handled) return;
|
|
48
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
49
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
50
|
+
const ok = await handleUsers(req, res, pathSegments, queryParams);
|
|
51
|
+
if (!ok) {
|
|
52
|
+
res.writeHead(404);
|
|
53
|
+
res.end("Not Found");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let server: Server;
|
|
59
|
+
let port: number;
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
63
|
+
initDb(TEST_DB_PATH);
|
|
64
|
+
process.env.AGENT_SWARM_API_KEY = API_KEY;
|
|
65
|
+
server = createTestServer(API_KEY);
|
|
66
|
+
port = await listen(server);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterAll(async () => {
|
|
70
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
71
|
+
closeDb();
|
|
72
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
73
|
+
if (ORIGINAL_API_KEY === undefined) {
|
|
74
|
+
delete process.env.AGENT_SWARM_API_KEY;
|
|
75
|
+
} else {
|
|
76
|
+
process.env.AGENT_SWARM_API_KEY = ORIGINAL_API_KEY;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
const db = getDb();
|
|
82
|
+
db.run("DELETE FROM user_identity_events");
|
|
83
|
+
db.run("DELETE FROM user_tokens");
|
|
84
|
+
db.run("DELETE FROM users");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function url(path: string): string {
|
|
88
|
+
return `http://127.0.0.1:${port}${path}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
92
|
+
const headers: Record<string, string> = {
|
|
93
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
...((init.headers as Record<string, string>) ?? {}),
|
|
96
|
+
};
|
|
97
|
+
return fetch(url(path), { ...init, headers });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type TokenSummary = {
|
|
101
|
+
id: string;
|
|
102
|
+
userId: string;
|
|
103
|
+
label: string | null;
|
|
104
|
+
tokenPreview: string;
|
|
105
|
+
createdAt: string;
|
|
106
|
+
lastUsedAt: string | null;
|
|
107
|
+
revokedAt: string | null;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
describe("operator MCP token routes", () => {
|
|
111
|
+
test("POST mints an aswt_ plaintext once and persists only hash + preview", async () => {
|
|
112
|
+
const user = createUser({ name: "Token User", email: "token@example.com" });
|
|
113
|
+
|
|
114
|
+
const response = await authedFetch(`/api/users/${user.id}/mcp-tokens`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
body: JSON.stringify({ label: "laptop" }),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(response.status).toBe(200);
|
|
120
|
+
const body = (await response.json()) as {
|
|
121
|
+
plaintext: string;
|
|
122
|
+
token: TokenSummary;
|
|
123
|
+
user: { id: string; tokens: TokenSummary[]; recentEvents: Array<{ eventType: string }> };
|
|
124
|
+
};
|
|
125
|
+
expect(body.plaintext.startsWith("aswt_")).toBe(true);
|
|
126
|
+
expect(body.token.label).toBe("laptop");
|
|
127
|
+
expect(body.token.tokenPreview).toBe(body.plaintext.slice(-4));
|
|
128
|
+
expect(body.token.userId).toBe(user.id);
|
|
129
|
+
expect(body.user.tokens).toContainEqual(body.token);
|
|
130
|
+
expect(body.user.recentEvents.map((event) => event.eventType)).toContain("token_minted");
|
|
131
|
+
|
|
132
|
+
const stored = getDb()
|
|
133
|
+
.prepare<{ tokenHash: string; tokenPreview: string }, string>(
|
|
134
|
+
"SELECT tokenHash, tokenPreview FROM user_tokens WHERE id = ?",
|
|
135
|
+
)
|
|
136
|
+
.get(body.token.id);
|
|
137
|
+
expect(stored).toBeTruthy();
|
|
138
|
+
expect(stored!.tokenHash).not.toBe(body.plaintext);
|
|
139
|
+
expect(stored!.tokenHash).toHaveLength(64);
|
|
140
|
+
expect(stored!.tokenPreview).toBe(body.plaintext.slice(-4));
|
|
141
|
+
|
|
142
|
+
const reread = await authedFetch(`/api/users/${user.id}`);
|
|
143
|
+
const rereadBody = (await reread.json()) as {
|
|
144
|
+
user: { tokens: TokenSummary[]; recentEvents: Array<{ eventType: string }> };
|
|
145
|
+
};
|
|
146
|
+
expect(JSON.stringify(rereadBody)).not.toContain(body.plaintext);
|
|
147
|
+
expect(rereadBody.user.tokens[0]!.tokenPreview).toBe(body.plaintext.slice(-4));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("DELETE revokes a token and records token_revoked", async () => {
|
|
151
|
+
const user = createUser({ name: "Revoked User" });
|
|
152
|
+
const mintResponse = await authedFetch(`/api/users/${user.id}/mcp-tokens`, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
body: JSON.stringify({ label: null }),
|
|
155
|
+
});
|
|
156
|
+
const minted = (await mintResponse.json()) as { token: TokenSummary };
|
|
157
|
+
|
|
158
|
+
const revokeResponse = await authedFetch(
|
|
159
|
+
`/api/users/${user.id}/mcp-tokens/${minted.token.id}`,
|
|
160
|
+
{ method: "DELETE" },
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(revokeResponse.status).toBe(200);
|
|
164
|
+
const body = (await revokeResponse.json()) as {
|
|
165
|
+
user: { tokens: TokenSummary[]; recentEvents: Array<{ eventType: string }> };
|
|
166
|
+
};
|
|
167
|
+
expect(body.user.tokens[0]!.id).toBe(minted.token.id);
|
|
168
|
+
expect(body.user.tokens[0]!.revokedAt).toBeTruthy();
|
|
169
|
+
expect(body.user.recentEvents.map((event) => event.eventType)).toEqual(
|
|
170
|
+
expect.arrayContaining(["token_minted", "token_revoked"]),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("POST and DELETE reject without the swarm key", async () => {
|
|
175
|
+
const user = createUser({ name: "Auth User" });
|
|
176
|
+
|
|
177
|
+
const post = await fetch(url(`/api/users/${user.id}/mcp-tokens`), {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body: JSON.stringify({ label: "missing-auth" }),
|
|
181
|
+
});
|
|
182
|
+
expect(post.status).toBe(401);
|
|
183
|
+
|
|
184
|
+
const deleteResponse = await fetch(url(`/api/users/${user.id}/mcp-tokens/unknown`), {
|
|
185
|
+
method: "DELETE",
|
|
186
|
+
});
|
|
187
|
+
expect(deleteResponse.status).toBe(401);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("DELETE unknown token returns 404", async () => {
|
|
191
|
+
const user = createUser({ name: "Unknown Token User" });
|
|
192
|
+
const response = await authedFetch(`/api/users/${user.id}/mcp-tokens/not-a-token`, {
|
|
193
|
+
method: "DELETE",
|
|
194
|
+
});
|
|
195
|
+
expect(response.status).toBe(404);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("POST unknown user returns 404", async () => {
|
|
199
|
+
const response = await authedFetch("/api/users/not-a-user/mcp-tokens", {
|
|
200
|
+
method: "POST",
|
|
201
|
+
body: JSON.stringify({ label: "nope" }),
|
|
202
|
+
});
|
|
203
|
+
expect(response.status).toBe(404);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("operator events are tagged with the API-key fingerprint", async () => {
|
|
207
|
+
const user = createUser({ name: "Actor User" });
|
|
208
|
+
const response = await authedFetch(`/api/users/${user.id}/mcp-tokens`, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
body: JSON.stringify({ label: "actor" }),
|
|
211
|
+
});
|
|
212
|
+
expect(response.status).toBe(200);
|
|
213
|
+
|
|
214
|
+
const row = getDb()
|
|
215
|
+
.prepare<{ actor: string }, string>(
|
|
216
|
+
"SELECT actor FROM user_identity_events WHERE userId = ? AND eventType = 'token_minted'",
|
|
217
|
+
)
|
|
218
|
+
.get(user.id);
|
|
219
|
+
expect(row?.actor).toBe(`operator:${fingerprintApiKey(API_KEY)}`);
|
|
220
|
+
});
|
|
221
|
+
});
|