@openparachute/hub 0.7.5-rc.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.5-rc.2",
3
+ "version": "0.7.5-rc.3",
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": {
@@ -0,0 +1,207 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { routeAdminSurfaces } from "../admin-surfaces.ts";
6
+ import { isSurfaceRegistered, repoDirFor } from "../git-registry.ts";
7
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
8
+ import { signAccessToken } from "../jwt-sign.ts";
9
+ import { rotateSigningKey } from "../signing-keys.ts";
10
+ import { createUser } from "../users.ts";
11
+
12
+ const ISSUER = "http://127.0.0.1:1939";
13
+
14
+ interface Harness {
15
+ gitRoot: string;
16
+ db: ReturnType<typeof openHubDb>;
17
+ userId: string;
18
+ cleanup: () => void;
19
+ }
20
+
21
+ async function makeHarness(): Promise<Harness> {
22
+ const dir = mkdtempSync(join(tmpdir(), "phub-adminsurf-"));
23
+ const db = openHubDb(hubDbPath(dir));
24
+ rotateSigningKey(db);
25
+ const u = await createUser(db, "owner", "pw");
26
+ return {
27
+ gitRoot: join(dir, "git"),
28
+ db,
29
+ userId: u.id,
30
+ cleanup: () => {
31
+ db.close();
32
+ rmSync(dir, { recursive: true, force: true });
33
+ },
34
+ };
35
+ }
36
+
37
+ async function mint(h: Harness, scopes: string[]): Promise<string> {
38
+ const { token } = await signAccessToken(h.db, {
39
+ sub: h.userId,
40
+ scopes,
41
+ audience: "operator",
42
+ clientId: "test-operator",
43
+ issuer: ISSUER,
44
+ });
45
+ return token;
46
+ }
47
+
48
+ function deps(h: Harness) {
49
+ return { db: h.db, gitRoot: h.gitRoot, issuer: ISSUER, knownIssuers: [ISSUER] };
50
+ }
51
+
52
+ function req(method: string, headers?: Record<string, string>, body?: unknown): Request {
53
+ return new Request("http://127.0.0.1/admin/surfaces", {
54
+ method,
55
+ headers,
56
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
57
+ });
58
+ }
59
+
60
+ describe("routeAdminSurfaces — routing", () => {
61
+ test("returns null for a non-/admin/surfaces path", async () => {
62
+ const h = await makeHarness();
63
+ try {
64
+ const res = await routeAdminSurfaces(new Request("http://127.0.0.1/admin/other"), deps(h));
65
+ expect(res).toBeNull();
66
+ } finally {
67
+ h.cleanup();
68
+ }
69
+ });
70
+
71
+ test("405 on an unsupported method", async () => {
72
+ const h = await makeHarness();
73
+ try {
74
+ const token = await mint(h, ["parachute:host:admin"]);
75
+ const res = await routeAdminSurfaces(
76
+ req("DELETE", { authorization: `Bearer ${token}` }),
77
+ deps(h),
78
+ );
79
+ expect(res?.status).toBe(405);
80
+ } finally {
81
+ h.cleanup();
82
+ }
83
+ });
84
+ });
85
+
86
+ describe("routeAdminSurfaces — auth", () => {
87
+ test("401 without a bearer", async () => {
88
+ const h = await makeHarness();
89
+ try {
90
+ const res = await routeAdminSurfaces(req("POST", {}, { name: "foo" }), deps(h));
91
+ expect(res?.status).toBe(401);
92
+ } finally {
93
+ h.cleanup();
94
+ }
95
+ });
96
+
97
+ test("403 when the token lacks parachute:host:admin", async () => {
98
+ const h = await makeHarness();
99
+ try {
100
+ const token = await mint(h, ["surface:foo:write"]);
101
+ const res = await routeAdminSurfaces(
102
+ req("POST", { authorization: `Bearer ${token}` }, { name: "foo" }),
103
+ deps(h),
104
+ );
105
+ expect(res?.status).toBe(403);
106
+ // No provisioning happened on a rejected auth.
107
+ expect(existsSync(repoDirFor(h.gitRoot, "foo"))).toBe(false);
108
+ } finally {
109
+ h.cleanup();
110
+ }
111
+ });
112
+
113
+ test("401 on a garbage token", async () => {
114
+ const h = await makeHarness();
115
+ try {
116
+ const res = await routeAdminSurfaces(
117
+ req("POST", { authorization: "Bearer not-a-jwt" }, { name: "foo" }),
118
+ deps(h),
119
+ );
120
+ expect(res?.status).toBe(401);
121
+ } finally {
122
+ h.cleanup();
123
+ }
124
+ });
125
+ });
126
+
127
+ describe("routeAdminSurfaces — register + list", () => {
128
+ test("POST registers a surface (provisions the repo + returns the entry)", async () => {
129
+ const h = await makeHarness();
130
+ try {
131
+ const token = await mint(h, ["parachute:host:admin"]);
132
+ const res = await routeAdminSurfaces(
133
+ req(
134
+ "POST",
135
+ { authorization: `Bearer ${token}`, "content-type": "application/json" },
136
+ { name: "brain", mount: "/surface/brain", mode: "prod" },
137
+ ),
138
+ deps(h),
139
+ );
140
+ expect(res?.status).toBe(200);
141
+ const body = (await res?.json()) as {
142
+ ok: boolean;
143
+ surface: { name: string; mount?: string };
144
+ };
145
+ expect(body.ok).toBe(true);
146
+ expect(body.surface.name).toBe("brain");
147
+ expect(body.surface.mount).toBe("/surface/brain");
148
+ expect(isSurfaceRegistered(h.gitRoot, "brain")).toBe(true);
149
+ } finally {
150
+ h.cleanup();
151
+ }
152
+ });
153
+
154
+ test("POST with a missing name → 400", async () => {
155
+ const h = await makeHarness();
156
+ try {
157
+ const token = await mint(h, ["parachute:host:admin"]);
158
+ const res = await routeAdminSurfaces(
159
+ req("POST", { authorization: `Bearer ${token}` }, { mount: "/surface/x" }),
160
+ deps(h),
161
+ );
162
+ expect(res?.status).toBe(400);
163
+ } finally {
164
+ h.cleanup();
165
+ }
166
+ });
167
+
168
+ test("POST with an invalid name → 400 (no repo provisioned)", async () => {
169
+ const h = await makeHarness();
170
+ try {
171
+ const token = await mint(h, ["parachute:host:admin"]);
172
+ const res = await routeAdminSurfaces(
173
+ req("POST", { authorization: `Bearer ${token}` }, { name: "a/b" }),
174
+ deps(h),
175
+ );
176
+ expect(res?.status).toBe(400);
177
+ } finally {
178
+ h.cleanup();
179
+ }
180
+ });
181
+
182
+ test("GET lists registered surfaces", async () => {
183
+ const h = await makeHarness();
184
+ try {
185
+ const token = await mint(h, ["parachute:host:admin"]);
186
+ await routeAdminSurfaces(
187
+ req("POST", { authorization: `Bearer ${token}` }, { name: "alpha" }),
188
+ deps(h),
189
+ );
190
+ await routeAdminSurfaces(
191
+ req("POST", { authorization: `Bearer ${token}` }, { name: "zeta" }),
192
+ deps(h),
193
+ );
194
+ const res = await routeAdminSurfaces(
195
+ new Request("http://127.0.0.1/admin/surfaces", {
196
+ headers: { authorization: `Bearer ${token}` },
197
+ }),
198
+ deps(h),
199
+ );
200
+ expect(res?.status).toBe(200);
201
+ const body = (await res?.json()) as { surfaces: Array<{ name: string }> };
202
+ expect(body.surfaces.map((s) => s.name)).toEqual(["alpha", "zeta"]);
203
+ } finally {
204
+ h.cleanup();
205
+ }
206
+ });
207
+ });
@@ -0,0 +1,203 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ ensureSurfaceRepo,
8
+ isSurfaceRegistered,
9
+ listSurfaces,
10
+ loadRegistry,
11
+ registerSurface,
12
+ registryPath,
13
+ repoDirFor,
14
+ } from "../git-registry.ts";
15
+
16
+ function tmpGitRoot(): { gitRoot: string; cleanup: () => void } {
17
+ const dir = mkdtempSync(join(tmpdir(), "phub-registry-"));
18
+ const gitRoot = join(dir, "git");
19
+ return { gitRoot, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
20
+ }
21
+
22
+ describe("loadRegistry", () => {
23
+ test("missing file → empty registry", () => {
24
+ const { gitRoot, cleanup } = tmpGitRoot();
25
+ try {
26
+ expect(loadRegistry(gitRoot)).toEqual({ version: 1, surfaces: {} });
27
+ } finally {
28
+ cleanup();
29
+ }
30
+ });
31
+
32
+ test("corrupt JSON → empty registry (never throws)", () => {
33
+ const { gitRoot, cleanup } = tmpGitRoot();
34
+ try {
35
+ mkdirSync(gitRoot, { recursive: true });
36
+ writeFileSync(registryPath(gitRoot), "{ not json");
37
+ expect(loadRegistry(gitRoot)).toEqual({ version: 1, surfaces: {} });
38
+ } finally {
39
+ cleanup();
40
+ }
41
+ });
42
+ });
43
+
44
+ describe("ensureSurfaceRepo", () => {
45
+ test("provisions a bare repo with http.receivepack=true + post-receive hook", async () => {
46
+ const { gitRoot, cleanup } = tmpGitRoot();
47
+ try {
48
+ const repoDir = await ensureSurfaceRepo(gitRoot, "foo");
49
+ expect(repoDir).toBe(repoDirFor(gitRoot, "foo"));
50
+ expect(existsSync(repoDir)).toBe(true);
51
+ expect(existsSync(join(repoDir, "hooks", "post-receive"))).toBe(true);
52
+ const rp = spawnSync("git", ["-C", repoDir, "config", "http.receivepack"], {
53
+ encoding: "utf8",
54
+ });
55
+ expect(rp.stdout.trim()).toBe("true");
56
+ } finally {
57
+ cleanup();
58
+ }
59
+ });
60
+
61
+ test("idempotent — a second call is a no-op that returns the same path", async () => {
62
+ const { gitRoot, cleanup } = tmpGitRoot();
63
+ try {
64
+ const a = await ensureSurfaceRepo(gitRoot, "foo");
65
+ const b = await ensureSurfaceRepo(gitRoot, "foo");
66
+ expect(a).toBe(b);
67
+ } finally {
68
+ cleanup();
69
+ }
70
+ });
71
+
72
+ test("rejects an invalid surface name", async () => {
73
+ const { gitRoot, cleanup } = tmpGitRoot();
74
+ try {
75
+ await expect(ensureSurfaceRepo(gitRoot, "../evil")).rejects.toThrow();
76
+ } finally {
77
+ cleanup();
78
+ }
79
+ });
80
+ });
81
+
82
+ describe("registerSurface", () => {
83
+ test("provisions the repo + records the entry", async () => {
84
+ const { gitRoot, cleanup } = tmpGitRoot();
85
+ try {
86
+ const entry = await registerSurface(gitRoot, "brain", {
87
+ mount: "/surface/brain",
88
+ mode: "prod",
89
+ });
90
+ expect(entry.name).toBe("brain");
91
+ expect(entry.mount).toBe("/surface/brain");
92
+ expect(entry.mode).toBe("prod");
93
+ expect(existsSync(repoDirFor(gitRoot, "brain"))).toBe(true);
94
+ expect(loadRegistry(gitRoot).surfaces.brain?.name).toBe("brain");
95
+ } finally {
96
+ cleanup();
97
+ }
98
+ });
99
+
100
+ test("idempotent re-register preserves the original registeredAt", async () => {
101
+ const { gitRoot, cleanup } = tmpGitRoot();
102
+ try {
103
+ const first = await registerSurface(gitRoot, "brain", {
104
+ now: () => new Date("2026-01-01T00:00:00Z"),
105
+ });
106
+ const second = await registerSurface(gitRoot, "brain", {
107
+ mount: "/surface/brain",
108
+ now: () => new Date("2026-02-02T00:00:00Z"),
109
+ });
110
+ expect(second.registeredAt).toBe(first.registeredAt);
111
+ expect(second.registeredAt).toBe("2026-01-01T00:00:00.000Z");
112
+ // Later metadata still applies.
113
+ expect(second.mount).toBe("/surface/brain");
114
+ } finally {
115
+ cleanup();
116
+ }
117
+ });
118
+
119
+ test("rejects an invalid name without provisioning", async () => {
120
+ const { gitRoot, cleanup } = tmpGitRoot();
121
+ try {
122
+ await expect(registerSurface(gitRoot, "a/b")).rejects.toThrow(/invalid surface name/);
123
+ expect(existsSync(registryPath(gitRoot))).toBe(false);
124
+ } finally {
125
+ cleanup();
126
+ }
127
+ });
128
+ });
129
+
130
+ describe("isSurfaceRegistered", () => {
131
+ test("false for an unknown name, true after register", async () => {
132
+ const { gitRoot, cleanup } = tmpGitRoot();
133
+ try {
134
+ expect(isSurfaceRegistered(gitRoot, "foo")).toBe(false);
135
+ await registerSurface(gitRoot, "foo");
136
+ expect(isSurfaceRegistered(gitRoot, "foo")).toBe(true);
137
+ } finally {
138
+ cleanup();
139
+ }
140
+ });
141
+
142
+ test("grandfathers a bare repo that exists on disk without a registry entry", async () => {
143
+ const { gitRoot, cleanup } = tmpGitRoot();
144
+ try {
145
+ await ensureSurfaceRepo(gitRoot, "legacy");
146
+ // No registry.json entry, but the repo exists → still registered.
147
+ expect(existsSync(registryPath(gitRoot))).toBe(false);
148
+ expect(isSurfaceRegistered(gitRoot, "legacy")).toBe(true);
149
+ } finally {
150
+ cleanup();
151
+ }
152
+ });
153
+
154
+ test("false for a name that fails the charset (never touches the filesystem)", () => {
155
+ const { gitRoot, cleanup } = tmpGitRoot();
156
+ try {
157
+ expect(isSurfaceRegistered(gitRoot, "../etc")).toBe(false);
158
+ } finally {
159
+ cleanup();
160
+ }
161
+ });
162
+ });
163
+
164
+ describe("listSurfaces", () => {
165
+ test("returns entries sorted by name", async () => {
166
+ const { gitRoot, cleanup } = tmpGitRoot();
167
+ try {
168
+ await registerSurface(gitRoot, "zeta");
169
+ await registerSurface(gitRoot, "alpha");
170
+ await registerSurface(gitRoot, "mid");
171
+ expect(listSurfaces(gitRoot).map((s) => s.name)).toEqual(["alpha", "mid", "zeta"]);
172
+ } finally {
173
+ cleanup();
174
+ }
175
+ });
176
+
177
+ test("excludes a grandfathered disk-only repo (registry entries only)", async () => {
178
+ const { gitRoot, cleanup } = tmpGitRoot();
179
+ try {
180
+ await registerSurface(gitRoot, "registered");
181
+ await ensureSurfaceRepo(gitRoot, "grandfathered"); // repo exists, no entry
182
+ // isSurfaceRegistered grandfathers it (pushable), but listSurfaces does not.
183
+ expect(isSurfaceRegistered(gitRoot, "grandfathered")).toBe(true);
184
+ expect(listSurfaces(gitRoot).map((s) => s.name)).toEqual(["registered"]);
185
+ } finally {
186
+ cleanup();
187
+ }
188
+ });
189
+ });
190
+
191
+ describe("saveRegistry atomicity", () => {
192
+ test("registry.json is written pretty + reloadable", async () => {
193
+ const { gitRoot, cleanup } = tmpGitRoot();
194
+ try {
195
+ await registerSurface(gitRoot, "foo");
196
+ const raw = readFileSync(registryPath(gitRoot), "utf8");
197
+ expect(raw.endsWith("\n")).toBe(true);
198
+ expect(JSON.parse(raw).surfaces.foo.name).toBe("foo");
199
+ } finally {
200
+ cleanup();
201
+ }
202
+ });
203
+ });
@@ -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
  }
