@openparachute/hub 0.7.5-rc.3 → 0.7.5-rc.5
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 +2 -1
- package/scripts/git-credential-parachute +50 -0
- package/src/__tests__/admin-agent-grants.test.ts +310 -0
- package/src/__tests__/grants-store.test.ts +13 -0
- package/src/__tests__/surface-command.test.ts +492 -0
- package/src/__tests__/surface-token.test.ts +276 -0
- package/src/admin-agent-grants.ts +156 -6
- package/src/cli.ts +6 -0
- package/src/commands/auth.ts +1 -25
- package/src/commands/surface.ts +493 -0
- package/src/git-registry.ts +13 -0
- package/src/grants-store.ts +25 -4
- package/src/help.ts +64 -0
- package/src/hub-issuer.ts +30 -0
- package/src/jwt-sign.ts +9 -1
- package/src/surface-token.ts +244 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.7.5-rc.
|
|
3
|
+
"version": "0.7.5-rc.5",
|
|
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": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"src",
|
|
19
19
|
"web/ui/dist",
|
|
20
|
+
"scripts/git-credential-parachute",
|
|
20
21
|
"README.md",
|
|
21
22
|
"LICENSE"
|
|
22
23
|
],
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# git-credential-parachute — static git credential helper for Parachute surface
|
|
3
|
+
# deploy tokens (Surface Git Transport Phase 3a).
|
|
4
|
+
#
|
|
5
|
+
# Lets a remote/external git client (a `claude -p` agent, or any machine) push
|
|
6
|
+
# and pull a Parachute surface's hub-hosted repo (https://<hub>/git/<name>) with
|
|
7
|
+
# nothing but a static secret — the "add a secret, git just works" path. Same
|
|
8
|
+
# credential shape the internal-agent GIT_ASKPASS uses (Phase 2), generalized to
|
|
9
|
+
# a static token. NO parachute install required — only POSIX sh + git.
|
|
10
|
+
#
|
|
11
|
+
# It reads the deploy token from $PARACHUTE_SURFACE_TOKEN and hands git a Basic
|
|
12
|
+
# credential of `x-access-token:<token>`, which the hub git endpoint accepts
|
|
13
|
+
# (git-transport.ts extractToken). Works on any git version.
|
|
14
|
+
#
|
|
15
|
+
# Setup on the remote machine:
|
|
16
|
+
#
|
|
17
|
+
# 1. Put this file on PATH (any of these works):
|
|
18
|
+
# - copy it to a dir on PATH as `git-credential-parachute`, chmod +x, then
|
|
19
|
+
# git config --global credential.helper parachute
|
|
20
|
+
# - or point git at its absolute path:
|
|
21
|
+
# git config --global credential.helper /abs/path/to/git-credential-parachute
|
|
22
|
+
#
|
|
23
|
+
# 2. Export the token the operator gave you:
|
|
24
|
+
# export PARACHUTE_SURFACE_TOKEN=<the-token>
|
|
25
|
+
#
|
|
26
|
+
# 3. Clone / push:
|
|
27
|
+
# git clone https://<hub-origin>/git/<name>
|
|
28
|
+
# git push
|
|
29
|
+
#
|
|
30
|
+
# Security: the token stays in the environment, never in .git/config or the URL.
|
|
31
|
+
# Scope it to one surface + verb, and revoke a leaked one with
|
|
32
|
+
# `parachute surface token revoke <jti>` on the hub.
|
|
33
|
+
#
|
|
34
|
+
# This is the STATIC-token helper only. A later phase adds an interactive
|
|
35
|
+
# loopback-PKCE / device-flow mode for humans (git-credential-oauth style).
|
|
36
|
+
|
|
37
|
+
# git calls the helper with the operation as the first arg: get | store | erase.
|
|
38
|
+
# We only answer `get`; store/erase are no-ops (nothing is cached to disk).
|
|
39
|
+
[ "$1" = "get" ] || exit 0
|
|
40
|
+
|
|
41
|
+
if [ -z "$PARACHUTE_SURFACE_TOKEN" ]; then
|
|
42
|
+
echo "git-credential-parachute: PARACHUTE_SURFACE_TOKEN is not set" >&2
|
|
43
|
+
# Emit nothing — git falls through to its next helper / prompts.
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# The password carries the token; the username is the GitHub-compat sentinel the
|
|
48
|
+
# hub endpoint recognizes for Basic auth.
|
|
49
|
+
printf 'username=x-access-token\n'
|
|
50
|
+
printf 'password=%s\n' "$PARACHUTE_SURFACE_TOKEN"
|
|
@@ -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",
|