@openparachute/hub 0.7.5-rc.2 → 0.7.5-rc.4

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.
@@ -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.
@@ -202,6 +202,19 @@ describe("connectionKey / grantId derivation (idempotency)", () => {
202
202
  expect(k1).toBe("service:github");
203
203
  });
204
204
 
205
+ test("surface key includes the access verb", () => {
206
+ expect(connectionKey({ kind: "surface", target: "gitcoin-brain", access: "write" })).toBe(
207
+ "surface:gitcoin-brain:write",
208
+ );
209
+ expect(connectionKey({ kind: "surface", target: "gitcoin-brain", access: "read" })).toBe(
210
+ "surface:gitcoin-brain:read",
211
+ );
212
+ // access defaults to read (matches the vault default); target lowercased for the slug
213
+ expect(connectionKey({ kind: "surface", target: "Gitcoin-Brain" })).toBe(
214
+ "surface:gitcoin-brain:read",
215
+ );
216
+ });
217
+
205
218
  test("mcp key is the url target", () => {
206
219
  expect(connectionKey({ kind: "mcp", target: "https://x.test/mcp" })).toBe(
207
220
  "mcp:https://x.test/mcp",
@@ -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", () => {