@openparachute/hub 0.7.5-rc.1 → 0.7.5-rc.3

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.
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { ensureSurfaceRepo, isSurfaceRegistered, registerSurface } from "../git-registry.ts";
6
7
  import {
7
8
  type GitTransportDeps,
8
9
  extractToken,
@@ -61,6 +62,11 @@ function deps(h: Harness, extra?: Partial<GitTransportDeps>): GitTransportDeps {
61
62
  db: h.db,
62
63
  gitRoot: h.gitRoot,
63
64
  knownIssuers: () => [ISSUER],
65
+ // Default: treat every name as declared + provision on demand (the Phase-0a
66
+ // "feel it" behavior). The declaration-gate tests below override `isDeclared`
67
+ // or use the real registry.
68
+ isDeclared: () => true,
69
+ ensureRepo: (name) => ensureSurfaceRepo(h.gitRoot, name),
64
70
  ...extra,
65
71
  };
66
72
  }
@@ -285,6 +291,27 @@ describe("handleGitTransport — auth gate", () => {
285
291
  }
286
292
  });
287
293
 
294
+ test("onPushed does NOT fire for a fetch (upload-pack) advertisement", async () => {
295
+ const h = await makeHarness();
296
+ let pushed = 0;
297
+ try {
298
+ const token = await mint(h, ["surface:foo:read"]);
299
+ const res = await handleGitTransport(
300
+ gitReq("/git/foo/info/refs?service=git-upload-pack", {
301
+ headers: { authorization: `Bearer ${token}` },
302
+ }),
303
+ deps(h, { onPushed: () => void pushed++ }),
304
+ );
305
+ expect(res.status).toBe(200);
306
+ await res.text();
307
+ // Give any (erroneous) background fire a tick to run.
308
+ await new Promise((r) => setTimeout(r, 50));
309
+ expect(pushed).toBe(0);
310
+ } finally {
311
+ h.cleanup();
312
+ }
313
+ });
314
+
288
315
  test("write ⊇ read: a write token may also fetch", async () => {
289
316
  const h = await makeHarness();
290
317
  try {
@@ -321,6 +348,176 @@ describe("handleGitTransport — auth gate", () => {
321
348
  });
322
349
  });
323
350
 
351
+ // ---------------------------------------------------------------------------
352
+ // Declaration gate (Phase 1) — provision/serve only a REGISTERED surface
353
+ // ---------------------------------------------------------------------------
354
+
355
+ describe("handleGitTransport — declaration gate", () => {
356
+ /** Deps wired to the REAL registry (isSurfaceRegistered + ensureSurfaceRepo). */
357
+ function regDeps(h: Harness): GitTransportDeps {
358
+ return deps(h, {
359
+ isDeclared: (name) => isSurfaceRegistered(h.gitRoot, name),
360
+ ensureRepo: (name) => ensureSurfaceRepo(h.gitRoot, name),
361
+ });
362
+ }
363
+
364
+ test("404 for an authed write to an UNdeclared surface (no auto-provision)", async () => {
365
+ const h = await makeHarness();
366
+ try {
367
+ const token = await mint(h, ["surface:foo:write"]);
368
+ const res = await handleGitTransport(
369
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
370
+ headers: { authorization: `Bearer ${token}` },
371
+ }),
372
+ regDeps(h),
373
+ );
374
+ // A valid write token for an undeclared name is NOT enough — the registry
375
+ // gate 404s (indistinguishable from a bad path) and nothing is provisioned.
376
+ expect(res.status).toBe(404);
377
+ expect(existsSync(join(h.gitRoot, "foo.git"))).toBe(false);
378
+ } finally {
379
+ h.cleanup();
380
+ }
381
+ });
382
+
383
+ test("a declared (registered) surface serves the push advertisement", async () => {
384
+ const h = await makeHarness();
385
+ try {
386
+ // Lifecycle: surface-host registers the discovered `#surface` first.
387
+ await registerSurface(h.gitRoot, "foo");
388
+ expect(existsSync(join(h.gitRoot, "foo.git"))).toBe(true);
389
+ const token = await mint(h, ["surface:foo:write"]);
390
+ const res = await handleGitTransport(
391
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
392
+ headers: { authorization: `Bearer ${token}` },
393
+ }),
394
+ regDeps(h),
395
+ );
396
+ expect(res.status).toBe(200);
397
+ expect(res.headers.get("content-type")).toContain("git-receive-pack-advertisement");
398
+ await res.text();
399
+ } finally {
400
+ h.cleanup();
401
+ }
402
+ });
403
+
404
+ test("grandfathering: an already-provisioned bare repo counts as declared", async () => {
405
+ const h = await makeHarness();
406
+ try {
407
+ // A Phase-0a repo that exists on disk but has no registry.json entry.
408
+ await ensureSurfaceRepo(h.gitRoot, "legacy");
409
+ expect(isSurfaceRegistered(h.gitRoot, "legacy")).toBe(true);
410
+ const token = await mint(h, ["surface:legacy:read"]);
411
+ const res = await handleGitTransport(
412
+ gitReq("/git/legacy/info/refs?service=git-upload-pack", {
413
+ headers: { authorization: `Bearer ${token}` },
414
+ }),
415
+ regDeps(h),
416
+ );
417
+ expect(res.status).toBe(200);
418
+ await res.text();
419
+ } finally {
420
+ h.cleanup();
421
+ }
422
+ });
423
+
424
+ test("gate runs AFTER auth: no token on an undeclared name still 401 (not 404)", async () => {
425
+ const h = await makeHarness();
426
+ try {
427
+ const res = await handleGitTransport(
428
+ gitReq("/git/foo/info/refs?service=git-upload-pack"),
429
+ regDeps(h),
430
+ );
431
+ // 401 (not 404) — an unauthenticated probe never learns registry membership.
432
+ expect(res.status).toBe(401);
433
+ } finally {
434
+ h.cleanup();
435
+ }
436
+ });
437
+
438
+ test("gate runs AFTER scope: wrong-surface token on an undeclared name is 403 (not 404)", async () => {
439
+ const h = await makeHarness();
440
+ try {
441
+ const token = await mint(h, ["surface:other:write"]);
442
+ const res = await handleGitTransport(
443
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
444
+ headers: { authorization: `Bearer ${token}` },
445
+ }),
446
+ regDeps(h),
447
+ );
448
+ // 403 (scope) before 404 (registry) — a valid-but-wrong token never learns
449
+ // membership either.
450
+ expect(res.status).toBe(403);
451
+ } finally {
452
+ h.cleanup();
453
+ }
454
+ });
455
+ });
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Direct transfer POST (no prior info/refs GET) — auth enforced at BOTH points
459
+ // ---------------------------------------------------------------------------
460
+
461
+ describe("handleGitTransport — direct transfer POST", () => {
462
+ test("401 on a direct POST to git-receive-pack with no credential", async () => {
463
+ const h = await makeHarness();
464
+ try {
465
+ const res = await handleGitTransport(
466
+ gitReq("/git/foo/git-receive-pack", {
467
+ method: "POST",
468
+ headers: { "content-type": "application/x-git-receive-pack-request" },
469
+ }),
470
+ deps(h),
471
+ );
472
+ // A client that skips the info/refs GET is still gated at the POST.
473
+ expect(res.status).toBe(401);
474
+ expect(res.headers.get("www-authenticate") ?? "").toContain("Bearer");
475
+ expect(existsSync(join(h.gitRoot, "foo.git"))).toBe(false);
476
+ } finally {
477
+ h.cleanup();
478
+ }
479
+ });
480
+
481
+ test("403 on a direct POST to git-receive-pack with only read scope", async () => {
482
+ const h = await makeHarness();
483
+ try {
484
+ const token = await mint(h, ["surface:foo:read"]);
485
+ const res = await handleGitTransport(
486
+ gitReq("/git/foo/git-receive-pack", {
487
+ method: "POST",
488
+ headers: {
489
+ authorization: `Bearer ${token}`,
490
+ "content-type": "application/x-git-receive-pack-request",
491
+ },
492
+ }),
493
+ deps(h),
494
+ );
495
+ // receive-pack requires write; a read token is refused at the POST itself.
496
+ expect(res.status).toBe(403);
497
+ expect(existsSync(join(h.gitRoot, "foo.git"))).toBe(false);
498
+ } finally {
499
+ h.cleanup();
500
+ }
501
+ });
502
+
503
+ test("401 on a direct POST to git-upload-pack with no credential", async () => {
504
+ const h = await makeHarness();
505
+ try {
506
+ const res = await handleGitTransport(
507
+ gitReq("/git/foo/git-upload-pack", {
508
+ method: "POST",
509
+ headers: { "content-type": "application/x-git-upload-pack-request" },
510
+ }),
511
+ deps(h),
512
+ );
513
+ expect(res.status).toBe(401);
514
+ expect(res.headers.get("www-authenticate") ?? "").toContain("Bearer");
515
+ } finally {
516
+ h.cleanup();
517
+ }
518
+ });
519
+ });
520
+
324
521
  // ---------------------------------------------------------------------------
