@openparachute/hub 0.7.4 → 0.7.5-rc.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4",
3
+ "version": "0.7.5-rc.2",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -11,8 +11,15 @@
11
11
  "bin": {
12
12
  "parachute": "src/cli.ts"
13
13
  },
14
- "workspaces": ["packages/*"],
15
- "files": ["src", "web/ui/dist", "README.md", "LICENSE"],
14
+ "workspaces": [
15
+ "packages/*"
16
+ ],
17
+ "files": [
18
+ "src",
19
+ "web/ui/dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
16
23
  "repository": {
17
24
  "type": "git",
18
25
  "url": "https://github.com/ParachuteComputer/parachute-hub.git"
@@ -0,0 +1,144 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { notifySurfacePushed } from "../git-notify.ts";
6
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
7
+ import { validateAccessToken } from "../jwt-sign.ts";
8
+ import { rotateSigningKey } from "../signing-keys.ts";
9
+
10
+ const ISSUER = "http://127.0.0.1:1939";
11
+
12
+ interface Harness {
13
+ db: ReturnType<typeof openHubDb>;
14
+ cleanup: () => void;
15
+ }
16
+
17
+ function makeHarness(): Harness {
18
+ const dir = mkdtempSync(join(tmpdir(), "phub-notify-"));
19
+ const db = openHubDb(hubDbPath(dir));
20
+ rotateSigningKey(db);
21
+ return {
22
+ db,
23
+ cleanup: () => {
24
+ db.close();
25
+ rmSync(dir, { recursive: true, force: true });
26
+ },
27
+ };
28
+ }
29
+
30
+ /** A fetch spy that records the last call and returns a canned response. */
31
+ function fetchSpy(status = 200, body = '{"ok":true}') {
32
+ const calls: Array<{ url: string; init: RequestInit }> = [];
33
+ const impl = ((url: string | URL | Request, init?: RequestInit) => {
34
+ calls.push({ url: String(url), init: init ?? {} });
35
+ return Promise.resolve(new Response(body, { status }));
36
+ }) as unknown as typeof fetch;
37
+ return { impl, calls };
38
+ }
39
+
40
+ describe("notifySurfacePushed", () => {
41
+ test("no surface module installed → no-op, no fetch", async () => {
42
+ const h = makeHarness();
43
+ const spy = fetchSpy();
44
+ try {
45
+ const out = await notifySurfacePushed("brain", {
46
+ db: h.db,
47
+ issuer: ISSUER,
48
+ resolveModuleOrigin: () => null,
49
+ cloneBaseOrigin: ISSUER,
50
+ fetchImpl: spy.impl,
51
+ log: { warn() {}, info() {} },
52
+ });
53
+ expect(out.notified).toBe(false);
54
+ expect(out.reason).toBe("surface-module-not-installed");
55
+ expect(spy.calls.length).toBe(0);
56
+ } finally {
57
+ h.cleanup();
58
+ }
59
+ });
60
+
61
+ test("posts to /surface/api/git-pushed with a surface:admin bearer + surface:<name>:read pull token", async () => {
62
+ const h = makeHarness();
63
+ const spy = fetchSpy();
64
+ try {
65
+ const out = await notifySurfacePushed("brain", {
66
+ db: h.db,
67
+ issuer: ISSUER,
68
+ resolveModuleOrigin: (short) => (short === "surface" ? "http://127.0.0.1:1946" : null),
69
+ cloneBaseOrigin: ISSUER,
70
+ fetchImpl: spy.impl,
71
+ log: { warn() {}, info() {} },
72
+ });
73
+ expect(out.notified).toBe(true);
74
+ expect(spy.calls.length).toBe(1);
75
+
76
+ const call = spy.calls[0]!;
77
+ expect(call.url).toBe("http://127.0.0.1:1946/surface/api/git-pushed");
78
+ expect(call.init.method).toBe("POST");
79
+
80
+ const headers = new Headers(call.init.headers as Record<string, string>);
81
+ const auth = headers.get("authorization") ?? "";
82
+ expect(auth.startsWith("Bearer ")).toBe(true);
83
+
84
+ // notify-auth bearer validates as surface:admin, aud "surface".
85
+ const notifyTok = auth.slice("Bearer ".length);
86
+ const notifyClaims = await validateAccessToken(h.db, notifyTok, [ISSUER]);
87
+ expect((notifyClaims.payload as { scope?: string }).scope).toBe("surface:admin");
88
+ expect(notifyClaims.payload.aud).toBe("surface");
89
+
90
+ // Body carries the surface name, a loopback clone_url, and a pull token
91
+ // scoped to exactly surface:brain:read.
92
+ const body = JSON.parse(String(call.init.body)) as {
93
+ surface: string;
94
+ clone_url: string;
95
+ pull_token: string;
96
+ };
97
+ expect(body.surface).toBe("brain");
98
+ expect(body.clone_url).toBe("http://127.0.0.1:1939/git/brain");
99
+ const pullClaims = await validateAccessToken(h.db, body.pull_token, [ISSUER]);
100
+ expect((pullClaims.payload as { scope?: string }).scope).toBe("surface:brain:read");
101
+ expect(pullClaims.payload.aud).toBe("surface");
102
+ } finally {
103
+ h.cleanup();
104
+ }
105
+ });
106
+
107
+ test("surface-host rejection is reported, never thrown", async () => {
108
+ const h = makeHarness();
109
+ const spy = fetchSpy(403, "forbidden");
110
+ try {
111
+ const out = await notifySurfacePushed("brain", {
112
+ db: h.db,
113
+ issuer: ISSUER,
114
+ resolveModuleOrigin: () => "http://127.0.0.1:1946",
115
+ cloneBaseOrigin: ISSUER,
116
+ fetchImpl: spy.impl,
117
+ log: { warn() {}, info() {} },
118
+ });
119
+ expect(out.notified).toBe(false);
120
+ expect(out.reason).toBe("notify-rejected:403");
121
+ } finally {
122
+ h.cleanup();
123
+ }
124
+ });
125
+
126
+ test("a fetch throw is swallowed (best-effort)", async () => {
127
+ const h = makeHarness();
128
+ const throwing = (() => Promise.reject(new Error("econnrefused"))) as unknown as typeof fetch;
129
+ try {
130
+ const out = await notifySurfacePushed("brain", {
131
+ db: h.db,
132
+ issuer: ISSUER,
133
+ resolveModuleOrigin: () => "http://127.0.0.1:1946",
134
+ cloneBaseOrigin: ISSUER,
135
+ fetchImpl: throwing,
136
+ log: { warn() {}, info() {} },
137
+ });
138
+ expect(out.notified).toBe(false);
139
+ expect(out.reason).toBe("notify-error");
140
+ } finally {
141
+ h.cleanup();
142
+ }
143
+ });
144
+ });
@@ -0,0 +1,492 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ type GitTransportDeps,
8
+ extractToken,
9
+ findHeaderEnd,
10
+ handleGitTransport,
11
+ parseCgiHeaders,
12
+ parseGitPath,
13
+ requiredAccess,
14
+ } from "../git-transport.ts";
15
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
16
+ import { hubFetch } from "../hub-server.ts";
17
+ import { signAccessToken } from "../jwt-sign.ts";
18
+ import { rotateSigningKey } from "../signing-keys.ts";
19
+ import { createUser } from "../users.ts";
20
+
21
+ const ISSUER = "http://127.0.0.1:1939";
22
+
23
+ interface Harness {
24
+ dir: string;
25
+ gitRoot: string;
26
+ db: ReturnType<typeof openHubDb>;
27
+ userId: string;
28
+ cleanup: () => void;
29
+ }
30
+
31
+ async function makeHarness(): Promise<Harness> {
32
+ const dir = mkdtempSync(join(tmpdir(), "phub-git-"));
33
+ const db = openHubDb(hubDbPath(dir));
34
+ rotateSigningKey(db);
35
+ const u = await createUser(db, "owner", "pw");
36
+ return {
37
+ dir,
38
+ gitRoot: join(dir, "git"),
39
+ db,
40
+ userId: u.id,
41
+ cleanup: () => {
42
+ db.close();
43
+ rmSync(dir, { recursive: true, force: true });
44
+ },
45
+ };
46
+ }
47
+
48
+ async function mint(h: Harness, scopes: string[]): Promise<string> {
49
+ const { token } = await signAccessToken(h.db, {
50
+ sub: h.userId,
51
+ scopes,
52
+ audience: "surface",
53
+ clientId: "test-client",
54
+ issuer: ISSUER,
55
+ });
56
+ return token;
57
+ }
58
+
59
+ function deps(h: Harness, extra?: Partial<GitTransportDeps>): GitTransportDeps {
60
+ return {
61
+ db: h.db,
62
+ gitRoot: h.gitRoot,
63
+ knownIssuers: () => [ISSUER],
64
+ ...extra,
65
+ };
66
+ }
67
+
68
+ function gitReq(path: string, init?: RequestInit): Request {
69
+ return new Request(`http://127.0.0.1${path}`, init);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Pure parsers
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe("parseGitPath", () => {
77
+ test("parses name + subpath", () => {
78
+ expect(parseGitPath("/git/gitcoin-brain/info/refs")).toEqual({
79
+ name: "gitcoin-brain",
80
+ gitSubpath: "info/refs",
81
+ });
82
+ });
83
+ test("strips a trailing .git on the name segment", () => {
84
+ expect(parseGitPath("/git/foo.git/git-receive-pack")).toEqual({
85
+ name: "foo",
86
+ gitSubpath: "git-receive-pack",
87
+ });
88
+ });
89
+ test("rejects non-/git paths", () => {
90
+ expect(parseGitPath("/surface/foo")).toBeNull();
91
+ });
92
+ test("rejects a bare /git/<name> with no subpath", () => {
93
+ expect(parseGitPath("/git/foo")).toBeNull();
94
+ expect(parseGitPath("/git/foo/")).toBeNull();
95
+ });
96
+ test("rejects path traversal in the name", () => {
97
+ expect(parseGitPath("/git/../etc/info/refs")).toBeNull();
98
+ expect(parseGitPath("/git/.../info/refs")).toBeNull();
99
+ });
100
+ test("rejects traversal in the subpath", () => {
101
+ expect(parseGitPath("/git/foo/../../etc/passwd")).toBeNull();
102
+ });
103
+ test("rejects slashes / illegal chars in the name", () => {
104
+ expect(parseGitPath("/git/a@b/info/refs")).toBeNull();
105
+ });
106
+ });
107
+
108
+ describe("requiredAccess", () => {
109
+ test("receive-pack is write", () => {
110
+ expect(requiredAccess("git-receive-pack", null)).toBe("write");
111
+ expect(requiredAccess("info/refs", "git-receive-pack")).toBe("write");
112
+ });
113
+ test("upload-pack is read", () => {
114
+ expect(requiredAccess("git-upload-pack", null)).toBe("read");
115
+ expect(requiredAccess("info/refs", "git-upload-pack")).toBe("read");
116
+ });
117
+ test("dumb / unknown paths default to read", () => {
118
+ expect(requiredAccess("info/refs", null)).toBe("read");
119
+ expect(requiredAccess("HEAD", null)).toBe("read");
120
+ expect(requiredAccess("objects/info/packs", null)).toBe("read");
121
+ });
122
+ });
123
+
124
+ describe("extractToken", () => {
125
+ test("Bearer", () => {
126
+ const r = gitReq("/git/foo/info/refs", { headers: { authorization: "Bearer abc.def.ghi" } });
127
+ expect(extractToken(r)).toBe("abc.def.ghi");
128
+ });
129
+ test("Basic x-access-token:<jwt>", () => {
130
+ const b64 = Buffer.from("x-access-token:my.jwt.tok").toString("base64");
131
+ const r = gitReq("/git/foo/info/refs", { headers: { authorization: `Basic ${b64}` } });
132
+ expect(extractToken(r)).toBe("my.jwt.tok");
133
+ });
134
+ test("Basic <jwt>:x-oauth-basic (legacy, token in username)", () => {
135
+ const b64 = Buffer.from("my.jwt.tok:x-oauth-basic").toString("base64");
136
+ const r = gitReq("/git/foo/info/refs", { headers: { authorization: `Basic ${b64}` } });
137
+ expect(extractToken(r)).toBe("my.jwt.tok");
138
+ });
139
+ test("no header → null", () => {
140
+ expect(extractToken(gitReq("/git/foo/info/refs"))).toBeNull();
141
+ });
142
+ });
143
+
144
+ describe("parseCgiHeaders", () => {
145
+ test("default 200 when no Status line", () => {
146
+ const { status, headers } = parseCgiHeaders(
147
+ "Content-Type: application/x-git-upload-pack-advertisement",
148
+ );
149
+ expect(status).toBe(200);
150
+ expect(headers.get("content-type")).toBe("application/x-git-upload-pack-advertisement");
151
+ });
152
+ test("honors a Status line", () => {
153
+ const { status } = parseCgiHeaders("Status: 404 Not Found\r\nContent-Type: text/plain");
154
+ expect(status).toBe(404);
155
+ });
156
+ });
157
+
158
+ describe("findHeaderEnd", () => {
159
+ const enc = new TextEncoder();
160
+ test("finds CRLFCRLF", () => {
161
+ const buf = enc.encode("A: b\r\nC: d\r\n\r\nBODY");
162
+ const r = findHeaderEnd(buf);
163
+ expect(r?.sepLen).toBe(4);
164
+ expect(new TextDecoder().decode(buf.slice(0, r?.idx))).toBe("A: b\r\nC: d");
165
+ });
166
+ test("finds LFLF", () => {
167
+ const buf = enc.encode("A: b\n\nBODY");
168
+ const r = findHeaderEnd(buf);
169
+ expect(r?.sepLen).toBe(2);
170
+ });
171
+ test("returns null without a boundary", () => {
172
+ expect(findHeaderEnd(enc.encode("A: b\r\n"))).toBeNull();
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Auth gate (handler-direct — no real server needed)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe("handleGitTransport — auth gate", () => {
181
+ test("401 + WWW-Authenticate: Bearer when no credential", async () => {
182
+ const h = await makeHarness();
183
+ try {
184
+ const res = await handleGitTransport(
185
+ gitReq("/git/foo/info/refs?service=git-receive-pack"),
186
+ deps(h),
187
+ );
188
+ expect(res.status).toBe(401);
189
+ expect(res.headers.get("www-authenticate") ?? "").toContain("Bearer");
190
+ // Nothing provisioned on an unauthenticated probe.
191
+ expect(existsSync(join(h.gitRoot, "foo.git"))).toBe(false);
192
+ } finally {
193
+ h.cleanup();
194
+ }
195
+ });
196
+
197
+ test("401 on an invalid/garbage token", async () => {
198
+ const h = await makeHarness();
199
+ try {
200
+ const res = await handleGitTransport(
201
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
202
+ headers: { authorization: "Bearer not-a-jwt" },
203
+ }),
204
+ deps(h),
205
+ );
206
+ expect(res.status).toBe(401);
207
+ expect(res.headers.get("www-authenticate") ?? "").toContain("Bearer");
208
+ } finally {
209
+ h.cleanup();
210
+ }
211
+ });
212
+
213
+ test("403 when a valid token lacks surface:<name>:write (push)", async () => {
214
+ const h = await makeHarness();
215
+ try {
216
+ const token = await mint(h, ["surface:foo:read", "vault:default:read"]);
217
+ const res = await handleGitTransport(
218
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
219
+ headers: { authorization: `Bearer ${token}` },
220
+ }),
221
+ deps(h),
222
+ );
223
+ expect(res.status).toBe(403);
224
+ // A read-only credential never provisions a write repo on a push attempt.
225
+ expect(existsSync(join(h.gitRoot, "foo.git"))).toBe(false);
226
+ } finally {
227
+ h.cleanup();
228
+ }
229
+ });
230
+
231
+ test("403 on a read (upload-pack) with neither read nor write scope", async () => {
232
+ const h = await makeHarness();
233
+ try {
234
+ const token = await mint(h, ["surface:other:read"]);
235
+ const res = await handleGitTransport(
236
+ gitReq("/git/foo/info/refs?service=git-upload-pack", {
237
+ headers: { authorization: `Bearer ${token}` },
238
+ }),
239
+ deps(h),
240
+ );
241
+ expect(res.status).toBe(403);
242
+ } finally {
243
+ h.cleanup();
244
+ }
245
+ });
246
+
247
+ test("a write token authorizes a push info/refs (advertisement, 200)", async () => {
248
+ const h = await makeHarness();
249
+ try {
250
+ const token = await mint(h, ["surface:foo:write"]);
251
+ const res = await handleGitTransport(
252
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
253
+ headers: { authorization: `Bearer ${token}` },
254
+ }),
255
+ deps(h),
256
+ );
257
+ expect(res.status).toBe(200);
258
+ expect(res.headers.get("content-type")).toContain("git-receive-pack-advertisement");
259
+ // First authenticated access provisions the bare repo.
260
+ expect(existsSync(join(h.gitRoot, "foo.git"))).toBe(true);
261
+ const body = await res.text();
262
+ expect(body).toContain("git-receive-pack");
263
+ } finally {
264
+ h.cleanup();
265
+ }
266
+ });
267
+
268
+ test("a read token authorizes a fetch info/refs (upload-pack, 200)", async () => {
269
+ const h = await makeHarness();
270
+ try {
271
+ const token = await mint(h, ["surface:foo:read"]);
272
+ const res = await handleGitTransport(
273
+ gitReq("/git/foo/info/refs?service=git-upload-pack", {
274
+ headers: { authorization: `Bearer ${token}` },
275
+ }),
276
+ deps(h),
277
+ );
278
+ expect(res.status).toBe(200);
279
+ expect(res.headers.get("content-type")).toContain("git-upload-pack-advertisement");
280
+ // Drain the body so the http-backend service finishes before teardown
281
+ // (else cleanup's rmSync can race the still-running `git upload-pack`).
282
+ await res.text();
283
+ } finally {
284
+ h.cleanup();
285
+ }
286
+ });
287
+
288
+ test("onPushed does NOT fire for a fetch (upload-pack) advertisement", async () => {
289
+ const h = await makeHarness();
290
+ let pushed = 0;
291
+ try {
292
+ const token = await mint(h, ["surface:foo:read"]);
293
+ const res = await handleGitTransport(
294
+ gitReq("/git/foo/info/refs?service=git-upload-pack", {
295
+ headers: { authorization: `Bearer ${token}` },
296
+ }),
297
+ deps(h, { onPushed: () => void pushed++ }),
298
+ );
299
+ expect(res.status).toBe(200);
300
+ await res.text();
301
+ // Give any (erroneous) background fire a tick to run.
302
+ await new Promise((r) => setTimeout(r, 50));
303
+ expect(pushed).toBe(0);
304
+ } finally {
305
+ h.cleanup();
306
+ }
307
+ });
308
+
309
+ test("write ⊇ read: a write token may also fetch", async () => {
310
+ const h = await makeHarness();
311
+ try {
312
+ const token = await mint(h, ["surface:foo:write"]);
313
+ const res = await handleGitTransport(
314
+ gitReq("/git/foo/info/refs?service=git-upload-pack", {
315
+ headers: { authorization: `Bearer ${token}` },
316
+ }),
317
+ deps(h),
318
+ );
319
+ expect(res.status).toBe(200);
320
+ await res.text();
321
+ } finally {
322
+ h.cleanup();
323
+ }
324
+ });
325
+
326
+ test("Basic x-access-token:<jwt> is accepted (older-git compat)", async () => {
327
+ const h = await makeHarness();
328
+ try {
329
+ const token = await mint(h, ["surface:foo:write"]);
330
+ const b64 = Buffer.from(`x-access-token:${token}`).toString("base64");
331
+ const res = await handleGitTransport(
332
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
333
+ headers: { authorization: `Basic ${b64}` },
334
+ }),
335
+ deps(h),
336
+ );
337
+ expect(res.status).toBe(200);
338
+ await res.text();
339
+ } finally {
340
+ h.cleanup();
341
+ }
342
+ });
343
+ });
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Dispatch wiring through hubFetch
347
+ // ---------------------------------------------------------------------------
348
+
349
+ describe("hubFetch /git dispatch", () => {
350
+ test("routes /git/* to the transport (401 unauth, with WWW-Authenticate)", async () => {
351
+ const h = await makeHarness();
352
+ try {
353
+ const handler = hubFetch(h.dir, {
354
+ getDb: () => h.db,
355
+ gitRoot: h.gitRoot,
356
+ issuer: ISSUER,
357
+ loopbackPort: 1939,
358
+ });
359
+ const res = await handler(gitReq("/git/foo/info/refs?service=git-upload-pack"));
360
+ expect(res.status).toBe(401);
361
+ expect(res.headers.get("www-authenticate") ?? "").toContain("Bearer");
362
+ } finally {
363
+ h.cleanup();
364
+ }
365
+ });
366
+ });
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Real git push round-trip through a live server
370
+ // ---------------------------------------------------------------------------
371
+
372
+ function git(
373
+ args: string[],
374
+ cwd: string,
375
+ env?: Record<string, string>,
376
+ ): { code: number; out: string; err: string } {
377
+ const r = spawnSync("git", args, {
378
+ cwd,
379
+ encoding: "utf8",
380
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
381
+ });
382
+ return { code: r.status ?? -1, out: r.stdout ?? "", err: r.stderr ?? "" };
383
+ }
384
+
385
+ /**
386
+ * Async git — for any client op that talks to the in-process `Bun.serve`. The
387
+ * synchronous `spawnSync` would BLOCK Bun's single event loop, starving the
388
+ * server that's meant to answer this very request → deadlock. The async spawn
389
+ * keeps the loop free so the server can respond. (Setup ops above use the sync
390
+ * helper because they never touch the server.)
391
+ */
392
+ async function gitAsync(
393
+ args: string[],
394
+ cwd: string,
395
+ env?: Record<string, string>,
396
+ ): Promise<{ code: number; err: string }> {
397
+ const proc = Bun.spawn(["git", ...args], {
398
+ cwd,
399
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
400
+ stdout: "pipe",
401
+ stderr: "pipe",
402
+ });
403
+ const [code, err] = await Promise.all([
404
+ proc.exited,
405
+ new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
406
+ ]);
407
+ return { code, err };
408
+ }
409
+
410
+ describe("git push round-trip", () => {
411
+ test("an authed push lands the ref in the bare repo + fires post-receive", async () => {
412
+ const h = await makeHarness();
413
+ // Capture the onPushed deploy hand-off. Wire it through the low-level
414
+ // handler with a live server so we exercise the true subprocess-exit fire.
415
+ const pushedNames: string[] = [];
416
+ let resolvePushed: (name: string) => void = () => {};
417
+ const pushedOnce = new Promise<string>((r) => {
418
+ resolvePushed = r;
419
+ });
420
+ const server = Bun.serve({
421
+ port: 0,
422
+ hostname: "127.0.0.1",
423
+ fetch: (req) =>
424
+ handleGitTransport(req, {
425
+ db: h.db,
426
+ gitRoot: h.gitRoot,
427
+ knownIssuers: () => [ISSUER],
428
+ onPushed: (name) => {
429
+ pushedNames.push(name);
430
+ resolvePushed(name);
431
+ },
432
+ }),
433
+ });
434
+ const work = mkdtempSync(join(tmpdir(), "phub-git-work-"));
435
+ try {
436
+ const token = await mint(h, ["surface:foo:write"]);
437
+ const base = `http://127.0.0.1:${server.port}`;
438
+
439
+ // Author a commit in a throwaway working repo.
440
+ expect(git(["init", "-q", "-b", "main", work], tmpdir()).code).toBe(0);
441
+ git(["config", "user.email", "test@parachute.computer"], work);
442
+ git(["config", "user.name", "Test"], work);
443
+ Bun.write(join(work, "index.html"), "<h1>surface</h1>\n");
444
+ expect(git(["add", "-A"], work).code).toBe(0);
445
+ expect(git(["commit", "-q", "-m", "first"], work).code).toBe(0);
446
+ const localRev = git(["rev-parse", "HEAD"], work).out.trim();
447
+
448
+ // Push through the hub-authenticated endpoint (token via extraHeader, so
449
+ // the request carries Authorization up-front — exercises info/refs +
450
+ // receive-pack transfer end-to-end). Async spawn (see gitAsync) so the
451
+ // in-process server can answer.
452
+ const push = await gitAsync(
453
+ [
454
+ "-c",
455
+ `http.extraHeader=Authorization: Bearer ${token}`,
456
+ "push",
457
+ `${base}/git/foo`,
458
+ "main",
459
+ ],
460
+ work,
461
+ );
462
+ expect(push.code).toBe(0);
463
+
464
+ // The ref landed in the hub-side bare repo at the pushed sha.
465
+ const bare = join(h.gitRoot, "foo.git");
466
+ const serverRev = git(
467
+ ["--git-dir", bare, "rev-parse", "refs/heads/main"],
468
+ tmpdir(),
469
+ ).out.trim();
470
+ expect(serverRev).toBe(localRev);
471
+
472
+ // post-receive placeholder fired and logged the ref.
473
+ const logPath = join(bare, "post-receive.log");
474
+ expect(existsSync(logPath)).toBe(true);
475
+ expect(readFileSync(logPath, "utf8")).toContain("refs/heads/main");
476
+
477
+ // The deploy hand-off fired with the surface name (the receive-pack
478
+ // subprocess exited 0). It's observed off the subprocess, which can lag
479
+ // the client's push return by a tick — await the signal.
480
+ const pushedName = await Promise.race([
481
+ pushedOnce,
482
+ new Promise<string>((_, rej) => setTimeout(() => rej(new Error("onPushed timeout")), 5000)),
483
+ ]);
484
+ expect(pushedName).toBe("foo");
485
+ expect(pushedNames).toEqual(["foo"]);
486
+ } finally {
487
+ server.stop(true);
488
+ rmSync(work, { recursive: true, force: true });
489
+ h.cleanup();
490
+ }
491
+ });
492
+ });