@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,396 @@
1
+ /**
2
+ * Smart-HTTP `git-upload-pack` request handler. Parses the
3
+ * `want`/`have`/`done` pkt-line body, builds an `includeSha`
4
+ * predicate from the principal's `tokenClaims.refPattern` (a SHA is
5
+ * included only if reachable from at least one matching ref), and
6
+ * streams the negotiated packfile back wrapped in side-band-64k
7
+ * channel-1 frames.
8
+ *
9
+ * Denial during negotiation is reported as a pkt-line `ERR <msg>\n`
10
+ * frame, never as a non-200 HTTP status: stock git surfaces the ERR
11
+ * payload as `remote: <msg>; fatal: protocol error`, which is the
12
+ * protocol-correct shape for refPattern denial and unknown-ref
13
+ * rejections. (Receive-pack uses `ng` during report-status instead;
14
+ * that vocabulary is not used here.)
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import git from "isomorphic-git";
19
+ import type { RepoId } from "@intx/types/sidecar";
20
+ import { glob } from "@intx/hub-common";
21
+ import {
22
+ createNegotiatedPack,
23
+ collectReachableObjects,
24
+ } from "@intx/storage-isogit";
25
+ import { readPktLine, writePktLine, writeFlush, writeErr } from "./pkt-line";
26
+ import { chunkPackToSideBand } from "./side-band-64k";
27
+ import type { RefEntry } from "./advertise-refs";
28
+
29
+ export const UPLOAD_PACK_RESULT_CONTENT_TYPE =
30
+ "application/x-git-upload-pack-result";
31
+
32
+ /**
33
+ * Principal contract consumed by the upload-pack handler. Narrowed to
34
+ * the single field the handler reads (`tokenClaims.refPattern`) so any
35
+ * principal carrying that claim satisfies the type without coupling to
36
+ * the full user-principal shape.
37
+ */
38
+ export type UploadPackPrincipal = {
39
+ readonly kind: string;
40
+ readonly tokenClaims: {
41
+ readonly refPattern: string;
42
+ };
43
+ };
44
+
45
+ /**
46
+ * Repository-store capability the upload-pack handler depends on. The
47
+ * substrate or a repo-direct adapter implements this; the handler does
48
+ * not care which. `listRefs` provides the ref names + tip SHAs the
49
+ * refPattern is matched against; `getRepoDir` returns the on-disk path
50
+ * passed to `createNegotiatedPack`.
51
+ */
52
+ export interface UploadPackRepoStore {
53
+ listRefs(principal: UploadPackPrincipal, repoId: RepoId): Promise<RefEntry[]>;
54
+ getRepoDir(principal: UploadPackPrincipal, repoId: RepoId): Promise<string>;
55
+ }
56
+
57
+ type ParsedRequest = {
58
+ wants: string[];
59
+ haves: string[];
60
+ };
61
+
62
+ async function parseUploadRequest(body: Uint8Array): Promise<ParsedRequest> {
63
+ const stream = new ReadableStream<Uint8Array>({
64
+ start(controller) {
65
+ if (body.length > 0) controller.enqueue(body);
66
+ controller.close();
67
+ },
68
+ });
69
+ const reader = stream.getReader();
70
+ const wants: string[] = [];
71
+ const haves: string[] = [];
72
+ const dec = new TextDecoder();
73
+ let seenFlush = false;
74
+ for (;;) {
75
+ const frame = await readPktLine(reader);
76
+ if (frame === null) break;
77
+ if (frame.kind === "flush") {
78
+ seenFlush = true;
79
+ continue;
80
+ }
81
+ if (frame.kind === "delim") continue;
82
+ const line = dec.decode(frame.payload).replace(/\n$/, "");
83
+ if (!seenFlush) {
84
+ if (line.startsWith("want ")) {
85
+ const rest = line.slice("want ".length);
86
+ const sha = rest.split(" ", 1)[0];
87
+ if (sha === undefined || sha.length === 0) {
88
+ throw new Error(`upload-pack: malformed want line: ${line}`);
89
+ }
90
+ wants.push(sha);
91
+ continue;
92
+ }
93
+ throw new Error(`upload-pack: unexpected pre-flush line: ${line}`);
94
+ }
95
+ if (line.startsWith("have ")) {
96
+ const sha = line.slice("have ".length).split(" ", 1)[0];
97
+ if (sha === undefined || sha.length === 0) {
98
+ throw new Error(`upload-pack: malformed have line: ${line}`);
99
+ }
100
+ haves.push(sha);
101
+ continue;
102
+ }
103
+ if (line === "done") {
104
+ break;
105
+ }
106
+ }
107
+ return { wants, haves };
108
+ }
109
+
110
+ /**
111
+ * Per-commit cache entry built during the allowed-tip walk: the
112
+ * objects reachable from a given commit (tree + blobs + the commit
113
+ * itself) and the commit's parent SHAs. The cache lets the downstream
114
+ * want walk compute reachable-from-wants without re-reading commits
115
+ * or re-walking their trees.
116
+ */
117
+ type CommitIndexEntry = {
118
+ readonly objects: ReadonlySet<string>;
119
+ readonly parents: readonly string[];
120
+ };
121
+ type CommitIndex = ReadonlyMap<string, CommitIndexEntry>;
122
+
123
+ async function walkAllowedAndIndex(
124
+ dir: string,
125
+ allowedTipShas: readonly string[],
126
+ ): Promise<{ allowed: Set<string>; perCommit: CommitIndex }> {
127
+ const allowed = new Set<string>();
128
+ const perCommit = new Map<string, CommitIndexEntry>();
129
+ const seenCommits = new Set<string>();
130
+ const queue: string[] = [...allowedTipShas];
131
+ while (queue.length > 0) {
132
+ const oid = queue.shift();
133
+ if (oid === undefined) break;
134
+ if (seenCommits.has(oid)) continue;
135
+ seenCommits.add(oid);
136
+ let commit;
137
+ try {
138
+ commit = await git.readCommit({ fs, dir, oid });
139
+ } catch {
140
+ // Tip SHA points at something that no longer reads as a commit;
141
+ // skip rather than fail the whole walk.
142
+ continue;
143
+ }
144
+ const objects = await collectReachableObjects(dir, oid);
145
+ const objectSet = new Set(objects);
146
+ perCommit.set(oid, { objects: objectSet, parents: commit.commit.parent });
147
+ for (const o of objects) allowed.add(o);
148
+ for (const p of commit.commit.parent) {
149
+ if (!seenCommits.has(p)) queue.push(p);
150
+ }
151
+ }
152
+ return { allowed, perCommit };
153
+ }
154
+
155
+ /**
156
+ * Compute reachable-from-`starts` in pure memory using the commit
157
+ * index built by `walkAllowedAndIndex`. Every commit reachable from
158
+ * an allowed tip is in the index; ancestors of a valid want are
159
+ * therefore present without any further `git.readCommit` calls.
160
+ *
161
+ * The caller is responsible for ensuring every start OID is a commit
162
+ * present in the index — `classifyWants` enforces both. A start OID
163
+ * missing from the index is silently skipped; if it slips past the
164
+ * classifier (a non-commit OID reachable from an allowed ref's tree,
165
+ * or any other contract violation upstream) the result will be the
166
+ * empty set rather than a thrown error.
167
+ */
168
+ function reachableFromIndex(
169
+ starts: readonly string[],
170
+ perCommit: CommitIndex,
171
+ ): Set<string> {
172
+ const reachable = new Set<string>();
173
+ const seen = new Set<string>();
174
+ const queue: string[] = [...starts];
175
+ while (queue.length > 0) {
176
+ const oid = queue.shift();
177
+ if (oid === undefined) break;
178
+ if (seen.has(oid)) continue;
179
+ seen.add(oid);
180
+ const entry = perCommit.get(oid);
181
+ if (entry === undefined) continue;
182
+ for (const o of entry.objects) reachable.add(o);
183
+ for (const p of entry.parents) {
184
+ if (!seen.has(p)) queue.push(p);
185
+ }
186
+ }
187
+ return reachable;
188
+ }
189
+
190
+ async function shaExistsAsCommit(dir: string, sha: string): Promise<boolean> {
191
+ try {
192
+ await git.readCommit({ fs, dir, oid: sha });
193
+ return true;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ type WantClassification =
200
+ | { kind: "ok" }
201
+ | { kind: "forbidden" }
202
+ | { kind: "unknown" };
203
+
204
+ async function classifyWants(
205
+ dir: string,
206
+ wants: readonly string[],
207
+ allowedObjects: ReadonlySet<string>,
208
+ ): Promise<WantClassification> {
209
+ // Two-pass classification with a stable preference: `forbidden`
210
+ // outranks `unknown` so the client always sees the same error
211
+ // vocabulary regardless of how it ordered its want lines. A SHA
212
+ // that exists but is reachable only from refs the token cannot see
213
+ // is more useful diagnostic information than a SHA we cannot find
214
+ // at all, and incident triage benefits from determinism.
215
+ //
216
+ // Membership in `allowedObjects` is necessary but not sufficient:
217
+ // the substrate's reachable-objects walk includes commits, trees,
218
+ // and blobs alike, so a hand-crafted client could otherwise want a
219
+ // blob OID that appears in some allowed ref's tree. This handler
220
+ // expects `want` lines to name commits — annotated-tag wants are
221
+ // not supported here today; a non-commit want is classified the
222
+ // same as a SHA that does not exist at all.
223
+ let sawForbidden = false;
224
+ let sawUnknown = false;
225
+ for (const want of wants) {
226
+ if (allowedObjects.has(want)) {
227
+ if (await shaExistsAsCommit(dir, want)) continue;
228
+ sawUnknown = true;
229
+ continue;
230
+ }
231
+ const exists = await shaExistsAsCommit(dir, want);
232
+ if (exists) {
233
+ sawForbidden = true;
234
+ } else {
235
+ sawUnknown = true;
236
+ }
237
+ }
238
+ if (sawForbidden) return { kind: "forbidden" };
239
+ if (sawUnknown) return { kind: "unknown" };
240
+ return { kind: "ok" };
241
+ }
242
+
243
+ function errorResponse(msg: string): Response {
244
+ const stream = new ReadableStream<Uint8Array>({
245
+ async start(controller) {
246
+ const sink = new WritableStream<Uint8Array>({
247
+ write(chunk) {
248
+ controller.enqueue(chunk);
249
+ },
250
+ });
251
+ const writer = sink.getWriter();
252
+ try {
253
+ await writeErr(writer, msg);
254
+ await writer.close();
255
+ controller.close();
256
+ } catch (cause) {
257
+ await writer.abort(cause).catch(() => undefined);
258
+ controller.error(cause);
259
+ }
260
+ },
261
+ });
262
+ return new Response(stream, {
263
+ status: 200,
264
+ headers: { "content-type": UPLOAD_PACK_RESULT_CONTENT_TYPE },
265
+ });
266
+ }
267
+
268
+ function packToReadable(pack: Uint8Array): ReadableStream<Uint8Array> {
269
+ return new ReadableStream<Uint8Array>({
270
+ start(controller) {
271
+ if (pack.length > 0) controller.enqueue(pack);
272
+ controller.close();
273
+ },
274
+ });
275
+ }
276
+
277
+ function successResponse(pack: Uint8Array | null): Response {
278
+ const stream = new ReadableStream<Uint8Array>({
279
+ async start(controller) {
280
+ const sink = new WritableStream<Uint8Array>({
281
+ write(chunk) {
282
+ controller.enqueue(chunk);
283
+ },
284
+ });
285
+ const writer = sink.getWriter();
286
+ try {
287
+ await writePktLine(writer, "NAK\n");
288
+ } catch (cause) {
289
+ await writer.abort(cause).catch(() => undefined);
290
+ controller.error(cause);
291
+ return;
292
+ }
293
+ if (pack !== null && pack.length > 0) {
294
+ const sideBand = chunkPackToSideBand(packToReadable(pack));
295
+ const reader = sideBand.getReader();
296
+ try {
297
+ for (;;) {
298
+ const r = await reader.read();
299
+ if (r.done) break;
300
+ if (r.value) controller.enqueue(r.value);
301
+ }
302
+ } catch (cause) {
303
+ await writer.abort(cause).catch(() => undefined);
304
+ controller.error(cause);
305
+ return;
306
+ }
307
+ }
308
+ // Per the smart-HTTP transfer spec, the upload-pack response
309
+ // terminates with a flush pkt-line after the side-band stream.
310
+ // Without this stock git aborts with `unexpected disconnect
311
+ // while reading sideband packet`.
312
+ try {
313
+ await writeFlush(writer);
314
+ await writer.close();
315
+ controller.close();
316
+ } catch (cause) {
317
+ await writer.abort(cause).catch(() => undefined);
318
+ controller.error(cause);
319
+ }
320
+ },
321
+ });
322
+ return new Response(stream, {
323
+ status: 200,
324
+ headers: { "content-type": UPLOAD_PACK_RESULT_CONTENT_TYPE },
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Translate substrate-thrown errors that escape the listRefs /
330
+ * getRepoDir / pack-build call chain into the upload-pack pkt-line
331
+ * ERR vocabulary. Only `authorize_denied:` is reachable from the
332
+ * upload-pack call chain; the receive-pack prefixes belong to
333
+ * `receive-pack.ts`'s translator. Upload-pack has no per-ref status
334
+ * channel, so the substrate's authorize denial collapses into the
335
+ * same `ERR forbidden ref` shape used for refPattern denial.
336
+ *
337
+ * Returns `null` when the error message does not carry the known
338
+ * substrate prefix; the caller rethrows in that case so a genuine
339
+ * crash still bubbles to the HTTP layer as a 500.
340
+ */
341
+ function translateSubstrateError(err: unknown): Response | null {
342
+ if (!(err instanceof Error)) return null;
343
+ if (err.message.startsWith("authorize_denied:")) {
344
+ return errorResponse("forbidden ref");
345
+ }
346
+ return null;
347
+ }
348
+
349
+ export async function handleUploadPack(
350
+ repoStore: UploadPackRepoStore,
351
+ principal: UploadPackPrincipal,
352
+ repoId: RepoId,
353
+ request: Request,
354
+ ): Promise<Response> {
355
+ const bodyBuf = new Uint8Array(await request.arrayBuffer());
356
+ const { wants, haves } = await parseUploadRequest(bodyBuf);
357
+ if (wants.length === 0) {
358
+ return errorResponse("upload-pack: no want lines");
359
+ }
360
+
361
+ try {
362
+ const dir = await repoStore.getRepoDir(principal, repoId);
363
+ const allRefs = await repoStore.listRefs(principal, repoId);
364
+ const refPattern = principal.tokenClaims.refPattern;
365
+ const allowedTips = allRefs
366
+ .filter((r) => glob.match(refPattern, r.name))
367
+ .map((r) => r.sha);
368
+
369
+ const { allowed: allowedObjects, perCommit } = await walkAllowedAndIndex(
370
+ dir,
371
+ allowedTips,
372
+ );
373
+
374
+ const classification = await classifyWants(dir, wants, allowedObjects);
375
+ if (classification.kind === "forbidden") {
376
+ return errorResponse("forbidden ref");
377
+ }
378
+ if (classification.kind === "unknown") {
379
+ return errorResponse("upload-pack: not our ref");
380
+ }
381
+
382
+ const wantedObjects = reachableFromIndex(wants, perCommit);
383
+ const result = await createNegotiatedPack(
384
+ dir,
385
+ wants,
386
+ haves,
387
+ (oid) => allowedObjects.has(oid),
388
+ { wantedObjects },
389
+ );
390
+ return successResponse(result === null ? null : result.pack);
391
+ } catch (err) {
392
+ const translated = translateSubstrateError(err);
393
+ if (translated !== null) return translated;
394
+ throw err;
395
+ }
396
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { createAuth, type Auth } from "./auth";
2
+ export {
3
+ createApp,
4
+ createHubContextMiddleware,
5
+ mountHubRoutes,
6
+ type App,
7
+ type CreateAppOpts,
8
+ type CreateHubContextMiddlewareDeps,
9
+ type MountHubRoutesDeps,
10
+ } from "./app";
11
+ export {
12
+ createRequireGrant,
13
+ idResource,
14
+ type CreateRequireGrantDeps,
15
+ type RequireGrant,
16
+ } from "./middleware/grant";
17
+ export {
18
+ createResolveTenant,
19
+ requireAuth,
20
+ type CreateResolveTenantDeps,
21
+ } from "./middleware/tenant";
22
+ export type { AppEnv, TenantEnv, TenantRow, PrincipalRow } from "./context";
23
+ export type { GetSession, SessionInfo, SessionUser } from "./session";