create-ailk 0.12.1 → 0.13.0
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/dist/module-architecture.js +27 -0
- package/package.json +3 -3
- package/templates/.env.example +23 -9
- package/templates/apps/api/CLAUDE.md +21 -17
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
- package/templates/apps/api/src/config/index.ts +35 -20
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
- package/templates/apps/api/src/lib/agent-store.ts +521 -0
- package/templates/apps/api/src/lib/spoke-store.ts +13 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
- package/templates/apps/api/src/middleware/auth.ts +219 -5
- package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
- package/templates/apps/api/src/routes/leads/index.ts +14 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
- package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
- package/templates/apps/api/src/server.ts +27 -0
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
- package/templates/apps/api/src/services/audit-log.ts +278 -47
- package/templates/apps/api/src/services/default-tenant.ts +55 -5
- package/templates/apps/api/src/services/leads-inbox.ts +12 -21
- package/templates/apps/api/src/services/project-listings.ts +1 -21
- package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
- package/templates/apps/mcp/CLAUDE.md +44 -5
- package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
- package/templates/apps/mcp/src/http-transport.ts +107 -1
- package/templates/apps/mcp/src/workspace-server.ts +193 -0
- package/templates/apps/web/.env.example +64 -37
- package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
- package/templates/apps/web/lib/relay.ts +279 -0
- package/templates/apps/web/middleware.ts +5 -4
- package/templates/apps/web/next.config.mjs +6 -0
- package/templates/database/CHANGELOG.md +15 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +1 -1
- package/versions.json +34 -34
|
@@ -25,11 +25,19 @@ jest.mock("../../lib/prisma.js", () => ({
|
|
|
25
25
|
findUnique: jest.fn(),
|
|
26
26
|
update: jest.fn().mockResolvedValue(undefined),
|
|
27
27
|
},
|
|
28
|
+
// #3591 — the agent branch's lookup and its best-effort lastActiveAt bump.
|
|
29
|
+
agent: {
|
|
30
|
+
findUnique: jest.fn(),
|
|
31
|
+
update: jest.fn().mockResolvedValue(undefined),
|
|
32
|
+
},
|
|
28
33
|
},
|
|
29
34
|
}));
|
|
30
35
|
|
|
36
|
+
import { hashAgentKey, mintAgentKey } from "../../lib/agent-store.js";
|
|
31
37
|
import { prisma } from "../../lib/prisma.js";
|
|
32
|
-
import {
|
|
38
|
+
import { leadListRoute } from "../../routes/leads/list.js";
|
|
39
|
+
import { tokenRoutes } from "../../routes/auth/tokens.js";
|
|
40
|
+
import { __resetAgentRateBuckets, authPreHandlerPlugin } from "../auth.js";
|
|
33
41
|
|
|
34
42
|
const FRONTEND = "https://app.example.com";
|
|
35
43
|
const BEARER = "tok_live_abc";
|
|
@@ -173,3 +181,378 @@ describe("auth preHandler — bearer path", () => {
|
|
|
173
181
|
expect(res.statusCode).toBe(401);
|
|
174
182
|
});
|
|
175
183
|
});
|
|
184
|
+
|
|
185
|
+
// ── #3591 — the agent branch (spec docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md D4, D6, D9) ──
|
|
186
|
+
|
|
187
|
+
const AGENT_A = "agent_00000000-0000-7000-8000-0000000000c1";
|
|
188
|
+
const AGENT_B = "agent_00000000-0000-7000-8000-0000000000c2";
|
|
189
|
+
const PERSON = "user_00000000-0000-7000-8000-0000000000f2";
|
|
190
|
+
const PERSON_MEMBERSHIP = "member_00000000-0000-7000-8000-0000000000f2";
|
|
191
|
+
const ADMIN_MEMBERSHIP = "member_00000000-0000-7000-8000-0000000000f1";
|
|
192
|
+
const ORG_A = "org_00000000-0000-7000-8000-000000000aaa";
|
|
193
|
+
const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
|
|
194
|
+
|
|
195
|
+
type AgentFixture = {
|
|
196
|
+
id: string;
|
|
197
|
+
enabled?: boolean;
|
|
198
|
+
keyIssuedToMembershipId?: string | null;
|
|
199
|
+
lastActiveAt?: Date | null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Keys are minted at run time — a key-shaped literal in a fixture trips the
|
|
204
|
+
* repo-wide secret scan. Each key maps, by its digest, to one agent row.
|
|
205
|
+
*/
|
|
206
|
+
function installAgents(agents: Array<AgentFixture & { key: string }>): void {
|
|
207
|
+
const byHash = new Map(agents.map((a) => [hashAgentKey(a.key), a]));
|
|
208
|
+
(prisma.agent.findUnique as jest.Mock).mockImplementation(
|
|
209
|
+
({ where }: { where: { credentialHash: string } }) => {
|
|
210
|
+
const a = byHash.get(where.credentialHash);
|
|
211
|
+
return Promise.resolve(
|
|
212
|
+
a
|
|
213
|
+
? {
|
|
214
|
+
id: a.id,
|
|
215
|
+
workspaceId: WS_A1,
|
|
216
|
+
organizationId: ORG_A,
|
|
217
|
+
permissionLevel: "member",
|
|
218
|
+
enabled: a.enabled ?? true,
|
|
219
|
+
keyIssuedToMembershipId:
|
|
220
|
+
a.keyIssuedToMembershipId === undefined ? ADMIN_MEMBERSHIP : a.keyIssuedToMembershipId,
|
|
221
|
+
lastActiveAt: a.lastActiveAt ?? null,
|
|
222
|
+
responsible: { id: PERSON_MEMBERSHIP, userId: PERSON },
|
|
223
|
+
}
|
|
224
|
+
: null,
|
|
225
|
+
);
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Collects every JSON log line the app writes. */
|
|
231
|
+
function captureLogs(): { lines: Array<Record<string, unknown>>; raw: string[]; stream: { write(line: string): void } } {
|
|
232
|
+
const lines: Array<Record<string, unknown>> = [];
|
|
233
|
+
const raw: string[] = [];
|
|
234
|
+
return {
|
|
235
|
+
lines,
|
|
236
|
+
raw,
|
|
237
|
+
stream: {
|
|
238
|
+
write(line: string) {
|
|
239
|
+
raw.push(line);
|
|
240
|
+
lines.push(JSON.parse(line) as Record<string, unknown>);
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Echo routes: one the tenant guard would tag, one it would not (D6). */
|
|
247
|
+
function buildAgentApp(stream?: { write(line: string): void }): FastifyInstance {
|
|
248
|
+
const app = Fastify(stream ? { logger: { level: "info", stream } } : {});
|
|
249
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
250
|
+
const echo = async (req: import("fastify").FastifyRequest) => ({
|
|
251
|
+
userId: req.userId ?? null,
|
|
252
|
+
agent: req.agent ?? null,
|
|
253
|
+
actor: req.actor ?? null,
|
|
254
|
+
sessionTenant: req.sessionTenant ?? null,
|
|
255
|
+
});
|
|
256
|
+
for (const method of ["GET", "POST", "PATCH"] as const) {
|
|
257
|
+
app.route({ method, url: "/guarded", config: { tenantGuarded: true }, handler: echo });
|
|
258
|
+
app.route({ method, url: "/unguarded", handler: echo });
|
|
259
|
+
}
|
|
260
|
+
return app;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
describe("auth preHandler — agent branch (#3591)", () => {
|
|
264
|
+
let app: FastifyInstance;
|
|
265
|
+
let keyA: string;
|
|
266
|
+
let keyB: string;
|
|
267
|
+
|
|
268
|
+
beforeEach(() => {
|
|
269
|
+
jest.clearAllMocks();
|
|
270
|
+
__resetAgentRateBuckets();
|
|
271
|
+
keyA = mintAgentKey();
|
|
272
|
+
keyB = mintAgentKey();
|
|
273
|
+
installAgents([
|
|
274
|
+
{ id: AGENT_A, key: keyA },
|
|
275
|
+
{ id: AGENT_B, key: keyB },
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
afterEach(async () => {
|
|
280
|
+
await app.close();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const agentGet = (url: string, key: string) =>
|
|
284
|
+
app.inject({ method: "GET", url, headers: { authorization: `Bearer ${key}` } });
|
|
285
|
+
|
|
286
|
+
it("an ailk_agt_ bearer takes the agent branch: request.userId is the person, request.agent the binding, request.actor the agent — never sessionTenant", async () => {
|
|
287
|
+
app = buildAgentApp();
|
|
288
|
+
await app.ready();
|
|
289
|
+
|
|
290
|
+
const res = await agentGet("/guarded", keyA);
|
|
291
|
+
|
|
292
|
+
expect(res.statusCode).toBe(200);
|
|
293
|
+
expect(res.json()).toEqual({
|
|
294
|
+
userId: PERSON,
|
|
295
|
+
agent: {
|
|
296
|
+
agentId: AGENT_A,
|
|
297
|
+
workspaceId: WS_A1,
|
|
298
|
+
organizationId: ORG_A,
|
|
299
|
+
responsibleMembershipId: PERSON_MEMBERSHIP,
|
|
300
|
+
permissionLevel: "member",
|
|
301
|
+
},
|
|
302
|
+
actor: { type: "agent", agentId: AGENT_A, onBehalfOfUserId: PERSON },
|
|
303
|
+
sessionTenant: null,
|
|
304
|
+
});
|
|
305
|
+
expect(prisma.agent.findUnique).toHaveBeenCalledWith(
|
|
306
|
+
expect.objectContaining({ where: { credentialHash: hashAgentKey(keyA) } }),
|
|
307
|
+
);
|
|
308
|
+
// The ApiToken table is never consulted for an agent key.
|
|
309
|
+
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("bumps lastActiveAt best-effort — a failed bump never 401s", async () => {
|
|
313
|
+
(prisma.agent.update as jest.Mock).mockRejectedValueOnce(new Error("db down"));
|
|
314
|
+
app = buildAgentApp();
|
|
315
|
+
await app.ready();
|
|
316
|
+
|
|
317
|
+
const res = await agentGet("/guarded", keyA);
|
|
318
|
+
|
|
319
|
+
expect(res.statusCode).toBe(200);
|
|
320
|
+
expect(prisma.agent.update).toHaveBeenCalledWith(
|
|
321
|
+
expect.objectContaining({ where: { id: AGENT_A } }),
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("writes lastActiveAt at most once a minute: a stamp under 60s old is left alone, an older one is refreshed", async () => {
|
|
326
|
+
const fresh = mintAgentKey();
|
|
327
|
+
const stale = mintAgentKey();
|
|
328
|
+
installAgents([
|
|
329
|
+
{ id: AGENT_A, key: fresh, lastActiveAt: new Date(Date.now() - 10_000) },
|
|
330
|
+
{ id: AGENT_B, key: stale, lastActiveAt: new Date(Date.now() - 120_000) },
|
|
331
|
+
]);
|
|
332
|
+
app = buildAgentApp();
|
|
333
|
+
await app.ready();
|
|
334
|
+
|
|
335
|
+
expect((await agentGet("/guarded", fresh)).statusCode).toBe(200);
|
|
336
|
+
expect(prisma.agent.update).not.toHaveBeenCalled();
|
|
337
|
+
|
|
338
|
+
expect((await agentGet("/guarded", stale)).statusCode).toBe(200);
|
|
339
|
+
expect(prisma.agent.update).toHaveBeenCalledTimes(1);
|
|
340
|
+
expect(prisma.agent.update).toHaveBeenCalledWith(
|
|
341
|
+
expect.objectContaining({ where: { id: AGENT_B } }),
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("a refused request writes no lastActiveAt", async () => {
|
|
346
|
+
app = buildAgentApp();
|
|
347
|
+
await app.ready();
|
|
348
|
+
|
|
349
|
+
expect((await agentGet("/unguarded", keyA)).statusCode).toBe(403);
|
|
350
|
+
expect(prisma.agent.update).not.toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it.each([
|
|
354
|
+
["an unknown key", () => mintAgentKey()],
|
|
355
|
+
["a switched-off agent", () => "off"],
|
|
356
|
+
["an agent whose key-holder has left (keyIssuedToMembershipId null)", () => "orphaned"],
|
|
357
|
+
])("answers 401 with one body for %s", async (_label, pick) => {
|
|
358
|
+
const off = mintAgentKey();
|
|
359
|
+
const orphaned = mintAgentKey();
|
|
360
|
+
installAgents([
|
|
361
|
+
{ id: AGENT_A, key: off, enabled: false },
|
|
362
|
+
{ id: AGENT_B, key: orphaned, keyIssuedToMembershipId: null },
|
|
363
|
+
]);
|
|
364
|
+
const chosen = pick();
|
|
365
|
+
const key = chosen === "off" ? off : chosen === "orphaned" ? orphaned : chosen;
|
|
366
|
+
app = buildAgentApp();
|
|
367
|
+
await app.ready();
|
|
368
|
+
|
|
369
|
+
const res = await agentGet("/guarded", key);
|
|
370
|
+
|
|
371
|
+
expect(res.statusCode).toBe(401);
|
|
372
|
+
expect(res.json()).toEqual({ error: "unauthorized" });
|
|
373
|
+
expect(prisma.agent.update).not.toHaveBeenCalled();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("a lookup failure is 401, not a 500", async () => {
|
|
377
|
+
(prisma.agent.findUnique as jest.Mock).mockRejectedValueOnce(new Error("db down"));
|
|
378
|
+
app = buildAgentApp();
|
|
379
|
+
await app.ready();
|
|
380
|
+
|
|
381
|
+
const res = await agentGet("/guarded", keyA);
|
|
382
|
+
|
|
383
|
+
expect(res.statusCode).toBe(401);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("the cookie still wins over an agent bearer (precedence unchanged)", async () => {
|
|
387
|
+
(prisma.session.findUnique as jest.Mock).mockResolvedValue({
|
|
388
|
+
id: "sess_1",
|
|
389
|
+
userId: "user_cookie",
|
|
390
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
391
|
+
activeOrganizationId: null,
|
|
392
|
+
activeWorkspaceId: null,
|
|
393
|
+
});
|
|
394
|
+
app = buildAgentApp();
|
|
395
|
+
await app.ready();
|
|
396
|
+
|
|
397
|
+
const res = await app.inject({
|
|
398
|
+
method: "GET",
|
|
399
|
+
url: "/unguarded",
|
|
400
|
+
headers: { cookie: "better-auth.session_token=tok", authorization: `Bearer ${keyA}` },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
expect(res.statusCode).toBe(200);
|
|
404
|
+
expect(res.json().actor).toEqual({ type: "user", userId: "user_cookie" });
|
|
405
|
+
expect(prisma.agent.findUnique).not.toHaveBeenCalled();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe("D6 — an agent reaches only routes the tenant guard runs on", () => {
|
|
409
|
+
it("is 403 on a route the guard did not tag", async () => {
|
|
410
|
+
app = buildAgentApp();
|
|
411
|
+
await app.ready();
|
|
412
|
+
|
|
413
|
+
const res = await agentGet("/unguarded", keyA);
|
|
414
|
+
|
|
415
|
+
expect(res.statusCode).toBe(403);
|
|
416
|
+
expect(res.json()).toEqual({ ok: false, error: { code: "forbidden", message: "Forbidden" } });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("a personal ApiToken still reaches an untagged route (tok_ behaviour unchanged)", async () => {
|
|
420
|
+
(prisma.apiToken.findUnique as jest.Mock).mockResolvedValue({
|
|
421
|
+
id: "tok_id",
|
|
422
|
+
userId: "user_bear",
|
|
423
|
+
expiresAt: null,
|
|
424
|
+
revokedAt: null,
|
|
425
|
+
});
|
|
426
|
+
app = buildAgentApp();
|
|
427
|
+
await app.ready();
|
|
428
|
+
|
|
429
|
+
const res = await app.inject({
|
|
430
|
+
method: "GET",
|
|
431
|
+
url: "/unguarded",
|
|
432
|
+
headers: { authorization: `Bearer ${BEARER}` },
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(res.statusCode).toBe(200);
|
|
436
|
+
expect(res.json().actor).toEqual({ type: "user", userId: "user_bear" });
|
|
437
|
+
expect(res.json().agent).toBeNull();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("is 403 on GET /v1/leads and POST /v1/auth/tokens, before either handler reads anything", async () => {
|
|
441
|
+
app = Fastify();
|
|
442
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
443
|
+
app.register(tokenRoutes);
|
|
444
|
+
app.register(leadListRoute);
|
|
445
|
+
await app.ready();
|
|
446
|
+
|
|
447
|
+
const leads = await agentGet("/v1/leads", keyA);
|
|
448
|
+
const tokens = await app.inject({
|
|
449
|
+
method: "POST",
|
|
450
|
+
url: "/v1/auth/tokens",
|
|
451
|
+
headers: { authorization: `Bearer ${keyA}` },
|
|
452
|
+
payload: { name: "escalation" },
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
expect(leads.statusCode).toBe(403);
|
|
456
|
+
expect(tokens.statusCode).toBe(403);
|
|
457
|
+
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe("the per-agent limit (D4 step 5, SEC-3591-04)", () => {
|
|
462
|
+
const write = (key: string) =>
|
|
463
|
+
app.inject({ method: "PATCH", url: "/guarded", headers: { authorization: `Bearer ${key}` } });
|
|
464
|
+
|
|
465
|
+
it("the eleventh non-GET request in a minute is 429 with Retry-After, while a second agent is unaffected", async () => {
|
|
466
|
+
app = buildAgentApp();
|
|
467
|
+
await app.ready();
|
|
468
|
+
|
|
469
|
+
for (let i = 0; i < 10; i += 1) {
|
|
470
|
+
expect((await write(keyA)).statusCode).toBe(200);
|
|
471
|
+
}
|
|
472
|
+
const eleventh = await write(keyA);
|
|
473
|
+
expect(eleventh.statusCode).toBe(429);
|
|
474
|
+
expect(Number(eleventh.headers["retry-after"])).toBeGreaterThan(0);
|
|
475
|
+
expect(eleventh.json()).toEqual({
|
|
476
|
+
ok: false,
|
|
477
|
+
error: { code: "rate_limited", message: "Too many requests" },
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
expect((await write(keyB)).statusCode).toBe(200);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("reads count toward 60 a minute, and the sixty-first is 429", async () => {
|
|
484
|
+
app = buildAgentApp();
|
|
485
|
+
await app.ready();
|
|
486
|
+
|
|
487
|
+
for (let i = 0; i < 60; i += 1) {
|
|
488
|
+
expect((await agentGet("/guarded", keyA)).statusCode).toBe(200);
|
|
489
|
+
}
|
|
490
|
+
expect((await agentGet("/guarded", keyA)).statusCode).toBe(429);
|
|
491
|
+
expect((await agentGet("/guarded", keyB)).statusCode).toBe(200);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("an invented key never gets a bucket: it is 401 every time, never 429", async () => {
|
|
495
|
+
app = buildAgentApp();
|
|
496
|
+
await app.ready();
|
|
497
|
+
|
|
498
|
+
const invented = mintAgentKey();
|
|
499
|
+
for (let i = 0; i < 15; i += 1) {
|
|
500
|
+
expect((await write(invented)).statusCode).toBe(401);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe("D9 — the completion log line names the actor", () => {
|
|
506
|
+
it("carries actorType agent, the agent id and its person; the key appears in no line", async () => {
|
|
507
|
+
const logs = captureLogs();
|
|
508
|
+
app = buildAgentApp(logs.stream);
|
|
509
|
+
await app.ready();
|
|
510
|
+
|
|
511
|
+
const res = await agentGet("/guarded", keyA);
|
|
512
|
+
expect(res.statusCode).toBe(200);
|
|
513
|
+
|
|
514
|
+
const completed = logs.lines.find((l) => l.msg === "request completed");
|
|
515
|
+
expect(completed).toMatchObject({
|
|
516
|
+
actorType: "agent",
|
|
517
|
+
agentId: AGENT_A,
|
|
518
|
+
onBehalfOfUserId: PERSON,
|
|
519
|
+
});
|
|
520
|
+
expect(logs.raw.join("\n")).not.toContain(keyA);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("a refused agent's lines carry no key either", async () => {
|
|
524
|
+
const logs = captureLogs();
|
|
525
|
+
app = buildAgentApp(logs.stream);
|
|
526
|
+
await app.ready();
|
|
527
|
+
|
|
528
|
+
await agentGet("/unguarded", keyA);
|
|
529
|
+
await agentGet("/guarded", mintAgentKey());
|
|
530
|
+
|
|
531
|
+
expect(logs.raw.length).toBeGreaterThan(0);
|
|
532
|
+
expect(logs.raw.join("\n")).not.toContain(keyA);
|
|
533
|
+
expect(logs.raw.join("\n")).not.toContain("ailk_agt_");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("an ApiToken request's completion line carries actorType user", async () => {
|
|
537
|
+
(prisma.apiToken.findUnique as jest.Mock).mockResolvedValue({
|
|
538
|
+
id: "tok_id",
|
|
539
|
+
userId: "user_bear",
|
|
540
|
+
expiresAt: null,
|
|
541
|
+
revokedAt: null,
|
|
542
|
+
});
|
|
543
|
+
const logs = captureLogs();
|
|
544
|
+
app = buildAgentApp(logs.stream);
|
|
545
|
+
await app.ready();
|
|
546
|
+
|
|
547
|
+
await app.inject({
|
|
548
|
+
method: "GET",
|
|
549
|
+
url: "/unguarded",
|
|
550
|
+
headers: { authorization: `Bearer ${BEARER}` },
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const completed = logs.lines.find((l) => l.msg === "request completed");
|
|
554
|
+
expect(completed).toMatchObject({ actorType: "user" });
|
|
555
|
+
expect(completed).not.toHaveProperty("agentId");
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
});
|
|
@@ -157,3 +157,64 @@ describe("auth preHandler — cookie path", () => {
|
|
|
157
157
|
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
|
+
|
|
161
|
+
// ── #3591 D9 — every request names its actor ────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe("auth preHandler — cookie path names the actor (#3591 D9)", () => {
|
|
164
|
+
let app: FastifyInstance;
|
|
165
|
+
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
jest.clearAllMocks();
|
|
168
|
+
(prisma.session.findUnique as jest.Mock).mockResolvedValue({
|
|
169
|
+
id: "sess_1",
|
|
170
|
+
userId: "user_42",
|
|
171
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
172
|
+
activeOrganizationId: null,
|
|
173
|
+
activeWorkspaceId: null,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
afterEach(async () => {
|
|
178
|
+
await app.close();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("decorates request.actor as the person, and no request.agent", async () => {
|
|
182
|
+
app = Fastify();
|
|
183
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
184
|
+
app.get("/actor", async (req) => ({ actor: req.actor ?? null, agent: req.agent ?? null }));
|
|
185
|
+
await app.ready();
|
|
186
|
+
|
|
187
|
+
const res = await app.inject({
|
|
188
|
+
method: "GET",
|
|
189
|
+
url: "/actor",
|
|
190
|
+
headers: { cookie: `${COOKIE_NAME}=sess_tok_abc` },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(res.statusCode).toBe(200);
|
|
194
|
+
expect(res.json()).toEqual({ actor: { type: "user", userId: "user_42" }, agent: null });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("the completion log line carries actorType user, and no agent fields", async () => {
|
|
198
|
+
const lines: Array<Record<string, unknown>> = [];
|
|
199
|
+
app = Fastify({
|
|
200
|
+
logger: {
|
|
201
|
+
level: "info",
|
|
202
|
+
stream: { write: (line: string) => void lines.push(JSON.parse(line)) },
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
206
|
+
app.get("/actor", async () => ({ ok: true }));
|
|
207
|
+
await app.ready();
|
|
208
|
+
|
|
209
|
+
await app.inject({
|
|
210
|
+
method: "GET",
|
|
211
|
+
url: "/actor",
|
|
212
|
+
headers: { cookie: `${COOKIE_NAME}=sess_tok_abc` },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const completed = lines.find((l) => l.msg === "request completed");
|
|
216
|
+
expect(completed).toMatchObject({ actorType: "user" });
|
|
217
|
+
expect(completed).not.toHaveProperty("agentId");
|
|
218
|
+
expect(completed).not.toHaveProperty("onBehalfOfUserId");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -519,3 +519,179 @@ describe("resolveDefaultTenant — the operator bind's own verified-email check
|
|
|
519
519
|
await expect(resolveDefaultTenant(USER_ID)).resolves.toBeNull();
|
|
520
520
|
});
|
|
521
521
|
});
|
|
522
|
+
|
|
523
|
+
// ── #3591 — the base-build agent branch (spec D5, D6, D7) ───────────────────
|
|
524
|
+
|
|
525
|
+
describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
526
|
+
const AGENT_ID = "agent_00000000-0000-7000-8000-0000000000c1" as Id<"agent">;
|
|
527
|
+
|
|
528
|
+
function agentBinding(
|
|
529
|
+
overrides: Partial<{
|
|
530
|
+
workspaceId: string;
|
|
531
|
+
organizationId: string;
|
|
532
|
+
responsibleMembershipId: string;
|
|
533
|
+
}> = {},
|
|
534
|
+
) {
|
|
535
|
+
return {
|
|
536
|
+
agentId: AGENT_ID,
|
|
537
|
+
workspaceId: (overrides.workspaceId ?? DEFAULT_WORKSPACE_ID) as Id<"workspace">,
|
|
538
|
+
organizationId: (overrides.organizationId ?? DEFAULT_ORG_ID) as Id<"organization">,
|
|
539
|
+
responsibleMembershipId: (overrides.responsibleMembershipId ??
|
|
540
|
+
OPERATOR_MEMBERSHIP_ID) as Id<"membership">,
|
|
541
|
+
permissionLevel: "member" as const,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** An agent caller: `request.agent` set, `request.sessionTenant` never. */
|
|
546
|
+
function buildAgentApp(agent: ReturnType<typeof agentBinding>): FastifyInstance {
|
|
547
|
+
const app = Fastify();
|
|
548
|
+
app.addHook("preHandler", async (req) => {
|
|
549
|
+
req.userId = USER_ID;
|
|
550
|
+
req.agent = agent;
|
|
551
|
+
});
|
|
552
|
+
app.register(defaultTenantPreHandlerPlugin);
|
|
553
|
+
app.get("/scoped", async (req) => ({ tenant: req.tenant ?? null }));
|
|
554
|
+
app.get("/workspaces/:workspaceId/general", async (req) => ({
|
|
555
|
+
workspaceId: req.tenant?.workspaceId ?? null,
|
|
556
|
+
}));
|
|
557
|
+
return app;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function mockOperatorMembership(): void {
|
|
561
|
+
mockBoundOperator();
|
|
562
|
+
(prisma.membership.findUnique as jest.Mock).mockResolvedValue({
|
|
563
|
+
id: OPERATOR_MEMBERSHIP_ID,
|
|
564
|
+
role: "owner",
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
it("the pinned operator's agent, bound to the default workspace, is granted the operator's tenant with role member", async () => {
|
|
569
|
+
mockSingleTenantWorkspace();
|
|
570
|
+
mockOperatorMembership();
|
|
571
|
+
const app = buildAgentApp(agentBinding());
|
|
572
|
+
|
|
573
|
+
const res = await app.inject({ method: "GET", url: "/scoped" });
|
|
574
|
+
|
|
575
|
+
expect(res.statusCode).toBe(200);
|
|
576
|
+
expect(res.json().tenant).toEqual({
|
|
577
|
+
userId: USER_ID,
|
|
578
|
+
organizationId: DEFAULT_ORG_ID,
|
|
579
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
580
|
+
role: "member",
|
|
581
|
+
});
|
|
582
|
+
await app.close();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("reaches the grant through the operator's own reads: the same membership compound key", async () => {
|
|
586
|
+
mockSingleTenantWorkspace();
|
|
587
|
+
mockOperatorMembership();
|
|
588
|
+
const app = buildAgentApp(agentBinding());
|
|
589
|
+
|
|
590
|
+
await app.inject({ method: "GET", url: "/scoped" });
|
|
591
|
+
|
|
592
|
+
expect(prisma.membership.findUnique).toHaveBeenCalledWith({
|
|
593
|
+
where: {
|
|
594
|
+
organizationId_userId: { organizationId: DEFAULT_ORG_ID, userId: USER_ID },
|
|
595
|
+
},
|
|
596
|
+
select: { id: true, role: true },
|
|
597
|
+
});
|
|
598
|
+
await app.close();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it.each([
|
|
602
|
+
["bound to another workspace", { workspaceId: FOREIGN_WORKSPACE_ID }],
|
|
603
|
+
["created under another membership", { responsibleMembershipId: "member_00000000-0000-7000-8000-0000000000dd" }],
|
|
604
|
+
["of another organization", { organizationId: "org_00000000-0000-7000-8000-0000000000dd" }],
|
|
605
|
+
])("refuses (403) an agent %s", async (_label, overrides) => {
|
|
606
|
+
mockSingleTenantWorkspace();
|
|
607
|
+
mockOperatorMembership();
|
|
608
|
+
const app = buildAgentApp(agentBinding(overrides));
|
|
609
|
+
|
|
610
|
+
const res = await app.inject({ method: "GET", url: "/scoped" });
|
|
611
|
+
|
|
612
|
+
expect(res.statusCode).toBe(403);
|
|
613
|
+
await app.close();
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("refuses (403) the agent of a person who is not the pinned operator, before any membership read", async () => {
|
|
617
|
+
mockSingleTenantWorkspace();
|
|
618
|
+
mockOperatorMembership();
|
|
619
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
|
620
|
+
email: "someone-else@example.com",
|
|
621
|
+
emailVerified: true,
|
|
622
|
+
});
|
|
623
|
+
const app = buildAgentApp(agentBinding());
|
|
624
|
+
|
|
625
|
+
const res = await app.inject({ method: "GET", url: "/scoped" });
|
|
626
|
+
|
|
627
|
+
expect(res.statusCode).toBe(403);
|
|
628
|
+
expect(prisma.membership.findUnique).not.toHaveBeenCalled();
|
|
629
|
+
await app.close();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("refuses (403) every agent when the operator pin is unset", async () => {
|
|
633
|
+
delete process.env.AILK_OPERATOR_EMAIL;
|
|
634
|
+
mockSingleTenantWorkspace();
|
|
635
|
+
mockOperatorMembership();
|
|
636
|
+
const app = buildAgentApp(agentBinding());
|
|
637
|
+
|
|
638
|
+
const res = await app.inject({ method: "GET", url: "/scoped" });
|
|
639
|
+
|
|
640
|
+
expect(res.statusCode).toBe(403);
|
|
641
|
+
await app.close();
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("refuses (403) an agent naming a foreign workspace in the URL, before the handler runs", async () => {
|
|
645
|
+
mockSingleTenantWorkspace();
|
|
646
|
+
mockOperatorMembership();
|
|
647
|
+
const app = buildAgentApp(agentBinding());
|
|
648
|
+
|
|
649
|
+
const res = await app.inject({
|
|
650
|
+
method: "GET",
|
|
651
|
+
url: `/workspaces/${FOREIGN_WORKSPACE_ID}/general`,
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
expect(res.statusCode).toBe(403);
|
|
655
|
+
await app.close();
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe("defaultTenantPreHandlerPlugin — the tenantGuarded tag (#3591 D6)", () => {
|
|
660
|
+
it("tags every route registered after it in its scope, and no route before it or outside it", async () => {
|
|
661
|
+
// A root onRoute hook runs first and keeps each route's options object;
|
|
662
|
+
// the guard's own hook then tags that same object.
|
|
663
|
+
const routes: Record<string, { config?: { tenantGuarded?: boolean } }> = {};
|
|
664
|
+
const app = Fastify();
|
|
665
|
+
app.addHook("onRoute", (opts) => {
|
|
666
|
+
if (opts.method === "GET") routes[opts.url] = opts;
|
|
667
|
+
});
|
|
668
|
+
app.get("/before", async () => ({}));
|
|
669
|
+
app.register(async (scope) => {
|
|
670
|
+
scope.get("/scope-before", async () => ({}));
|
|
671
|
+
await scope.register(defaultTenantPreHandlerPlugin);
|
|
672
|
+
scope.get("/after", async () => ({}));
|
|
673
|
+
await scope.register(async (child) => {
|
|
674
|
+
child.get("/child", async () => ({}));
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
app.register(async (sibling) => {
|
|
678
|
+
sibling.get("/sibling", async () => ({}));
|
|
679
|
+
});
|
|
680
|
+
await app.ready();
|
|
681
|
+
const tagged = Object.fromEntries(
|
|
682
|
+
["/before", "/scope-before", "/after", "/child", "/sibling"].map((url) => [
|
|
683
|
+
url,
|
|
684
|
+
routes[url]?.config?.tenantGuarded,
|
|
685
|
+
]),
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
expect(tagged).toEqual({
|
|
689
|
+
"/before": undefined,
|
|
690
|
+
"/scope-before": undefined,
|
|
691
|
+
"/after": true,
|
|
692
|
+
"/child": true,
|
|
693
|
+
"/sibling": undefined,
|
|
694
|
+
});
|
|
695
|
+
await app.close();
|
|
696
|
+
});
|
|
697
|
+
});
|