@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.
@@ -33,15 +33,18 @@
33
33
  * GET /admin/grants?agent=<name> → { grants: [...] } — NO material on any row.
34
34
  * GET /admin/grants/<id>/material → APPROVED grants only: the injectable
35
35
  * secret. vault → { kind, token, mcpUrl };
36
- * service → { kind, token, inject }.
36
+ * service → { kind, token, inject };
37
+ * surface → { kind, token, remoteUrl }.
37
38
  * 404 unknown id, 409 not approved.
38
39
  *
39
40
  * OPERATOR-AUTH (a first-admin session cookie; CSRF-belted by the dispatch in
40
41
  * hub-server.ts, exactly like /admin/connections POST/DELETE):
41
42
  *
42
43
  * POST /admin/grants/<id>/approve { token? } → vault: MINT now + store;
43
- * service: store the pasted `token`. Returns
44
- * the updated grant (no material).
44
+ * surface: MINT a `surface:<name>:<verb>`
45
+ * token now + store; service: store the
46
+ * pasted `token`. Returns the updated
47
+ * grant (no material).
45
48
  * POST /admin/grants/<id>/revoke → drop the stored material + status=revoked
46
49
  * (the agent loses it next spawn).
47
50
  *
@@ -58,6 +61,7 @@ import {
58
61
  requireScope,
59
62
  } from "./admin-auth.ts";
60
63
  import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
64
+ import { SURFACE_NAME_RE, surfaceGitRemoteUrl } from "./git-registry.ts";
61
65
  import {
62
66
  type ConnectionSpec,
63
67
  type GrantAccess,
@@ -92,6 +96,12 @@ import { validateVaultName } from "./vault-name.ts";
92
96
  * table so revoke can drop it.
93
97
  */
94
98
  const VAULT_GRANT_TTL_SECONDS = 90 * 24 * 60 * 60;
99
+ /**
100
+ * TTL of a minted surface grant token — 90 days, matching the vault grant posture
101
+ * (a headless agent re-fetches at spawn; a long-lived token spares a re-mint every
102
+ * turn). Registered in the tokens table so revoke can drop it (registered-mint rule).
103
+ */
104
+ const SURFACE_GRANT_TTL_SECONDS = 90 * 24 * 60 * 60;
95
105
  const GRANT_CLIENT_ID = "parachute-hub-spa";
96
106
 
97
107
  /** Agent-name charset — lands in the grant id + a `?agent=` query. Conservative slug. */
@@ -367,8 +377,8 @@ function parseConnectionSpec(raw: unknown): { spec: ConnectionSpec } | { error:
367
377
  }
368
378
  const c = raw as Record<string, unknown>;
369
379
  const kind = c.kind;
370
- if (kind !== "vault" && kind !== "service" && kind !== "mcp") {
371
- return { error: `connection.kind must be "vault", "service", or "mcp"` };
380
+ if (kind !== "vault" && kind !== "service" && kind !== "surface" && kind !== "mcp") {
381
+ return { error: `connection.kind must be "vault", "service", "surface", or "mcp"` };
372
382
  }
373
383
  const target = typeof c.target === "string" ? c.target.trim() : "";
374
384
  if (!target) return { error: "connection.target is required" };
@@ -435,6 +445,34 @@ function parseConnectionSpec(raw: unknown): { spec: ConnectionSpec } | { error:
435
445
  };
436
446
  }
437
447
 
448
+ if (kind === "surface") {
449
+ // A surface's hub-hosted git repo (Phase 2). `target` is the surface name —
450
+ // the SAME `SURFACE_NAME_RE` slug the git-transport URL parser + registry
451
+ // enforce (no slashes/dots → no path traversal in the git endpoint), so a
452
+ // grant can only ever name a well-formed surface. NORMALIZED to lowercase (the
453
+ // canonical form): `connectionKey` + `grantId` already lowercase, so the STORED
454
+ // target, the minted `surface:<name>:<verb>` scope, and the `/git/<name>` remote
455
+ // must lowercase too — else a mixed-case want (e.g. `GitCoin-Brain`) would collapse
456
+ // to the same grantId as its lowercase twin but mint a differently-cased scope,
457
+ // and the agent's status key (from its echoed target) would diverge. Surface names
458
+ // are lowercase-kebab by convention (surface-host registers them lowercase). The
459
+ // agent side lowercases at parse too (grants.ts parseSurfaceWant) so both repos'
460
+ // keys agree. `access` is `read` (default) or `write`; the agent always declares
461
+ // the verb explicitly (`surface:<name>:<verb>`).
462
+ if (!SURFACE_NAME_RE.test(target)) {
463
+ return { error: `connection.target "${target}" is not a valid surface name` };
464
+ }
465
+ const name = target.toLowerCase();
466
+ let access: GrantAccess = "read";
467
+ if (c.access !== undefined) {
468
+ if (c.access !== "read" && c.access !== "write") {
469
+ return { error: `connection.access must be "read" or "write"` };
470
+ }
471
+ access = c.access;
472
+ }
473
+ return { spec: { kind: "surface", target: name, access } };
474
+ }
475
+
438
476
  // mcp — modeled, not grantable in 4b-1. Accept a URL target; the grant lands
439
477
  // pending with the slice-2 reason. Validate it parses as an http(s) URL so a
440
478
  // typo doesn't sit pending forever masquerading as an OAuth-blocked grant.
@@ -520,6 +558,15 @@ async function grantMaterial(req: Request, id: string, deps: AgentGrantsDeps): P
520
558
  token: grant.material.token,
521
559
  inject: grant.connection.kind === "service" ? (grant.connection.inject ?? []) : [],
522
560
  };
