@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.
- package/package.json +10 -3
- package/src/__tests__/admin-surfaces.test.ts +207 -0
- package/src/__tests__/git-notify.test.ts +144 -0
- package/src/__tests__/git-registry.test.ts +203 -0
- package/src/__tests__/git-transport.test.ts +229 -6
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/admin-surfaces.ts +158 -0
- package/src/git-notify.ts +176 -0
- package/src/git-registry.ts +234 -0
- package/src/git-transport.ts +99 -70
- package/src/hub-server.ts +48 -1
- package/src/scope-explanations.ts +16 -0
|
@@ -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:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
+
}
|