@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 +10 -3
- package/src/__tests__/git-notify.test.ts +144 -0
- package/src/__tests__/git-transport.test.ts +48 -6
- package/src/git-notify.ts +176 -0
- package/src/git-transport.ts +42 -0
- package/src/hub-server.ts +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.7.5-rc.
|
|
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": [
|
|
15
|
-
|
|
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:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
+
}
|
package/src/git-transport.ts
CHANGED
|
@@ -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
|
|
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
|
|