561
+ } else if (grant.material.kind === "surface") {
562
+ // The minted `surface:<name>:<verb>` token + the surface's git remote — the
563
+ // agent injects the token into `git clone`/`git push` (via GIT_ASKPASS) to
564
+ // the remote. One token covers clone AND push (write ⊇ read at the endpoint).
565
+ payload = {
566
+ kind: "surface",
567
+ token: grant.material.token,
568
+ remoteUrl: surfaceGitRemoteUrl(deps.hubOrigin, grant.connection.target),
569
+ };
523
570
  } else {
524
571
  // mcp — refresh first if it's an OAuth grant near/past expiry. A refresh
525
572
  // FAILURE flips the grant to needs_consent (material dropped) and 409s.
@@ -714,6 +761,57 @@ async function approveGrant(req: Request, id: string, deps: AgentGrantsDeps): Pr
714
761
  return grantResponse(200, updated);
715
762
  }
716
763
 
764
+ if (conn.kind === "surface") {
765
+ // Surface git grant (Phase 2, §6a step 3). The operator approving here IS the
766
+ // "a note can only REQUEST, never GRANT" gate: the module registered this
767
+ // pending, and only this operator-cookie + first-admin path mints the token.
768
+ // We deliberately do NOT require the surface to be REGISTERED at approve time:
769
+ // registration (surface-host discovering the `#surface` note) is async +
770
+ // declarative and may lag the grant; the git-transport already fails closed
771
+ // (404) on an unregistered name even with a valid token, so a pre-approved
772
+ // grant for a not-yet-declared surface is simply inert until it's declared —
773
+ // no escalation, and no ordering dependency between the two operator actions.
774
+ //
775
+ // Re-approval of an already-approved surface grant: revoke the prior minted
776
+ // token first so exactly one live token exists per grant (mirrors vault).
777
+ if (grant.material?.kind === "surface") {
778
+ try {
779
+ revokeTokenByJti(deps.db, grant.material.jti, now);
780
+ } catch {
781
+ // Best-effort — a missing registry row leaves nothing to revoke.
782
+ }
783
+ }
784
+ const access = conn.access ?? "read";
785
+ const scope = `surface:${conn.target}:${access}`;
786
+ let minted: { token: string; jti: string; expiresAt: string };
787
+ try {
788
+ minted = await mintSurfaceGrant(deps, op.userId, scope);
789
+ } catch (err) {
790
+ return jsonError(
791
+ 500,
792
+ "mint_failed",
793
+ `failed to mint surface grant: ${err instanceof Error ? err.message : String(err)}`,
794
+ );
795
+ }
796
+ const updated: GrantRecord = {
797
+ id: grant.id,
798
+ agent: grant.agent,
799
+ connection: grant.connection,
800
+ status: "approved",
801
+ createdAt: grant.createdAt,
802
+ approvedAt,
803
+ material: {
804
+ kind: "surface",
805
+ token: minted.token,
806
+ jti: minted.jti,
807
+ expiresAt: minted.expiresAt,
808
+ },
809
+ };
810
+ putGrant(deps.storePath, updated);
811
+ console.log(`agent grant approved: id=${id} agent=${grant.agent} kind=surface scope=${scope}`);
812
+ return grantResponse(200, updated);
813
+ }
814
+
717
815
  // service — store the operator-pasted API token.
718
816
  // Trim — a pasted " tok " must not inject whitespace into the eventual
719
817
  // `Authorization: Bearer` header (drive-by correctness fix; the mcp
