@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
package/src/context.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { Env } from "hono";
2
+
3
+ import type { SessionInfo, SessionUser } from "./session";
4
+
5
+ export type TenantRow = {
6
+ id: string;
7
+ name: string;
8
+ slug: string;
9
+ domain: string;
10
+ parentId: string | null;
11
+ config: unknown;
12
+ createdAt: Date;
13
+ updatedAt: Date;
14
+ };
15
+
16
+ export type PrincipalRow = {
17
+ id: string;
18
+ tenantId: string;
19
+ kind: "user" | "agent";
20
+ refId: string;
21
+ status: "active" | "suspended" | "invited" | "deactivated";
22
+ createdAt: Date;
23
+ updatedAt: Date;
24
+ };
25
+
26
+ export type AppEnv = Env & {
27
+ Variables: {
28
+ user: SessionUser | null;
29
+ session: SessionInfo | null;
30
+ };
31
+ };
32
+
33
+ export type TenantEnv = Env & {
34
+ Variables: AppEnv["Variables"] & {
35
+ tenant: TenantRow;
36
+ principal: PrincipalRow;
37
+ };
38
+ };
package/src/format.ts ADDED
@@ -0,0 +1,9 @@
1
+ export function ts(d: Date): string {
2
+ return d.toISOString();
3
+ }
4
+
5
+ export function first<T>(rows: T[]): T {
6
+ const row = rows[0];
7
+ if (!row) throw new Error("Expected at least one row from returning()");
8
+ return row;
9
+ }
@@ -0,0 +1,459 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { RepoId } from "@intx/types/sidecar";
3
+ import {
4
+ advertiseUploadPack,
5
+ advertiseReceivePack,
6
+ UPLOAD_PACK_CAPABILITIES,
7
+ RECEIVE_PACK_CAPABILITIES,
8
+ EMPTY_REPO_OID,
9
+ type AdvertisePrincipal,
10
+ type RefEntry,
11
+ type RefSource,
12
+ } from "./advertise-refs";
13
+
14
+ const REPO_ID: RepoId = { kind: "agent-state", id: "test" };
15
+
16
+ function principalWith(refPattern: string): AdvertisePrincipal {
17
+ return {
18
+ kind: "user",
19
+ tokenClaims: { refPattern },
20
+ };
21
+ }
22
+
23
+ function refSourceOf(refs: RefEntry[]): RefSource {
24
+ return {
25
+ listRefs: async () => refs.slice(),
26
+ resolveHead: async () => null,
27
+ };
28
+ }
29
+
30
+ function refSourceWithHead(
31
+ refs: RefEntry[],
32
+ head: { symbolicTarget: string; sha: string } | null,
33
+ ): RefSource {
34
+ return {
35
+ listRefs: async () => refs.slice(),
36
+ resolveHead: async () => head,
37
+ };
38
+ }
39
+
40
+ async function collect(
41
+ stream: ReadableStream<Uint8Array>,
42
+ ): Promise<Uint8Array> {
43
+ const reader = stream.getReader();
44
+ const chunks: Uint8Array[] = [];
45
+ for (;;) {
46
+ const r = await reader.read();
47
+ if (r.done) break;
48
+ if (r.value) chunks.push(r.value);
49
+ }
50
+ const total = chunks.reduce((n, c) => n + c.length, 0);
51
+ const out = new Uint8Array(total);
52
+ let off = 0;
53
+ for (const c of chunks) {
54
+ out.set(c, off);
55
+ off += c.length;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function hex4(n: number): string {
61
+ return n.toString(16).padStart(4, "0");
62
+ }
63
+
64
+ function pkt(payload: string): string {
65
+ const enc = new TextEncoder().encode(payload);
66
+ return hex4(enc.length + 4) + payload;
67
+ }
68
+
69
+ const FLUSH = "0000";
70
+
71
+ describe("advertiseUploadPack capability set", () => {
72
+ test("contains side-band-64k, ofs-delta, object-format=sha1, agent", () => {
73
+ expect(UPLOAD_PACK_CAPABILITIES).toContain("side-band-64k");
74
+ expect(UPLOAD_PACK_CAPABILITIES).toContain("ofs-delta");
75
+ expect(UPLOAD_PACK_CAPABILITIES).toContain("object-format=sha1");
76
+ expect(UPLOAD_PACK_CAPABILITIES).toMatch(/\bagent=interchange-hub\/[^\s]+/);
77
+ });
78
+
79
+ test("does not include receive-pack-only capabilities", () => {
80
+ expect(UPLOAD_PACK_CAPABILITIES).not.toContain("report-status");
81
+ expect(UPLOAD_PACK_CAPABILITIES).not.toContain("multi_ack");
82
+ expect(UPLOAD_PACK_CAPABILITIES).not.toContain("thin-pack");
83
+ expect(UPLOAD_PACK_CAPABILITIES).not.toContain("shallow");
84
+ });
85
+ });
86
+
87
+ describe("advertiseReceivePack capability set", () => {
88
+ test("advertises report-status alongside the shared baseline", () => {
89
+ expect(RECEIVE_PACK_CAPABILITIES).toContain("report-status");
90
+ expect(RECEIVE_PACK_CAPABILITIES).toContain("ofs-delta");
91
+ expect(RECEIVE_PACK_CAPABILITIES).toContain("object-format=sha1");
92
+ expect(RECEIVE_PACK_CAPABILITIES).toMatch(
93
+ /\bagent=interchange-hub\/[^\s]+/,
94
+ );
95
+ });
96
+
97
+ test("does not advertise side-band-64k on receive-pack", () => {
98
+ // The receive-pack handler returns the report-status payload as
99
+ // raw pkt-lines. Advertising side-band-64k would make stock git
100
+ // expect a channel-framed response and abort with
101
+ // `protocol error: bad band`.
102
+ expect(RECEIVE_PACK_CAPABILITIES).not.toContain("side-band-64k");
103
+ });
104
+
105
+ test("does not include thin-pack on receive-pack", () => {
106
+ expect(RECEIVE_PACK_CAPABILITIES).not.toContain("thin-pack");
107
+ });
108
+ });
109
+
110
+ describe("advertiseUploadPack stream shape", () => {
111
+ test("service-prefix, flush, single ref carries caps NUL-separated, trailing flush", async () => {
112
+ const sha = "1111111111111111111111111111111111111111";
113
+ const refs: RefEntry[] = [{ name: "refs/heads/main", sha }];
114
+ const out = await collect(
115
+ await advertiseUploadPack(
116
+ refSourceOf(refs),
117
+ principalWith("**"),
118
+ REPO_ID,
119
+ ),
120
+ );
121
+ const expected =
122
+ pkt("# service=git-upload-pack\n") +
123
+ FLUSH +
124
+ pkt(`${sha} refs/heads/main\0${UPLOAD_PACK_CAPABILITIES}\n`) +
125
+ FLUSH;
126
+ expect(new TextDecoder().decode(out)).toBe(expected);
127
+ });
128
+
129
+ test("first ref carries caps NUL-separated; subsequent refs have no NUL", async () => {
130
+ const shaA = "a".repeat(40);
131
+ const shaB = "b".repeat(40);
132
+ const refs: RefEntry[] = [
133
+ { name: "refs/heads/main", sha: shaA },
134
+ { name: "refs/tags/v1", sha: shaB },
135
+ ];
136
+ const out = await collect(
137
+ await advertiseUploadPack(
138
+ refSourceOf(refs),
139
+ principalWith("**"),
140
+ REPO_ID,
141
+ ),
142
+ );
143
+ const decoded = new TextDecoder().decode(out);
144
+ const expected =
145
+ pkt("# service=git-upload-pack\n") +
146
+ FLUSH +
147
+ pkt(`${shaA} refs/heads/main\0${UPLOAD_PACK_CAPABILITIES}\n`) +
148
+ pkt(`${shaB} refs/tags/v1\n`) +
149
+ FLUSH;
150
+ expect(decoded).toBe(expected);
151
+ expect(decoded.includes(`refs/tags/v1\0`)).toBe(false);
152
+ });
153
+
154
+ test("refs are listed deterministically by lexicographic name", async () => {
155
+ const sha = "c".repeat(40);
156
+ const refs: RefEntry[] = [
157
+ { name: "refs/heads/zeta", sha },
158
+ { name: "refs/heads/alpha", sha },
159
+ { name: "refs/heads/main", sha },
160
+ ];
161
+ const out = await collect(
162
+ await advertiseUploadPack(
163
+ refSourceOf(refs),
164
+ principalWith("**"),
165
+ REPO_ID,
166
+ ),
167
+ );
168
+ const decoded = new TextDecoder().decode(out);
169
+ const expected =
170
+ pkt("# service=git-upload-pack\n") +
171
+ FLUSH +
172
+ pkt(`${sha} refs/heads/alpha\0${UPLOAD_PACK_CAPABILITIES}\n`) +
173
+ pkt(`${sha} refs/heads/main\n`) +
174
+ pkt(`${sha} refs/heads/zeta\n`) +
175
+ FLUSH;
176
+ expect(decoded).toBe(expected);
177
+ });
178
+ });
179
+
180
+ describe("advertiseUploadPack empty-repo special case", () => {
181
+ test("emits zero-oid capabilities^{} record so git clone succeeds", async () => {
182
+ const out = await collect(
183
+ await advertiseUploadPack(refSourceOf([]), principalWith("**"), REPO_ID),
184
+ );
185
+ const expected =
186
+ pkt("# service=git-upload-pack\n") +
187
+ FLUSH +
188
+ pkt(`${EMPTY_REPO_OID} capabilities^{}\0${UPLOAD_PACK_CAPABILITIES}\n`) +
189
+ FLUSH;
190
+ expect(new TextDecoder().decode(out)).toBe(expected);
191
+ expect(EMPTY_REPO_OID).toBe("0".repeat(40));
192
+ });
193
+
194
+ test("empty-repo record emitted when refPattern filters all refs out", async () => {
195
+ const sha = "d".repeat(40);
196
+ const refs: RefEntry[] = [
197
+ { name: "refs/heads/main", sha },
198
+ { name: "refs/tags/v1", sha },
199
+ ];
200
+ const out = await collect(
201
+ await advertiseUploadPack(
202
+ refSourceOf(refs),
203
+ principalWith("refs/heads/release/*"),
204
+ REPO_ID,
205
+ ),
206
+ );
207
+ const expected =
208
+ pkt("# service=git-upload-pack\n") +
209
+ FLUSH +
210
+ pkt(`${EMPTY_REPO_OID} capabilities^{}\0${UPLOAD_PACK_CAPABILITIES}\n`) +
211
+ FLUSH;
212
+ expect(new TextDecoder().decode(out)).toBe(expected);
213
+ });
214
+ });
215
+
216
+ describe("advertiseUploadPack ref filtering by refPattern", () => {
217
+ test("hides refs that do not match the principal's refPattern", async () => {
218
+ const sha = "e".repeat(40);
219
+ const refs: RefEntry[] = [
220
+ { name: "refs/heads/main", sha },
221
+ { name: "refs/heads/feature/x", sha },
222
+ { name: "refs/tags/v1", sha },
223
+ ];
224
+ const out = await collect(
225
+ await advertiseUploadPack(
226
+ refSourceOf(refs),
227
+ principalWith("refs/heads/*"),
228
+ REPO_ID,
229
+ ),
230
+ );
231
+ const decoded = new TextDecoder().decode(out);
232
+ const expected =
233
+ pkt("# service=git-upload-pack\n") +
234
+ FLUSH +
235
+ pkt(`${sha} refs/heads/main\0${UPLOAD_PACK_CAPABILITIES}\n`) +
236
+ FLUSH;
237
+ expect(decoded).toBe(expected);
238
+ expect(decoded.includes("refs/heads/feature/x")).toBe(false);
239
+ expect(decoded.includes("refs/tags/v1")).toBe(false);
240
+ });
241
+
242
+ test("doublestar pattern allows nested refs through", async () => {
243
+ const sha = "f".repeat(40);
244
+ const refs: RefEntry[] = [
245
+ { name: "refs/heads/main", sha },
246
+ { name: "refs/heads/feature/x", sha },
247
+ ];
248
+ const out = await collect(
249
+ await advertiseUploadPack(
250
+ refSourceOf(refs),
251
+ principalWith("refs/heads/**"),
252
+ REPO_ID,
253
+ ),
254
+ );
255
+ const decoded = new TextDecoder().decode(out);
256
+ const expected =
257
+ pkt("# service=git-upload-pack\n") +
258
+ FLUSH +
259
+ pkt(`${sha} refs/heads/feature/x\0${UPLOAD_PACK_CAPABILITIES}\n`) +
260
+ pkt(`${sha} refs/heads/main\n`) +
261
+ FLUSH;
262
+ expect(decoded).toBe(expected);
263
+ });
264
+ });
265
+
266
+ describe("advertiseReceivePack stream shape", () => {
267
+ test("service-prefix is git-receive-pack and caps include report-status", async () => {
268
+ const sha = "1".repeat(40);
269
+ const refs: RefEntry[] = [{ name: "refs/heads/main", sha }];
270
+ const out = await collect(
271
+ await advertiseReceivePack(
272
+ refSourceOf(refs),
273
+ principalWith("**"),
274
+ REPO_ID,
275
+ ),
276
+ );
277
+ const expected =
278
+ pkt("# service=git-receive-pack\n") +
279
+ FLUSH +
280
+ pkt(`${sha} refs/heads/main\0${RECEIVE_PACK_CAPABILITIES}\n`) +
281
+ FLUSH;
282
+ expect(new TextDecoder().decode(out)).toBe(expected);
283
+ });
284
+
285
+ test("empty repo on receive-pack emits zero-oid capabilities^{} record", async () => {
286
+ const out = await collect(
287
+ await advertiseReceivePack(refSourceOf([]), principalWith("**"), REPO_ID),
288
+ );
289
+ const expected =
290
+ pkt("# service=git-receive-pack\n") +
291
+ FLUSH +
292
+ pkt(`${EMPTY_REPO_OID} capabilities^{}\0${RECEIVE_PACK_CAPABILITIES}\n`) +
293
+ FLUSH;
294
+ expect(new TextDecoder().decode(out)).toBe(expected);
295
+ });
296
+ });
297
+
298
+ describe("advertiseUploadPack HEAD symref", () => {
299
+ test("prepends HEAD with symref capability when target ref is visible", async () => {
300
+ const sha = "a".repeat(40);
301
+ const refs: RefEntry[] = [{ name: "refs/heads/main", sha }];
302
+ const out = await collect(
303
+ await advertiseUploadPack(
304
+ refSourceWithHead(refs, { symbolicTarget: "refs/heads/main", sha }),
305
+ principalWith("**"),
306
+ REPO_ID,
307
+ ),
308
+ );
309
+ const expected =
310
+ pkt("# service=git-upload-pack\n") +
311
+ FLUSH +
312
+ pkt(
313
+ `${sha} HEAD\0${UPLOAD_PACK_CAPABILITIES} symref=HEAD:refs/heads/main\n`,
314
+ ) +
315
+ pkt(`${sha} refs/heads/main\n`) +
316
+ FLUSH;
317
+ expect(new TextDecoder().decode(out)).toBe(expected);
318
+ });
319
+
320
+ test("HEAD is canonical first ref ahead of every visible ref", async () => {
321
+ const shaMain = "a".repeat(40);
322
+ const shaFeature = "b".repeat(40);
323
+ const shaTag = "c".repeat(40);
324
+ const refs: RefEntry[] = [
325
+ { name: "refs/heads/feature/x", sha: shaFeature },
326
+ { name: "refs/heads/main", sha: shaMain },
327
+ { name: "refs/tags/v1", sha: shaTag },
328
+ ];
329
+ const out = await collect(
330
+ await advertiseUploadPack(
331
+ refSourceWithHead(refs, {
332
+ symbolicTarget: "refs/heads/main",
333
+ sha: shaMain,
334
+ }),
335
+ principalWith("**"),
336
+ REPO_ID,
337
+ ),
338
+ );
339
+ const expected =
340
+ pkt("# service=git-upload-pack\n") +
341
+ FLUSH +
342
+ pkt(
343
+ `${shaMain} HEAD\0${UPLOAD_PACK_CAPABILITIES} symref=HEAD:refs/heads/main\n`,
344
+ ) +
345
+ pkt(`${shaFeature} refs/heads/feature/x\n`) +
346
+ pkt(`${shaMain} refs/heads/main\n`) +
347
+ pkt(`${shaTag} refs/tags/v1\n`) +
348
+ FLUSH;
349
+ expect(new TextDecoder().decode(out)).toBe(expected);
350
+ });
351
+
352
+ test("HEAD null leaves the advertisement unchanged from prior behavior", async () => {
353
+ const sha = "d".repeat(40);
354
+ const refs: RefEntry[] = [{ name: "refs/heads/main", sha }];
355
+ const out = await collect(
356
+ await advertiseUploadPack(
357
+ refSourceWithHead(refs, null),
358
+ principalWith("**"),
359
+ REPO_ID,
360
+ ),
361
+ );
362
+ const expected =
363
+ pkt("# service=git-upload-pack\n") +
364
+ FLUSH +
365
+ pkt(`${sha} refs/heads/main\0${UPLOAD_PACK_CAPABILITIES}\n`) +
366
+ FLUSH;
367
+ expect(new TextDecoder().decode(out)).toBe(expected);
368
+ });
369
+
370
+ test("HEAD is not advertised when its target ref is filtered out by refPattern", async () => {
371
+ // refPattern hides refs/heads/main, so HEAD's symbolic target is
372
+ // not visible to this principal. The filtered ref list is empty
373
+ // and the empty-repo capabilities^{} path is taken.
374
+ const sha = "e".repeat(40);
375
+ const refs: RefEntry[] = [{ name: "refs/heads/main", sha }];
376
+ const out = await collect(
377
+ await advertiseUploadPack(
378
+ refSourceWithHead(refs, { symbolicTarget: "refs/heads/main", sha }),
379
+ principalWith("refs/heads/release/*"),
380
+ REPO_ID,
381
+ ),
382
+ );
383
+ const expected =
384
+ pkt("# service=git-upload-pack\n") +
385
+ FLUSH +
386
+ pkt(`${EMPTY_REPO_OID} capabilities^{}\0${UPLOAD_PACK_CAPABILITIES}\n`) +
387
+ FLUSH;
388
+ const decoded = new TextDecoder().decode(out);
389
+ expect(decoded).toBe(expected);
390
+ expect(decoded.includes("HEAD")).toBe(false);
391
+ expect(decoded.includes("refs/heads/main")).toBe(false);
392
+ });
393
+
394
+ test("HEAD with target not in refs list falls back to non-HEAD layout", async () => {
395
+ // resolveHead returned a target that is not present in the ref
396
+ // list (e.g. the repo's HEAD was reset between listRefs and
397
+ // resolveHead, or HEAD points at a ref hidden by some other
398
+ // future filter). The advertiser does not synthesize HEAD against
399
+ // a ref it has not advertised.
400
+ const sha = "f".repeat(40);
401
+ const refs: RefEntry[] = [{ name: "refs/heads/main", sha }];
402
+ const out = await collect(
403
+ await advertiseUploadPack(
404
+ refSourceWithHead(refs, {
405
+ symbolicTarget: "refs/heads/gone",
406
+ sha: "1".repeat(40),
407
+ }),
408
+ principalWith("**"),
409
+ REPO_ID,
410
+ ),
411
+ );
412
+ const expected =
413
+ pkt("# service=git-upload-pack\n") +
414
+ FLUSH +
415
+ pkt(`${sha} refs/heads/main\0${UPLOAD_PACK_CAPABILITIES}\n`) +
416
+ FLUSH;
417
+ expect(new TextDecoder().decode(out)).toBe(expected);
418
+ });
419
+ });
420
+
421
+ describe("advertiseReceivePack HEAD symref", () => {
422
+ test("receive-pack also projects HEAD as a symref when the target is visible", async () => {
423
+ const sha = "9".repeat(40);
424
+ const refs: RefEntry[] = [{ name: "refs/heads/main", sha }];
425
+ const out = await collect(
426
+ await advertiseReceivePack(
427
+ refSourceWithHead(refs, { symbolicTarget: "refs/heads/main", sha }),
428
+ principalWith("**"),
429
+ REPO_ID,
430
+ ),
431
+ );
432
+ const expected =
433
+ pkt("# service=git-receive-pack\n") +
434
+ FLUSH +
435
+ pkt(
436
+ `${sha} HEAD\0${RECEIVE_PACK_CAPABILITIES} symref=HEAD:refs/heads/main\n`,
437
+ ) +
438
+ pkt(`${sha} refs/heads/main\n`) +
439
+ FLUSH;
440
+ expect(new TextDecoder().decode(out)).toBe(expected);
441
+ });
442
+ });
443
+
444
+ describe("advertise functions pass principal and repoId through to RefSource", () => {
445
+ test("listRefs receives principal and repoId untouched", async () => {
446
+ const sha = "2".repeat(40);
447
+ const seen: { principal: AdvertisePrincipal; repoId: RepoId }[] = [];
448
+ const refSource: RefSource = {
449
+ listRefs: async (principal, repoId) => {
450
+ seen.push({ principal, repoId });
451
+ return [{ name: "refs/heads/main", sha }];
452
+ },
453
+ resolveHead: async () => null,
454
+ };
455
+ const principal = principalWith("**");
456
+ await collect(await advertiseUploadPack(refSource, principal, REPO_ID));
457
+ expect(seen).toEqual([{ principal, repoId: REPO_ID }]);
458
+ });
459
+ });