@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.
@@ -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
- * 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.
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 { spawnSync } from "node:child_process";
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. Each surface lives at
54
- * `<gitRoot>/<name>.git`. Production: `<CONFIG_DIR>/hub/git`. Tests point
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
- * Surface-name charset. Kebab/alnum only — NO slashes or dots, so a parsed
93
- * name can never escape `gitRoot` via path traversal. A trailing `.git` on the
94
- * URL segment is stripped before this check (so `/git/foo.git/...` and
95
- * `/git/foo/...` both resolve to `foo`). Bounded length keeps a hostile name
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
- // --- Provision (first access) + proxy -------------------------------------
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
- ensureBareRepo(deps.gitRoot, name, log);
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}`);
@@ -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 only — `read` (default) or `write`. */
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 === "mcp") && typeof s.target === "string"
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