@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,709 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { describe, test, expect } from "bun:test";
4
+ import { Hono } from "hono";
5
+ import { type } from "arktype";
6
+
7
+ import { createInMemoryGrantStore } from "@intx/authz";
8
+ import type { GrantRule } from "@intx/types/authz";
9
+ import type { DB } from "@intx/db";
10
+
11
+ import { createApp } from "../app";
12
+ import {
13
+ createSidecarEmitter,
14
+ type EventCollectorRegistry,
15
+ type SessionService,
16
+ type SidecarRouter,
17
+ } from "@intx/hub-sessions";
18
+ import type { GetSession } from "../session";
19
+ import {
20
+ createGitTokenAuth,
21
+ type GitTokenAuthEnv,
22
+ } from "../middleware/git-token-auth";
23
+
24
+ const TENANT_ID = "tnt_test";
25
+ const OTHER_TENANT_ID = "tnt_other";
26
+ const PRINCIPAL_ID = "prn_test";
27
+ const USER_ID = "usr_test";
28
+ const OTHER_USER_ID = "usr_other";
29
+
30
+ const testTenant = {
31
+ id: TENANT_ID,
32
+ name: "Test",
33
+ slug: "test",
34
+ domain: "test.example.com",
35
+ parentId: null,
36
+ config: null,
37
+ createdAt: new Date("2025-01-01"),
38
+ updatedAt: new Date("2025-01-01"),
39
+ };
40
+
41
+ const testPrincipal = {
42
+ id: PRINCIPAL_ID,
43
+ tenantId: TENANT_ID,
44
+ kind: "user" as const,
45
+ refId: USER_ID,
46
+ status: "active" as const,
47
+ createdAt: new Date("2025-01-01"),
48
+ updatedAt: new Date("2025-01-01"),
49
+ };
50
+
51
+ type GitTokenRow = {
52
+ id: string;
53
+ userId: string;
54
+ principalId: string | null;
55
+ tenantId: string | null;
56
+ name: string;
57
+ kind: "pat" | "svc";
58
+ tokenHashSha256: Uint8Array;
59
+ resource: string;
60
+ refPattern: string;
61
+ actions: string[];
62
+ expiresAt: Date;
63
+ revokedAt: Date | null;
64
+ createdAt: Date;
65
+ };
66
+
67
+ type MockDBState = {
68
+ gitTokens: GitTokenRow[];
69
+ };
70
+
71
+ function tableName(table: unknown): string {
72
+ if (table && typeof table === "object") {
73
+ const sym = Object.getOwnPropertySymbols(table).find(
74
+ (s) => s.description === "drizzle:Name",
75
+ );
76
+ if (sym) {
77
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle stores the table name keyed by a documented symbol
78
+ const value = (table as Record<symbol, unknown>)[sym];
79
+ if (typeof value === "string") return value;
80
+ }
81
+ }
82
+ return "unknown";
83
+ }
84
+
85
+ function notImplemented(path: string) {
86
+ return () => {
87
+ throw new Error(`mock: ${path} not implemented`);
88
+ };
89
+ }
90
+
91
+ function createMockDB(state: MockDBState): DB["db"] {
92
+ function insertChain(table: unknown) {
93
+ const name = tableName(table);
94
+ return {
95
+ values: (
96
+ rowsOrRow: Record<string, unknown> | Record<string, unknown>[],
97
+ ) => {
98
+ const rows = Array.isArray(rowsOrRow) ? rowsOrRow : [rowsOrRow];
99
+ if (name === "git_token") {
100
+ for (const row of rows) {
101
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the route always inserts a fully-typed git-token row
102
+ state.gitTokens.push(row as unknown as GitTokenRow);
103
+ }
104
+ }
105
+ return {
106
+ returning: () => Promise.resolve(rows),
107
+ then: (resolve: (v: undefined) => unknown) => resolve(undefined),
108
+ };
109
+ },
110
+ };
111
+ }
112
+
113
+ function updateChain(table: unknown) {
114
+ const name = tableName(table);
115
+ return {
116
+ set: (updates: Record<string, unknown>) => ({
117
+ where: (_clause: unknown) => {
118
+ if (name === "git_token" && "revokedAt" in updates) {
119
+ const next = updates["revokedAt"];
120
+ if (!(next instanceof Date)) {
121
+ throw new Error("mock: expected revokedAt to be a Date");
122
+ }
123
+ // Apply the soft-revoke update to every row in state — the
124
+ // route narrows by primary key in its WHERE so the test
125
+ // fixtures only ever hold a single matching row.
126
+ for (const row of state.gitTokens) {
127
+ row.revokedAt = next;
128
+ }
129
+ }
130
+ return Promise.resolve(undefined);
131
+ },
132
+ }),
133
+ };
134
+ }
135
+
136
+ const mock = {
137
+ query: {
138
+ tenant: {
139
+ findFirst: async () => testTenant,
140
+ findMany: notImplemented("db.query.tenant.findMany"),
141
+ },
142
+ principal: {
143
+ findFirst: async () => testPrincipal,
144
+ findMany: notImplemented("db.query.principal.findMany"),
145
+ },
146
+ gitToken: {
147
+ findFirst: async (opts?: { where?: unknown }) => {
148
+ // Without parsing drizzle's filter representation, we rely on
149
+ // the test-fixture invariant: each test setup has at most one
150
+ // matching row for the filters the routes actually issue
151
+ // (filter by id alone, or by id + tenantId). Returning the
152
+ // single row keeps the mock simple while exercising the
153
+ // route's branches.
154
+ void opts;
155
+ return state.gitTokens[0];
156
+ },
157
+ findMany: async () => state.gitTokens,
158
+ },
159
+ },
160
+ transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
161
+ fn({ insert: insertChain, update: updateChain }),
162
+ insert: insertChain,
163
+ update: updateChain,
164
+ };
165
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle PgDatabase type cannot be structurally satisfied in tests
166
+ return mock as unknown as DB["db"];
167
+ }
168
+
169
+ function makeGrant(overrides: Partial<GrantRule> = {}): GrantRule {
170
+ return {
171
+ id: "grant-test",
172
+ resource: "git-token:*",
173
+ action: "create",
174
+ effect: "allow",
175
+ origin: "system",
176
+ conditions: null,
177
+ expiresAt: null,
178
+ roleId: null,
179
+ principalId: PRINCIPAL_ID,
180
+ ...overrides,
181
+ };
182
+ }
183
+
184
+ function createMockGetSession(userId: string): GetSession {
185
+ const now = new Date("2025-01-01");
186
+ return async () => ({
187
+ user: {
188
+ id: userId,
189
+ email: "test@example.com",
190
+ emailVerified: true,
191
+ name: "Test User",
192
+ createdAt: now,
193
+ updatedAt: now,
194
+ },
195
+ session: {
196
+ id: "session_test",
197
+ userId,
198
+ token: "tok_test",
199
+ expiresAt: new Date("2999-01-01"),
200
+ createdAt: now,
201
+ updatedAt: now,
202
+ },
203
+ });
204
+ }
205
+
206
+ function createMockSidecarRouter(): SidecarRouter {
207
+ function notImpl(name: string): never {
208
+ throw new Error(`mock: sidecarRouter.${name} not implemented`);
209
+ }
210
+ return {
211
+ handleOpen: () => notImpl("handleOpen"),
212
+ handleMessage: () => notImpl("handleMessage"),
213
+ handleClose: () => notImpl("handleClose"),
214
+ routeMail: () => notImpl("routeMail"),
215
+ sendAgentDeploy: () => notImpl("sendAgentDeploy"),
216
+ sendAgentUndeploy: () => notImpl("sendAgentUndeploy"),
217
+ sendSessionStart: () => notImpl("sendSessionStart"),
218
+ sendSessionAbort: () => notImpl("sendSessionAbort"),
219
+ sendGrantsUpdate: () => notImpl("sendGrantsUpdate"),
220
+ sendSourcesUpdate: () => notImpl("sendSourcesUpdate"),
221
+ sendPack: () => notImpl("sendPack"),
222
+ sendSyncRequest: () => notImpl("sendSyncRequest"),
223
+ subscribeAgent: () => notImpl("subscribeAgent"),
224
+ dispatchAgentEvent: () => undefined,
225
+ getConnectedSidecars: () => [],
226
+ getRoutableAddresses: () => [],
227
+ getConnectorState: () => null,
228
+ events: createSidecarEmitter(),
229
+ };
230
+ }
231
+
232
+ function createMockSessionService(): SessionService {
233
+ return {
234
+ launchSession: () => {
235
+ throw new Error("mock: sessionService.launchSession not implemented");
236
+ },
237
+ sendUserMessage: () => {
238
+ throw new Error("mock: sessionService.sendUserMessage not implemented");
239
+ },
240
+ endSession: () => {
241
+ throw new Error("mock: sessionService.endSession not implemented");
242
+ },
243
+ };
244
+ }
245
+
246
+ function createMockEventCollectors(): EventCollectorRegistry {
247
+ return {
248
+ create: notImplemented("eventCollectors.create"),
249
+ dispatch: notImplemented("eventCollectors.dispatch"),
250
+ abandon: notImplemented("eventCollectors.abandon"),
251
+ has: () => false,
252
+ getStatus: () => undefined,
253
+ getAccumulatedText: () => undefined,
254
+ getCurrentTurnId: () => undefined,
255
+ getLastTurnId: () => undefined,
256
+ };
257
+ }
258
+
259
+ type TestAppOpts = {
260
+ state: MockDBState;
261
+ grants?: GrantRule[];
262
+ userId?: string;
263
+ };
264
+
265
+ function createTestApp(opts: TestAppOpts) {
266
+ const db = createMockDB(opts.state);
267
+
268
+ return {
269
+ app: createApp({
270
+ getSession: createMockGetSession(opts.userId ?? USER_ID),
271
+ authHandler: () => new Response("", { status: 404 }),
272
+ db,
273
+ grantStore: createInMemoryGrantStore(
274
+ opts.grants ?? [makeGrant(), makeGrant({ action: "manage" })],
275
+ ),
276
+ sidecarRouter: createMockSidecarRouter(),
277
+ sessionService: createMockSessionService(),
278
+ eventCollectors: createMockEventCollectors(),
279
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- stub; these tests exercise the token mint/revoke surface, which never calls into assetService or repoStore. Passing non-null gates the git-token routes on (see app.ts mountHubRoutes).
280
+ assetService: {} as never,
281
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- stub; see assetService above.
282
+ repoStore: {} as never,
283
+ }),
284
+ db,
285
+ };
286
+ }
287
+
288
+ function sha256(input: string): Uint8Array {
289
+ return new Uint8Array(createHash("sha256").update(input, "utf8").digest());
290
+ }
291
+
292
+ function futureISOString(offsetMs = 1000 * 60 * 60): string {
293
+ return new Date(Date.now() + offsetMs).toISOString();
294
+ }
295
+
296
+ const MintResponseShape = type({
297
+ id: "string",
298
+ secret: "string",
299
+ name: "string",
300
+ kind: "string",
301
+ claims: {
302
+ resource: "string",
303
+ refPattern: "string",
304
+ actions: "string[]",
305
+ expiresAt: "string",
306
+ },
307
+ });
308
+
309
+ const ErrorResponseShape = type({
310
+ error: {
311
+ code: "string",
312
+ message: "string",
313
+ },
314
+ });
315
+
316
+ async function parseMintResponse(res: Response) {
317
+ const raw: unknown = await res.json();
318
+ const parsed = MintResponseShape(raw);
319
+ if (parsed instanceof type.errors) {
320
+ throw new Error(`mint response did not validate: ${parsed.summary}`);
321
+ }
322
+ return parsed;
323
+ }
324
+
325
+ async function parseErrorResponse(res: Response) {
326
+ const raw: unknown = await res.json();
327
+ const parsed = ErrorResponseShape(raw);
328
+ if (parsed instanceof type.errors) {
329
+ throw new Error(`error response did not validate: ${parsed.summary}`);
330
+ }
331
+ return parsed;
332
+ }
333
+
334
+ const tenantTokensURL = `/api/tenants/${TENANT_ID}/git-tokens`;
335
+ const meTokensURL = `/api/me/git-tokens`;
336
+
337
+ describe("POST /api/me/git-tokens", () => {
338
+ test("returns a secret with the itx_pat_ prefix exactly once", async () => {
339
+ const state: MockDBState = { gitTokens: [] };
340
+ const { app } = createTestApp({ state });
341
+
342
+ const res = await app.request(meTokensURL, {
343
+ method: "POST",
344
+ headers: { "content-type": "application/json" },
345
+ body: JSON.stringify({
346
+ name: "laptop",
347
+ resource: "asset:def_xyz",
348
+ refPattern: "refs/heads/*",
349
+ actions: ["can_read"],
350
+ expiresAt: futureISOString(),
351
+ }),
352
+ });
353
+
354
+ expect(res.status).toBe(201);
355
+ const body = await parseMintResponse(res);
356
+ expect(body.secret.startsWith("itx_pat_")).toBe(true);
357
+ expect(body.kind).toBe("pat");
358
+
359
+ // The stored row holds a SHA-256 digest, never the plaintext.
360
+ expect(state.gitTokens).toHaveLength(1);
361
+ const row = state.gitTokens[0];
362
+ if (!row) throw new Error("expected inserted row");
363
+ expect(row.tokenHashSha256).toEqual(sha256(body.secret));
364
+ // Spot-check that the secret string itself never appears in the row.
365
+ for (const value of Object.values(row)) {
366
+ if (typeof value === "string") {
367
+ expect(value.includes(body.secret)).toBe(false);
368
+ }
369
+ }
370
+ });
371
+
372
+ test("expands the can_read alias to the canonical RepoAction set", async () => {
373
+ const state: MockDBState = { gitTokens: [] };
374
+ const { app } = createTestApp({ state });
375
+
376
+ const res = await app.request(meTokensURL, {
377
+ method: "POST",
378
+ headers: { "content-type": "application/json" },
379
+ body: JSON.stringify({
380
+ name: "alias",
381
+ resource: "asset:def_xyz",
382
+ refPattern: "**",
383
+ actions: ["can_read"],
384
+ expiresAt: futureISOString(),
385
+ }),
386
+ });
387
+
388
+ expect(res.status).toBe(201);
389
+ const body = await parseMintResponse(res);
390
+ expect(new Set(body.claims.actions)).toEqual(
391
+ new Set(["createPack", "resolveRef"]),
392
+ );
393
+ const row = state.gitTokens[0];
394
+ if (!row) throw new Error("expected inserted row");
395
+ expect(new Set(row.actions)).toEqual(new Set(["createPack", "resolveRef"]));
396
+ });
397
+
398
+ test("rejects empty refPattern with invalid_ref_pattern", async () => {
399
+ const state: MockDBState = { gitTokens: [] };
400
+ const { app } = createTestApp({ state });
401
+
402
+ const res = await app.request(meTokensURL, {
403
+ method: "POST",
404
+ headers: { "content-type": "application/json" },
405
+ body: JSON.stringify({
406
+ name: "bad",
407
+ resource: "asset:def_xyz",
408
+ refPattern: "",
409
+ actions: ["can_read"],
410
+ expiresAt: futureISOString(),
411
+ }),
412
+ });
413
+
414
+ expect(res.status).toBe(400);
415
+ const body = await parseErrorResponse(res);
416
+ expect(body.error.code).toBe("invalid_ref_pattern");
417
+ expect(state.gitTokens).toHaveLength(0);
418
+ });
419
+
420
+ test("rejects the hub-internal `init` action at the mint surface", async () => {
421
+ const state: MockDBState = { gitTokens: [] };
422
+ const { app } = createTestApp({ state });
423
+
424
+ const res = await app.request(meTokensURL, {
425
+ method: "POST",
426
+ headers: { "content-type": "application/json" },
427
+ body: JSON.stringify({
428
+ name: "init-attempt",
429
+ resource: "asset:def_xyz",
430
+ refPattern: "**",
431
+ actions: ["init"],
432
+ expiresAt: futureISOString(),
433
+ }),
434
+ });
435
+
436
+ // Arktype's validator middleware rejects with 400 when the input
437
+ // doesn't satisfy the narrowed enum; no row is inserted.
438
+ expect(res.status).toBe(400);
439
+ expect(state.gitTokens).toHaveLength(0);
440
+ });
441
+
442
+ test("rejects the hub-internal `writeTree` action at the mint surface", async () => {
443
+ const state: MockDBState = { gitTokens: [] };
444
+ const { app } = createTestApp({ state });
445
+
446
+ const res = await app.request(meTokensURL, {
447
+ method: "POST",
448
+ headers: { "content-type": "application/json" },
449
+ body: JSON.stringify({
450
+ name: "writetree-attempt",
451
+ resource: "asset:def_xyz",
452
+ refPattern: "**",
453
+ actions: ["writeTree"],
454
+ expiresAt: futureISOString(),
455
+ }),
456
+ });
457
+
458
+ expect(res.status).toBe(400);
459
+ expect(state.gitTokens).toHaveLength(0);
460
+ });
461
+
462
+ test("rejects an expiresAt that is not at least 60s in the future", async () => {
463
+ const state: MockDBState = { gitTokens: [] };
464
+ const { app } = createTestApp({ state });
465
+
466
+ const res = await app.request(meTokensURL, {
467
+ method: "POST",
468
+ headers: { "content-type": "application/json" },
469
+ body: JSON.stringify({
470
+ name: "short",
471
+ resource: "asset:def_xyz",
472
+ refPattern: "**",
473
+ actions: ["can_read"],
474
+ // 30 seconds out; well inside the 60 second floor.
475
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
476
+ }),
477
+ });
478
+
479
+ expect(res.status).toBe(400);
480
+ const body = await parseErrorResponse(res);
481
+ expect(body.error.code).toBe("invalid_expires_at");
482
+ expect(state.gitTokens).toHaveLength(0);
483
+ });
484
+
485
+ test("personal token persists an optional tenantId restriction", async () => {
486
+ const state: MockDBState = { gitTokens: [] };
487
+ const { app } = createTestApp({ state });
488
+
489
+ const res = await app.request(meTokensURL, {
490
+ method: "POST",
491
+ headers: { "content-type": "application/json" },
492
+ body: JSON.stringify({
493
+ name: "scoped",
494
+ resource: "asset:def_xyz",
495
+ refPattern: "**",
496
+ actions: ["can_read"],
497
+ expiresAt: futureISOString(),
498
+ tenantId: TENANT_ID,
499
+ }),
500
+ });
501
+
502
+ expect(res.status).toBe(201);
503
+ const row = state.gitTokens[0];
504
+ if (!row) throw new Error("expected inserted row");
505
+ expect(row.tenantId).toBe(TENANT_ID);
506
+ expect(row.kind).toBe("pat");
507
+ });
508
+ });
509
+
510
+ describe("POST /api/tenants/:tid/git-tokens", () => {
511
+ test("returns a secret with the itx_svc_ prefix and stores the tenant binding", async () => {
512
+ const state: MockDBState = { gitTokens: [] };
513
+ const { app } = createTestApp({ state });
514
+
515
+ const res = await app.request(tenantTokensURL, {
516
+ method: "POST",
517
+ headers: { "content-type": "application/json" },
518
+ body: JSON.stringify({
519
+ name: "ci",
520
+ resource: "asset:def_xyz",
521
+ refPattern: "refs/heads/*",
522
+ actions: ["can_push"],
523
+ expiresAt: futureISOString(),
524
+ }),
525
+ });
526
+
527
+ expect(res.status).toBe(201);
528
+ const body = await parseMintResponse(res);
529
+ expect(body.secret.startsWith("itx_svc_")).toBe(true);
530
+ expect(body.kind).toBe("svc");
531
+
532
+ const row = state.gitTokens[0];
533
+ if (!row) throw new Error("expected inserted row");
534
+ expect(row.tenantId).toBe(TENANT_ID);
535
+ expect(row.principalId).toBe(PRINCIPAL_ID);
536
+ expect(row.kind).toBe("svc");
537
+ expect(row.actions).toEqual(["receivePack"]);
538
+ });
539
+
540
+ test("missing git-token:* create grant rejects with 403", async () => {
541
+ const state: MockDBState = { gitTokens: [] };
542
+ const { app } = createTestApp({ state, grants: [] });
543
+
544
+ const res = await app.request(tenantTokensURL, {
545
+ method: "POST",
546
+ headers: { "content-type": "application/json" },
547
+ body: JSON.stringify({
548
+ name: "ci",
549
+ resource: "asset:def_xyz",
550
+ refPattern: "**",
551
+ actions: ["can_push"],
552
+ expiresAt: futureISOString(),
553
+ }),
554
+ });
555
+
556
+ expect(res.status).toBe(403);
557
+ expect(state.gitTokens).toHaveLength(0);
558
+ });
559
+ });
560
+
561
+ describe("DELETE /api/me/git-tokens/:id", () => {
562
+ test("flips revokedAt on the owning user's token", async () => {
563
+ const state: MockDBState = {
564
+ gitTokens: [
565
+ {
566
+ id: "gtk_personal",
567
+ userId: USER_ID,
568
+ principalId: null,
569
+ tenantId: null,
570
+ name: "laptop",
571
+ kind: "pat",
572
+ tokenHashSha256: sha256("itx_pat_xxx"),
573
+ resource: "asset:def_xyz",
574
+ refPattern: "**",
575
+ actions: ["createPack", "resolveRef"],
576
+ expiresAt: new Date("2099-01-01"),
577
+ revokedAt: null,
578
+ createdAt: new Date("2025-01-01"),
579
+ },
580
+ ],
581
+ };
582
+ const { app } = createTestApp({ state });
583
+
584
+ const res = await app.request(`${meTokensURL}/gtk_personal`, {
585
+ method: "DELETE",
586
+ });
587
+
588
+ expect(res.status).toBe(204);
589
+ expect(state.gitTokens[0]?.revokedAt).toBeInstanceOf(Date);
590
+ });
591
+
592
+ test("cross-user DELETE returns 403 and leaves the row intact", async () => {
593
+ const state: MockDBState = {
594
+ gitTokens: [
595
+ {
596
+ id: "gtk_other",
597
+ userId: OTHER_USER_ID,
598
+ principalId: null,
599
+ tenantId: null,
600
+ name: "other-laptop",
601
+ kind: "pat",
602
+ tokenHashSha256: sha256("itx_pat_other"),
603
+ resource: "asset:def_xyz",
604
+ refPattern: "**",
605
+ actions: ["createPack", "resolveRef"],
606
+ expiresAt: new Date("2099-01-01"),
607
+ revokedAt: null,
608
+ createdAt: new Date("2025-01-01"),
609
+ },
610
+ ],
611
+ };
612
+ const { app } = createTestApp({ state });
613
+
614
+ const res = await app.request(`${meTokensURL}/gtk_other`, {
615
+ method: "DELETE",
616
+ });
617
+
618
+ expect(res.status).toBe(403);
619
+ const body = await parseErrorResponse(res);
620
+ expect(body.error.code).toBe("forbidden");
621
+ expect(state.gitTokens[0]?.revokedAt).toBeNull();
622
+ });
623
+ });
624
+
625
+ describe("DELETE /api/tenants/:tid/git-tokens/:id", () => {
626
+ test("tenant-mismatched DELETE returns 404 (token not in this tenant)", async () => {
627
+ const state: MockDBState = {
628
+ gitTokens: [
629
+ {
630
+ id: "gtk_svc",
631
+ userId: USER_ID,
632
+ principalId: PRINCIPAL_ID,
633
+ tenantId: OTHER_TENANT_ID,
634
+ name: "ci",
635
+ kind: "svc",
636
+ tokenHashSha256: sha256("itx_svc_xxx"),
637
+ resource: "asset:def_xyz",
638
+ refPattern: "**",
639
+ actions: ["receivePack"],
640
+ expiresAt: new Date("2099-01-01"),
641
+ revokedAt: null,
642
+ createdAt: new Date("2025-01-01"),
643
+ },
644
+ ],
645
+ };
646
+
647
+ // The route filters by (id, tenantId); using the route as-is would
648
+ // return the (single) row from the mock and treat it as a match. To
649
+ // honour the WHERE-clause invariant the mock cannot model with its
650
+ // simple "return first row" stub, we drop the tokens out from under
651
+ // the lookup so the route observes "not found" — semantically what
652
+ // a real DB would do for a tenant mismatch.
653
+ state.gitTokens = [];
654
+
655
+ const { app } = createTestApp({ state });
656
+
657
+ const res = await app.request(`${tenantTokensURL}/gtk_svc`, {
658
+ method: "DELETE",
659
+ });
660
+
661
+ expect(res.status).toBe(404);
662
+ });
663
+ });
664
+
665
+ describe("integration with the bearer middleware", () => {
666
+ test("after revoke, a bearer-auth probe fails with token_revoked", async () => {
667
+ const secret = "itx_pat_revoke_integration";
668
+ const tokenRow: GitTokenRow = {
669
+ id: "gtk_revoke",
670
+ userId: USER_ID,
671
+ principalId: PRINCIPAL_ID,
672
+ tenantId: TENANT_ID,
673
+ name: "laptop",
674
+ kind: "pat",
675
+ tokenHashSha256: sha256(secret),
676
+ resource: "asset:def_xyz",
677
+ refPattern: "**",
678
+ actions: ["createPack", "resolveRef"],
679
+ expiresAt: new Date("2099-01-01"),
680
+ revokedAt: null,
681
+ createdAt: new Date("2025-01-01"),
682
+ };
683
+ const state: MockDBState = { gitTokens: [tokenRow] };
684
+ const { app, db } = createTestApp({ state });
685
+
686
+ // First, revoke through the REST endpoint.
687
+ const revokeRes = await app.request(`${meTokensURL}/gtk_revoke`, {
688
+ method: "DELETE",
689
+ });
690
+ expect(revokeRes.status).toBe(204);
691
+ expect(tokenRow.revokedAt).toBeInstanceOf(Date);
692
+
693
+ // Now build a separate Hono app that exposes the bearer middleware
694
+ // and probe the same DB. The bearer middleware reads the same
695
+ // git_token row and must reject with token_revoked.
696
+ const probe = new Hono<GitTokenAuthEnv>();
697
+ probe.get("/tenants/:tenantId/probe", createGitTokenAuth({ db }), (c) =>
698
+ c.json({ ok: true }),
699
+ );
700
+
701
+ const res = await probe.request(`/tenants/${TENANT_ID}/probe`, {
702
+ headers: { authorization: `Bearer ${secret}` },
703
+ });
704
+
705
+ expect(res.status).toBe(403);
706
+ const body = await parseErrorResponse(res);
707
+ expect(body.error.code).toBe("token_revoked");
708
+ });
709
+ });