@@ -342,6 +348,176 @@ describe("handleGitTransport — auth gate", () => {
342
348
  });
343
349
  });
344
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
+
345
521
  // ---------------------------------------------------------------------------
346
522
  // Dispatch wiring through hubFetch
347
523
  // ---------------------------------------------------------------------------
@@ -425,6 +601,8 @@ describe("git push round-trip", () => {
425
601
  db: h.db,
426
602
  gitRoot: h.gitRoot,
427
603
  knownIssuers: () => [ISSUER],
604
+ isDeclared: (name) => isSurfaceRegistered(h.gitRoot, name),
605
+ ensureRepo: (name) => ensureSurfaceRepo(h.gitRoot, name),
428
606
  onPushed: (name) => {
429
607
  pushedNames.push(name);
430
608
  resolvePushed(name);
@@ -434,6 +612,9 @@ describe("git push round-trip", () => {
434
612
  const work = mkdtempSync(join(tmpdir(), "phub-git-work-"));
435
613
  try {
436
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");
437
618
  const base = `http://127.0.0.1:${server.port}`;
438
619
 
439
620
  // Author a commit in a throwaway working repo.
@@ -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,234 @@
1
+ /**
2
+ * Surface → bare-repo registry for the Surface Git Transport (Phase 1, design
3
+ * doc 2026-06-30-surface-git-transport.md §9 + §10, "Decisions locked" #3).
4
+ *
5
+ * This is the hub-side half of "vault declares, hub authenticates, surface-host
6
+ * serves." The vault holds the `#surface` declaration; surface-host discovers it
7
+ * (it custodies a vault read cred) and REGISTERS the surface with the hub over
8
+ * `POST /admin/surfaces` (operator-authed). This module owns the resulting
9
+ * mapping:
10
+ *
11
+ * - the persisted `name → bare-repo` registry (`<gitRoot>/registry.json`), and
12
+ * - the async bare-repo provisioning (`ensureSurfaceRepo`).
13
+ *
14
+ * The registry is what TIES provisioning to a declared surface (§10 step 1): the
15
+ * git-transport endpoint only serves — and only ever provisions a repo for — a
16
+ * name that is REGISTERED (`isSurfaceRegistered`), a scoping improvement over
17
+ * Phase 0a's provision-on-first-push-of-any-name. The scope gate
18
+ * (`surface:<name>:write`, operator-granted) still runs first; this is a second,
19
+ * declaration-level gate.
20
+ *
21
+ * Grandfathering: a name whose bare repo already exists on disk (a Phase 0a
22
+ * auto-provisioned repo) counts as registered even without a registry.json
23
+ * entry, so the tightening never orphans an already-provisioned surface.
24
+ *
25
+ * Substrate discipline (§4): this module NEVER reads the vault and NEVER builds
26
+ * or executes a pushed tree. It only records names + creates empty bare repos.
27
+ * The vault read + the sandboxed build live in surface-host.
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+
32
+ /** Logger seam — defaults to `console`. */
33
+ export interface GitRegistryLog {
34
+ warn: (...args: unknown[]) => void;
35
+ info: (...args: unknown[]) => void;
36
+ }
37
+
38
+ /**
39
+ * Surface-name charset — the single source of truth shared with the
40
+ * git-transport URL parser (imported there). Kebab/alnum only, NO slashes or
41
+ * dots, so a parsed name can never escape `gitRoot` via path traversal. Bounded
42
+ * length keeps a hostile name from ballooning a path.
43
+ */
44
+ export const SURFACE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
45
+
46
+ /** One registered surface's metadata (the declaration pointer, not the artifact). */
47
+ export interface SurfaceRegistryEntry {
48
+ /** Canonical surface name (== the `/git/<name>` + `/surface/<name>` segment). */
49
+ name: string;
50
+ /** Declared mount path (from the `#surface` note), informational. */
51
+ mount?: string;
52
+ /** Declared mode (from the `#surface` note), informational. */
53
+ mode?: "dev" | "prod";
54
+ /** ISO timestamp the surface was first registered. Preserved across re-registers. */
55
+ registeredAt: string;
56
+ /** ISO timestamp the bare repo was (first) provisioned. */
57
+ provisionedAt: string;
58
+ }
59
+
60
+ /** The persisted registry shape (`<gitRoot>/registry.json`). */
61
+ export interface SurfaceRegistry {
62
+ version: 1;
63
+ surfaces: Record<string, SurfaceRegistryEntry>;
64
+ }
65
+
66
+ const EMPTY_REGISTRY: SurfaceRegistry = { version: 1, surfaces: {} };
67
+
68
+ /** `<gitRoot>/registry.json`. */
69
+ export function registryPath(gitRoot: string): string {
70
+ return join(gitRoot, "registry.json");
71
+ }
72
+
73
+ /** `<gitRoot>/<name>.git`. */
74
+ export function repoDirFor(gitRoot: string, name: string): string {
75
+ return join(gitRoot, `${name}.git`);
76
+ }
77
+
78
+ /**
79
+ * Read + parse the registry. A missing or corrupt file yields an empty registry
80
+ * (the transport still fails closed on unregistered names — see
81
+ * `isSurfaceRegistered`), never a throw: a torn registry.json must not take the
82
+ * git endpoint down.
83
+ */
84
+ export function loadRegistry(gitRoot: string): SurfaceRegistry {
85
+ const file = registryPath(gitRoot);
86
+ if (!existsSync(file)) return { version: 1, surfaces: {} };
87
+ try {
88
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
89
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
90
+ return { ...EMPTY_REGISTRY };
91
+ const surfaces = (parsed as { surfaces?: unknown }).surfaces;
92
+ if (!surfaces || typeof surfaces !== "object" || Array.isArray(surfaces)) {
93
+ return { ...EMPTY_REGISTRY };
94
+ }
95
+ return { version: 1, surfaces: surfaces as Record<string, SurfaceRegistryEntry> };
96
+ } catch {
97
+ return { ...EMPTY_REGISTRY };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Persist the registry ATOMICALLY (stage 0600 → rename), so a crash mid-write
103
+ * leaves the prior registry intact and no reader observes a partial file.
104
+ */
105
+ export function saveRegistry(gitRoot: string, reg: SurfaceRegistry): void {
106
+ mkdirSync(gitRoot, { recursive: true });
107
+ const file = registryPath(gitRoot);
108
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
109
+ writeFileSync(tmp, `${JSON.stringify(reg, null, 2)}\n`, { mode: 0o600 });
110
+ renameSync(tmp, file);
111
+ }
112
+
113
+ /**
114
+ * Is `name` a registered surface? True when it has a registry.json entry OR its
115
+ * bare repo already exists on disk (grandfathering a Phase 0a auto-provisioned
116
+ * repo). This is the declaration gate the git-transport endpoint consults after
117
+ * the scope check passes.
118
+ */
119
+ export function isSurfaceRegistered(gitRoot: string, name: string): boolean {
120
+ if (!SURFACE_NAME_RE.test(name)) return false;
121
+ if (loadRegistry(gitRoot).surfaces[name]) return true;
122
+ return existsSync(repoDirFor(gitRoot, name));
123
+ }
124
+
125
+ /**
126
+ * Every registered surface, sorted by name (for `GET /admin/surfaces`). NOTE:
127
+ * this lists only registry.json entries — a grandfathered disk-only bare repo (a
128
+ * Phase-0a auto-provisioned repo with no entry) is still *pushable*
129
+ * (`isSurfaceRegistered` grandfathers it) but does NOT appear here until
130
+ * surface-host's next discovery pass re-registers it and writes its entry.
131
+ */
132
+ export function listSurfaces(gitRoot: string): SurfaceRegistryEntry[] {
133
+ const reg = loadRegistry(gitRoot);
134
+ return Object.values(reg.surfaces).sort((a, b) => a.name.localeCompare(b.name));
135
+ }
136
+
137
+ /**
138
+ * Ensure `<gitRoot>/<name>.git` exists as an exportable bare repo, provisioning
139
+ * it if absent. ASYNC (Phase 1 nit): uses `Bun.spawn` + `await`, never the
140
+ * event-loop-blocking `spawnSync` — a slow disk on `git init` no longer stalls
141
+ * the whole hub. Idempotent: an existing repo is returned untouched.
142
+ *
143
+ * `http.receivepack = true` is REQUIRED for push: `git http-backend` enables
144
+ * upload-pack from `GIT_HTTP_EXPORT_ALL` alone but refuses receive-pack unless
145
+ * the repo opts in explicitly.
146
+ */
147
+ export async function ensureSurfaceRepo(
148
+ gitRoot: string,
149
+ name: string,
150
+ log: GitRegistryLog = console,
151
+ ): Promise<string> {
152
+ if (!SURFACE_NAME_RE.test(name)) {
153
+ throw new Error(`refusing to provision repo for invalid surface name "${name}"`);
154
+ }
155
+ const repoDir = repoDirFor(gitRoot, name);
156
+ if (existsSync(repoDir)) return repoDir;
157
+ mkdirSync(gitRoot, { recursive: true });
158
+
159
+ const init = await runGit(["init", "--bare", repoDir]);
160
+ if (init.code !== 0) {
161
+ throw new Error(`git init --bare failed: ${init.stderr || "unknown"}`);
162
+ }
163
+ const cfg = await runGit(["-C", repoDir, "config", "http.receivepack", "true"]);
164
+ if (cfg.code !== 0) {
165
+ throw new Error(`git config http.receivepack failed: ${cfg.stderr || "unknown"}`);
166
+ }
167
+ writePostReceiveHook(repoDir, name);
168
+ log.info(`[git-registry] provisioned bare repo for surface "${name}" at ${repoDir}`);
169
+ return repoDir;
170
+ }
171
+
172
+ /**
173
+ * Register (or re-register) a declared surface: validate the name, ensure its
174
+ * bare repo, and upsert the registry entry (preserving the original
175
+ * `registeredAt` on a re-register). Idempotent — surface-host calls this on
176
+ * every discovery pass.
177
+ */
178
+ export async function registerSurface(
179
+ gitRoot: string,
180
+ name: string,
181
+ opts: { mount?: string; mode?: "dev" | "prod"; now?: () => Date; log?: GitRegistryLog } = {},
182
+ ): Promise<SurfaceRegistryEntry> {
183
+ const now = opts.now ?? (() => new Date());
184
+ if (!SURFACE_NAME_RE.test(name)) {
185
+ throw new Error(`invalid surface name "${name}" (must match ${SURFACE_NAME_RE})`);
186
+ }
187
+ await ensureSurfaceRepo(gitRoot, name, opts.log ?? console);
188
+
189
+ const reg = loadRegistry(gitRoot);
190
+ const prior = reg.surfaces[name];
191
+ const nowIso = now().toISOString();
192
+ const entry: SurfaceRegistryEntry = {
193
+ name,
194
+ ...(opts.mount !== undefined ? { mount: opts.mount } : {}),
195
+ ...(opts.mode !== undefined ? { mode: opts.mode } : {}),
196
+ registeredAt: prior?.registeredAt ?? nowIso,
197
+ provisionedAt: prior?.provisionedAt ?? nowIso,
198
+ };
199
+ reg.surfaces[name] = entry;
200
+ saveRegistry(gitRoot, reg);
201
+ return entry;
202
+ }
203
+
204
+ /**
205
+ * Phase-0a placeholder post-receive hook: logs the received refs (to stdout,
206
+ * relayed to the pusher as `remote:` lines, and appended to `post-receive.log`
207
+ * in the repo dir for verification). The real deploy hand-off is the hub's
208
+ * `onPushed` → HTTP + hub-JWT notify to surface-host (git-notify.ts) — this hook
209
+ * NEVER builds the pushed tree (that exec authority belongs to the module's
210
+ * sandbox, not the substrate — §5/§7).
211
+ */
212
+ function writePostReceiveHook(repoDir: string, name: string): void {
213
+ const hook = `#!/bin/sh
214
+ # Parachute Surface Git Transport — post-receive placeholder.
215
+ # Logs received refs only. The deploy hand-off is the hub's onPushed → HTTP +
216
+ # hub-JWT notify to surface-host; the pushed tree is NEVER built in this process
217
+ # (that exec authority belongs to the module's sandbox, not the substrate).
218
+ while read -r oldrev newrev refname; do
219
+ printf '[parachute] surface %s received %s (%s..%s)\\n' "${name}" "$refname" "$oldrev" "$newrev"
220
+ printf '%s %s %s\\n' "$oldrev" "$newrev" "$refname" >> post-receive.log
221
+ done
222
+ `;
223
+ const hookPath = join(repoDir, "hooks", "post-receive");
224
+ writeFileSync(hookPath, hook, { mode: 0o755 });
225
+ }
226
+
227
+ async function runGit(args: string[]): Promise<{ code: number; stderr: string }> {
228
+ const proc = Bun.spawn(["git", ...args], { stdout: "ignore", stderr: "pipe" });
229
+ const [code, stderr] = await Promise.all([
230
+ proc.exited,
231
+ new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
232
+ ]);
233
+ return { code, stderr: stderr.trim() };
234
+ }
@@ -11,10 +11,16 @@
11
11
  * versioned, authenticated, file-shaped content movement.
12
12
  *
13
13
  * What this layer does NOT do (by deliberate trust boundary, §7): it never
14
- * BUILDS or executes the pushed tree. The hub only receives + stores bytes;
15
- * the `post-receive` hook here is a Phase-0a placeholder that logs the refs.
16
- * Building pushed source is surface-host's sandboxed job (Phase 0b) — keeping
17
- * the RCE surface out of the substrate is the whole point of the split.
14
+ * BUILDS or executes the pushed tree. The hub only receives + stores bytes; the
15
+ * `post-receive` hook (written by git-registry.ts) is a placeholder that only
16
+ * logs the refs — the deploy hand-off is the hub's `onPushed` HTTP + hub-JWT
17
+ * notify to surface-host. Building pushed source is surface-host's sandboxed job
18
+ * — keeping the RCE surface out of the substrate is the whole point of the split.
19
+ *
20
+ * Provisioning is gated on DECLARATION (Phase 1, §9/§10): the hub serves — and
21
+ * ever provisions a repo for — only a REGISTERED surface (`isDeclared`), never
22
+ * any arbitrary name a write token happens to name. surface-host discovers a
23
+ * `#surface` note and registers it via `POST /admin/surfaces` (git-registry.ts).
18
24
  *
19
25
  * The mechanism (grounded in git's smart-HTTP protocol):
20
26
  * 1. Discovery `GET /git/<name>/info/refs?service=git-(upload|receive)-pack`
@@ -35,9 +41,7 @@
35
41
  * Never buffers whole packs.
36
42
  */
37
43
  import type { Database } from "bun:sqlite";
38
- import { spawnSync } from "node:child_process";
39
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
40
- import { join } from "node:path";
44
+ import { SURFACE_NAME_RE } from "./git-registry.ts";
41
45
  import { validateAccessToken } from "./jwt-sign.ts";
42
46
 
43
47
  /** Logger seam — defaults to `console`. */
@@ -50,11 +54,26 @@ export interface GitTransportDeps {
50
54
  /** Hub DB handle — for signature/kid lookup + revocation in `validateAccessToken`. */
51
55
  db: Database;
52
56
  /**
53
- * Directory holding the bare repos. Each surface lives at
54
- * `<gitRoot>/<name>.git`. Production: `<CONFIG_DIR>/hub/git`. Tests point
55
- * this at a tmpdir.
57
+ * Directory holding the bare repos (`GIT_PROJECT_ROOT` for `http-backend`).
58
+ * Each surface lives at `<gitRoot>/<name>.git`. Production: `<CONFIG_DIR>/hub/git`.
59
+ * Tests point this at a tmpdir.
56
60
  */
57
61
  gitRoot: string;
62
+ /**
63
+ * The declaration gate: is `<name>` a REGISTERED surface? Consulted AFTER the
64
+ * scope check passes — so an unauthorized probe always gets 401/403 and never
65
+ * learns registry membership; only a caller already holding a valid
66
+ * `surface:<name>:*` token can distinguish registered (proceeds) from
67
+ * unregistered (404). Production wires this to `isSurfaceRegistered(gitRoot, …)`
68
+ * (git-registry.ts), which grandfathers already-provisioned bare repos.
69
+ */
70
+ isDeclared: (name: string) => boolean | Promise<boolean>;
71
+ /**
72
+ * Idempotently ensure the bare repo for a registered `<name>` exists, returning
73
+ * its path. Only ever called for a name that passed `isDeclared`. Production
74
+ * wires this to `ensureSurfaceRepo(gitRoot, …)` (async; the Phase-1 async nit).
75
+ */
76
+ ensureRepo: (name: string) => Promise<string>;
58
77
  /**
59
78
  * The SET of origins this hub legitimately answers on
60
79
  * (`buildHubBoundOrigins` — loopback ∪ expose-state ∪ platform ∪ per-request
@@ -88,14 +107,11 @@ export interface GitTransportDeps {
88
107
  log?: GitTransportLog;
89
108
  }
90
109
 
91
- /**
92
- * Surface-name charset. Kebab/alnum only — NO slashes or dots, so a parsed
93
- * name can never escape `gitRoot` via path traversal. A trailing `.git` on the
94
- * URL segment is stripped before this check (so `/git/foo.git/...` and
95
- * `/git/foo/...` both resolve to `foo`). Bounded length keeps a hostile name
96
- * from ballooning a path.
97
- */
98
- const SURFACE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
110
+ // Surface-name charset (`SURFACE_NAME_RE`, imported from git-registry.ts): the
111
+ // shared kebab/alnum-only allowlist — NO slashes or dots, so a parsed name can
112
+ // never escape `gitRoot` via path traversal. A trailing `.git` on the URL
113
+ // segment is stripped before the check (so `/git/foo.git/...` and `/git/foo/...`
114
+ // both resolve to `foo`).
99
115
 
100
116
  /** Which authority a request needs, keyed purely off the git service/path. */
101
117
  type Access = "read" | "write";
@@ -214,56 +230,6 @@ function forbidden(scope: string): Response {
214
230
  });
215
231
  }
216
232
 
217
- /**
218
- * Ensure `<gitRoot>/<name>.git` exists as an exportable bare repo, creating it
219
- * on first authenticated access (Phase 1 will add a real registry; this keeps
220
- * it simple now). Returns the repo dir. Only ever called AFTER the auth gate
221
- * passes, so unauthenticated probing can never provision a repo.
222
- *
223
- * `http.receivepack = true` is REQUIRED for push: `git http-backend` enables
224
- * upload-pack from `GIT_HTTP_EXPORT_ALL` alone but refuses receive-pack unless
225
- * the repo opts in explicitly.
226
- */
227
- function ensureBareRepo(gitRoot: string, name: string, log: GitTransportLog): string {
228
- const repoDir = join(gitRoot, `${name}.git`);
229
- if (existsSync(repoDir)) return repoDir;
230
- mkdirSync(gitRoot, { recursive: true });
231
- const init = spawnSync("git", ["init", "--bare", repoDir], { encoding: "utf8" });
232
- if (init.status !== 0) {
233
- throw new Error(`git init --bare failed: ${init.stderr || init.error?.message || "unknown"}`);
234
- }
235
- const cfg = spawnSync("git", ["-C", repoDir, "config", "http.receivepack", "true"], {
236
- encoding: "utf8",
237
- });
238
- if (cfg.status !== 0) {
239
- throw new Error(`git config http.receivepack failed: ${cfg.stderr || "unknown"}`);
240
- }
241
- writePostReceiveHook(repoDir, name);
242
- log.info(`[git-transport] provisioned bare repo for surface "${name}" at ${repoDir}`);
243
- return repoDir;
244
- }
245
-
246
- /**
247
- * Phase-0a placeholder hook: log the received refs (to stdout, relayed to the
248
- * pusher as `remote:` lines, and appended to `post-receive.log` in the repo
249
- * dir for verification). Phase 0b replaces the body with an HTTP + hub-JWT
250
- * notify to surface-host (NEVER a shell-out that builds the pushed tree — §5/§7).
251
- */
252
- function writePostReceiveHook(repoDir: string, name: string): void {
253
- const hook = `#!/bin/sh
254
- # Parachute Surface Git Transport — Phase 0a placeholder.
255
- # Logs received refs only. Phase 0b: notify surface-host over HTTP + a hub JWT
256
- # (never build the pushed tree in this process — that exec authority belongs to
257
- # the module's sandbox, not the substrate).
258
- while read -r oldrev newrev refname; do
259
- printf '[parachute] surface %s received %s (%s..%s)\\n' "${name}" "$refname" "$oldrev" "$newrev"
260
- printf '%s %s %s\\n' "$oldrev" "$newrev" "$refname" >> post-receive.log
261
- done
262
- `;
263
- const hookPath = join(repoDir, "hooks", "post-receive");
264
- writeFileSync(hookPath, hook, { mode: 0o755 });
265
- }
266
-
267
233
  /**
268
234
  * The byte offset + separator length where CGI headers end (first blank line).
269
235
  * Handles both `\r\n\r\n` (4) and `\n\n` (2). Returns null if no boundary yet.
@@ -418,9 +384,30 @@ export async function handleGitTransport(req: Request, deps: GitTransportDeps):
418
384
  : scopes.includes(readScope) || scopes.includes(writeScope);
419
385
  if (!ok) return forbidden(access === "write" ? writeScope : readScope);
420
386
 
421
- // --- Provision (first access) + proxy -------------------------------------
387
+ // --- Declaration gate (AFTER auth, so it never leaks registry membership) --
388
+ // The scope check above already proved the caller is authorized for this
389
+ // surface; only now do we consult the registry. An unregistered name 404s
390
+ // (indistinguishable from a malformed path — we don't reveal which names
391
+ // exist), which is the Phase-1 tightening: a repo is provisioned/served only
392
+ // for a DECLARED surface, never any arbitrary name a write token was minted
393
+ // for. Grandfathering (an already-provisioned bare repo counts as declared)
394
+ // lives in `isSurfaceRegistered`.
395
+ let declared: boolean;
396
+ try {
397
+ declared = await deps.isDeclared(name);
398
+ } catch (err) {
399
+ const msg = err instanceof Error ? err.message : String(err);
400
+ log.warn(`[git-transport] declaration check failed for "${name}": ${msg}`);
401
+ return new Response("internal error: could not resolve surface registry\n", {
402
+ status: 500,
403
+ headers: { "content-type": "text/plain; charset=utf-8" },
404
+ });
405
+ }
406
+ if (!declared) return new Response("not found", { status: 404 });
407
+
408
+ // --- Ensure repo (idempotent, async) + proxy ------------------------------
422
409
  try {
423
- ensureBareRepo(deps.gitRoot, name, log);
410
+ await deps.ensureRepo(name);
424
411
  } catch (err) {
425
412
  const msg = err instanceof Error ? err.message : String(err);
426
413
  log.warn(`[git-transport] repo provisioning failed for "${name}": ${msg}`);
package/src/hub-server.ts CHANGED
@@ -187,6 +187,7 @@ import {
187
187
  } from "./admin-handlers.ts";
188
188
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
189
189
  import { handleModuleToken } from "./admin-module-token.ts";
190
+ import { routeAdminSurfaces } from "./admin-surfaces.ts";
190
191
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
191
192
  import { handleCreateVault, handleDeleteVault } from "./admin-vaults.ts";
192
193
  import { handleApiAccount } from "./api-account-2fa.ts";
@@ -240,6 +241,7 @@ import { applyCorsHeaders, corsPreflightResponse, isCorsAllowedRoute } from "./c
240
241
  import { ensureCsrfToken } from "./csrf.ts";
241
242
  import { readExposeState } from "./expose-state.ts";
242
243
  import { notifySurfacePushed } from "./git-notify.ts";
244
+ import { ensureSurfaceRepo, isSurfaceRegistered } from "./git-registry.ts";
243
245
  import { handleGitTransport } from "./git-transport.ts";
244
246
  import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
245
247
  import {
@@ -3792,6 +3794,29 @@ export function hubFetch(
3792
3794
  return new Response("not found", { status: 404 });
3793
3795
  }
3794
3796
 
3797
+ // /admin/surfaces — the surface → bare-repo registry (Surface Git
3798
+ // Transport Phase 1). surface-host discovers a `#surface` note (it reads
3799
+ // the vault) and POSTs here to register it → the hub provisions the bare
3800
+ // repo + records name→repo, which the /git/ endpoint then gates
3801
+ // provisioning on. Operator-authed (parachute:host:admin — the operator
3802
+ // token surface-host already reads). Placed BEFORE the /admin/* SPA
3803
+ // fallback so its POST/GET aren't swallowed by the GET-only shell.
3804
+ if (pathname === "/admin/surfaces") {
3805
+ if (!getDb) return dbNotConfigured();
3806
+ const od = oauthDeps(req);
3807
+ const handled = await routeAdminSurfaces(req, {
3808
+ db: getDb(),
3809
+ gitRoot,
3810
+ issuer: od.issuer,
3811
+ knownIssuers: od.hubBoundOrigins(),
3812
+ });
3813
+ // routeAdminSurfaces returns null ONLY for a non-matching path, which
3814
+ // can't happen inside this exact-match branch — so `handled` is always a
3815
+ // Response here. The guard is a belt: a null would harmlessly fall to the
3816
+ // /admin/* SPA below (which 405s a non-GET).
3817
+ if (handled) return handled;
3818
+ }
3819
+
3795
3820
  // /admin/* SPA mount. All non-SPA admin handlers (host-admin-token,
3796
3821
  // vault-admin-token, login, logout, config, api/auth/*, api/grants,
3797
3822
  // grants/*) ran above and either matched or returned. Anything that
@@ -3827,6 +3852,12 @@ export function hubFetch(
3827
3852
  gitRoot,
3828
3853
  knownIssuers: () => oauthDeps(req).hubBoundOrigins(),
3829
3854
  peerAddr,
3855
+ // Declaration gate (Phase 1): serve/provision ONLY a registered
3856
+ // surface (grandfathering already-provisioned bare repos), never any
3857
+ // arbitrary name a write token happens to carry. surface-host
3858
+ // registers a discovered `#surface` note via /admin/surfaces.
3859
+ isDeclared: (name) => isSurfaceRegistered(gitRoot, name),
3860
+ ensureRepo: (name) => ensureSurfaceRepo(gitRoot, name),
3830
3861
  // Deploy hand-off (Phase 0b §5 step 5): on a successful push, notify
3831
3862
  // the surface module over HTTP + a hub JWT so it pulls + builds +
3832
3863
  // serves. NEVER a shell-out that builds the pushed tree — the hub
@@ -342,6 +342,18 @@ export function isRequestableScope(scope: string): boolean {
342
342
  */
343
343
  const VAULT_VERB_RE = /^vault:[a-zA-Z0-9_*-]+:(read|write|admin)$/;
344
344
 
345
+ /**
346
+ * Named per-surface scopes (`surface:<name>:<verb>` for verb ∈ {read, write}) —
347
+ * the Surface Git Transport grant shape (Decisions-locked #2: read = clone,
348
+ * write = push). The 3→2-segment collapse means the hub validates every
349
+ * `surface:<name>:<verb>` off the declared unnamed `surface:read`/`surface:write`,
350
+ * so the consent screen must render the named form with the SAME operator-facing
351
+ * label — else `surface:gitcoin-brain:write` shows raw. Parallel to
352
+ * `VAULT_VERB_RE`. (No named `admin` form: surface admin is the unnamed,
353
+ * module-level `surface:admin`.)
354
+ */
355
+ const SURFACE_VERB_RE = /^surface:[a-zA-Z0-9_*-]+:(read|write)$/;
356
+
345
357
  export function explainScope(scope: string): ScopeExplanation | null {
346
358
  const direct = SCOPE_EXPLANATIONS[scope];
347
359
  if (direct) return direct;
@@ -349,6 +361,10 @@ export function explainScope(scope: string): ScopeExplanation | null {
349
361
  const verb = scope.split(":")[2] as "read" | "write" | "admin";
350
362
  return SCOPE_EXPLANATIONS[`vault:${verb}`] ?? null;
351
363
  }
364
+ if (SURFACE_VERB_RE.test(scope)) {
365
+ const verb = scope.split(":")[2] as "read" | "write";
366
+ return SCOPE_EXPLANATIONS[`surface:${verb}`] ?? null;
367
+ }
352
368
  return null;
353
369
  }
354
370