@openparachute/hub 0.7.5-rc.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.5-rc.3",
3
+ "version": "0.7.5-rc.4",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -1658,3 +1658,313 @@ describe("revoke(mcp)", () => {
1658
1658
  expect(readGrants(harness.storePath).find((r) => r.id === id)?.material).toBeUndefined();
1659
1659
  });
1660
1660
  });
1661
+
1662
+ // === surface grants (Surface Git Transport Phase 2, §6a) ===================
1663
+
1664
+ describe("surface grants (Phase 2)", () => {
1665
+ test("PUT: creates a pending surface grant (201) — no material, no self-grant", async () => {
1666
+ const bearer = await moduleBearer();
1667
+ const res = await dispatch(
1668
+ bearerReq("PUT", "/admin/grants", bearer, {
1669
+ agent: "surfacer",
1670
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1671
+ }),
1672
+ );
1673
+ expect(res.status).toBe(201);
1674
+ const body = await json(res);
1675
+ // A note can only REQUEST — it lands pending with NO material until the
1676
+ // operator approves. This IS the "a note can never GRANT" invariant.
1677
+ expect(body.status).toBe("pending");
1678
+ expect(body).not.toHaveProperty("material");
1679
+ expect((body.connection as Record<string, unknown>).kind).toBe("surface");
1680
+ expect((body.connection as Record<string, unknown>).access).toBe("write");
1681
+ });
1682
+
1683
+ test("PUT: a pending surface grant's /material 409s (never grantable pre-approval)", async () => {
1684
+ const bearer = await moduleBearer();
1685
+ const created = await json(
1686
+ await dispatch(
1687
+ bearerReq("PUT", "/admin/grants", bearer, {
1688
+ agent: "surfacer",
1689
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1690
+ }),
1691
+ ),
1692
+ );
1693
+ const res = await dispatch(bearerReq("GET", `/admin/grants/${created.id}/material`, bearer));
1694
+ expect(res.status).toBe(409);
1695
+ });
1696
+
1697
+ test("PUT: rejects an invalid surface name (400)", async () => {
1698
+ const bearer = await moduleBearer();
1699
+ const res = await dispatch(
1700
+ bearerReq("PUT", "/admin/grants", bearer, {
1701
+ agent: "surfacer",
1702
+ connection: { kind: "surface", target: "bad/name", access: "write" },
1703
+ }),
1704
+ );
1705
+ expect(res.status).toBe(400);
1706
+ });
1707
+
1708
+ test("PUT: rejects a bad surface access verb (400)", async () => {
1709
+ const bearer = await moduleBearer();
1710
+ const res = await dispatch(
1711
+ bearerReq("PUT", "/admin/grants", bearer, {
1712
+ agent: "surfacer",
1713
+ connection: { kind: "surface", target: "gitcoin-brain", access: "admin" },
1714
+ }),
1715
+ );
1716
+ expect(res.status).toBe(400);
1717
+ });
1718
+
1719
+ test("approve: MINTS a registered surface:<name>:write token (operator-gated)", async () => {
1720
+ const bearer = await moduleBearer();
1721
+ const cookie = await operatorCookie();
1722
+ const created = await json(
1723
+ await dispatch(
1724
+ bearerReq("PUT", "/admin/grants", bearer, {
1725
+ agent: "surfacer",
1726
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1727
+ }),
1728
+ ),
1729
+ );
1730
+ const id = created.id as string;
1731
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1732
+ expect(res.status).toBe(200);
1733
+ const body = await json(res);
1734
+ expect(body.status).toBe("approved");
1735
+ expect(body).not.toHaveProperty("material");
1736
+ expect(body.approvedAt).toBeDefined();
1737
+
1738
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
1739
+ const mat = stored?.material as { kind: string; token: string; jti: string };
1740
+ expect(mat.kind).toBe("surface");
1741
+ const claims = decodeJwt(mat.token) as Record<string, unknown>;
1742
+ expect(claims.scope).toBe("surface:gitcoin-brain:write");
1743
+ expect(claims.aud).toBe("surface.gitcoin-brain");
1744
+ // registered → revocable
1745
+ expect(findTokenRowByJti(harness.db, mat.jti)).not.toBeNull();
1746
+ });
1747
+
1748
+ test("approve: is operator-only — a module Bearer cannot approve", async () => {
1749
+ const bearer = await moduleBearer();
1750
+ const created = await json(
1751
+ await dispatch(
1752
+ bearerReq("PUT", "/admin/grants", bearer, {
1753
+ agent: "surfacer",
1754
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1755
+ }),
1756
+ ),
1757
+ );
1758
+ const id = created.id as string;
1759
+ // A host-admin Bearer is module-auth, NOT the operator cookie the approve
1760
+ // path requires — so it 401s (a note/module can never self-approve).
1761
+ const res = await dispatch(bearerReq("POST", `/admin/grants/${id}/approve`, bearer));
1762
+ expect(res.status).toBe(401);
1763
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("pending");
1764
+ });
1765
+
1766
+ test("approve: a non-first-admin operator cannot approve (403)", async () => {
1767
+ const bearer = await moduleBearer();
1768
+ const friend = await friendCookie();
1769
+ const created = await json(
1770
+ await dispatch(
1771
+ bearerReq("PUT", "/admin/grants", bearer, {
1772
+ agent: "surfacer",
1773
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1774
+ }),
1775
+ ),
1776
+ );
1777
+ const id = created.id as string;
1778
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, friend));
1779
+ expect(res.status).toBe(403);
1780
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("pending");
1781
+ });
1782
+
1783
+ test("material: an approved surface grant returns { kind:surface, token, remoteUrl }", async () => {
1784
+ const bearer = await moduleBearer();
1785
+ const cookie = await operatorCookie();
1786
+ const created = await json(
1787
+ await dispatch(
1788
+ bearerReq("PUT", "/admin/grants", bearer, {
1789
+ agent: "surfacer",
1790
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1791
+ }),
1792
+ ),
1793
+ );
1794
+ const id = created.id as string;
1795
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1796
+ const res = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1797
+ expect(res.status).toBe(200);
1798
+ const mat = await json(res);
1799
+ expect(mat.kind).toBe("surface");
1800
+ expect(typeof mat.token).toBe("string");
1801
+ // The git remote the agent clones/pushes to — the git-transport endpoint.
1802
+ expect(mat.remoteUrl).toBe(`${HUB_ORIGIN}/git/gitcoin-brain`);
1803
+ const claims = decodeJwt(mat.token as string) as Record<string, unknown>;
1804
+ expect(claims.scope).toBe("surface:gitcoin-brain:write");
1805
+ });
1806
+
1807
+ test("a read-only surface grant mints surface:<name>:read", async () => {
1808
+ const bearer = await moduleBearer();
1809
+ const cookie = await operatorCookie();
1810
+ const created = await json(
1811
+ await dispatch(
1812
+ bearerReq("PUT", "/admin/grants", bearer, {
1813
+ agent: "surfacer",
1814
+ connection: { kind: "surface", target: "gitcoin-brain", access: "read" },
1815
+ }),
1816
+ ),
1817
+ );
1818
+ const id = created.id as string;
1819
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1820
+ const mat = await json(
1821
+ await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer)),
1822
+ );
1823
+ expect(mat.remoteUrl).toBe(`${HUB_ORIGIN}/git/gitcoin-brain`);
1824
+ const claims = decodeJwt(mat.token as string) as Record<string, unknown>;
1825
+ expect(claims.scope).toBe("surface:gitcoin-brain:read");
1826
+ });
1827
+
1828
+ test("a mixed-case surface name is canonicalized to lowercase (scope + remote + idempotency)", async () => {
1829
+ const bearer = await moduleBearer();
1830
+ const cookie = await operatorCookie();
1831
+ const created = await json(
1832
+ await dispatch(
1833
+ bearerReq("PUT", "/admin/grants", bearer, {
1834
+ agent: "surfacer",
1835
+ connection: { kind: "surface", target: "GitCoin-Brain", access: "write" },
1836
+ }),
1837
+ ),
1838
+ );
1839
+ // Stored target is normalized to lowercase, so the minted scope + the /material
1840
+ // remote match a lowercase-registered surface and the agent's echoed-target key
1841
+ // stays consistent (no mixed-case collapse mismatch).
1842
+ expect((created.connection as Record<string, unknown>).target).toBe("gitcoin-brain");
1843
+ // A second PUT with the lowercase twin is the SAME grant (idempotent), not a fork.
1844
+ const twin = await json(
1845
+ await dispatch(
1846
+ bearerReq("PUT", "/admin/grants", bearer, {
1847
+ agent: "surfacer",
1848
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1849
+ }),
1850
+ ),
1851
+ );
1852
+ expect(twin.id).toBe(created.id);
1853
+ expect(readGrants(harness.storePath).filter((r) => r.agent === "surfacer")).toHaveLength(1);
1854
+ // Approve → the minted scope + the /material remote are both lowercase.
1855
+ await dispatch(cookieReq("POST", `/admin/grants/${created.id as string}/approve`, cookie));
1856
+ const mat = await json(
1857
+ await dispatch(bearerReq("GET", `/admin/grants/${created.id as string}/material`, bearer)),
1858
+ );
1859
+ expect(mat.remoteUrl).toBe(`${HUB_ORIGIN}/git/gitcoin-brain`);
1860
+ expect((decodeJwt(mat.token as string) as Record<string, unknown>).scope).toBe(
1861
+ "surface:gitcoin-brain:write",
1862
+ );
1863
+ });
1864
+
1865
+ test("re-approval revokes the prior minted surface token", async () => {
1866
+ const bearer = await moduleBearer();
1867
+ const cookie = await operatorCookie();
1868
+ const created = await json(
1869
+ await dispatch(
1870
+ bearerReq("PUT", "/admin/grants", bearer, {
1871
+ agent: "surfacer",
1872
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1873
+ }),
1874
+ ),
1875
+ );
1876
+ const id = created.id as string;
1877
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1878
+ const firstJti = (
1879
+ readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
1880
+ ).jti;
1881
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1882
+ const secondJti = (
1883
+ readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
1884
+ ).jti;
1885
+ expect(secondJti).not.toBe(firstJti);
1886
+ expect(findTokenRowByJti(harness.db, firstJti)?.revokedAt).toBeTruthy();
1887
+ expect(findTokenRowByJti(harness.db, secondJti)?.revokedAt).toBeFalsy();
1888
+ });
1889
+
1890
+ test("revoke: drops the material AND revokes the minted token in the registry", async () => {
1891
+ const bearer = await moduleBearer();
1892
+ const cookie = await operatorCookie();
1893
+ const created = await json(
1894
+ await dispatch(
1895
+ bearerReq("PUT", "/admin/grants", bearer, {
1896
+ agent: "surfacer",
1897
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1898
+ }),
1899
+ ),
1900
+ );
1901
+ const id = created.id as string;
1902
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1903
+ const jti = (
1904
+ readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
1905
+ ).jti;
1906
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/revoke`, cookie));
1907
+ expect(res.status).toBe(200);
1908
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
1909
+ expect(stored?.status).toBe("revoked");
1910
+ expect(stored?.material).toBeUndefined();
1911
+ expect(findTokenRowByJti(harness.db, jti)?.revokedAt).toBeTruthy();
1912
+ // /material now 409s
1913
+ const mat = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1914
+ expect(mat.status).toBe(409);
1915
+ });
1916
+
1917
+ test("reconcile prunes a surface grant that's no longer wanted", async () => {
1918
+ const bearer = await moduleBearer();
1919
+ const cookie = await operatorCookie();
1920
+ const created = await json(
1921
+ await dispatch(
1922
+ bearerReq("PUT", "/admin/grants", bearer, {
1923
+ agent: "surfacer",
1924
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1925
+ }),
1926
+ ),
1927
+ );
1928
+ const id = created.id as string;
1929
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1930
+ const jti = (
1931
+ readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
1932
+ ).jti;
1933
+ // reconcile with NO live connections → prune (tears down the token too)
1934
+ const res = await dispatch(
1935
+ bearerReq("POST", "/admin/grants/reconcile", bearer, {
1936
+ agent: "surfacer",
1937
+ liveConnections: [],
1938
+ }),
1939
+ );
1940
+ expect(res.status).toBe(200);
1941
+ const body = await json(res);
1942
+ expect(body.pruned).toBe(1);
1943
+ expect(getGrant(harness.storePath, id)).toBeNull();
1944
+ expect(findTokenRowByJti(harness.db, jti)?.revokedAt).toBeTruthy();
1945
+ });
1946
+
1947
+ test("reconcile KEEPS a surface grant that's still declared (spec-derived key match)", async () => {
1948
+ const bearer = await moduleBearer();
1949
+ const created = await json(
1950
+ await dispatch(
1951
+ bearerReq("PUT", "/admin/grants", bearer, {
1952
+ agent: "surfacer",
1953
+ connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
1954
+ }),
1955
+ ),
1956
+ );
1957
+ const id = created.id as string;
1958
+ // The agent sends the SPEC (not a pre-computed key); the hub re-derives the
1959
+ // key with its OWN connectionKey → the still-wanted grant is kept.
1960
+ const res = await dispatch(
1961
+ bearerReq("POST", "/admin/grants/reconcile", bearer, {
1962
+ agent: "surfacer",
1963
+ liveConnections: [{ kind: "surface", target: "gitcoin-brain", access: "write" }],
1964
+ }),
1965
+ );
1966
+ expect(res.status).toBe(200);
1967
+ expect((await json(res)).pruned).toBe(0);
1968
+ expect(getGrant(harness.storePath, id)).not.toBeNull();
1969
+ });
1970
+ });
@@ -202,6 +202,19 @@ describe("connectionKey / grantId derivation (idempotency)", () => {
202
202
  expect(k1).toBe("service:github");
203
203
  });
204
204
 
205
+ test("surface key includes the access verb", () => {
206
+ expect(connectionKey({ kind: "surface", target: "gitcoin-brain", access: "write" })).toBe(
207
+ "surface:gitcoin-brain:write",
208
+ );
209
+ expect(connectionKey({ kind: "surface", target: "gitcoin-brain", access: "read" })).toBe(
210
+ "surface:gitcoin-brain:read",
211
+ );
212
+ // access defaults to read (matches the vault default); target lowercased for the slug
213
+ expect(connectionKey({ kind: "surface", target: "Gitcoin-Brain" })).toBe(
214
+ "surface:gitcoin-brain:read",
215
+ );
216
+ });
217
+
205
218
  test("mcp key is the url target", () => {
206
219
  expect(connectionKey({ kind: "mcp", target: "https://x.test/mcp" })).toBe(
207
220
  "mcp:https://x.test/mcp",
@@ -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
  // ===========================================================================
@@ -75,6 +75,19 @@ export function repoDirFor(gitRoot: string, name: string): string {
75
75
  return join(gitRoot, `${name}.git`);
76
76
  }
77
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
+
78
91
  /**
79
92
  * Read + parse the registry. A missing or corrupt file yields an empty registry
80
93
  * (the transport still fails closed on unregistered names — see
@@ -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