@intx/hub-api 0.1.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.
Files changed (55) hide show
  1. package/README.md +29 -0
  2. package/package.json +28 -0
  3. package/src/app.test.ts +225 -0
  4. package/src/app.ts +382 -0
  5. package/src/auth.ts +21 -0
  6. package/src/context.ts +38 -0
  7. package/src/format.ts +9 -0
  8. package/src/git-http/advertise-refs.test.ts +459 -0
  9. package/src/git-http/advertise-refs.ts +226 -0
  10. package/src/git-http/pkt-line.test.ts +220 -0
  11. package/src/git-http/pkt-line.ts +235 -0
  12. package/src/git-http/receive-pack.test.ts +397 -0
  13. package/src/git-http/receive-pack.ts +261 -0
  14. package/src/git-http/side-band-64k.test.ts +181 -0
  15. package/src/git-http/side-band-64k.ts +134 -0
  16. package/src/git-http/upload-pack.test.ts +545 -0
  17. package/src/git-http/upload-pack.ts +396 -0
  18. package/src/index.ts +23 -0
  19. package/src/middleware/git-token-auth.test.ts +587 -0
  20. package/src/middleware/git-token-auth.ts +315 -0
  21. package/src/middleware/grant.ts +106 -0
  22. package/src/middleware/session.ts +13 -0
  23. package/src/middleware/tenant.test.ts +192 -0
  24. package/src/middleware/tenant.ts +101 -0
  25. package/src/openapi.ts +66 -0
  26. package/src/pagination.ts +117 -0
  27. package/src/routes/agent-data.ts +179 -0
  28. package/src/routes/agent-state-git.ts +562 -0
  29. package/src/routes/agents.test.ts +337 -0
  30. package/src/routes/agents.ts +704 -0
  31. package/src/routes/approvals.ts +130 -0
  32. package/src/routes/assets.test.ts +567 -0
  33. package/src/routes/assets.ts +592 -0
  34. package/src/routes/credentials.ts +435 -0
  35. package/src/routes/git-tokens.test.ts +709 -0
  36. package/src/routes/git-tokens.ts +771 -0
  37. package/src/routes/grants.ts +509 -0
  38. package/src/routes/instances.test.ts +1103 -0
  39. package/src/routes/instances.ts +1797 -0
  40. package/src/routes/me.ts +405 -0
  41. package/src/routes/oauth-clients.ts +349 -0
  42. package/src/routes/observability.ts +146 -0
  43. package/src/routes/offerings.ts +382 -0
  44. package/src/routes/principals.ts +515 -0
  45. package/src/routes/providers.ts +351 -0
  46. package/src/routes/roles.ts +452 -0
  47. package/src/routes/sidecars.ts +221 -0
  48. package/src/routes/tenant-federation.ts +225 -0
  49. package/src/routes/tenants.ts +369 -0
  50. package/src/routes/wallets.ts +370 -0
  51. package/src/session.ts +44 -0
  52. package/src/timeline-reconstruction.test.ts +786 -0
  53. package/src/timeline-reconstruction.ts +383 -0
  54. package/tsconfig.json +4 -0
  55. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,1103 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { createInMemoryGrantStore } from "@intx/authz";
