@openparachute/hub 0.7.5-rc.2 → 0.7.5-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +310 -0
- package/src/__tests__/admin-surfaces.test.ts +207 -0
- package/src/__tests__/git-registry.test.ts +203 -0
- package/src/__tests__/git-transport.test.ts +181 -0
- package/src/__tests__/grants-store.test.ts +13 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/admin-agent-grants.ts +156 -6
- package/src/admin-surfaces.ts +158 -0
- package/src/git-registry.ts +247 -0
- package/src/git-transport.ts +57 -70
- package/src/grants-store.ts +25 -4
- package/src/hub-server.ts +31 -0
- package/src/scope-explanations.ts +16 -0
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/grants-store.ts
CHANGED
|
@@ -43,15 +43,21 @@ export type GrantInject = "env" | "mcp";
|
|
|
43
43
|
* `target` is the service key; `inject` hints how the agent side wires it
|
|
44
44
|
* (`env` → an env var, `mcp` → the service's MCP server, or both). Grant =
|
|
45
45
|
* an operator-pasted API token.
|
|
46
|
+
* - `surface` — a Parachute SURFACE's hub-hosted git repo (Surface Git
|
|
47
|
+
* Transport Phase 2, design 2026-06-30-surface-git-transport.md §6a).
|
|
48
|
+
* `target` is the surface name; `access` the verb (`write` = clone+push,
|
|
49
|
+
* `read` = clone only — write ⊇ read at the git-transport endpoint). Grant =
|
|
50
|
+
* a hub-minted, revocable `surface:<target>:<access>` JWT the agent injects
|
|
51
|
+
* into `git push`/`git clone` via a per-spawn GIT_ASKPASS (never `.git/config`).
|
|
46
52
|
* - `mcp` — a remote MCP / remote vault. `target` is the MCP URL. Grant =
|
|
47
53
|
* an OAuth token — NOT implemented in 4b-1 (slice 2). Modeled here; the
|
|
48
54
|
* grant stays `pending` with a clear reason.
|
|
49
55
|
*/
|
|
50
56
|
export interface ConnectionSpec {
|
|
51
|
-
readonly kind: "vault" | "service" | "mcp";
|
|
52
|
-
/** Vault name / service key / MCP URL, per `kind`. */
|
|
57
|
+
readonly kind: "vault" | "service" | "surface" | "mcp";
|
|
58
|
+
/** Vault name / service key / surface name / MCP URL, per `kind`. */
|
|
53
59
|
readonly target: string;
|
|
54
|
-
/** Vault grants
|
|
60
|
+
/** Vault + surface grants — `read` (default) or `write`. */
|
|
55
61
|
readonly access?: GrantAccess;
|
|
56
62
|
/** Vault grants only — tag-scope (`scoped_tags`); empty/absent = vault-wide. */
|
|
57
63
|
readonly tags?: readonly string[];
|
|
@@ -87,6 +93,16 @@ export type GrantMaterial =
|
|
|
87
93
|
/** The operator-pasted API token. */
|
|
88
94
|
readonly token: string;
|
|
89
95
|
}
|
|
96
|
+
| {
|
|
97
|
+
readonly kind: "surface";
|
|
98
|
+
/** The minted `surface:<target>:<access>` JWT (write ⊇ read — one token
|
|
99
|
+
* authenticates both `git clone` and `git push`). */
|
|
100
|
+
readonly token: string;
|
|
101
|
+
/** jti of the minted token — registered so revoke can drop it. */
|
|
102
|
+
readonly jti: string;
|
|
103
|
+
/** ISO expiry of the minted token. */
|
|
104
|
+
readonly expiresAt: string;
|
|
105
|
+
}
|
|
90
106
|
| {
|
|
91
107
|
readonly kind: "mcp";
|
|
92
108
|
/**
|
|
@@ -151,6 +167,10 @@ export function connectionKey(spec: ConnectionSpec): string {
|
|
|
151
167
|
if (spec.kind === "service") {
|
|
152
168
|
return `service:${target}`;
|
|
153
169
|
}
|
|
170
|
+
if (spec.kind === "surface") {
|
|
171
|
+
const access = spec.access ?? "read";
|
|
172
|
+
return `surface:${target}:${access}`;
|
|
173
|
+
}
|
|
154
174
|
// mcp — keyed on the URL only (its target).
|
|
155
175
|
return `mcp:${target}`;
|
|
156
176
|
}
|
|
@@ -181,7 +201,8 @@ function isConnectionSpec(v: unknown): v is ConnectionSpec {
|
|
|
181
201
|
if (!v || typeof v !== "object") return false;
|
|
182
202
|
const s = v as Record<string, unknown>;
|
|
183
203
|
return (
|
|
184
|
-
(s.kind === "vault" || s.kind === "service" || s.kind === "
|
|
204
|
+
(s.kind === "vault" || s.kind === "service" || s.kind === "surface" || s.kind === "mcp") &&
|
|
205
|
+
typeof s.target === "string"
|
|
185
206
|
);
|
|
186
207
|
}
|
|
187
208
|
|
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
|
|