325
522
  // Dispatch wiring through hubFetch
326
523
  // ---------------------------------------------------------------------------
@@ -389,19 +586,35 @@ async function gitAsync(
389
586
  describe("git push round-trip", () => {
390
587
  test("an authed push lands the ref in the bare repo + fires post-receive", async () => {
391
588
  const h = await makeHarness();
589
+ // Capture the onPushed deploy hand-off. Wire it through the low-level
590
+ // handler with a live server so we exercise the true subprocess-exit fire.
591
+ const pushedNames: string[] = [];
592
+ let resolvePushed: (name: string) => void = () => {};
593
+ const pushedOnce = new Promise<string>((r) => {
594
+ resolvePushed = r;
595
+ });
392
596
  const server = Bun.serve({
393
597
  port: 0,
394
598
  hostname: "127.0.0.1",
395
- fetch: hubFetch(h.dir, {
396
- getDb: () => h.db,
397
- gitRoot: h.gitRoot,
398
- issuer: ISSUER,
399
- loopbackPort: 1939,
400
- }),
599
+ fetch: (req) =>
600
+ handleGitTransport(req, {
601
+ db: h.db,
602
+ gitRoot: h.gitRoot,
603
+ knownIssuers: () => [ISSUER],
604
+ isDeclared: (name) => isSurfaceRegistered(h.gitRoot, name),
605
+ ensureRepo: (name) => ensureSurfaceRepo(h.gitRoot, name),
606
+ onPushed: (name) => {
607
+ pushedNames.push(name);
608
+ resolvePushed(name);
609
+ },
610
+ }),
401
611
  });
402
612
  const work = mkdtempSync(join(tmpdir(), "phub-git-work-"));
403
613
  try {
404
614
  const token = await mint(h, ["surface:foo:write"]);
615
+ // Declare the surface first (the Phase-1 lifecycle: surface-host registers
616
+ // a discovered `#surface` note before the push) — provisions the bare repo.
617
+ await registerSurface(h.gitRoot, "foo");
405
618
  const base = `http://127.0.0.1:${server.port}`;
406
619
 
407
620
  // Author a commit in a throwaway working repo.
@@ -441,6 +654,16 @@ describe("git push round-trip", () => {
441
654
  const logPath = join(bare, "post-receive.log");
442
655
  expect(existsSync(logPath)).toBe(true);
443
656
  expect(readFileSync(logPath, "utf8")).toContain("refs/heads/main");
657
+
658
+ // The deploy hand-off fired with the surface name (the receive-pack
659
+ // subprocess exited 0). It's observed off the subprocess, which can lag
660
+ // the client's push return by a tick — await the signal.
661
+ const pushedName = await Promise.race([
662
+ pushedOnce,
663
+ new Promise<string>((_, rej) => setTimeout(() => rej(new Error("onPushed timeout")), 5000)),
664
+ ]);
665
+ expect(pushedName).toBe("foo");
666
+ expect(pushedNames).toEqual(["foo"]);
444
667
  } finally {
445
668
  server.stop(true);
446
669
  rmSync(work, { recursive: true, force: true });
@@ -93,6 +93,22 @@ describe("explainScope", () => {
93
93
  expect(explainScope("vault:my-techne_2:admin")?.level).toBe("admin");
94
94
  expect(explainScope("vault:*:admin")?.level).toBe("admin");
95
95
  });
96
+
97
+ // Surface Git Transport Phase 1: named per-surface scopes
98
+ // (`surface:<name>:<verb>`) reach the consent screen via the 3→2-segment
99
+ // collapse, so explainScope MUST resolve them to the unnamed surface:read /
100
+ // surface:write labels — else the operator sees the raw scope string.
101
+ test("named surface scopes (surface:<name>:<verb>) reuse the unnamed-verb explanation", () => {
102
+ expect(explainScope("surface:gitcoin-brain:read")?.label).toBe(
103
+ SCOPE_EXPLANATIONS["surface:read"]?.label,
104
+ );
105
+ expect(explainScope("surface:gitcoin-brain:read")?.level).toBe("read");
106
+ expect(explainScope("surface:my-app_2:write")?.label).toBe(
107
+ SCOPE_EXPLANATIONS["surface:write"]?.label,
108
+ );
109
+ expect(explainScope("surface:my-app_2:write")?.level).toBe("write");
110
+ expect(explainScope("surface:*:write")?.level).toBe("write");
111
+ });
96
112
  });
97
113
 
98
114
  describe("scopeIsAdmin", () => {
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `/admin/surfaces` — the surface → bare-repo registry endpoint (Surface Git
3
+ * Transport Phase 1, design doc 2026-06-30-surface-git-transport.md §9/§10).
4
+ *
5
+ * This is the seam by which "vault declares" reaches the hub substrate. The
6
+ * vault holds the `#surface` note; surface-host discovers it (it custodies a
7
+ * vault read cred) and POSTs here to REGISTER the surface — which provisions its
8
+ * bare repo and records the name→repo mapping (git-registry.ts). The
9
+ * git-transport endpoint then serves/provisions ONLY registered names (§10 step
10
+ * 1). The hub never reads the vault itself — surface-host is the reader; this
11
+ * endpoint just records what it's told, gated on operator authority.
12
+ *
13
+ * - `POST /admin/surfaces` {name, mount?, mode?} → register (idempotent)
14
+ * - `GET /admin/surfaces` → list registered surfaces
15
+ *
16
+ * Auth: a Bearer carrying `parachute:host:admin` — the operator token
17
+ * surface-host already reads for its DCR + redirect-self-heal calls. Same
18
+ * validation shape as `api-modules-ops.ts` (`validateHostAdminToken` against the
19
+ * multi-origin known-issuer set, then a scope check): the scope is
20
+ * operator-only/non-requestable, so the iss relaxation can't reach an OAuth
21
+ * token.
22
+ */
23
+ import type { Database } from "bun:sqlite";
24
+ import { type SurfaceRegistryEntry, listSurfaces, registerSurface } from "./git-registry.ts";
25
+ import { validateHostAdminToken } from "./host-admin-token-validation.ts";
26
+
27
+ /** Scope required to register/list surfaces — the operator token carries it. */
28
+ export const ADMIN_SURFACES_REQUIRED_SCOPE = "parachute:host:admin";
29
+
30
+ export interface AdminSurfacesLog {
31
+ warn: (...args: unknown[]) => void;
32
+ info: (...args: unknown[]) => void;
33
+ }
34
+
35
+ export interface AdminSurfacesDeps {
36
+ db: Database;
37
+ /** Bare-repo root (`<CONFIG_DIR>/hub/git`). */
38
+ gitRoot: string;
39
+ /** Per-request hub issuer (`oauthDeps(req).issuer`). */
40
+ issuer: string;
41
+ /**
42
+ * The SET of origins the hub answers on (`oauthDeps(req).hubBoundOrigins()`),
43
+ * so an operator token minted under a prior origin keeps validating across an
44
+ * origin switch (hub#516 pattern).
45
+ */
46
+ knownIssuers?: readonly string[];
47
+ log?: AdminSurfacesLog;
48
+ }
49
+
50
+ function json(status: number, body: unknown): Response {
51
+ return new Response(JSON.stringify(body), {
52
+ status,
53
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
54
+ });
55
+ }
56
+
57
+ function jsonError(status: number, error: string, description: string): Response {
58
+ return json(status, { error, error_description: description });
59
+ }
60
+
61
+ /** Validate the operator bearer + require the surfaces scope. Mirrors api-modules-ops. */
62
+ async function authorize(req: Request, deps: AdminSurfacesDeps): Promise<Response | undefined> {
63
+ const auth = req.headers.get("authorization");
64
+ if (!auth || !auth.startsWith("Bearer ")) {
65
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
66
+ }
67
+ const bearer = auth.slice("Bearer ".length).trim();
68
+ if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
69
+ try {
70
+ const validated = await validateHostAdminToken(
71
+ deps.db,
72
+ bearer,
73
+ deps.knownIssuers ?? [deps.issuer],
74
+ );
75
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
76
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
77
+ }
78
+ const scopes =
79
+ typeof validated.payload.scope === "string"
80
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
81
+ : [];
82
+ if (!scopes.includes(ADMIN_SURFACES_REQUIRED_SCOPE)) {
83
+ return jsonError(
84
+ 403,
85
+ "insufficient_scope",
86
+ `bearer token lacks ${ADMIN_SURFACES_REQUIRED_SCOPE}`,
87
+ );
88
+ }
89
+ } catch (err) {
90
+ const msg = err instanceof Error ? err.message : String(err);
91
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ interface RegisterBody {
97
+ name?: unknown;
98
+ mount?: unknown;
99
+ mode?: unknown;
100
+ }
101
+
102
+ /**
103
+ * Route `/admin/surfaces`. Returns null when the path isn't ours (the caller
104
+ * falls through). GET lists; POST registers; other methods 405.
105
+ */
106
+ export async function routeAdminSurfaces(
107
+ req: Request,
108
+ deps: AdminSurfacesDeps,
109
+ ): Promise<Response | null> {
110
+ const { pathname } = new URL(req.url);
111
+ if (pathname !== "/admin/surfaces") return null;
112
+ const log = deps.log ?? console;
113
+
114
+ if (req.method === "GET") {
115
+ const authFail = await authorize(req, deps);
116
+ if (authFail) return authFail;
117
+ return json(200, { surfaces: listSurfaces(deps.gitRoot) });
118
+ }
119
+
120
+ if (req.method === "POST") {
121
+ const authFail = await authorize(req, deps);
122
+ if (authFail) return authFail;
123
+
124
+ let body: RegisterBody;
125
+ try {
126
+ body = (await req.json()) as RegisterBody;
127
+ } catch {
128
+ return jsonError(400, "invalid_body", "request body must be JSON");
129
+ }
130
+ if (typeof body.name !== "string" || body.name.length === 0) {
131
+ return jsonError(400, "invalid_name", "`name` is required (non-empty string)");
132
+ }
133
+ if (body.mount !== undefined && typeof body.mount !== "string") {
134
+ return jsonError(400, "invalid_mount", "`mount`, when present, must be a string");
135
+ }
136
+ if (body.mode !== undefined && body.mode !== "dev" && body.mode !== "prod") {
137
+ return jsonError(400, "invalid_mode", '`mode`, when present, must be "dev" or "prod"');
138
+ }
139
+ let entry: SurfaceRegistryEntry;
140
+ try {
141
+ entry = await registerSurface(deps.gitRoot, body.name, {
142
+ ...(typeof body.mount === "string" ? { mount: body.mount } : {}),
143
+ ...(body.mode === "dev" || body.mode === "prod" ? { mode: body.mode } : {}),
144
+ log,
145
+ });
146
+ } catch (err) {
147
+ const msg = err instanceof Error ? err.message : String(err);
148
+ // A bad name is the caller's fault (400); a provisioning failure is ours (500).
149
+ if (/invalid surface name/.test(msg)) return jsonError(400, "invalid_name", msg);
150
+ log.warn(`[admin-surfaces] register failed for "${String(body.name)}": ${msg}`);
151
+ return jsonError(500, "register_failed", "could not provision the surface repo");
152
+ }
153
+ log.info(`[admin-surfaces] registered surface "${entry.name}"`);
154
+ return json(200, { ok: true, surface: entry });
155
+ }
156
+
157
+ return jsonError(405, "method_not_allowed", "use GET or POST on /admin/surfaces");
158
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Deploy hand-off for the Surface Git Transport (Phase 0b, design doc
3
+ * 2026-06-30-surface-git-transport.md §5 step 5 + §7).
4
+ *
5
+ * After a successful `git push` to `/git/<name>` (receive-pack), the hub
6
+ * NOTIFIES the surface module over HTTP so it can pull + build + serve the new
7
+ * source. This is the settled "service-to-service via HTTP, not shell-out"
8
+ * seam: the `post-receive` hook does NOT build the pushed tree (that would run
9
+ * attacker-influenceable code as the hub/git user — RCE §7); the exec authority
10
+ * stays inside surface-host's own sandbox. The hub only sends an authenticated
11
+ * signal + a short-lived, narrowly-scoped read credential.
12
+ *
13
+ * Two hub-minted tokens ride this hand-off (both SHORT-LIVED + UNREGISTERED —
14
+ * they expire in minutes and are consumed inline, mirroring the H4
15
+ * credential-delivery provisioning-token pattern in admin-connections.ts):
16
+ *
17
+ * 1. notify-auth — a `surface:admin` bearer (aud `surface`) that
18
+ * authenticates the hub→surface-host POST. surface-host validates it with
19
+ * the SAME `enforceScope(surface:admin)` it uses for the hub's credential
20
+ * deliveries, so a random on-box process can't forge a push-notify.
21
+ * 2. pull-token — a `surface:<name>:read` bearer (aud `surface`) that
22
+ * surface-host presents back to THIS hub's `/git/<name>` endpoint to
23
+ * `git clone` the freshly-pushed source. Least-privilege: read on exactly
24
+ * the one surface, valid only long enough to clone.
25
+ *
26
+ * Modular by design (§1): surface-host pulls over the network (not a shared
27
+ * disk), so the seam already works when hub + surface-host are separate
28
+ * containers. `clone_url` is the hub's own loopback origin today; a cloud
29
+ * deploy supplies the internal hub URL instead. The token's `iss` is the hub
30
+ * issuer, which is a member of the hub's own bound-origin set, so the clone
31
+ * validates when it comes back in over loopback.
32
+ */
33
+ import type { Database } from "bun:sqlite";
34
+ import { signAccessToken } from "./jwt-sign.ts";
35
+
36
+ /** Provenance identity stamped on the hub-internal notify + pull tokens. */
37
+ const NOTIFY_SUBJECT = "surface-git-transport";
38
+ const NOTIFY_CLIENT_ID = "surface-git-transport";
39
+
40
+ /** aud of both minted tokens — surface-host declares `aud: "surface"`. */
41
+ const SURFACE_AUDIENCE = "surface";
42
+
43
+ /**
44
+ * notify-auth TTL. The POST is fired immediately; a small window covers a
45
+ * momentarily-busy loopback without leaving a usable credential lying around.
46
+ */
47
+ const NOTIFY_TTL_SECONDS = 120;
48
+
49
+ /**
50
+ * pull-token TTL. Long enough for surface-host to `git clone --depth 1` a
51
+ * source surface right after the notify lands, short enough that a leaked
52
+ * token is near-useless. Both TTLs here MUST stay well under the hub's
53
+ * registered-mint threshold (admin-connections REGISTERED_MINT_TTL_THRESHOLD,
54
+ * 600s) so these fire-and-forget tokens remain unregistered-by-policy — bumping
55
+ * either past it without registering them would leak unrevocable tokens.
56
+ */
57
+ const PULL_TTL_SECONDS = 300;
58
+
59
+ /** Bound the notify HTTP call so a wedged surface-host can't hang the caller. */
60
+ const NOTIFY_FETCH_TIMEOUT_MS = 10_000;
61
+
62
+ export interface GitNotifyLog {
63
+ warn: (...args: unknown[]) => void;
64
+ info: (...args: unknown[]) => void;
65
+ }
66
+
67
+ export interface NotifySurfacePushedDeps {
68
+ /** Hub DB — for `signAccessToken`'s active-signing-key lookup. */
69
+ db: Database;
70
+ /**
71
+ * Hub issuer (the `iss` claim), resolved per-request via `resolveIssuer`
72
+ * (`oauthDeps(req).issuer`). Both minted tokens carry it; the pull token
73
+ * validates against the hub's own bound-origin set on the clone-back.
74
+ */
75
+ issuer: string;
76
+ /**
77
+ * Resolve a module's loopback origin by short name (`makeResolveModuleOrigin`
78
+ * over services.json). Returns null when the surface module isn't installed —
79
+ * in which case there's nothing to notify and we no-op.
80
+ */
81
+ resolveModuleOrigin: (short: string) => string | null;
82
+ /**
83
+ * Origin surface-host should `git clone` from — the hub's own loopback origin
84
+ * today (`http://127.0.0.1:<port>`). The `/git/<name>` suffix is appended
85
+ * here so the module gets a ready-to-use URL.
86
+ */
87
+ cloneBaseOrigin: string;
88
+ fetchImpl?: typeof fetch;
89
+ now?: () => Date;
90
+ log?: GitNotifyLog;
91
+ /** Test seam — defaults to the real `signAccessToken`. */
92
+ signToken?: typeof signAccessToken;
93
+ }
94
+
95
+ /**
96
+ * Notify surface-host that surface `<name>` was pushed. Best-effort +
97
+ * fire-and-forget from the caller's perspective: this never throws (the git
98
+ * transport handler already returned the push response to the client); every
99
+ * failure path logs and returns.
100
+ *
101
+ * Returns a small outcome for tests/log assertions; production ignores it.
102
+ */
103
+ export async function notifySurfacePushed(
104
+ name: string,
105
+ deps: NotifySurfacePushedDeps,
106
+ ): Promise<{ notified: boolean; reason?: string }> {
107
+ const log = deps.log ?? console;
108
+ const sign = deps.signToken ?? signAccessToken;
109
+
110
+ const moduleOrigin = deps.resolveModuleOrigin("surface");
111
+ if (!moduleOrigin) {
112
+ // No surface module installed on this hub — nothing to serve the push.
113
+ log.info(`[git-notify] surface module not installed; skipping notify for "${name}"`);
114
+ return { notified: false, reason: "surface-module-not-installed" };
115
+ }
116
+
117
+ let notifyAuth: string;
118
+ let pullToken: string;
119
+ try {
120
+ const now = deps.now;
121
+ const common = {
122
+ sub: NOTIFY_SUBJECT,
123
+ clientId: NOTIFY_CLIENT_ID,
124
+ issuer: deps.issuer,
125
+ audience: SURFACE_AUDIENCE,
126
+ ...(now !== undefined ? { now } : {}),
127
+ };
128
+ notifyAuth = (
129
+ await sign(deps.db, { ...common, scopes: ["surface:admin"], ttlSeconds: NOTIFY_TTL_SECONDS })
130
+ ).token;
131
+ pullToken = (
132
+ await sign(deps.db, {
133
+ ...common,
134
+ scopes: [`surface:${name}:read`],
135
+ ttlSeconds: PULL_TTL_SECONDS,
136
+ })
137
+ ).token;
138
+ } catch (err) {
139
+ const msg = err instanceof Error ? err.message : String(err);
140
+ log.warn(`[git-notify] failed to mint notify tokens for "${name}": ${msg}`);
141
+ return { notified: false, reason: "mint-failed" };
142
+ }
143
+
144
+ const cloneUrl = `${deps.cloneBaseOrigin.replace(/\/+$/, "")}/git/${name}`;
145
+ const endpoint = `${moduleOrigin.replace(/\/+$/, "")}/surface/api/git-pushed`;
146
+ const fetchImpl = deps.fetchImpl ?? fetch;
147
+
148
+ try {
149
+ const res = await fetchImpl(endpoint, {
150
+ method: "POST",
151
+ headers: {
152
+ authorization: `Bearer ${notifyAuth}`,
153
+ "content-type": "application/json",
154
+ },
155
+ body: JSON.stringify({ surface: name, clone_url: cloneUrl, pull_token: pullToken }),
156
+ signal: AbortSignal.timeout(NOTIFY_FETCH_TIMEOUT_MS),
157
+ });
158
+ if (!res.ok) {
159
+ let detail = `HTTP ${res.status}`;
160
+ try {
161
+ const text = (await res.text()).trim();
162
+ if (text) detail += `: ${text.slice(0, 300)}`;
163
+ } catch {
164
+ // best-effort detail
165
+ }
166
+ log.warn(`[git-notify] surface-host rejected push notify for "${name}" (${detail})`);
167
+ return { notified: false, reason: `notify-rejected:${res.status}` };
168
+ }
169
+ log.info(`[git-notify] notified surface-host of push to "${name}"`);
170
+ return { notified: true };
171
+ } catch (err) {
172
+ const msg = err instanceof Error ? err.message : String(err);
173
+ log.warn(`[git-notify] push notify to surface-host failed for "${name}": ${msg}`);
174
+ return { notified: false, reason: "notify-error" };
175
+ }
176
+ }