@openparachute/hub 0.7.5-rc.1 → 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.5-rc.1",
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
+ });
@@ -285,6 +285,27 @@ describe("handleGitTransport — auth gate", () => {
285
285
  }
286
286
  });
287
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
+
288
309
  test("write ⊇ read: a write token may also fetch", async () => {
289
310
  const h = await makeHarness();
290
311
  try {
@@ -389,15 +410,26 @@ async function gitAsync(
389
410
  describe("git push round-trip", () => {
390
411
  test("an authed push lands the ref in the bare repo + fires post-receive", async () => {
391
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
+ });
392
420
  const server = Bun.serve({
393
421
  port: 0,
394
422
  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
- }),
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
+ }),
401
433
  });
402
434
  const work = mkdtempSync(join(tmpdir(), "phub-git-work-"));
403
435
  try {
@@ -441,6 +473,16 @@ describe("git push round-trip", () => {
441
473
  const logPath = join(bare, "post-receive.log");
442
474
  expect(existsSync(logPath)).toBe(true);
443
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"]);
444
486
  } finally {
445
487
  server.stop(true);
446
488
  rmSync(work, { recursive: true, force: true });
@@ -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
+ }
@@ -68,6 +68,23 @@ export interface GitTransportDeps {
68
68
  knownIssuers: () => readonly string[];
69
69
  /** Resolved peer address, surfaced to the backend as REMOTE_ADDR. */
70
70
  peerAddr?: string | null;
71
+ /**
72
+ * Fired AFTER a `git-receive-pack` POST subprocess exits 0 — i.e. a push
73
+ * landed (the refs are updated + the post-receive hook has run by the time
74
+ * `http-backend` exits). The deploy hand-off (design §5 step 5): the hub
75
+ * notifies surface-host over HTTP + a hub JWT, NEVER a shell-out that builds
76
+ * the pushed tree (that exec authority belongs to the module's sandbox, not
77
+ * this substrate). Fire-and-forget + best-effort: a notify failure never
78
+ * affects the push response the client already received. Keyed off the
79
+ * subprocess exit, not the streamed response, so it observes the true push
80
+ * outcome. Phase 0b wires this in hub-server.ts; tests inject a spy.
81
+ *
82
+ * Precision note: this fires on every SUCCESSFUL receive-pack, not strictly
83
+ * per ref-update — a no-op re-push (no new objects) still exits 0 and
84
+ * notifies. surface-host's re-pull→re-build→re-serve is idempotent, so the
85
+ * worst case is a redundant rebuild of identical bytes.
86
+ */
87
+ onPushed?: (name: string) => void | Promise<void>;
71
88
  log?: GitTransportLog;
72
89
  }
73
90
 
@@ -469,5 +486,30 @@ export async function handleGitTransport(req: Request, deps: GitTransportDeps):
469
486
  }
470
487
  })();
471
488
 
489
+ // Deploy hand-off (§5 step 5). On a SUCCESSFUL push (receive-pack POST exits
490
+ // 0 → refs updated, post-receive ran), notify the surface module so it pulls
491
+ // + builds + serves. Fire-and-forget, observed off the subprocess exit (not
492
+ // the streamed response), and fully decoupled from the client's response:
493
+ // a notify error is logged, never surfaced to the pusher. The hub NEVER
494
+ // builds here — `onPushed` only sends an authenticated HTTP notify.
495
+ if (access === "write" && gitSubpath === "git-receive-pack" && deps.onPushed) {
496
+ const onPushed = deps.onPushed;
497
+ void (async () => {
498
+ let code: number;
499
+ try {
500
+ code = await proc.exited;
501
+ } catch {
502
+ return; // subprocess vanished — nothing to notify about
503
+ }
504
+ if (code !== 0) return;
505
+ try {
506
+ await onPushed(name);
507
+ } catch (err) {
508
+ const msg = err instanceof Error ? err.message : String(err);
509
+ log.warn(`[git-transport] post-push notify failed for "${name}": ${msg}`);
510
+ }
511
+ })();
512
+ }
513
+
472
514
  return cgiResponse(proc.stdout as ReadableStream<Uint8Array>);
473
515
  }
package/src/hub-server.ts CHANGED
@@ -239,6 +239,7 @@ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
239
239
  import { applyCorsHeaders, corsPreflightResponse, isCorsAllowedRoute } from "./cors.ts";
240
240
  import { ensureCsrfToken } from "./csrf.ts";
241
241
  import { readExposeState } from "./expose-state.ts";
242
+ import { notifySurfacePushed } from "./git-notify.ts";
242
243
  import { handleGitTransport } from "./git-transport.ts";
243
244
  import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
244
245
  import {
@@ -3819,11 +3820,26 @@ export function hubFetch(
3819
3820
  // sandboxed job, Phase 0b). See src/git-transport.ts.
3820
3821
  if (pathname.startsWith("/git/")) {
3821
3822
  if (!getDb) return new Response("not found", { status: 404 });
3823
+ const db = getDb();
3824
+ const issuer = oauthDeps(req).issuer;
3822
3825
  return handleGitTransport(req, {
3823
- db: getDb(),
3826
+ db,
3824
3827
  gitRoot,
3825
3828
  knownIssuers: () => oauthDeps(req).hubBoundOrigins(),
3826
3829
  peerAddr,
3830
+ // Deploy hand-off (Phase 0b §5 step 5): on a successful push, notify
3831
+ // the surface module over HTTP + a hub JWT so it pulls + builds +
3832
+ // serves. NEVER a shell-out that builds the pushed tree — the hub
3833
+ // only sends the authenticated signal (git-notify.ts). Fire-and-
3834
+ // forget; a notify failure is logged, never surfaced to the pusher.
3835
+ onPushed: async (name) => {
3836
+ await notifySurfacePushed(name, {
3837
+ db,
3838
+ issuer: issuer ?? `http://127.0.0.1:${loopbackPort ?? 1939}`,
3839
+ resolveModuleOrigin: makeResolveModuleOrigin(manifestPath),
3840
+ cloneBaseOrigin: `http://127.0.0.1:${loopbackPort ?? 1939}`,
3841
+ });
3842
+ },
3827
3843
  });
3828
3844
  }
3829
3845