@openparachute/hub 0.7.4 → 0.7.5-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4",
3
+ "version": "0.7.5-rc.1",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -0,0 +1,450 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ type GitTransportDeps,
8
+ extractToken,
9
+ findHeaderEnd,
10
+ handleGitTransport,
11
+ parseCgiHeaders,
12
+ parseGitPath,
13
+ requiredAccess,
14
+ } from "../git-transport.ts";
15
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
16
+ import { hubFetch } from "../hub-server.ts";
17
+ import { signAccessToken } from "../jwt-sign.ts";
18
+ import { rotateSigningKey } from "../signing-keys.ts";
19
+ import { createUser } from "../users.ts";
20
+
21
+ const ISSUER = "http://127.0.0.1:1939";
22
+
23
+ interface Harness {
24
+ dir: string;
25
+ gitRoot: string;
26
+ db: ReturnType<typeof openHubDb>;
27
+ userId: string;
28
+ cleanup: () => void;
29
+ }
30
+
31
+ async function makeHarness(): Promise<Harness> {
32
+ const dir = mkdtempSync(join(tmpdir(), "phub-git-"));
33
+ const db = openHubDb(hubDbPath(dir));
34
+ rotateSigningKey(db);
35
+ const u = await createUser(db, "owner", "pw");
36
+ return {
37
+ dir,
38
+ gitRoot: join(dir, "git"),
39
+ db,
40
+ userId: u.id,
41
+ cleanup: () => {
42
+ db.close();
43
+ rmSync(dir, { recursive: true, force: true });
44
+ },
45
+ };
46
+ }
47
+
48
+ async function mint(h: Harness, scopes: string[]): Promise<string> {
49
+ const { token } = await signAccessToken(h.db, {
50
+ sub: h.userId,
51
+ scopes,
52
+ audience: "surface",
53
+ clientId: "test-client",
54
+ issuer: ISSUER,
55
+ });
56
+ return token;
57
+ }
58
+
59
+ function deps(h: Harness, extra?: Partial<GitTransportDeps>): GitTransportDeps {
60
+ return {
61
+ db: h.db,
62
+ gitRoot: h.gitRoot,
63
+ knownIssuers: () => [ISSUER],
64
+ ...extra,
65
+ };
66
+ }
67
+
68
+ function gitReq(path: string, init?: RequestInit): Request {
69
+ return new Request(`http://127.0.0.1${path}`, init);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Pure parsers
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe("parseGitPath", () => {
77
+ test("parses name + subpath", () => {
78
+ expect(parseGitPath("/git/gitcoin-brain/info/refs")).toEqual({
79
+ name: "gitcoin-brain",
80
+ gitSubpath: "info/refs",
81
+ });
82
+ });
83
+ test("strips a trailing .git on the name segment", () => {
84
+ expect(parseGitPath("/git/foo.git/git-receive-pack")).toEqual({
85
+ name: "foo",
86
+ gitSubpath: "git-receive-pack",
87
+ });
88
+ });
89
+ test("rejects non-/git paths", () => {
90
+ expect(parseGitPath("/surface/foo")).toBeNull();
91
+ });
92
+ test("rejects a bare /git/<name> with no subpath", () => {
93
+ expect(parseGitPath("/git/foo")).toBeNull();
94
+ expect(parseGitPath("/git/foo/")).toBeNull();
95
+ });
96
+ test("rejects path traversal in the name", () => {
97
+ expect(parseGitPath("/git/../etc/info/refs")).toBeNull();
98
+ expect(parseGitPath("/git/.../info/refs")).toBeNull();
99
+ });
100
+ test("rejects traversal in the subpath", () => {
101
+ expect(parseGitPath("/git/foo/../../etc/passwd")).toBeNull();
102
+ });
103
+ test("rejects slashes / illegal chars in the name", () => {
104
+ expect(parseGitPath("/git/a@b/info/refs")).toBeNull();
105
+ });
106
+ });
107
+
108
+ describe("requiredAccess", () => {
109
+ test("receive-pack is write", () => {
110
+ expect(requiredAccess("git-receive-pack", null)).toBe("write");
111
+ expect(requiredAccess("info/refs", "git-receive-pack")).toBe("write");
112
+ });
113
+ test("upload-pack is read", () => {
114
+ expect(requiredAccess("git-upload-pack", null)).toBe("read");
115
+ expect(requiredAccess("info/refs", "git-upload-pack")).toBe("read");
116
+ });
117
+ test("dumb / unknown paths default to read", () => {
118
+ expect(requiredAccess("info/refs", null)).toBe("read");
119
+ expect(requiredAccess("HEAD", null)).toBe("read");
120
+ expect(requiredAccess("objects/info/packs", null)).toBe("read");
121
+ });
122
+ });
123
+
124
+ describe("extractToken", () => {
125
+ test("Bearer", () => {
126
+ const r = gitReq("/git/foo/info/refs", { headers: { authorization: "Bearer abc.def.ghi" } });
127
+ expect(extractToken(r)).toBe("abc.def.ghi");
128
+ });
129
+ test("Basic x-access-token:<jwt>", () => {
130
+ const b64 = Buffer.from("x-access-token:my.jwt.tok").toString("base64");
131
+ const r = gitReq("/git/foo/info/refs", { headers: { authorization: `Basic ${b64}` } });
132
+ expect(extractToken(r)).toBe("my.jwt.tok");
133
+ });
134
+ test("Basic <jwt>:x-oauth-basic (legacy, token in username)", () => {
135
+ const b64 = Buffer.from("my.jwt.tok:x-oauth-basic").toString("base64");
136
+ const r = gitReq("/git/foo/info/refs", { headers: { authorization: `Basic ${b64}` } });
137
+ expect(extractToken(r)).toBe("my.jwt.tok");
138
+ });
139
+ test("no header → null", () => {
140
+ expect(extractToken(gitReq("/git/foo/info/refs"))).toBeNull();
141
+ });
142
+ });
143
+
144
+ describe("parseCgiHeaders", () => {
145
+ test("default 200 when no Status line", () => {
146
+ const { status, headers } = parseCgiHeaders(
147
+ "Content-Type: application/x-git-upload-pack-advertisement",
148
+ );
149
+ expect(status).toBe(200);
150
+ expect(headers.get("content-type")).toBe("application/x-git-upload-pack-advertisement");
151
+ });
152
+ test("honors a Status line", () => {
153
+ const { status } = parseCgiHeaders("Status: 404 Not Found\r\nContent-Type: text/plain");
154
+ expect(status).toBe(404);
155
+ });
156
+ });
157
+
158
+ describe("findHeaderEnd", () => {
159
+ const enc = new TextEncoder();
160
+ test("finds CRLFCRLF", () => {
161
+ const buf = enc.encode("A: b\r\nC: d\r\n\r\nBODY");
162
+ const r = findHeaderEnd(buf);
163
+ expect(r?.sepLen).toBe(4);
164
+ expect(new TextDecoder().decode(buf.slice(0, r?.idx))).toBe("A: b\r\nC: d");
165
+ });
166
+ test("finds LFLF", () => {
167
+ const buf = enc.encode("A: b\n\nBODY");
168
+ const r = findHeaderEnd(buf);
169
+ expect(r?.sepLen).toBe(2);
170
+ });
171
+ test("returns null without a boundary", () => {
172
+ expect(findHeaderEnd(enc.encode("A: b\r\n"))).toBeNull();
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Auth gate (handler-direct — no real server needed)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe("handleGitTransport — auth gate", () => {
181
+ test("401 + WWW-Authenticate: Bearer when no credential", async () => {
182
+ const h = await makeHarness();
183
+ try {
184
+ const res = await handleGitTransport(
185
+ gitReq("/git/foo/info/refs?service=git-receive-pack"),
186
+ deps(h),
187
+ );
188
+ expect(res.status).toBe(401);
189
+ expect(res.headers.get("www-authenticate") ?? "").toContain("Bearer");
190
+ // Nothing provisioned on an unauthenticated probe.
191
+ expect(existsSync(join(h.gitRoot, "foo.git"))).toBe(false);
192
+ } finally {
193
+ h.cleanup();
194
+ }
195
+ });
196
+
197
+ test("401 on an invalid/garbage token", async () => {
198
+ const h = await makeHarness();
199
+ try {
200
+ const res = await handleGitTransport(
201
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
202
+ headers: { authorization: "Bearer not-a-jwt" },
203
+ }),
204
+ deps(h),
205
+ );
206
+ expect(res.status).toBe(401);
207
+ expect(res.headers.get("www-authenticate") ?? "").toContain("Bearer");
208
+ } finally {
209
+ h.cleanup();
210
+ }
211
+ });
212
+
213
+ test("403 when a valid token lacks surface:<name>:write (push)", async () => {
214
+ const h = await makeHarness();
215
+ try {
216
+ const token = await mint(h, ["surface:foo:read", "vault:default:read"]);
217
+ const res = await handleGitTransport(
218
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
219
+ headers: { authorization: `Bearer ${token}` },
220
+ }),
221
+ deps(h),
222
+ );
223
+ expect(res.status).toBe(403);
224
+ // A read-only credential never provisions a write repo on a push attempt.
225
+ expect(existsSync(join(h.gitRoot, "foo.git"))).toBe(false);
226
+ } finally {
227
+ h.cleanup();
228
+ }
229
+ });
230
+
231
+ test("403 on a read (upload-pack) with neither read nor write scope", async () => {
232
+ const h = await makeHarness();
233
+ try {
234
+ const token = await mint(h, ["surface:other:read"]);
235
+ const res = await handleGitTransport(
236
+ gitReq("/git/foo/info/refs?service=git-upload-pack", {
237
+ headers: { authorization: `Bearer ${token}` },
238
+ }),
239
+ deps(h),
240
+ );
241
+ expect(res.status).toBe(403);
242
+ } finally {
243
+ h.cleanup();
244
+ }
245
+ });
246
+
247
+ test("a write token authorizes a push info/refs (advertisement, 200)", async () => {
248
+ const h = await makeHarness();
249
+ try {
250
+ const token = await mint(h, ["surface:foo:write"]);
251
+ const res = await handleGitTransport(
252
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
253
+ headers: { authorization: `Bearer ${token}` },
254
+ }),
255
+ deps(h),
256
+ );
257
+ expect(res.status).toBe(200);
258
+ expect(res.headers.get("content-type")).toContain("git-receive-pack-advertisement");
259
+ // First authenticated access provisions the bare repo.
260
+ expect(existsSync(join(h.gitRoot, "foo.git"))).toBe(true);
261
+ const body = await res.text();
262
+ expect(body).toContain("git-receive-pack");
263
+ } finally {
264
+ h.cleanup();
265
+ }
266
+ });
267
+
268
+ test("a read token authorizes a fetch info/refs (upload-pack, 200)", async () => {
269
+ const h = await makeHarness();
270
+ try {
271
+ const token = await mint(h, ["surface:foo:read"]);
272
+ const res = await handleGitTransport(
273
+ gitReq("/git/foo/info/refs?service=git-upload-pack", {
274
+ headers: { authorization: `Bearer ${token}` },
275
+ }),
276
+ deps(h),
277
+ );
278
+ expect(res.status).toBe(200);
279
+ expect(res.headers.get("content-type")).toContain("git-upload-pack-advertisement");
280
+ // Drain the body so the http-backend service finishes before teardown
281
+ // (else cleanup's rmSync can race the still-running `git upload-pack`).
282
+ await res.text();
283
+ } finally {
284
+ h.cleanup();
285
+ }
286
+ });
287
+
288
+ test("write ⊇ read: a write token may also fetch", async () => {
289
+ const h = await makeHarness();
290
+ try {
291
+ const token = await mint(h, ["surface:foo:write"]);
292
+ const res = await handleGitTransport(
293
+ gitReq("/git/foo/info/refs?service=git-upload-pack", {
294
+ headers: { authorization: `Bearer ${token}` },
295
+ }),
296
+ deps(h),
297
+ );
298
+ expect(res.status).toBe(200);
299
+ await res.text();
300
+ } finally {
301
+ h.cleanup();
302
+ }
303
+ });
304
+
305
+ test("Basic x-access-token:<jwt> is accepted (older-git compat)", async () => {
306
+ const h = await makeHarness();
307
+ try {
308
+ const token = await mint(h, ["surface:foo:write"]);
309
+ const b64 = Buffer.from(`x-access-token:${token}`).toString("base64");
310
+ const res = await handleGitTransport(
311
+ gitReq("/git/foo/info/refs?service=git-receive-pack", {
312
+ headers: { authorization: `Basic ${b64}` },
313
+ }),
314
+ deps(h),
315
+ );
316
+ expect(res.status).toBe(200);
317
+ await res.text();
318
+ } finally {
319
+ h.cleanup();
320
+ }
321
+ });
322
+ });
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Dispatch wiring through hubFetch
326
+ // ---------------------------------------------------------------------------
327
+
328
+ describe("hubFetch /git dispatch", () => {
329
+ test("routes /git/* to the transport (401 unauth, with WWW-Authenticate)", async () => {
330
+ const h = await makeHarness();
331
+ try {
332
+ const handler = hubFetch(h.dir, {
333
+ getDb: () => h.db,
334
+ gitRoot: h.gitRoot,
335
+ issuer: ISSUER,
336
+ loopbackPort: 1939,
337
+ });
338
+ const res = await handler(gitReq("/git/foo/info/refs?service=git-upload-pack"));
339
+ expect(res.status).toBe(401);
340
+ expect(res.headers.get("www-authenticate") ?? "").toContain("Bearer");
341
+ } finally {
342
+ h.cleanup();
343
+ }
344
+ });
345
+ });
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Real git push round-trip through a live server
349
+ // ---------------------------------------------------------------------------
350
+
351
+ function git(
352
+ args: string[],
353
+ cwd: string,
354
+ env?: Record<string, string>,
355
+ ): { code: number; out: string; err: string } {
356
+ const r = spawnSync("git", args, {
357
+ cwd,
358
+ encoding: "utf8",
359
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
360
+ });
361
+ return { code: r.status ?? -1, out: r.stdout ?? "", err: r.stderr ?? "" };
362
+ }
363
+
364
+ /**
365
+ * Async git — for any client op that talks to the in-process `Bun.serve`. The
366
+ * synchronous `spawnSync` would BLOCK Bun's single event loop, starving the
367
+ * server that's meant to answer this very request → deadlock. The async spawn
368
+ * keeps the loop free so the server can respond. (Setup ops above use the sync
369
+ * helper because they never touch the server.)
370
+ */
371
+ async function gitAsync(
372
+ args: string[],
373
+ cwd: string,
374
+ env?: Record<string, string>,
375
+ ): Promise<{ code: number; err: string }> {
376
+ const proc = Bun.spawn(["git", ...args], {
377
+ cwd,
378
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
379
+ stdout: "pipe",
380
+ stderr: "pipe",
381
+ });
382
+ const [code, err] = await Promise.all([
383
+ proc.exited,
384
+ new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
385
+ ]);
386
+ return { code, err };
387
+ }
388
+
389
+ describe("git push round-trip", () => {
390
+ test("an authed push lands the ref in the bare repo + fires post-receive", async () => {
391
+ const h = await makeHarness();
392
+ const server = Bun.serve({
393
+ port: 0,
394
+ hostname: "127.0.0.1",
395
+ fetch: hubFetch(h.dir, {
396
+ getDb: () => h.db,
397
+ gitRoot: h.gitRoot,
398
+ issuer: ISSUER,
399
+ loopbackPort: 1939,
400
+ }),
401
+ });
402
+ const work = mkdtempSync(join(tmpdir(), "phub-git-work-"));
403
+ try {
404
+ const token = await mint(h, ["surface:foo:write"]);
405
+ const base = `http://127.0.0.1:${server.port}`;
406
+
407
+ // Author a commit in a throwaway working repo.
408
+ expect(git(["init", "-q", "-b", "main", work], tmpdir()).code).toBe(0);
409
+ git(["config", "user.email", "test@parachute.computer"], work);
410
+ git(["config", "user.name", "Test"], work);
411
+ Bun.write(join(work, "index.html"), "<h1>surface</h1>\n");
412
+ expect(git(["add", "-A"], work).code).toBe(0);
413
+ expect(git(["commit", "-q", "-m", "first"], work).code).toBe(0);
414
+ const localRev = git(["rev-parse", "HEAD"], work).out.trim();
415
+
416
+ // Push through the hub-authenticated endpoint (token via extraHeader, so
417
+ // the request carries Authorization up-front — exercises info/refs +
418
+ // receive-pack transfer end-to-end). Async spawn (see gitAsync) so the
419
+ // in-process server can answer.
420
+ const push = await gitAsync(
421
+ [
422
+ "-c",
423
+ `http.extraHeader=Authorization: Bearer ${token}`,
424
+ "push",
425
+ `${base}/git/foo`,
426
+ "main",
427
+ ],
428
+ work,
429
+ );
430
+ expect(push.code).toBe(0);
431
+
432
+ // The ref landed in the hub-side bare repo at the pushed sha.
433
+ const bare = join(h.gitRoot, "foo.git");
434
+ const serverRev = git(
435
+ ["--git-dir", bare, "rev-parse", "refs/heads/main"],
436
+ tmpdir(),
437
+ ).out.trim();
438
+ expect(serverRev).toBe(localRev);
439
+
440
+ // post-receive placeholder fired and logged the ref.
441
+ const logPath = join(bare, "post-receive.log");
442
+ expect(existsSync(logPath)).toBe(true);
443
+ expect(readFileSync(logPath, "utf8")).toContain("refs/heads/main");
444
+ } finally {
445
+ server.stop(true);
446
+ rmSync(work, { recursive: true, force: true });
447
+ h.cleanup();
448
+ }
449
+ });
450
+ });
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Hub-authenticated git smart-HTTP transport — the Surface Git Transport
3
+ * substrate (Phase 0a, design doc 2026-06-30-surface-git-transport.md).
4
+ *
5
+ * The hub provides ONE general primitive: an authenticated `git http-backend`
6
+ * endpoint at `/git/<name>/*` backed by a bare repo per `<name>`. A client
7
+ * (agent, human, or a standalone Claude Code session) authenticates with a
8
+ * hub-issued JWT carrying `surface:<name>:write` (push) or `surface:<name>:read`
9
+ * (fetch) and does a plain `git push` / `git clone`. Surfaces are the first
10
+ * consumer; "hub-authenticated git" generalizes to any module that wants
11
+ * versioned, authenticated, file-shaped content movement.
12
+ *
13
+ * What this layer does NOT do (by deliberate trust boundary, §7): it never
14
+ * BUILDS or executes the pushed tree. The hub only receives + stores bytes;
15
+ * the `post-receive` hook here is a Phase-0a placeholder that logs the refs.
16
+ * Building pushed source is surface-host's sandboxed job (Phase 0b) — keeping
17
+ * the RCE surface out of the substrate is the whole point of the split.
18
+ *
19
+ * The mechanism (grounded in git's smart-HTTP protocol):
20
+ * 1. Discovery `GET /git/<name>/info/refs?service=git-(upload|receive)-pack`
21
+ * then transfer `POST /git/<name>/git-(upload|receive)-pack`.
22
+ * Scope keys PURELY off the service/path — no pack parsing:
23
+ * receive-pack ⇒ write, upload-pack ⇒ read.
24
+ * 2. The 401 dance: an unauthenticated request gets `401` +
25
+ * `WWW-Authenticate` (LOAD-BEARING — git won't invoke its credential
26
+ * helper / retry without it). Enforced at BOTH the info/refs GET and the
27
+ * transfer POST.
28
+ * 3. Bearer or Basic: git ≥2.46 sends `Authorization: Bearer <jwt>`; older
29
+ * git uses Basic with `x-access-token:<jwt>` (GitHub's compat trick).
30
+ * Both are accepted.
31
+ * 4. The gate validates the JWT (signature → hub keys; `iss` ∈ the
32
+ * multi-origin hub-bound set; revocation — the existing
33
+ * `validateAccessToken` path) and checks the scope, then streams the
34
+ * request + response bodies through `git http-backend` with CGI env.
35
+ * Never buffers whole packs.
36
+ */
37
+ import type { Database } from "bun:sqlite";
38
+ import { spawnSync } from "node:child_process";
39
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
40
+ import { join } from "node:path";
41
+ import { validateAccessToken } from "./jwt-sign.ts";
42
+
43
+ /** Logger seam — defaults to `console`. */
44
+ export interface GitTransportLog {
45
+ warn: (...args: unknown[]) => void;
46
+ info: (...args: unknown[]) => void;
47
+ }
48
+
49
+ export interface GitTransportDeps {
50
+ /** Hub DB handle — for signature/kid lookup + revocation in `validateAccessToken`. */
51
+ db: Database;
52
+ /**
53
+ * Directory holding the bare repos. Each surface lives at
54
+ * `<gitRoot>/<name>.git`. Production: `<CONFIG_DIR>/hub/git`. Tests point
55
+ * this at a tmpdir.
56
+ */
57
+ gitRoot: string;
58
+ /**
59
+ * The SET of origins this hub legitimately answers on
60
+ * (`buildHubBoundOrigins` — loopback ∪ expose-state ∪ platform ∪ per-request
61
+ * issuer). Passed straight to `validateAccessToken` as the `iss` allow-set so
62
+ * a credential minted under a still-valid prior origin keeps validating
63
+ * across an origin switch. SECURITY: must come ONLY from
64
+ * `buildHubBoundOrigins`, never a raw request Host (the signature is verified
65
+ * against the hub's own key first, so this is an additive `iss` relaxation
66
+ * only — see `validateAccessToken`).
67
+ */
68
+ knownIssuers: () => readonly string[];
69
+ /** Resolved peer address, surfaced to the backend as REMOTE_ADDR. */
70
+ peerAddr?: string | null;
71
+ log?: GitTransportLog;
72
+ }
73
+
74
+ /**
75
+ * Surface-name charset. Kebab/alnum only — NO slashes or dots, so a parsed
76
+ * name can never escape `gitRoot` via path traversal. A trailing `.git` on the
77
+ * URL segment is stripped before this check (so `/git/foo.git/...` and
78
+ * `/git/foo/...` both resolve to `foo`). Bounded length keeps a hostile name
79
+ * from ballooning a path.
80
+ */
81
+ const SURFACE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
82
+
83
+ /** Which authority a request needs, keyed purely off the git service/path. */
84
+ type Access = "read" | "write";
85
+
86
+ interface ParsedGitPath {
87
+ /** Canonical surface name (trailing `.git` stripped). */
88
+ name: string;
89
+ /** The git subpath after the name, e.g. `info/refs` or `git-receive-pack`. */
90
+ gitSubpath: string;
91
+ }
92
+
93
+ /**
94
+ * Parse `/git/<name>/<gitSubpath>` (the `<name>` may carry a trailing `.git`).
95
+ * Returns null when the path is not a well-formed, safe git route — the caller
96
+ * 404s (we don't distinguish malformed-name from unknown-surface, to avoid
97
+ * leaking which names exist).
98
+ */
99
+ export function parseGitPath(pathname: string): ParsedGitPath | null {
100
+ if (!pathname.startsWith("/git/")) return null;
101
+ const rest = pathname.slice("/git/".length);
102
+ const slash = rest.indexOf("/");
103
+ // A bare `/git/<name>` with no git subpath is never a real smart-HTTP request.
104
+ if (slash <= 0) return null;
105
+ const rawName = rest.slice(0, slash);
106
+ const gitSubpath = rest.slice(slash + 1);
107
+ if (gitSubpath.length === 0) return null;
108
+ const name = rawName.endsWith(".git") ? rawName.slice(0, -".git".length) : rawName;
109
+ if (!SURFACE_NAME_RE.test(name)) return null;
110
+ // Defense-in-depth: reject any traversal sequence in the remaining subpath.
111
+ // `git http-backend` confines itself to GIT_PROJECT_ROOT, but we never want a
112
+ // `..` to reach it. (Legitimate subpaths are `info/refs`, `git-upload-pack`,
113
+ // `git-receive-pack`, `objects/...` — none contain `..`.)
114
+ if (gitSubpath.split("/").some((seg) => seg === "..")) return null;
115
+ return { name, gitSubpath };
116
+ }
117
+
118
+ /**
119
+ * Required authority for a request: write for receive-pack (push), read for
120
+ * upload-pack (fetch) and any other discovery/dumb path. Keys purely off the
121
+ * service param / path — no pack inspection.
122
+ */
123
+ export function requiredAccess(gitSubpath: string, serviceParam: string | null): Access {
124
+ if (gitSubpath === "git-receive-pack") return "write";
125
+ if (gitSubpath === "git-upload-pack") return "read";
126
+ if (gitSubpath === "info/refs") {
127
+ return serviceParam === "git-receive-pack" ? "write" : "read";
128
+ }
129
+ // Dumb-HTTP object/ref fetches (objects/*, HEAD, packed-refs) are read-only.
130
+ return "read";
131
+ }
132
+
133
+ /**
134
+ * Extract the presented JWT from either `Authorization: Bearer <jwt>` or HTTP
135
+ * Basic. Returns null when no credential is present.
136
+ *
137
+ * Basic forms accepted (GitHub-compat, §6.3):
138
+ * - `x-access-token:<jwt>` → token in the password (the documented form);
139
+ * - `<jwt>:x-oauth-basic` → token in the username (legacy);
140
+ * - `<jwt>:` → token in the username (empty password).
141
+ */
142
+ export function extractToken(req: Request): string | null {
143
+ const header = req.headers.get("authorization");
144
+ if (!header) return null;
145
+ const bearer = header.match(/^Bearer\s+(.+)$/i);
146
+ if (bearer?.[1]) return bearer[1].trim();
147
+ const basic = header.match(/^Basic\s+(.+)$/i);
148
+ if (basic?.[1]) {
149
+ let decoded: string;
150
+ try {
151
+ decoded = Buffer.from(basic[1].trim(), "base64").toString("utf8");
152
+ } catch {
153
+ return null;
154
+ }
155
+ const idx = decoded.indexOf(":");
156
+ const user = idx === -1 ? decoded : decoded.slice(0, idx);
157
+ const pass = idx === -1 ? "" : decoded.slice(idx + 1);
158
+ if (user === "x-access-token") return pass || null;
159
+ if (pass && pass !== "x-oauth-basic") return pass;
160
+ return user || null;
161
+ }
162
+ return null;
163
+ }
164
+
165
+ /**
166
+ * 401 — missing or invalid credential. The `WWW-Authenticate` header is
167
+ * LOAD-BEARING: without it git won't invoke its credential helper or retry.
168
+ * We advertise BOTH `Bearer` (git ≥2.46 native + modern helpers) and `Basic`
169
+ * (older git's helper-based retry with `x-access-token:<jwt>`), so the widest
170
+ * range of clients re-authenticates.
171
+ */
172
+ function unauthorized(reason: string): Response {
173
+ const headers = new Headers({
174
+ "content-type": "text/plain; charset=utf-8",
175
+ "cache-control": "no-store",
176
+ });
177
+ headers.append("www-authenticate", "Bearer");
178
+ headers.append("www-authenticate", 'Basic realm="Parachute Surface Git"');
179
+ return new Response(`Unauthorized: ${reason}\n`, { status: 401, headers });
180
+ }
181
+
182
+ /**
183
+ * 403 — a VALID credential that lacks the required scope. Deliberately NOT a
184
+ * 401: re-prompting the same identity yields no more authority, so a 401 would
185
+ * only spin the credential helper. The `WWW-Authenticate: ... insufficient_scope`
186
+ * header makes the reason machine-readable (RFC 6750), mirroring
187
+ * `adminAuthErrorResponse`.
188
+ */
189
+ function forbidden(scope: string): Response {
190
+ return new Response(`Forbidden: token missing required scope ${scope}\n`, {
191
+ status: 403,
192
+ headers: {
193
+ "content-type": "text/plain; charset=utf-8",
194
+ "cache-control": "no-store",
195
+ "www-authenticate": `Bearer error="insufficient_scope", scope="${scope}"`,
196
+ },
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Ensure `<gitRoot>/<name>.git` exists as an exportable bare repo, creating it
202
+ * on first authenticated access (Phase 1 will add a real registry; this keeps
203
+ * it simple now). Returns the repo dir. Only ever called AFTER the auth gate
204
+ * passes, so unauthenticated probing can never provision a repo.
205
+ *
206
+ * `http.receivepack = true` is REQUIRED for push: `git http-backend` enables
207
+ * upload-pack from `GIT_HTTP_EXPORT_ALL` alone but refuses receive-pack unless
208
+ * the repo opts in explicitly.
209
+ */
210
+ function ensureBareRepo(gitRoot: string, name: string, log: GitTransportLog): string {
211
+ const repoDir = join(gitRoot, `${name}.git`);
212
+ if (existsSync(repoDir)) return repoDir;
213
+ mkdirSync(gitRoot, { recursive: true });
214
+ const init = spawnSync("git", ["init", "--bare", repoDir], { encoding: "utf8" });
215
+ if (init.status !== 0) {
216
+ throw new Error(`git init --bare failed: ${init.stderr || init.error?.message || "unknown"}`);
217
+ }
218
+ const cfg = spawnSync("git", ["-C", repoDir, "config", "http.receivepack", "true"], {
219
+ encoding: "utf8",
220
+ });
221
+ if (cfg.status !== 0) {
222
+ throw new Error(`git config http.receivepack failed: ${cfg.stderr || "unknown"}`);
223
+ }
224
+ writePostReceiveHook(repoDir, name);
225
+ log.info(`[git-transport] provisioned bare repo for surface "${name}" at ${repoDir}`);
226
+ return repoDir;
227
+ }
228
+
229
+ /**
230
+ * Phase-0a placeholder hook: log the received refs (to stdout, relayed to the
231
+ * pusher as `remote:` lines, and appended to `post-receive.log` in the repo
232
+ * dir for verification). Phase 0b replaces the body with an HTTP + hub-JWT
233
+ * notify to surface-host (NEVER a shell-out that builds the pushed tree — §5/§7).
234
+ */
235
+ function writePostReceiveHook(repoDir: string, name: string): void {
236
+ const hook = `#!/bin/sh
237
+ # Parachute Surface Git Transport — Phase 0a placeholder.
238
+ # Logs received refs only. Phase 0b: notify surface-host over HTTP + a hub JWT
239
+ # (never build the pushed tree in this process — that exec authority belongs to
240
+ # the module's sandbox, not the substrate).
241
+ while read -r oldrev newrev refname; do
242
+ printf '[parachute] surface %s received %s (%s..%s)\\n' "${name}" "$refname" "$oldrev" "$newrev"
243
+ printf '%s %s %s\\n' "$oldrev" "$newrev" "$refname" >> post-receive.log
244
+ done
245
+ `;
246
+ const hookPath = join(repoDir, "hooks", "post-receive");
247
+ writeFileSync(hookPath, hook, { mode: 0o755 });
248
+ }
249
+
250
+ /**
251
+ * The byte offset + separator length where CGI headers end (first blank line).
252
+ * Handles both `\r\n\r\n` (4) and `\n\n` (2). Returns null if no boundary yet.
253
+ * Exported for unit testing.
254
+ */
255
+ export function findHeaderEnd(buf: Uint8Array): { idx: number; sepLen: number } | null {
256
+ for (let i = 0; i + 1 < buf.length; i++) {
257
+ if (buf[i] === 0x0a && buf[i + 1] === 0x0a) return { idx: i, sepLen: 2 };
258
+ if (
259
+ i + 3 < buf.length &&
260
+ buf[i] === 0x0d &&
261
+ buf[i + 1] === 0x0a &&
262
+ buf[i + 2] === 0x0d &&
263
+ buf[i + 3] === 0x0a
264
+ ) {
265
+ return { idx: i, sepLen: 4 };
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * Parse CGI response headers (the block before the first blank line) into an
273
+ * HTTP status + Headers. `Status: NNN reason` maps to the HTTP status (default
274
+ * 200 when absent); every other `Key: Value` line is forwarded verbatim.
275
+ * Exported for unit testing.
276
+ */
277
+ export function parseCgiHeaders(headerBlock: string): { status: number; headers: Headers } {
278
+ const headers = new Headers();
279
+ let status = 200;
280
+ for (const rawLine of headerBlock.split(/\r?\n/)) {
281
+ const line = rawLine.trim();
282
+ if (line.length === 0) continue;
283
+ const colon = line.indexOf(":");
284
+ if (colon === -1) continue;
285
+ const key = line.slice(0, colon).trim();
286
+ const value = line.slice(colon + 1).trim();
287
+ if (key.toLowerCase() === "status") {
288
+ const code = Number.parseInt(value.split(/\s+/)[0] ?? "", 10);
289
+ if (Number.isFinite(code) && code >= 100 && code < 600) status = code;
290
+ continue;
291
+ }
292
+ headers.append(key, value);
293
+ }
294
+ return { status, headers };
295
+ }
296
+
297
+ const MAX_CGI_HEADER_BYTES = 64 * 1024;
298
+
299
+ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
300
+ const out = new Uint8Array(a.length + b.length);
301
+ out.set(a, 0);
302
+ out.set(b, a.length);
303
+ return out;
304
+ }
305
+
306
+ /**
307
+ * Read the CGI header block off `stdout`, then return a Response whose body
308
+ * STREAMS the remainder (leftover bytes already read + the rest of the stream).
309
+ * Never buffers the whole pack — only the small header block is accumulated.
310
+ */
311
+ async function cgiResponse(stdout: ReadableStream<Uint8Array>): Promise<Response> {
312
+ const reader = stdout.getReader();
313
+ let buf: Uint8Array = new Uint8Array(0);
314
+ let boundary: { idx: number; sepLen: number } | null = null;
315
+ for (;;) {
316
+ const { done, value } = await reader.read();
317
+ if (value && value.length > 0) {
318
+ buf = concatBytes(buf, value);
319
+ boundary = findHeaderEnd(buf);
320
+ if (boundary) break;
321
+ if (buf.length > MAX_CGI_HEADER_BYTES) {
322
+ reader.cancel().catch(() => {});
323
+ return new Response("bad gateway: git http-backend emitted no CGI header block\n", {
324
+ status: 502,
325
+ headers: { "content-type": "text/plain; charset=utf-8" },
326
+ });
327
+ }
328
+ }
329
+ if (done) break;
330
+ }
331
+
332
+ const headerEnd = boundary ? boundary.idx : buf.length;
333
+ const sepLen = boundary ? boundary.sepLen : 0;
334
+ const headerBlock = new TextDecoder().decode(buf.slice(0, headerEnd));
335
+ const leftover = buf.slice(headerEnd + sepLen);
336
+ const { status, headers } = parseCgiHeaders(headerBlock);
337
+
338
+ const body = new ReadableStream<Uint8Array>({
339
+ start(controller) {
340
+ if (leftover.length > 0) controller.enqueue(leftover);
341
+ if (boundary === null) controller.close();
342
+ },
343
+ async pull(controller) {
344
+ const { done, value } = await reader.read();
345
+ if (done) {
346
+ controller.close();
347
+ return;
348
+ }
349
+ if (value && value.length > 0) controller.enqueue(value);
350
+ },
351
+ cancel(reason) {
352
+ reader.cancel(reason).catch(() => {});
353
+ },
354
+ });
355
+ return new Response(body, { status, headers });
356
+ }
357
+
358
+ /**
359
+ * Handle a `/git/<name>/*` request: parse → auth-gate → ensure bare repo →
360
+ * stream-proxy to `git http-backend`. Always returns a Response (the caller
361
+ * gates on the `/git/` prefix). A null `parseGitPath` 404s.
362
+ */
363
+ export async function handleGitTransport(req: Request, deps: GitTransportDeps): Promise<Response> {
364
+ const log = deps.log ?? console;
365
+ const url = new URL(req.url);
366
+ const parsed = parseGitPath(url.pathname);
367
+ if (!parsed) return new Response("not found", { status: 404 });
368
+ const { name, gitSubpath } = parsed;
369
+
370
+ const serviceParam = url.searchParams.get("service");
371
+ const access = requiredAccess(gitSubpath, serviceParam);
372
+
373
+ // --- Auth gate (BEFORE touching the filesystem or spawning anything) ------
374
+ const token = extractToken(req);
375
+ if (!token) return unauthorized("a hub access token is required");
376
+
377
+ let sub: string;
378
+ let scopes: string[];
379
+ try {
380
+ const validated = await validateAccessToken(deps.db, token, deps.knownIssuers());
381
+ const subClaim = validated.payload.sub;
382
+ if (typeof subClaim !== "string" || subClaim.length === 0) {
383
+ return unauthorized("token missing required `sub` claim");
384
+ }
385
+ sub = subClaim;
386
+ const scopeClaim = (validated.payload as { scope?: unknown }).scope;
387
+ scopes = typeof scopeClaim === "string" ? scopeClaim.split(/\s+/).filter(Boolean) : [];
388
+ } catch (err) {
389
+ const msg = err instanceof Error ? err.message : String(err);
390
+ return unauthorized(`invalid token: ${msg}`);
391
+ }
392
+
393
+ // Authority check. Write requires `surface:<name>:write`. Read is satisfied
394
+ // by either `surface:<name>:read` OR `surface:<name>:write` (write ⊇ read —
395
+ // a writer can always fetch, matching GitHub's model).
396
+ const writeScope = `surface:${name}:write`;
397
+ const readScope = `surface:${name}:read`;
398
+ const ok =
399
+ access === "write"
400
+ ? scopes.includes(writeScope)
401
+ : scopes.includes(readScope) || scopes.includes(writeScope);
402
+ if (!ok) return forbidden(access === "write" ? writeScope : readScope);
403
+
404
+ // --- Provision (first access) + proxy -------------------------------------
405
+ try {
406
+ ensureBareRepo(deps.gitRoot, name, log);
407
+ } catch (err) {
408
+ const msg = err instanceof Error ? err.message : String(err);
409
+ log.warn(`[git-transport] repo provisioning failed for "${name}": ${msg}`);
410
+ return new Response("internal error: could not provision surface repo\n", {
411
+ status: 500,
412
+ headers: { "content-type": "text/plain; charset=utf-8" },
413
+ });
414
+ }
415
+
416
+ // Minimal CGI env — we deliberately do NOT inherit the hub's full process
417
+ // env (no hub secrets reach the subprocess). REMOTE_USER is the validated
418
+ // token subject only. GIT_PROTOCOL passes the client's protocol negotiation
419
+ // (v2) through; QUERY_STRING/CONTENT_TYPE/REQUEST_METHOD are standard CGI.
420
+ const query = url.search.startsWith("?") ? url.search.slice(1) : url.search;
421
+ const env: Record<string, string> = {
422
+ PATH: process.env.PATH ?? "",
423
+ GIT_PROJECT_ROOT: deps.gitRoot,
424
+ GIT_HTTP_EXPORT_ALL: "1",
425
+ PATH_INFO: `/${name}.git/${gitSubpath}`,
426
+ REQUEST_METHOD: req.method,
427
+ QUERY_STRING: query,
428
+ CONTENT_TYPE: req.headers.get("content-type") ?? "",
429
+ REMOTE_USER: sub,
430
+ REMOTE_ADDR: deps.peerAddr ?? "",
431
+ GIT_PROTOCOL: req.headers.get("git-protocol") ?? "",
432
+ };
433
+ // Set CONTENT_LENGTH only for non-chunked bodies. Large pushes use chunked
434
+ // transfer (no Content-Length): the smart-service POST path reads the
435
+ // self-delimiting pkt-line/pack stream off stdin to its natural end, so we
436
+ // simply pipe the request body and let stdin EOF terminate it — never
437
+ // buffering the pack to compute a length.
438
+ const contentLength = req.headers.get("content-length");
439
+ if (contentLength) env.CONTENT_LENGTH = contentLength;
440
+
441
+ let proc: ReturnType<typeof Bun.spawn>;
442
+ try {
443
+ proc = Bun.spawn(["git", "http-backend"], {
444
+ env,
445
+ // Stream the request body straight to the backend's stdin (Bun pumps it
446
+ // concurrently with our stdout read — no deadlock, no buffering). GET
447
+ // discovery has no body.
448
+ stdin: req.body ?? "ignore",
449
+ stdout: "pipe",
450
+ stderr: "pipe",
451
+ });
452
+ } catch (err) {
453
+ const msg = err instanceof Error ? err.message : String(err);
454
+ log.warn(`[git-transport] failed to spawn git http-backend: ${msg}`);
455
+ return new Response("internal error: git http-backend unavailable\n", {
456
+ status: 500,
457
+ headers: { "content-type": "text/plain; charset=utf-8" },
458
+ });
459
+ }
460
+
461
+ // Drain stderr in the background — surfaces hook output + backend errors in
462
+ // the hub log without blocking the response stream.
463
+ void (async () => {
464
+ try {
465
+ const text = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
466
+ if (text.trim().length > 0) log.info(`[git-transport] ${name}: ${text.trim()}`);
467
+ } catch {
468
+ // stderr drain is best-effort.
469
+ }
470
+ })();
471
+
472
+ return cgiResponse(proc.stdout as ReadableStream<Uint8Array>);
473
+ }
package/src/hub-server.ts CHANGED
@@ -239,6 +239,7 @@ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
239
239
  import { applyCorsHeaders, corsPreflightResponse, isCorsAllowedRoute } from "./cors.ts";
240
240
  import { ensureCsrfToken } from "./csrf.ts";
241
241
  import { readExposeState } from "./expose-state.ts";
242
+ import { handleGitTransport } from "./git-transport.ts";
242
243
  import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
243
244
  import {
244
245
  classifyDbError,
@@ -1212,6 +1213,12 @@ export interface HubFetchDeps {
1212
1213
  * the doc reflect `parachute vault create` etc. without re-running expose.
1213
1214
  */
1214
1215
  manifestPath?: string;
1216
+ /**
1217
+ * Directory holding the per-surface bare repos for the git-transport endpoint
1218
+ * (`/git/<name>/*` → `<gitRoot>/<name>.git`). Tests point this at a tmpdir;
1219
+ * production defaults to `<CONFIG_DIR>/hub/git`.
1220
+ */
1221
+ gitRoot?: string;
1215
1222
  /**
1216
1223
  * Path to `connections.json` (the Connections store, P5). Tests point this
1217
1224
  * at a tmpdir; production defaults to `<CONFIG_DIR>/connections.json`.
@@ -1983,6 +1990,7 @@ export function hubFetch(
1983
1990
  const getDb = deps?.getDb;
1984
1991
  const configuredIssuer = deps?.issuer;
1985
1992
  const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
1993
+ const gitRoot = deps?.gitRoot ?? join(CONFIG_DIR, "hub", "git");
1986
1994
  const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
1987
1995
  const loopbackPort = deps?.loopbackPort;
1988
1996
  const loadExposeHubOrigin =
@@ -3801,6 +3809,24 @@ export function hubFetch(
3801
3809
  return serveSpa(spaDistDir, pathname, "/admin");
3802
3810
  }
3803
3811
 
3812
+ // /git/<name>/* — hub-authenticated git smart-HTTP transport (Surface
3813
+ // Git Transport, Phase 0a). Placed BEFORE the generic services.json
3814
+ // proxy so a `/git/` route is never shadowed by a module mount. The
3815
+ // endpoint is AUTH-gated, not LAYER-gated: it's reachable from any
3816
+ // exposure layer because the hub JWT (validated against the multi-origin
3817
+ // iss-set) is the gate. It NEVER builds or executes the pushed tree — the
3818
+ // hub only receives + stores bytes (the RCE-bearing build is surface-host's
3819
+ // sandboxed job, Phase 0b). See src/git-transport.ts.
3820
+ if (pathname.startsWith("/git/")) {
3821
+ if (!getDb) return new Response("not found", { status: 404 });
3822
+ return handleGitTransport(req, {
3823
+ db: getDb(),
3824
+ gitRoot,
3825
+ knownIssuers: () => oauthDeps(req).hubBoundOrigins(),
3826
+ peerAddr,
3827
+ });
3828
+ }
3829
+
3804
3830
  // Generic services.json-driven dispatch for non-vault modules. Reaches
3805
3831
  // here only after every hub-owned prefix above has had its turn — so
3806
3832
  // `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
@@ -425,6 +425,7 @@ function oauthErrorRedirect(
425
425
  const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, short: string]> = [
426
426
  ["scribe:", "scribe"],
427
427
  ["agent:", "agent"],
428
+ ["surface:", "surface"],
428
429
  ];
429
430
 
430
431
  /**
@@ -46,12 +46,13 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
46
46
  "Read and write everything, plus admin: config & settings, triggers & automation, GitHub backup, and minting access tokens.",
47
47
  level: "admin",
48
48
  },
49
- // Optional-module scopes (scribe / agent). These are in FIRST_PARTY_SCOPES
50
- // (= Object.keys(this map)) but the modules may not be installed — so they're
51
- // GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts) and only advertised in
52
- // `scopes_supported` when the service is in services.json. If you add scopes
53
- // for another optional module here, add a matching gate there too, or a
54
- // vault-only hub will over-advertise them (the bug behind hub#489).
49
+ // Optional-module scopes (scribe / agent / surface). These are in
50
+ // FIRST_PARTY_SCOPES (= Object.keys(this map)) but the modules may not be
51
+ // installed — so they're GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts)
52
+ // and only advertised in `scopes_supported` when the service is in
53
+ // services.json. If you add scopes for another optional module here, add a
54
+ // matching gate there too, or a vault-only hub will over-advertise them (the
55
+ // bug behind hub#489).
55
56
  "scribe:transcribe": {
56
57
  label: "Send audio to Scribe for transcription.",
57
58
  level: "write",
@@ -64,6 +65,20 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
64
65
  label: "Post messages to your Agent.",
65
66
  level: "send",
66
67
  },
68
+ // Surface Git Transport scopes (surface-host). `surface:read` = clone/fetch a
69
+ // surface's hub-hosted git repo; `surface:write` = push to it. Named forms
70
+ // (`surface:<name>:<verb>`) collapse to these via the 3→2-segment rule in
71
+ // `isKnownScope`. surface-host's module.json declares them too; listing them
72
+ // here makes them first-party (mintable + a consent label) even on a hub
73
+ // where surface-host isn't installed.
74
+ "surface:read": {
75
+ label: "Clone and fetch a surface's source (its hub-hosted git repo).",
76
+ level: "read",
77
+ },
78
+ "surface:write": {
79
+ label: "Push to a surface's source (its hub-hosted git repo).",
80
+ level: "write",
81
+ },
67
82
  "hub:admin": {
68
83
  label: "Manage hub identity (user accounts, signing keys, registered OAuth clients).",
69
84
  level: "admin",