@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.
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +310 -0
- package/src/__tests__/admin-surfaces.test.ts +207 -0
- package/src/__tests__/git-registry.test.ts +203 -0
- package/src/__tests__/git-transport.test.ts +181 -0
- package/src/__tests__/grants-store.test.ts +13 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/admin-agent-grants.ts +156 -6
- package/src/admin-surfaces.ts +158 -0
- package/src/git-registry.ts +247 -0
- package/src/git-transport.ts +57 -70
- package/src/grants-store.ts +25 -4
- package/src/hub-server.ts +31 -0
- package/src/scope-explanations.ts +16 -0
|
@@ -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", () => {
|