@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
|
@@ -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
|
-
*
|
|
44
|
-
*
|
|
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
|
+
}
|