@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 +1 -1
- 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__/scope-explanations.test.ts +16 -0
- package/src/admin-surfaces.ts +158 -0
- package/src/git-registry.ts +234 -0
- package/src/git-transport.ts +57 -70
- package/src/hub-server.ts +31 -0
- package/src/scope-explanations.ts +16 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/git-transport.ts
CHANGED
|
@@ -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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 {
|
|
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
|
|
54
|
-
* `<gitRoot>/<name>.git`. Production: `<CONFIG_DIR>/hub/git`.
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
// ---
|
|
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
|
-
|
|
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
|
|