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