@@ -1184,6 +1282,9 @@ async function revokeGrant(req: Request, id: string, deps: AgentGrantsDeps): Pro
1184
1282
  *
1185
1283
  * - vault → revoke the minted token in the registry so it's dead immediately,
1186
1284
  * not just absent from the next fetch.
1285
+ * - surface → same as vault: revoke the minted `surface:<name>:<verb>` token in
1286
+ * the registry so the git endpoint rejects it immediately (the
1287
+ * revocation list), not just on the agent's next fetch.
1187
1288
  * - mcp → best-effort revoke the refresh token at the issuer so the remote
1188
1289
  * credential dies, not just our local copy. A static bearer (no
1189
1290
  * refresh/revocation endpoint) is a no-op (operator rotates upstream).
@@ -1195,7 +1296,7 @@ async function tearDownGrantMaterial(
1195
1296
  deps: AgentGrantsDeps,
1196
1297
  now: Date,
1197
1298
  ): Promise<void> {
1198
- if (grant.material?.kind === "vault") {
1299
+ if (grant.material?.kind === "vault" || grant.material?.kind === "surface") {
1199
1300
  try {
1200
1301
  revokeTokenByJti(deps.db, grant.material.jti, now);
1201
1302
  } catch {
@@ -1373,6 +1474,55 @@ async function mintVaultGrant(
1373
1474
  return { token: signed.token, jti: signed.jti, expiresAt: signed.expiresAt };
1374
1475
  }
1375
1476
 
1477
+ /**
1478
+ * Mint the token for an approved SURFACE grant (Phase 2): a REGISTERED
1479
+ * (created_via "agent_grant") `surface:<name>:<access>` JWT the git-transport
1480
+ * endpoint validates (`validateAccessToken` → signature + `iss` ∈ hub-bound set +
1481
+ * revocation, then `scopes.includes("surface:<name>:<verb>")`). Mirrors
1482
+ * {@link mintVaultGrant} — same TTL posture + registered-mint discipline — minus
1483
+ * the vault-only bits: NO `vaultScope` pin (surface isn't a per-user vault) and
1484
+ * NO `scoped_tags`. Audience is `surface.<name>` for symmetry with `vault.<name>`;
1485
+ * the git endpoint doesn't check `aud` (it keys purely off the URL path + the
1486
+ * scope), so it's cosmetic but honest.
1487
+ *
1488
+ * The scope is signed VERBATIM (no `capScopesToUserAuthority` — that caps only the
1489
+ * OAuth-consent/mint-token paths, never `signAccessToken`), so a
1490
+ * `surface:<name>:write` grant mints exactly that authority. The operator-cookie +
1491
+ * first-admin approve gate upstream is the governance (a note can only REQUEST).
1492
+ */
1493
+ async function mintSurfaceGrant(
1494
+ deps: AgentGrantsDeps,
1495
+ userId: string,
1496
+ scope: string,
1497
+ ): Promise<{ token: string; jti: string; expiresAt: string }> {
1498
+ // `surface:<name>:<verb>` — the audience takes the surface name (parallel to
1499
+ // `vault.<name>`); split off the middle segment.
1500
+ const surfaceName = scope.split(":")[1] ?? "";
1501
+ const sign = deps.signToken ?? signAccessToken;
1502
+ const signed = await sign(deps.db, {
1503
+ sub: userId || "agent-grant",
1504
+ scopes: [scope],
1505
+ audience: `surface.${surfaceName}`,
1506
+ clientId: GRANT_CLIENT_ID,
1507
+ issuer: deps.hubOrigin,
1508
+ ttlSeconds: SURFACE_GRANT_TTL_SECONDS,
1509
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
1510
+ });
1511
+ // Register the long-lived mint so revoke can drop it (registered-mint rule —
1512
+ // an unregistered long-lived token is unrevocable).
1513
+ recordTokenMint(deps.db, {
1514
+ jti: signed.jti,
1515
+ createdVia: "agent_grant",
1516
+ subject: "agent-grant",
1517
+ ...(userId ? { userId } : {}),
1518
+ clientId: GRANT_CLIENT_ID,
1519
+ scopes: [scope],
1520
+ expiresAt: signed.expiresAt,
1521
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
1522
+ });
1523
+ return { token: signed.token, jti: signed.jti, expiresAt: signed.expiresAt };
1524
+ }
1525
+
1376
1526
  // ===========================================================================
1377
1527
  // Wire shapes
1378
1528
  // ===========================================================================
@@ -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,247 @@
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
+ * The client-facing git remote for a surface — `<hubOrigin>/git/<name>` — the URL
80
+ * an authorized client (agent / human / standalone Claude Code) clones + pushes to
81
+ * (the git-transport endpoint, `parseGitPath`). The trailing `/info/refs` git
82
+ * appends resolves to `parseGitPath("/git/<name>/info/refs")`, so no `.git` suffix
83
+ * is needed on the URL. Handed to a surface-grant holder as the `remoteUrl` in its
84
+ * `/material` (Phase 2 §6a) so the agent knows where to clone/push. NOTE: `name`
85
+ * is a `SURFACE_NAME_RE` slug (validated upstream) — no path traversal possible.
86
+ */
87
+ export function surfaceGitRemoteUrl(hubOrigin: string, name: string): string {
88
+ return `${hubOrigin.replace(/\/+$/, "")}/git/${name}`;
89
+ }
90
+
91
+ /**
92
+ * Read + parse the registry. A missing or corrupt file yields an empty registry
93
+ * (the transport still fails closed on unregistered names — see
94
+ * `isSurfaceRegistered`), never a throw: a torn registry.json must not take the
95
+ * git endpoint down.
96
+ */
97
+ export function loadRegistry(gitRoot: string): SurfaceRegistry {
98
+ const file = registryPath(gitRoot);
99
+ if (!existsSync(file)) return { version: 1, surfaces: {} };
100
+ try {
101
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
102
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
103
+ return { ...EMPTY_REGISTRY };
104
+ const surfaces = (parsed as { surfaces?: unknown }).surfaces;
105
+ if (!surfaces || typeof surfaces !== "object" || Array.isArray(surfaces)) {
106
+ return { ...EMPTY_REGISTRY };
107
+ }
108
+ return { version: 1, surfaces: surfaces as Record<string, SurfaceRegistryEntry> };
109
+ } catch {
110
+ return { ...EMPTY_REGISTRY };
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Persist the registry ATOMICALLY (stage 0600 → rename), so a crash mid-write
116
+ * leaves the prior registry intact and no reader observes a partial file.
117
+ */
118
+ export function saveRegistry(gitRoot: string, reg: SurfaceRegistry): void {
119
+ mkdirSync(gitRoot, { recursive: true });
120
+ const file = registryPath(gitRoot);
121
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
122
+ writeFileSync(tmp, `${JSON.stringify(reg, null, 2)}\n`, { mode: 0o600 });
123
+ renameSync(tmp, file);
124
+ }
125
+
126
+ /**
127
+ * Is `name` a registered surface? True when it has a registry.json entry OR its
128
+ * bare repo already exists on disk (grandfathering a Phase 0a auto-provisioned
129
+ * repo). This is the declaration gate the git-transport endpoint consults after
130
+ * the scope check passes.
131
+ */
132
+ export function isSurfaceRegistered(gitRoot: string, name: string): boolean {
133
+ if (!SURFACE_NAME_RE.test(name)) return false;
134
+ if (loadRegistry(gitRoot).surfaces[name]) return true;
135
+ return existsSync(repoDirFor(gitRoot, name));
136
+ }
137
+
138
+ /**
139
+ * Every registered surface, sorted by name (for `GET /admin/surfaces`). NOTE:
140
+ * this lists only registry.json entries — a grandfathered disk-only bare repo (a
141
+ * Phase-0a auto-provisioned repo with no entry) is still *pushable*
142
+ * (`isSurfaceRegistered` grandfathers it) but does NOT appear here until
143
+ * surface-host's next discovery pass re-registers it and writes its entry.
144
+ */
145
+ export function listSurfaces(gitRoot: string): SurfaceRegistryEntry[] {
146
+ const reg = loadRegistry(gitRoot);
147
+ return Object.values(reg.surfaces).sort((a, b) => a.name.localeCompare(b.name));
148
+ }
149
+
150
+ /**
151
+ * Ensure `<gitRoot>/<name>.git` exists as an exportable bare repo, provisioning
152
+ * it if absent. ASYNC (Phase 1 nit): uses `Bun.spawn` + `await`, never the
153
+ * event-loop-blocking `spawnSync` — a slow disk on `git init` no longer stalls
154
+ * the whole hub. Idempotent: an existing repo is returned untouched.
155
+ *
156
+ * `http.receivepack = true` is REQUIRED for push: `git http-backend` enables
157
+ * upload-pack from `GIT_HTTP_EXPORT_ALL` alone but refuses receive-pack unless
158
+ * the repo opts in explicitly.
159
+ */
160
+ export async function ensureSurfaceRepo(
161
+ gitRoot: string,
162
+ name: string,
163
+ log: GitRegistryLog = console,
164
+ ): Promise<string> {
165
+ if (!SURFACE_NAME_RE.test(name)) {
166
+ throw new Error(`refusing to provision repo for invalid surface name "${name}"`);
167
+ }
168
+ const repoDir = repoDirFor(gitRoot, name);
169
+ if (existsSync(repoDir)) return repoDir;
170
+ mkdirSync(gitRoot, { recursive: true });
171
+
172
+ const init = await runGit(["init", "--bare", repoDir]);
173
+ if (init.code !== 0) {
174
+ throw new Error(`git init --bare failed: ${init.stderr || "unknown"}`);
175
+ }
176
+ const cfg = await runGit(["-C", repoDir, "config", "http.receivepack", "true"]);
177
+ if (cfg.code !== 0) {
178
+ throw new Error(`git config http.receivepack failed: ${cfg.stderr || "unknown"}`);
179
+ }
180
+ writePostReceiveHook(repoDir, name);
181
+ log.info(`[git-registry] provisioned bare repo for surface "${name}" at ${repoDir}`);
182
+ return repoDir;
183
+ }
184
+
185
+ /**
186
+ * Register (or re-register) a declared surface: validate the name, ensure its
187
+ * bare repo, and upsert the registry entry (preserving the original
188
+ * `registeredAt` on a re-register). Idempotent — surface-host calls this on
189
+ * every discovery pass.
190
+ */
191
+ export async function registerSurface(
192
+ gitRoot: string,
193
+ name: string,
194
+ opts: { mount?: string; mode?: "dev" | "prod"; now?: () => Date; log?: GitRegistryLog } = {},
195
+ ): Promise<SurfaceRegistryEntry> {
196
+ const now = opts.now ?? (() => new Date());
197
+ if (!SURFACE_NAME_RE.test(name)) {
198
+ throw new Error(`invalid surface name "${name}" (must match ${SURFACE_NAME_RE})`);
199
+ }
200
+ await ensureSurfaceRepo(gitRoot, name, opts.log ?? console);
201
+
202
+ const reg = loadRegistry(gitRoot);
203
+ const prior = reg.surfaces[name];
204
+ const nowIso = now().toISOString();
205
+ const entry: SurfaceRegistryEntry = {
206
+ name,
207
+ ...(opts.mount !== undefined ? { mount: opts.mount } : {}),
208
+ ...(opts.mode !== undefined ? { mode: opts.mode } : {}),
209
+ registeredAt: prior?.registeredAt ?? nowIso,
210
+ provisionedAt: prior?.provisionedAt ?? nowIso,
211
+ };
212
+ reg.surfaces[name] = entry;
213
+ saveRegistry(gitRoot, reg);
214
+ return entry;
215
+ }
216
+
217
+ /**
218
+ * Phase-0a placeholder post-receive hook: logs the received refs (to stdout,
219
+ * relayed to the pusher as `remote:` lines, and appended to `post-receive.log`
220
+ * in the repo dir for verification). The real deploy hand-off is the hub's
221
+ * `onPushed` → HTTP + hub-JWT notify to surface-host (git-notify.ts) — this hook
222
+ * NEVER builds the pushed tree (that exec authority belongs to the module's
223
+ * sandbox, not the substrate — §5/§7).
224
+ */
225
+ function writePostReceiveHook(repoDir: string, name: string): void {
226
+ const hook = `#!/bin/sh
227
+ # Parachute Surface Git Transport — post-receive placeholder.
228
+ # Logs received refs only. The deploy hand-off is the hub's onPushed → HTTP +
229
+ # hub-JWT notify to surface-host; the pushed tree is NEVER built in this process
230
+ # (that exec authority belongs to the module's sandbox, not the substrate).
231
+ while read -r oldrev newrev refname; do
232
+ printf '[parachute] surface %s received %s (%s..%s)\\n' "${name}" "$refname" "$oldrev" "$newrev"
233
+ printf '%s %s %s\\n' "$oldrev" "$newrev" "$refname" >> post-receive.log
234
+ done
235
+ `;
236
+ const hookPath = join(repoDir, "hooks", "post-receive");
237
+ writeFileSync(hookPath, hook, { mode: 0o755 });
238
+ }
239
+
240
+ async function runGit(args: string[]): Promise<{ code: number; stderr: string }> {
241
+ const proc = Bun.spawn(["git", ...args], { stdout: "ignore", stderr: "pipe" });
242
+ const [code, stderr] = await Promise.all([
243
+ proc.exited,
244
+ new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
245
+ ]);
246
+ return { code, stderr: stderr.trim() };
247
+ }