4
+ import type { GrantRule } from "@intx/types/authz";
5
+ import type { SessionStatus } from "@intx/types";
6
+ import type { ConnectorThreadState } from "@intx/types/runtime";
7
+
8
+ import { createApp } from "../app";
9
+ import {
10
+ createSidecarEmitter,
11
+ type EventCollectorRegistry,
12
+ type SessionService,
13
+ type SidecarRouter,
14
+ } from "@intx/hub-sessions";
15
+ import type { GetSession } from "../session";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Test data constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const TENANT_ID = "tnt_test";
22
+ const PRINCIPAL_ID = "prn_test";
23
+ const USER_ID = "usr_test";
24
+ const INSTANCE_ID = "ins_test";
25
+ const AGENT_ID = "agt_test";
26
+ const ADDRESS = "ins_test@test.example.com";
27
+
28
+ const testTenant = {
29
+ id: TENANT_ID,
30
+ name: "Test",
31
+ slug: "test",
32
+ domain: "test.example.com",
33
+ parentId: null,
34
+ config: null,
35
+ createdAt: new Date("2025-01-01"),
36
+ updatedAt: new Date("2025-01-01"),
37
+ };
38
+
39
+ const testPrincipal = {
40
+ id: PRINCIPAL_ID,
41
+ tenantId: TENANT_ID,
42
+ kind: "user" as const,
43
+ refId: USER_ID,
44
+ status: "active" as const,
45
+ createdAt: new Date("2025-01-01"),
46
+ updatedAt: new Date("2025-01-01"),
47
+ };
48
+
49
+ const testInstance = {
50
+ id: INSTANCE_ID,
51
+ agentId: AGENT_ID,
52
+ tenantId: TENANT_ID,
53
+ address: ADDRESS,
54
+ status: "running" as const,
55
+ principalId: "prn_agent",
56
+ kernelId: null,
57
+ sidecarId: null,
58
+ sessionId: "ses_test",
59
+ publicKey: null,
60
+ createdAt: new Date("2025-01-01"),
61
+ updatedAt: new Date("2025-01-01"),
62
+ endedAt: null,
63
+ };
64
+
65
+ const testAgent = { id: AGENT_ID, name: "Test Agent" };
66
+
67
+ function makeGrant(overrides: Partial<GrantRule> = {}): GrantRule {
68
+ return {
69
+ id: "grant-test",
70
+ resource: "instance:*",
71
+ action: "read",
72
+ effect: "allow",
73
+ origin: "system",
74
+ conditions: null,
75
+ expiresAt: null,
76
+ roleId: null,
77
+ principalId: PRINCIPAL_ID,
78
+ ...overrides,
79
+ };
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Mock factories
84
+ //
85
+ // Each test sets up exactly the canned data it expects. The mock DB does NOT
86
+ // evaluate drizzle where-clauses — it returns the canned data as-is. This
87
+ // is intentional: we're testing route behavior, not drizzle's query builder.
88
+ // If a test wants a 404, it omits the relevant data from the mock.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ type TestInstance = Omit<typeof testInstance, "status" | "endedAt"> & {
92
+ status: string;
93
+ endedAt: Date | null;
94
+ };
95
+
96
+ type MockDBOpts = {
97
+ tenant?: typeof testTenant | undefined;
98
+ principal?: typeof testPrincipal | undefined;
99
+ instance?: TestInstance | undefined;
100
+ agent?: typeof testAgent | undefined;
101
+ offerings?: Record<string, unknown>[] | undefined;
102
+ /** Rows returned for the priorMail query used by POST /mail.
103
+ * Defaults to `[]` (no prior session mail). */
104
+ sessionMail?: { id: string }[];
105
+ /** Captured rows passed to db.insert(sessionMail).values(...). */
106
+ inserts?: Record<string, unknown>[];
107
+ };
108
+
109
+ function notImplemented(path: string) {
110
+ return () => {
111
+ throw new Error(`mock: ${path} not implemented`);
112
+ };
113
+ }
114
+
115
+ function createMockDB(opts: MockDBOpts) {
116
+ const sessionMailRows = opts.sessionMail ?? [];
117
+
118
+ // Builder chain that handles two shapes:
119
+ // 1. .from().innerJoin().where().{limit | orderBy().limit()} — the
120
+ // instance+agent join used by the offerings handler.
121
+ // 2. .from().where().orderBy().limit() — the priorMail query used by
122
+ // POST /:instanceId/mail.
123
+ // The mock distinguishes them by whether innerJoin is on the path.
124
+ function selectChain() {
125
+ const joinedRows =
126
+ opts.instance && opts.agent
127
+ ? [{ instance: opts.instance, agentName: opts.agent.name }]
128
+ : [];
129
+
130
+ return {
131
+ from: () => ({
132
+ // join-shaped chain
133
+ innerJoin: () => ({
134
+ where: () => ({
135
+ limit: () => Promise.resolve(joinedRows),
136
+ orderBy: (..._args: unknown[]) => ({
137
+ limit: () => Promise.resolve(joinedRows),
138
+ }),
139
+ }),
140
+ }),
141
+ // non-join chain (priorMail)
142
+ where: () => ({
143
+ orderBy: (..._args: unknown[]) => ({
144
+ limit: () => Promise.resolve(sessionMailRows),
145
+ }),
146
+ limit: () => Promise.resolve(sessionMailRows),
147
+ }),
148
+ }),
149
+ };
150
+ }
151
+
152
+ const insertCapture = opts.inserts;
153
+
154
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle PgDatabase type cannot be structurally satisfied in tests
155
+ return {
156
+ query: {
157
+ tenant: {
158
+ findFirst: async () => opts.tenant,
159
+ findMany: notImplemented("db.query.tenant.findMany"),
160
+ },
161
+ principal: {
162
+ findFirst: async () => opts.principal,
163
+ findMany: notImplemented("db.query.principal.findMany"),
164
+ },
165
+ agentInstance: {
166
+ findFirst: async () => opts.instance,
167
+ findMany: notImplemented("db.query.agentInstance.findMany"),
168
+ },
169
+ offering: {
170
+ findFirst: notImplemented("db.query.offering.findFirst"),
171
+ findMany: async () => opts.offerings ?? [],
172
+ },
173
+ },
174
+ select: selectChain,
175
+ insert: () => ({
176
+ values: (row: Record<string, unknown>) => {
177
+ if (insertCapture !== undefined) {
178
+ insertCapture.push(row);
179
+ }
180
+ return Promise.resolve();
181
+ },
182
+ }),
183
+ } as unknown as Parameters<typeof createApp>[0]["db"];
184
+ }
185
+
186
+ function createMockGetSession(userId: string): GetSession {
187
+ const now = new Date("2025-01-01");
188
+ return async () => ({
189
+ user: {
190
+ id: userId,
191
+ email: "test@example.com",
192
+ emailVerified: true,
193
+ name: "Test User",
194
+ createdAt: now,
195
+ updatedAt: now,
196
+ },
197
+ session: {
198
+ id: "session_test",
199
+ userId,
200
+ token: "tok_test",
201
+ expiresAt: new Date("2999-01-01"),
202
+ createdAt: now,
203
+ updatedAt: now,
204
+ },
205
+ });
206
+ }
207
+
208
+ function createMockSidecarRouter(
209
+ routableAddresses: string[] = [],
210
+ connectorStates = new Map<string, ConnectorThreadState | null>(),
211
+ ): SidecarRouter {
212
+ function notImpl(name: string): never {
213
+ throw new Error(`mock: sidecarRouter.${name} not implemented`);
214
+ }
215
+ return {
216
+ handleOpen(_ws) {
217
+ notImpl("handleOpen");
218
+ },
219
+ handleMessage(_ws, _data) {
220
+ notImpl("handleMessage");
221
+ },
222
+ handleClose(_ws) {
223
+ notImpl("handleClose");
224
+ },
225
+ routeMail(_addr, _msg) {
226
+ return notImpl("routeMail");
227
+ },
228
+ sendAgentDeploy(_addr, _config) {
229
+ return notImpl("sendAgentDeploy");
230
+ },
231
+ sendAgentUndeploy(_addr, _reason) {
232
+ return notImpl("sendAgentUndeploy");
233
+ },
234
+ sendSessionStart(_addr) {
235
+ return notImpl("sendSessionStart");
236
+ },
237
+ sendSessionAbort(_addr, _reason) {
238
+ return notImpl("sendSessionAbort");
239
+ },
240
+ sendGrantsUpdate(_addr, _grants) {
241
+ return notImpl("sendGrantsUpdate");
242
+ },
243
+ sendSourcesUpdate(_addr, _sources, _defaultSource) {
244
+ return notImpl("sendSourcesUpdate");
245
+ },
246
+ sendPack(_addr, _pack, _ref, _sha) {
247
+ return notImpl("sendPack");
248
+ },
249
+ sendSyncRequest(_addr) {
250
+ notImpl("sendSyncRequest");
251
+ },
252
+ subscribeAgent(_addr, _callback) {
253
+ return notImpl("subscribeAgent");
254
+ },
255
+ dispatchAgentEvent(_addr, _event) {
256
+ // No-op default: many routes dispatch events but the tests don't
257
+ // assert on them. Override at the test boundary if assertion is
258
+ // needed.
259
+ },
260
+ getConnectedSidecars: () => [],
261
+ getRoutableAddresses: () => routableAddresses,
262
+ getConnectorState: (addr) => connectorStates.get(addr) ?? null,
263
+ events: createSidecarEmitter(),
264
+ };
265
+ }
266
+
267
+ function createMockSessionService(): SessionService {
268
+ function notImpl(name: string): never {
269
+ throw new Error(`mock: sessionService.${name} not implemented`);
270
+ }
271
+ return {
272
+ launchSession(_params) {
273
+ return notImpl("launchSession");
274
+ },
275
+ sendUserMessage(_params) {
276
+ return notImpl("sendUserMessage");
277
+ },
278
+ endSession(_addr, _reason) {
279
+ return notImpl("endSession");
280
+ },
281
+ };
282
+ }
283
+
284
+ function createMockEventCollectors(
285
+ statuses = new Map<string, SessionStatus>(),
286
+ ): EventCollectorRegistry {
287
+ return {
288
+ create: notImplemented("eventCollectors.create"),
289
+ dispatch: notImplemented("eventCollectors.dispatch"),
290
+ abandon: notImplemented("eventCollectors.abandon"),
291
+ has: (address) => statuses.has(address),
292
+ getStatus: (address) => statuses.get(address),
293
+ getAccumulatedText: () => undefined,
294
+ getCurrentTurnId: () => undefined,
295
+ getLastTurnId: () => undefined,
296
+ };
297
+ }
298
+
299
+ type TestAppOpts = {
300
+ db?: MockDBOpts;
301
+ grants?: GrantRule[];
302
+ routableAddresses?: string[];
303
+ connectorStates?: Map<string, ConnectorThreadState | null>;
304
+ sessionService?: SessionService;
305
+ collectorStatuses?: Map<string, SessionStatus>;
306
+ };
307
+
308
+ function createTestApp(opts: TestAppOpts = {}) {
309
+ const db = createMockDB(
310
+ opts.db ?? {
311
+ tenant: testTenant,
312
+ principal: testPrincipal,
313
+ instance: testInstance,
314
+ agent: testAgent,
315
+ },
316
+ );
317
+
318
+ return createApp({
319
+ getSession: createMockGetSession(USER_ID),
320
+ authHandler: () => new Response("", { status: 404 }),
321
+ db,
322
+ grantStore: createInMemoryGrantStore(opts.grants ?? [makeGrant()]),
323
+ sidecarRouter: createMockSidecarRouter(
324
+ opts.routableAddresses,
325
+ opts.connectorStates,
326
+ ),
327
+ sessionService: opts.sessionService ?? createMockSessionService(),
328
+ eventCollectors: createMockEventCollectors(opts.collectorStatuses),
329
+ assetService: null,
330
+ repoStore: null,
331
+ });
332
+ }
333
+
334
+ function instanceURL(tenantId = TENANT_ID, instanceId = INSTANCE_ID): string {
335
+ return `/api/tenants/${tenantId}/agents/instances/${instanceId}`;
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Smoke test — verifies the mock infrastructure satisfies the middleware chain
340
+ // ---------------------------------------------------------------------------
341
+
342
+ describe("instance route test infrastructure", () => {
343
+ test("authenticated request reaches the route handler", async () => {
344
+ const app = createTestApp();
345
+ const res = await app.request(`${instanceURL()}/health`);
346
+ expect(res.status).toBe(200);
347
+ });
348
+
349
+ test("missing grant returns 403", async () => {
350
+ const app = createTestApp({ grants: [] });
351
+ const res = await app.request(`${instanceURL()}/health`);
352
+ expect(res.status).toBe(403);
353
+ });
354
+ });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Health endpoint tests
358
+ // ---------------------------------------------------------------------------
359
+
360
+ describe("GET /agents/instances/:instanceId/health", () => {
361
+ test("returns ok/ok when address is routable and collector exists", async () => {
362
+ const app = createTestApp({
363
+ routableAddresses: [ADDRESS],
364
+ collectorStatuses: new Map([[ADDRESS, { status: "idle" }]]),
365
+ });
366
+
367
+ const res = await app.request(`${instanceURL()}/health`);
368
+ expect(res.status).toBe(200);
369
+
370
+ const body = await res.json();
371
+ expect(body).toEqual({
372
+ liveness: "ok",
373
+ readiness: "ok",
374
+ lastCheckedAt: null,
375
+ });
376
+ });
377
+
378
+ test("returns unhealthy/not_ready when not routable and no collector", async () => {
379
+ const app = createTestApp({
380
+ routableAddresses: [],
381
+ collectorStatuses: new Map(),
382
+ });
383
+
384
+ const res = await app.request(`${instanceURL()}/health`);
385
+ expect(res.status).toBe(200);
386
+
387
+ const body = await res.json();
388
+ expect(body).toEqual({
389
+ liveness: "unhealthy",
390
+ readiness: "not_ready",
391
+ lastCheckedAt: null,
392
+ });
393
+ });
394
+
395
+ test("returns ok/not_ready when routable but no collector", async () => {
396
+ const app = createTestApp({
397
+ routableAddresses: [ADDRESS],
398
+ collectorStatuses: new Map(),
399
+ });
400
+
401
+ const res = await app.request(`${instanceURL()}/health`);
402
+ expect(res.status).toBe(200);
403
+
404
+ const body = await res.json();
405
+ expect(body).toEqual({
406
+ liveness: "ok",
407
+ readiness: "not_ready",
408
+ lastCheckedAt: null,
409
+ });
410
+ });
411
+
412
+ test("returns unhealthy/ok when not routable but collector exists", async () => {
413
+ const app = createTestApp({
414
+ routableAddresses: [],
415
+ collectorStatuses: new Map([[ADDRESS, { status: "busy" }]]),
416
+ });
417
+
418
+ const res = await app.request(`${instanceURL()}/health`);
419
+ expect(res.status).toBe(200);
420
+
421
+ const body = await res.json();
422
+ expect(body).toEqual({
423
+ liveness: "unhealthy",
424
+ readiness: "ok",
425
+ lastCheckedAt: null,
426
+ });
427
+ });
428
+
429
+ test("returns 404 when instance does not exist", async () => {
430
+ const app = createTestApp({
431
+ db: {
432
+ tenant: testTenant,
433
+ principal: testPrincipal,
434
+ instance: undefined,
435
+ agent: testAgent,
436
+ },
437
+ });
438
+
439
+ const res = await app.request(`${instanceURL()}/health`);
440
+ expect(res.status).toBe(404);
441
+
442
+ const body: unknown = await res.json();
443
+ expect(body).toMatchObject({ error: { code: "not_found" } });
444
+ });
445
+
446
+ test("returns 410 when instance is stopped", async () => {
447
+ const stoppedInstance = {
448
+ ...testInstance,
449
+ status: "stopped" as const,
450
+ endedAt: new Date("2025-06-01"),
451
+ };
452
+
453
+ const app = createTestApp({
454
+ db: {
455
+ tenant: testTenant,
456
+ principal: testPrincipal,
457
+ instance: stoppedInstance,
458
+ agent: testAgent,
459
+ },
460
+ });
461
+
462
+ const res = await app.request(`${instanceURL()}/health`);
463
+ expect(res.status).toBe(410);
464
+
465
+ const body: unknown = await res.json();
466
+ expect(body).toMatchObject({ error: { code: "gone" } });
467
+ });
468
+ });
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Offerings endpoint tests
472
+ // ---------------------------------------------------------------------------
473
+
474
+ describe("GET /agents/instances/:instanceId/offerings", () => {
475
+ test("returns offerings for the instance's agent definition", async () => {
476
+ const offerings = [
477
+ {
478
+ id: "off_1",
479
+ agentId: AGENT_ID,
480
+ tenantId: TENANT_ID,
481
+ name: "Translation",
482
+ description: "Translate text",
483
+ pricing: { base: { amount: "10", currency: "USD" } },
484
+ schema: null,
485
+ createdAt: new Date("2025-01-01"),
486
+ updatedAt: new Date("2025-01-01"),
487
+ },
488
+ {
489
+ id: "off_2",
490
+ agentId: AGENT_ID,
491
+ tenantId: TENANT_ID,
492
+ name: "Summarization",
493
+ description: null,
494
+ pricing: null,
495
+ schema: null,
496
+ createdAt: new Date("2025-01-02"),
497
+ updatedAt: new Date("2025-01-02"),
498
+ },
499
+ ];
500
+
501
+ const app = createTestApp({
502
+ db: {
503
+ tenant: testTenant,
504
+ principal: testPrincipal,
505
+ instance: testInstance,
506
+ agent: testAgent,
507
+ offerings,
508
+ },
509
+ });
510
+
511
+ const res = await app.request(`${instanceURL()}/offerings`);
512
+ expect(res.status).toBe(200);
513
+
514
+ const body: unknown = await res.json();
515
+ expect(body).toHaveLength(2);
516
+ expect(body).toMatchObject([
517
+ { id: "off_1", agentName: "Test Agent", name: "Translation" },
518
+ { id: "off_2", name: "Summarization" },
519
+ ]);
520
+ });
521
+
522
+ test("returns empty array when no offerings exist", async () => {
523
+ const app = createTestApp({
524
+ db: {
525
+ tenant: testTenant,
526
+ principal: testPrincipal,
527
+ instance: testInstance,
528
+ agent: testAgent,
529
+ offerings: [],
530
+ },
531
+ });
532
+
533
+ const res = await app.request(`${instanceURL()}/offerings`);
534
+ expect(res.status).toBe(200);
535
+
536
+ const body = await res.json();
537
+ expect(body).toEqual([]);
538
+ });
539
+
540
+ test("returns 404 when instance does not exist", async () => {
541
+ const app = createTestApp({
542
+ db: {
543
+ tenant: testTenant,
544
+ principal: testPrincipal,
545
+ instance: undefined,
546
+ agent: undefined,
547
+ },
548
+ });
549
+
550
+ const res = await app.request(`${instanceURL()}/offerings`);
551
+ expect(res.status).toBe(404);
552
+
553
+ const body: unknown = await res.json();
554
+ expect(body).toMatchObject({ error: { code: "not_found" } });
555
+ });
556
+
557
+ test("returns offerings for stopped instances", async () => {
558
+ const stoppedInstance = {
559
+ ...testInstance,
560
+ status: "stopped" as const,
561
+ endedAt: new Date("2025-06-01"),
562
+ };
563
+
564
+ const offerings = [
565
+ {
566
+ id: "off_1",
567
+ agentId: AGENT_ID,
568
+ tenantId: TENANT_ID,
569
+ name: "Translation",
570
+ description: "Translate text",
571
+ pricing: null,
572
+ schema: null,
573
+ createdAt: new Date("2025-01-01"),
574
+ updatedAt: new Date("2025-01-01"),
575
+ },
576
+ ];
577
+
578
+ const app = createTestApp({
579
+ db: {
580
+ tenant: testTenant,
581
+ principal: testPrincipal,
582
+ instance: stoppedInstance,
583
+ agent: testAgent,
584
+ offerings,
585
+ },
586
+ });
587
+
588
+ const res = await app.request(`${instanceURL()}/offerings`);
589
+ expect(res.status).toBe(200);
590
+
591
+ const body: unknown = await res.json();
592
+ expect(body).toHaveLength(1);
593
+ expect(body).toMatchObject([{ id: "off_1", agentName: "Test Agent" }]);
594
+ });
595
+ });
596
+
597
+ // ---------------------------------------------------------------------------
598
+ // Blob endpoint routing test
599
+ // ---------------------------------------------------------------------------
600
+
601
+ describe("GET /agents/instances/blobs/:blobId", () => {
602
+ test("blob route is reachable and not shadowed by /:instanceId", async () => {
603
+ const app = createTestApp();
604
+ const url = `/api/tenants/${TENANT_ID}/agents/instances/blobs/bad-format`;
605
+ const res = await app.request(url);
606
+
607
+ // The blob handler rejects malformed IDs with 400.
608
+ // If /:instanceId shadowed this route, we'd get 404 (no instance "blobs").
609
+ expect(res.status).toBe(400);
610
+ const body: unknown = await res.json();
611
+ expect(body).toMatchObject({ error: { code: "bad_request" } });
612
+ });
613
+ });
614
+
615
+ // ---------------------------------------------------------------------------
616
+ // POST /:instanceId/mail — threading-header policy
617
+ // ---------------------------------------------------------------------------
618
+
619
+ describe("POST /agents/instances/:instanceId/mail", () => {
620
+ // The user's bare addr-spec is `${principal.refId}@${tenant.domain}`.
621
+ const USER_ADDR = `${USER_ID}@${testTenant.domain}`;
622
+
623
+ function makeMailGrant(): GrantRule {
624
+ return makeGrant({ resource: "instance:*", action: "write" });
625
+ }
626
+
627
+ type CapturedSendArgs = {
628
+ inReplyTo?: string;
629
+ references?: string[];
630
+ };
631
+
632
+ function captureSendUserMessage(): {
633
+ service: SessionService;
634
+ captured: CapturedSendArgs[];
635
+ } {
636
+ const captured: CapturedSendArgs[] = [];
637
+ const service: SessionService = {
638
+ launchSession() {
639
+ throw new Error("not implemented");
640
+ },
641
+ endSession() {
642
+ throw new Error("not implemented");
643
+ },
644
+ sendUserMessage(params) {
645
+ captured.push({
646
+ ...(params.inReplyTo !== undefined
647
+ ? { inReplyTo: params.inReplyTo }
648
+ : {}),
649
+ ...(params.references !== undefined
650
+ ? { references: params.references }
651
+ : {}),
652
+ });
653
+ return Promise.resolve(new Uint8Array([1, 2, 3]));
654
+ },
655
+ };
656
+ return { service, captured };
657
+ }
658
+
659
+ async function postMail(app: ReturnType<typeof createTestApp>) {
660
+ return app.request(`${instanceURL()}/mail`, {
661
+ method: "POST",
662
+ headers: { "content-type": "application/json" },
663
+ body: JSON.stringify({ content: "hello agent" }),
664
+ });
665
+ }
666
+
667
+ test("no active connector → no threading headers", async () => {
668
+ const { service, captured } = captureSendUserMessage();
669
+ const app = createTestApp({
670
+ grants: [makeMailGrant()],
671
+ sessionService: service,
672
+ // connectorStates default empty → getConnectorState returns null
673
+ });
674
+
675
+ const res = await postMail(app);
676
+ expect(res.status).toBe(201);
677
+ expect(captured).toHaveLength(1);
678
+ expect(captured[0]?.inReplyTo).toBeUndefined();
679
+ expect(captured[0]?.references).toBeUndefined();
680
+ });
681
+
682
+ test("active connector started by the same user → user continues the thread", async () => {
683
+ const { service, captured } = captureSendUserMessage();
684
+ const connectorStates = new Map<string, ConnectorThreadState | null>();
685
+ connectorStates.set(ADDRESS, {
686
+ threadRoot: "<root@example.com>",
687
+ lastMessageId: "<last@example.com>",
688
+ replyTo: USER_ADDR,
689
+ cc: [],
690
+ });
691
+
692
+ const app = createTestApp({
693
+ grants: [makeMailGrant()],
694
+ sessionService: service,
695
+ connectorStates,
696
+ });
697
+
698
+ const res = await postMail(app);
699
+ expect(res.status).toBe(201);
700
+ expect(captured).toHaveLength(1);
701
+ expect(captured[0]?.inReplyTo).toBe("<last@example.com>");
702
+ expect(captured[0]?.references).toEqual(["<root@example.com>"]);
703
+ });
704
+
705
+ test("active connector started by another peer → user joins the same thread", async () => {
706
+ // The connector is one durable shared thread per agent. A user
707
+ // opening a session against an agent whose active thread was
708
+ // started by another peer (a parent agent that launched this one,
709
+ // a peer agent, a prior session by anyone else) joins that thread
710
+ // — the agent's next connector.reply will then CC the prior
711
+ // speaker alongside the user.
712
+ const { service, captured } = captureSendUserMessage();
713
+ const connectorStates = new Map<string, ConnectorThreadState | null>();
714
+ connectorStates.set(ADDRESS, {
715
+ threadRoot: "<root@example.com>",
716
+ lastMessageId: "<last@example.com>",
717
+ replyTo: "someone-else@example.com",
718
+ cc: [],
719
+ });
720
+
721
+ const app = createTestApp({
722
+ grants: [makeMailGrant()],
723
+ sessionService: service,
724
+ connectorStates,
725
+ });
726
+
727
+ const res = await postMail(app);
728
+ expect(res.status).toBe(201);
729
+ expect(captured).toHaveLength(1);
730
+ expect(captured[0]?.inReplyTo).toBe("<last@example.com>");
731
+ expect(captured[0]?.references).toEqual(["<root@example.com>"]);
732
+ });
733
+
734
+ test("session history takes precedence over the connector cache", async () => {
735
+ const { service, captured } = captureSendUserMessage();
736
+ const connectorStates = new Map<string, ConnectorThreadState | null>();
737
+ connectorStates.set(ADDRESS, {
738
+ threadRoot: "<root@example.com>",
739
+ lastMessageId: "<connector-last@example.com>",
740
+ replyTo: USER_ADDR,
741
+ cc: [],
742
+ });
743
+
744
+ const app = createTestApp({
745
+ grants: [makeMailGrant()],
746
+ sessionService: service,
747
+ connectorStates,
748
+ db: {
749
+ tenant: testTenant,
750
+ principal: testPrincipal,
751
+ instance: testInstance,
752
+ agent: testAgent,
753
+ sessionMail: [{ id: "prior-1" }],
754
+ },
755
+ });
756
+
757
+ const res = await postMail(app);
758
+ expect(res.status).toBe(201);
759
+ expect(captured).toHaveLength(1);
760
+ expect(captured[0]?.inReplyTo).toBe(`<prior-1@${testTenant.domain}>`);
761
+ expect(captured[0]?.references).toEqual([`<prior-1@${testTenant.domain}>`]);
762
+ });
763
+ });
764
+
765
+ // ---------------------------------------------------------------------------
766
+ // POST /agents/instances — creator-grant seed on launch
767
+ //
768
+ // These tests exercise the launch transaction directly. The mock DB below is
769
+ // independent of the smaller mock used by the other suites in this file: it
770
+ // supports db.transaction, captures insert calls per table, and stubs the
771
+ // surface area of credential resolution (providers, credentials, ancestor
772
+ // chain) that the launch path traverses before reaching the transaction
773
+ // block. The single instance launched per test is the canonical fixture; we
774
+ // assert on the grant row written for resource `agent-state:<instanceId>`.
775
+ // ---------------------------------------------------------------------------
776
+
777
+ describe("POST /agents/instances seeds creator agent-state grant", () => {
778
+ const CREATOR_ID = "prn_creator";
779
+ const PROVIDER_ID = "prv_test";
780
+ const CREDENTIAL_ID = "cred_test";
781
+ const AGENT_DEF_ID = "agt_def";
782
+
783
+ type TableInsert = { table: string; rows: Record<string, unknown>[] };
784
+
785
+ function drizzleTableName(table: unknown): string {
786
+ if (table && typeof table === "object") {
787
+ const sym = Object.getOwnPropertySymbols(table).find(
788
+ (s) => s.description === "drizzle:Name",
789
+ );
790
+ if (sym) {
791
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle stores the table name keyed by a documented symbol
792
+ const value = (table as Record<symbol, unknown>)[sym];
793
+ if (typeof value === "string") return value;
794
+ }
795
+ }
796
+ return "unknown";
797
+ }
798
+
799
+ type LaunchMockOpts = {
800
+ agent: Record<string, unknown> | undefined;
801
+ inserts: TableInsert[];
802
+ provider?: Record<string, unknown> | undefined;
803
+ credential?: Record<string, unknown> | undefined;
804
+ };
805
+
806
+ function createLaunchMockDB(opts: LaunchMockOpts) {
807
+ function insertChain(table: unknown) {
808
+ const name = drizzleTableName(table);
809
+ return {
810
+ values: (
811
+ rowsOrRow: Record<string, unknown> | Record<string, unknown>[],
812
+ ) => {
813
+ const rows = Array.isArray(rowsOrRow) ? rowsOrRow : [rowsOrRow];
814
+ opts.inserts.push({ table: name, rows });
815
+ return {
816
+ returning: () => Promise.resolve(rows),
817
+ then: (resolve: (v: undefined) => unknown) => resolve(undefined),
818
+ };
819
+ },
820
+ };
821
+ }
822
+
823
+ const txLike = { insert: insertChain };
824
+
825
+ function updateChain() {
826
+ return {
827
+ set: () => ({
828
+ where: () => {
829
+ const result = Promise.resolve();
830
+ return Object.assign(result, {
831
+ returning: () =>
832
+ Promise.resolve([
833
+ {
834
+ id: "ins_new",
835
+ agentId: AGENT_DEF_ID,
836
+ tenantId: TENANT_ID,
837
+ address: "ins_new@test.example.com",
838
+ status: "running",
839
+ principalId: "prn_instance",
840
+ kernelId: null,
841
+ sidecarId: null,
842
+ sessionId: "ses_new",
843
+ publicKey: null,
844
+ createdAt: new Date("2025-01-01"),
845
+ updatedAt: new Date("2025-01-01"),
846
+ endedAt: null,
847
+ },
848
+ ]),
849
+ });
850
+ },
851
+ }),
852
+ };
853
+ }
854
+
855
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle PgDatabase type cannot be structurally satisfied in tests
856
+ return {
857
+ query: {
858
+ tenant: {
859
+ findFirst: async () => testTenant,
860
+ findMany: notImplemented("db.query.tenant.findMany"),
861
+ },
862
+ principal: {
863
+ findFirst: async () => testPrincipal,
864
+ findMany: notImplemented("db.query.principal.findMany"),
865
+ },
866
+ agent: {
867
+ findFirst: async () => opts.agent,
868
+ findMany: notImplemented("db.query.agent.findMany"),
869
+ },
870
+ agentRole: {
871
+ findFirst: notImplemented("db.query.agentRole.findFirst"),
872
+ findMany: async () => [],
873
+ },
874
+ role: {
875
+ findFirst: notImplemented("db.query.role.findFirst"),
876
+ findMany: async () => [],
877
+ },
878
+ provider: {
879
+ findFirst: async () => opts.provider,
880
+ findMany: async () => (opts.provider ? [opts.provider] : []),
881
+ },
882
+ credential: {
883
+ findFirst: async () => opts.credential,
884
+ findMany: async () => (opts.credential ? [opts.credential] : []),
885
+ },
886
+ },
887
+ transaction: async (fn: (tx: typeof txLike) => Promise<unknown>) =>
888
+ fn(txLike),
889
+ insert: insertChain,
890
+ update: updateChain,
891
+ } as unknown as Parameters<typeof createApp>[0]["db"];
892
+ }
893
+
894
+ function createLaunchGrantStore(): ReturnType<
895
+ typeof createInMemoryGrantStore
896
+ > {
897
+ return createInMemoryGrantStore([
898
+ // The invoking user holds an instance:* create grant.
899
+ makeGrant({
900
+ id: "g-instance-create",
901
+ resource: "instance:*",
902
+ action: "create",
903
+ }),
904
+ ]);
905
+ }
906
+
907
+ function makeAgentDef(): Record<string, unknown> {
908
+ return {
909
+ id: AGENT_DEF_ID,
910
+ tenantId: TENANT_ID,
911
+ creatorPrincipalId: CREATOR_ID,
912
+ name: "Test Agent",
913
+ description: null,
914
+ systemPrompt: "You are a test agent.",
915
+ contextConfig: null,
916
+ initialState: null,
917
+ modelConfig: { defaultModel: "test-model" },
918
+ capabilities: null,
919
+ credentialRequirements: [
920
+ { source: "tenant", providerName: "test-provider" },
921
+ ],
922
+ grantRequirements: null,
923
+ currentVersion: "1",
924
+ status: "deployed",
925
+ createdAt: new Date("2025-01-01"),
926
+ updatedAt: new Date("2025-01-01"),
927
+ };
928
+ }
929
+
930
+ function makeProvider(): Record<string, unknown> {
931
+ return {
932
+ id: PROVIDER_ID,
933
+ tenantId: TENANT_ID,
934
+ name: "test-provider",
935
+ plugin: "openai",
936
+ metadata: { baseURL: "https://api.test.example.com" },
937
+ };
938
+ }
939
+
940
+ function makeCredential(): Record<string, unknown> {
941
+ return {
942
+ id: CREDENTIAL_ID,
943
+ tenantId: TENANT_ID,
944
+ providerId: PROVIDER_ID,
945
+ principalId: null,
946
+ name: "test-cred",
947
+ status: "active",
948
+ scopes: null,
949
+ secret: "sk-test",
950
+ };
951
+ }
952
+
953
+ function createCapturingSessionService(): SessionService {
954
+ return {
955
+ launchSession: async () => undefined,
956
+ sendUserMessage: () => {
957
+ throw new Error("mock: sendUserMessage not implemented");
958
+ },
959
+ endSession: () => {
960
+ throw new Error("mock: endSession not implemented");
961
+ },
962
+ };
963
+ }
964
+
965
+ function createCapturingEventCollectors(): EventCollectorRegistry {
966
+ return {
967
+ create: () => undefined,
968
+ dispatch: notImplemented("eventCollectors.dispatch"),
969
+ abandon: () => undefined,
970
+ has: () => false,
971
+ getStatus: () => undefined,
972
+ getAccumulatedText: () => undefined,
973
+ getCurrentTurnId: () => undefined,
974
+ getLastTurnId: () => undefined,
975
+ };
976
+ }
977
+
978
+ test("launch transaction inserts agent-state read grant on creator", async () => {
979
+ const inserts: TableInsert[] = [];
980
+
981
+ const db = createLaunchMockDB({
982
+ agent: makeAgentDef(),
983
+ provider: makeProvider(),
984
+ credential: makeCredential(),
985
+ inserts,
986
+ });
987
+
988
+ const app = createApp({
989
+ getSession: createMockGetSession(USER_ID),
990
+ authHandler: () => new Response("", { status: 404 }),
991
+ db,
992
+ grantStore: createLaunchGrantStore(),
993
+ sidecarRouter: createMockSidecarRouter(),
994
+ sessionService: createCapturingSessionService(),
995
+ eventCollectors: createCapturingEventCollectors(),
996
+ assetService: null,
997
+ repoStore: null,
998
+ });
999
+
1000
+ const res = await app.request(
1001
+ `/api/tenants/${TENANT_ID}/agents/instances`,
1002
+ {
1003
+ method: "POST",
1004
+ headers: { "content-type": "application/json" },
1005
+ body: JSON.stringify({ agentId: AGENT_DEF_ID }),
1006
+ },
1007
+ );
1008
+
1009
+ expect(res.status).toBe(201);
1010
+
1011
+ const grantInserts = inserts.filter((i) => i.table === "grant");
1012
+ expect(grantInserts.length).toBeGreaterThan(0);
1013
+
1014
+ const allGrantRows = grantInserts.flatMap((g) => g.rows);
1015
+ const stateGrant = allGrantRows.find(
1016
+ (g) =>
1017
+ typeof g["resource"] === "string" &&
1018
+ (g["resource"] as string).startsWith("agent-state:"),
1019
+ );
1020
+
1021
+ expect(stateGrant).toBeDefined();
1022
+ expect(stateGrant).toMatchObject({
1023
+ tenantId: TENANT_ID,
1024
+ principalId: CREATOR_ID,
1025
+ action: "read",
1026
+ effect: "allow",
1027
+ origin: "creator",
1028
+ });
1029
+
1030
+ const instanceInserts = inserts.filter((i) => i.table === "agent_instance");
1031
+ expect(instanceInserts).toHaveLength(1);
1032
+ const instanceRow = instanceInserts[0]?.rows[0];
1033
+ expect(instanceRow).toBeDefined();
1034
+ const instanceId = instanceRow?.["id"];
1035
+ if (typeof instanceId !== "string") {
1036
+ throw new Error(
1037
+ "expected captured agent_instance insert to carry a string id",
1038
+ );
1039
+ }
1040
+ expect(stateGrant?.["resource"]).toBe(`agent-state:${instanceId}`);
1041
+ });
1042
+
1043
+ test("agent-state grant insert is ordered after the agent_instance insert", async () => {
1044
+ const inserts: TableInsert[] = [];
1045
+
1046
+ const db = createLaunchMockDB({
1047
+ agent: makeAgentDef(),
1048
+ provider: makeProvider(),
1049
+ credential: makeCredential(),
1050
+ inserts,
1051
+ });
1052
+
1053
+ const app = createApp({
1054
+ getSession: createMockGetSession(USER_ID),
1055
+ authHandler: () => new Response("", { status: 404 }),
1056
+ db,
1057
+ grantStore: createLaunchGrantStore(),
1058
+ sidecarRouter: createMockSidecarRouter(),
1059
+ sessionService: createCapturingSessionService(),
1060
+ eventCollectors: createCapturingEventCollectors(),
1061
+ assetService: null,
1062
+ repoStore: null,
1063
+ });
1064
+
1065
+ const res = await app.request(
1066
+ `/api/tenants/${TENANT_ID}/agents/instances`,
1067
+ {
1068
+ method: "POST",
1069
+ headers: { "content-type": "application/json" },
1070
+ body: JSON.stringify({ agentId: AGENT_DEF_ID }),
1071
+ },
1072
+ );
1073
+
1074
+ expect(res.status).toBe(201);
1075
+
1076
+ // Walk the insert log: find the agent_instance row first, then the
1077
+ // first agent-state grant after it.
1078
+ let sawInstance = false;
1079
+ let sawStateGrantAfterInstance = false;
1080
+ for (const ins of inserts) {
1081
+ if (ins.table === "agent_instance") {
1082
+ sawInstance = true;
1083
+ continue;
1084
+ }
1085
+ if (!sawInstance) continue;
1086
+ if (ins.table === "grant") {
1087
+ for (const row of ins.rows) {
1088
+ if (
1089
+ typeof row["resource"] === "string" &&
1090
+ (row["resource"] as string).startsWith("agent-state:")
1091
+ ) {
1092
+ sawStateGrantAfterInstance = true;
1093
+ break;
1094
+ }
1095
+ }
1096
+ }
1097
+ if (sawStateGrantAfterInstance) break;
1098
+ }
1099
+
1100
+ expect(sawInstance).toBe(true);
1101
+ expect(sawStateGrantAfterInstance).toBe(true);
1102
+ });
1103
+ });