@openparachute/hub 0.7.1 → 0.7.2
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/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +8 -4
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-server.test.ts +9 -5
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +67 -50
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +12 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +3 -3
- package/src/grants-store.ts +272 -0
- package/src/help.ts +4 -1
- package/src/hub-server.ts +209 -27
- package/src/hub-settings.ts +23 -8
- package/src/jwt-sign.ts +5 -1
- package/src/module-manifest.ts +2 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +7 -2
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/setup-wizard.ts +56 -240
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-connector GRANTS — the approval-gated resource-grant subsystem for
|
|
3
|
+
* vault-native agents (Phase 4b-1, agent-connectors design 2026-06-17).
|
|
4
|
+
*
|
|
5
|
+
* An agent (a `#agent/definition` note in the agent module) declares connections
|
|
6
|
+
* it WANTS beyond its own def-vault — other LOCAL vaults (tag-scoped) and
|
|
7
|
+
* external SERVICE credentials (GitHub, Cloudflare). The agent module registers
|
|
8
|
+
* each as a PENDING grant here; the operator approves per-connection in hub
|
|
9
|
+
* admin; the hub mints (vault) / stores (service) the secret; the agent module
|
|
10
|
+
* fetches the material at spawn and injects it. The `mcp` (remote/OAuth) kind is
|
|
11
|
+
* MODELED but not grantable in 4b-1 — it stays `pending` with a clear reason
|
|
12
|
+
* (slice 2 is hub-as-OAuth-client).
|
|
13
|
+
*
|
|
14
|
+
* The one invariant: **a vault note can only REQUEST; it can never GRANT.** A
|
|
15
|
+
* grant created by the module sits `pending` and grants nothing until the
|
|
16
|
+
* operator approves. Worst case, a note written by anyone sits pending forever.
|
|
17
|
+
*
|
|
18
|
+
* Generalizes the hub's Connections engine from "event→action triggers" to
|
|
19
|
+
* "approval-gated resource grants": same vault-token mint path (`mintVaultGrant`
|
|
20
|
+
* mirrors `admin-connections.ts:mintCredential`), same registered-mint /
|
|
21
|
+
* revoke-on-teardown discipline, same auth gates.
|
|
22
|
+
*
|
|
23
|
+
* ── Endpoints (all under `/admin/grants`) ────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* MODULE-AUTH (a `parachute:host:admin` Bearer — the agent module presents one,
|
|
26
|
+
* minted the same way the SPA mints via `/admin/host-admin-token`):
|
|
27
|
+
*
|
|
28
|
+
* PUT /admin/grants { agent, connection } → upsert (idempotent
|
|
29
|
+
* by (agent, connection-key)); returns the
|
|
30
|
+
* grant (NO material). New mcp grants land
|
|
31
|
+
* pending with reason "oauth not yet
|
|
32
|
+
* supported".
|
|
33
|
+
* GET /admin/grants?agent=<name> → { grants: [...] } — NO material on any row.
|
|
34
|
+
* GET /admin/grants/<id>/material → APPROVED grants only: the injectable
|
|
35
|
+
* secret. vault → { kind, token, mcpUrl };
|
|
36
|
+
* service → { kind, token, inject }.
|
|
37
|
+
* 404 unknown id, 409 not approved.
|
|
38
|
+
*
|
|
39
|
+
* OPERATOR-AUTH (a first-admin session cookie; CSRF-belted by the dispatch in
|
|
40
|
+
* hub-server.ts, exactly like /admin/connections POST/DELETE):
|
|
41
|
+
*
|
|
42
|
+
* POST /admin/grants/<id>/approve { token? } → vault: MINT now + store;
|
|
43
|
+
* service: store the pasted `token`. Returns
|
|
44
|
+
* the updated grant (no material).
|
|
45
|
+
* POST /admin/grants/<id>/revoke → drop the stored material + status=revoked
|
|
46
|
+
* (the agent loses it next spawn).
|
|
47
|
+
*
|
|
48
|
+
* ── Secret discipline ────────────────────────────────────────────────────────
|
|
49
|
+
* The minted/pasted secret lives in the grant store on disk (0600), is NEVER
|
|
50
|
+
* logged, NEVER returned by PUT/GET-list/approve/revoke — ONLY by the
|
|
51
|
+
* approved-only, module-auth-gated `/material` endpoint.
|
|
52
|
+
*/
|
|
53
|
+
import type { Database } from "bun:sqlite";
|
|
54
|
+
import {
|
|
55
|
+
type AdminAuthContext,
|
|
56
|
+
type AdminAuthError,
|
|
57
|
+
adminAuthErrorResponse,
|
|
58
|
+
requireScope,
|
|
59
|
+
} from "./admin-auth.ts";
|
|
60
|
+
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
61
|
+
import {
|
|
62
|
+
type ConnectionSpec,
|
|
63
|
+
type GrantAccess,
|
|
64
|
+
type GrantInject,
|
|
65
|
+
type GrantMaterial,
|
|
66
|
+
type GrantRecord,
|
|
67
|
+
connectionKey,
|
|
68
|
+
getGrant,
|
|
69
|
+
grantId,
|
|
70
|
+
listGrantsForAgent,
|
|
71
|
+
putGrant,
|
|
72
|
+
readGrants,
|
|
73
|
+
removeGrant,
|
|
74
|
+
} from "./grants-store.ts";
|
|
75
|
+
import {
|
|
76
|
+
type signAccessToken as SignAccessTokenFn,
|
|
77
|
+
recordTokenMint,
|
|
78
|
+
revokeTokenByJti,
|
|
79
|
+
signAccessToken,
|
|
80
|
+
} from "./jwt-sign.ts";
|
|
81
|
+
import { type OAuthClient, deriveVaultScopeFromMcpUrl, realOAuthClient } from "./oauth-client.ts";
|
|
82
|
+
import { type PendingFlow, deleteFlow, getFlowByState, putFlow } from "./oauth-flows-store.ts";
|
|
83
|
+
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
84
|
+
import { isFirstAdmin } from "./users.ts";
|
|
85
|
+
import { validateVaultName } from "./vault-name.ts";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* TTL of a minted vault grant token. 90 days — matches the Connections
|
|
89
|
+
* engine's standing-credential posture (a headless agent re-fetches at spawn;
|
|
90
|
+
* a long-lived token spares a re-mint every turn). Registered in the tokens
|
|
91
|
+
* table so revoke can drop it.
|
|
92
|
+
*/
|
|
93
|
+
const VAULT_GRANT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
94
|
+
const GRANT_CLIENT_ID = "parachute-hub-spa";
|
|
95
|
+
|
|
96
|
+
/** Agent-name charset — lands in the grant id + a `?agent=` query. Conservative slug. */
|
|
97
|
+
const AGENT_NAME_RE = /^[a-z0-9][a-z0-9_.-]*$/i;
|
|
98
|
+
/** Grant-id charset — lands in a URL path segment. */
|
|
99
|
+
const GRANT_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
100
|
+
/** Service key charset — `github`, `cloudflare`, … lands in env-var / MCP names. */
|
|
101
|
+
const SERVICE_KEY_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
102
|
+
/** A tag the agent declares for vault tag-scope. Conservative — no whitespace. */
|
|
103
|
+
const TAG_RE = /^\S+$/;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Input length caps (reviewer N3). A host-admin Bearer is already
|
|
107
|
+
* high-privilege, but these bound disk bloat from a runaway/misconfigured
|
|
108
|
+
* module registering grants on the operator's own machine.
|
|
109
|
+
*/
|
|
110
|
+
const MAX_AGENT_LEN = 128;
|
|
111
|
+
const MAX_TARGET_LEN = 512;
|
|
112
|
+
const MAX_TAG_LEN = 128;
|
|
113
|
+
/** Cap the operator-pasted service token. Operator-gated (nil trust exposure), but
|
|
114
|
+
* bounds a fat-finger paste from bloating the on-disk store — matches the other caps. */
|
|
115
|
+
const MAX_TOKEN_LEN = 8192;
|
|
116
|
+
const MAX_TAGS = 64;
|
|
117
|
+
/**
|
|
118
|
+
* Reconcile cap (#96). A host-admin Bearer is high-privilege, but this bounds a
|
|
119
|
+
* runaway/misconfigured module from POSTing a giant liveConnections array. The cap
|
|
120
|
+
* is generous vs. realistic agent connection counts ("a handful per agent"); each
|
|
121
|
+
* entry is further validated + bounded by parseConnectionSpec.
|
|
122
|
+
*/
|
|
123
|
+
const MAX_LIVE_KEYS = 256;
|
|
124
|
+
|
|
125
|
+
export interface AgentGrantsDeps {
|
|
126
|
+
db: Database;
|
|
127
|
+
/**
|
|
128
|
+
* Hub origin — the minted-token `iss`, the base of the vault MCP URL, AND the
|
|
129
|
+
* base of the OAuth-client `redirect_uri`
|
|
130
|
+
* (`<hubOrigin>/oauth/agent-grant/callback`).
|
|
131
|
+
*/
|
|
132
|
+
hubOrigin: string;
|
|
133
|
+
/** Absolute path to `agent-grants.json` in the hub state dir. */
|
|
134
|
+
storePath: string;
|
|
135
|
+
/** Absolute path to `agent-oauth-flows.json` (the in-flight OAuth consents, 4b-2). */
|
|
136
|
+
flowsStorePath: string;
|
|
137
|
+
/**
|
|
138
|
+
* Resolve a vault's loopback origin from services.json, or `null` when no
|
|
139
|
+
* vault by that name is installed. Mirrors `ConnectionsDeps.resolveVaultOrigin`
|
|
140
|
+
* — used here purely as the "does this vault exist?" check at approve time.
|
|
141
|
+
*/
|
|
142
|
+
resolveVaultOrigin: (vaultName: string) => string | null;
|
|
143
|
+
/** Test seam — defaults to the real `signAccessToken`. */
|
|
144
|
+
signToken?: typeof SignAccessTokenFn;
|
|
145
|
+
/** Test seam — the OAuth-client engine (defaults to the real, network-bound one). */
|
|
146
|
+
oauthClient?: OAuthClient;
|
|
147
|
+
/** Test seam for the clock. */
|
|
148
|
+
now?: () => Date;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The OAuth-client engine — injected for tests, the real (network) one by default. */
|
|
152
|
+
function oauth(deps: AgentGrantsDeps): OAuthClient {
|
|
153
|
+
return deps.oauthClient ?? realOAuthClient;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `<hubOrigin>/oauth/agent-grant/callback` — the DCR-registered redirect_uri. */
|
|
157
|
+
function callbackUrl(hubOrigin: string): string {
|
|
158
|
+
return `${hubOrigin.replace(/\/+$/, "")}/oauth/agent-grant/callback`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Refresh skew — refresh an mcp access token this many ms before its expiry. */
|
|
162
|
+
const REFRESH_SKEW_MS = 120 * 1000;
|
|
163
|
+
|
|
164
|
+
// ===========================================================================
|
|
165
|
+
// Router
|
|
166
|
+
// ===========================================================================
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Dispatch `/admin/grants...`. `subPath` is the path AFTER `/admin/grants`
|
|
170
|
+
* (`""` for the collection, `/<id>/material|approve|revoke` for items).
|
|
171
|
+
*
|
|
172
|
+
* The two auth classes split by route, mirroring the Connections engine:
|
|
173
|
+
* - module-auth (host-admin Bearer): PUT collection, GET collection, GET /material.
|
|
174
|
+
* - operator-auth (first-admin cookie): POST /approve, POST /revoke.
|
|
175
|
+
*/
|
|
176
|
+
export async function handleAgentGrants(
|
|
177
|
+
req: Request,
|
|
178
|
+
subPath: string,
|
|
179
|
+
deps: AgentGrantsDeps,
|
|
180
|
+
): Promise<Response> {
|
|
181
|
+
const method = req.method.toUpperCase();
|
|
182
|
+
const segments = subPath.startsWith("/")
|
|
183
|
+
? subPath
|
|
184
|
+
.slice(1)
|
|
185
|
+
.split("/")
|
|
186
|
+
.map((s) => decodeURIComponent(s))
|
|
187
|
+
.filter((s) => s.length > 0)
|
|
188
|
+
: [];
|
|
189
|
+
|
|
190
|
+
// --- Collection: PUT (upsert) / GET (list) — module-auth. ---
|
|
191
|
+
if (segments.length === 0) {
|
|
192
|
+
if (method === "PUT") return upsertGrant(req, deps);
|
|
193
|
+
if (method === "GET") return listGrants(req, deps);
|
|
194
|
+
return jsonError(405, "method_not_allowed", "use PUT or GET on /admin/grants");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- Collection sub-action: POST /reconcile (grant-GC, #96) — module-auth. ---
|
|
198
|
+
// `reconcile` is a reserved single-segment action, NOT a grant id (the
|
|
199
|
+
// GRANT_ID_RE slug never collides — but anchoring it here makes the routing
|
|
200
|
+
// explicit). The agent module POSTs the live connection keys for one holder;
|
|
201
|
+
// the hub prunes every grant for that holder whose key is no longer live.
|
|
202
|
+
if (segments.length === 1 && segments[0] === "reconcile") {
|
|
203
|
+
if (method !== "POST") {
|
|
204
|
+
return jsonError(405, "method_not_allowed", "use POST on /admin/grants/reconcile");
|
|
205
|
+
}
|
|
206
|
+
return reconcileGrants(req, deps);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const id = segments[0] ?? "";
|
|
210
|
+
const verb = segments[1];
|
|
211
|
+
|
|
212
|
+
// --- Item: GET /<id>/material — module-auth. ---
|
|
213
|
+
if (verb === "material") {
|
|
214
|
+
if (method !== "GET") {
|
|
215
|
+
return jsonError(405, "method_not_allowed", "use GET on /admin/grants/<id>/material");
|
|
216
|
+
}
|
|
217
|
+
return grantMaterial(req, id, deps);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- Item: POST /<id>/approve | /revoke — operator-auth. ---
|
|
221
|
+
if (verb === "approve") {
|
|
222
|
+
if (method !== "POST") {
|
|
223
|
+
return jsonError(405, "method_not_allowed", "use POST on /admin/grants/<id>/approve");
|
|
224
|
+
}
|
|
225
|
+
return approveGrant(req, id, deps);
|
|
226
|
+
}
|
|
227
|
+
if (verb === "revoke") {
|
|
228
|
+
if (method !== "POST") {
|
|
229
|
+
return jsonError(405, "method_not_allowed", "use POST on /admin/grants/<id>/revoke");
|
|
230
|
+
}
|
|
231
|
+
return revokeGrant(req, id, deps);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return jsonError(
|
|
235
|
+
404,
|
|
236
|
+
"not_found",
|
|
237
|
+
"use PUT/GET /admin/grants, GET /admin/grants/<id>/material, POST /admin/grants/<id>/approve|revoke",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ===========================================================================
|
|
242
|
+
// Auth
|
|
243
|
+
// ===========================================================================
|
|
244
|
+
|
|
245
|
+
/** Module-auth: a `parachute:host:admin` Bearer (the agent module's host-admin token). */
|
|
246
|
+
async function requireModuleAuth(
|
|
247
|
+
req: Request,
|
|
248
|
+
deps: AgentGrantsDeps,
|
|
249
|
+
): Promise<AdminAuthContext | Response> {
|
|
250
|
+
try {
|
|
251
|
+
return await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.hubOrigin);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Operator-auth: a first-admin session cookie (CSRF belt is applied upstream). */
|
|
258
|
+
function requireOperator(req: Request, deps: AgentGrantsDeps): { userId: string } | Response {
|
|
259
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
260
|
+
const session = sid ? findSession(deps.db, sid) : null;
|
|
261
|
+
if (!session) {
|
|
262
|
+
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
263
|
+
}
|
|
264
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
265
|
+
return jsonError(
|
|
266
|
+
403,
|
|
267
|
+
"not_admin",
|
|
268
|
+
"grant approval is restricted to the hub admin — your account home is at /account/",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return { userId: session.userId };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ===========================================================================
|
|
275
|
+
// PUT /admin/grants — upsert a pending grant (module-auth)
|
|
276
|
+
// ===========================================================================
|
|
277
|
+
|
|
278
|
+
async function upsertGrant(req: Request, deps: AgentGrantsDeps): Promise<Response> {
|
|
279
|
+
const auth = await requireModuleAuth(req, deps);
|
|
280
|
+
if (auth instanceof Response) return auth;
|
|
281
|
+
|
|
282
|
+
let body: unknown;
|
|
283
|
+
try {
|
|
284
|
+
body = await req.json();
|
|
285
|
+
} catch {
|
|
286
|
+
return jsonError(400, "invalid_request", "body must be JSON");
|
|
287
|
+
}
|
|
288
|
+
if (!body || typeof body !== "object") {
|
|
289
|
+
return jsonError(400, "invalid_request", "body must be a JSON object");
|
|
290
|
+
}
|
|
291
|
+
const b = body as Record<string, unknown>;
|
|
292
|
+
|
|
293
|
+
const agent = typeof b.agent === "string" ? b.agent.trim() : "";
|
|
294
|
+
if (!agent || agent.length > MAX_AGENT_LEN || !AGENT_NAME_RE.test(agent)) {
|
|
295
|
+
return jsonError(
|
|
296
|
+
400,
|
|
297
|
+
"invalid_request",
|
|
298
|
+
`agent is required and must match [a-zA-Z0-9][a-zA-Z0-9_.-]* (max ${MAX_AGENT_LEN} chars)`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const parsed = parseConnectionSpec(b.connection);
|
|
303
|
+
if ("error" in parsed) return jsonError(400, "invalid_request", parsed.error);
|
|
304
|
+
const spec = parsed.spec;
|
|
305
|
+
|
|
306
|
+
const id = grantId(agent, spec);
|
|
307
|
+
const now = (deps.now?.() ?? new Date()).toISOString();
|
|
308
|
+
const existing = getGrant(deps.storePath, id);
|
|
309
|
+
|
|
310
|
+
// Idempotent upsert. An already-approved/revoked grant keeps its status +
|
|
311
|
+
// material on re-declare (the module re-registering the same want must NOT
|
|
312
|
+
// downgrade an active grant to pending, nor re-open a revoked one). A new
|
|
313
|
+
// grant lands pending — mcp pending with its slice-2 reason.
|
|
314
|
+
//
|
|
315
|
+
// Recovery from `revoked` (reviewer N2): re-declaring does NOT re-open it —
|
|
316
|
+
// the operator re-grants by approving the revoked row directly (the
|
|
317
|
+
// operator-gated approve path accepts any non-mcp grant regardless of prior
|
|
318
|
+
// status, re-minting fresh material). So a revoked grant is dormant, not
|
|
319
|
+
// dead; an explicit operator approve revives it.
|
|
320
|
+
if (existing) {
|
|
321
|
+
// Re-declare may refresh the agent-side `inject` hints on a service spec
|
|
322
|
+
// without changing the grant's identity (inject is not part of the key).
|
|
323
|
+
const merged: GrantRecord = { ...existing, connection: spec };
|
|
324
|
+
putGrant(deps.storePath, merged);
|
|
325
|
+
return grantResponse(200, merged);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 4b-2: an mcp want is now grantable — it sits pending awaiting the operator's
|
|
329
|
+
// OAuth consent (or a pasted static bearer), not "not yet supported".
|
|
330
|
+
const pendingReason = spec.kind === "mcp" ? "awaiting oauth consent" : undefined;
|
|
331
|
+
const record: GrantRecord = {
|
|
332
|
+
id,
|
|
333
|
+
agent,
|
|
334
|
+
connection: spec,
|
|
335
|
+
status: "pending",
|
|
336
|
+
...(pendingReason ? { reason: pendingReason } : {}),
|
|
337
|
+
createdAt: now,
|
|
338
|
+
};
|
|
339
|
+
putGrant(deps.storePath, record);
|
|
340
|
+
return grantResponse(201, record);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Parse + validate a connection spec from the request body. Returns either the
|
|
345
|
+
* normalized spec or a human-readable error. Normalizes `target`/`access`/`tags`
|
|
346
|
+
* to keep the stored shape + derived key stable.
|
|
347
|
+
*/
|
|
348
|
+
function parseConnectionSpec(raw: unknown): { spec: ConnectionSpec } | { error: string } {
|
|
349
|
+
if (!raw || typeof raw !== "object") {
|
|
350
|
+
return { error: "connection is required and must be an object" };
|
|
351
|
+
}
|
|
352
|
+
const c = raw as Record<string, unknown>;
|
|
353
|
+
const kind = c.kind;
|
|
354
|
+
if (kind !== "vault" && kind !== "service" && kind !== "mcp") {
|
|
355
|
+
return { error: `connection.kind must be "vault", "service", or "mcp"` };
|
|
356
|
+
}
|
|
357
|
+
const target = typeof c.target === "string" ? c.target.trim() : "";
|
|
358
|
+
if (!target) return { error: "connection.target is required" };
|
|
359
|
+
if (target.length > MAX_TARGET_LEN) {
|
|
360
|
+
return { error: `connection.target exceeds the ${MAX_TARGET_LEN}-char limit` };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (kind === "vault") {
|
|
364
|
+
// Full vault-name validation (reviewer N4) — length + charset + reserved
|
|
365
|
+
// names (`admin`, `list`, `new`, `assets`). Rejecting reserved names here
|
|
366
|
+
// stops a phantom pending row that could never be approved.
|
|
367
|
+
const v = validateVaultName(target);
|
|
368
|
+
if (!v.ok) {
|
|
369
|
+
return { error: `connection.target ${v.error}` };
|
|
370
|
+
}
|
|
371
|
+
let access: GrantAccess = "read";
|
|
372
|
+
if (c.access !== undefined) {
|
|
373
|
+
if (c.access !== "read" && c.access !== "write") {
|
|
374
|
+
return { error: `connection.access must be "read" or "write"` };
|
|
375
|
+
}
|
|
376
|
+
access = c.access;
|
|
377
|
+
}
|
|
378
|
+
const tags: string[] = [];
|
|
379
|
+
if (c.tags !== undefined) {
|
|
380
|
+
if (!Array.isArray(c.tags)) {
|
|
381
|
+
return { error: "connection.tags must be an array of tag strings" };
|
|
382
|
+
}
|
|
383
|
+
if (c.tags.length > MAX_TAGS) {
|
|
384
|
+
return { error: `connection.tags exceeds the ${MAX_TAGS}-entry limit` };
|
|
385
|
+
}
|
|
386
|
+
for (const t of c.tags) {
|
|
387
|
+
const trimmed = typeof t === "string" ? t.trim() : "";
|
|
388
|
+
if (trimmed.length === 0 || trimmed.length > MAX_TAG_LEN || !TAG_RE.test(trimmed)) {
|
|
389
|
+
return {
|
|
390
|
+
error: `connection.tags entries must be non-empty whitespace-free strings (max ${MAX_TAG_LEN} chars)`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
tags.push(trimmed);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
spec: { kind: "vault", target: v.name, access, ...(tags.length > 0 ? { tags } : {}) },
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (kind === "service") {
|
|
402
|
+
if (!SERVICE_KEY_RE.test(target)) {
|
|
403
|
+
return { error: `connection.target "${target}" is not a valid service key` };
|
|
404
|
+
}
|
|
405
|
+
const inject: GrantInject[] = [];
|
|
406
|
+
if (c.inject !== undefined) {
|
|
407
|
+
if (!Array.isArray(c.inject)) {
|
|
408
|
+
return { error: `connection.inject must be an array of "env"/"mcp"` };
|
|
409
|
+
}
|
|
410
|
+
for (const i of c.inject) {
|
|
411
|
+
if (i !== "env" && i !== "mcp") {
|
|
412
|
+
return { error: `connection.inject entries must be "env" or "mcp"` };
|
|
413
|
+
}
|
|
414
|
+
if (!inject.includes(i)) inject.push(i);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
spec: { kind: "service", target, ...(inject.length > 0 ? { inject } : {}) },
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// mcp — modeled, not grantable in 4b-1. Accept a URL target; the grant lands
|
|
423
|
+
// pending with the slice-2 reason. Validate it parses as an http(s) URL so a
|
|
424
|
+
// typo doesn't sit pending forever masquerading as an OAuth-blocked grant.
|
|
425
|
+
let url: URL;
|
|
426
|
+
try {
|
|
427
|
+
url = new URL(target);
|
|
428
|
+
} catch {
|
|
429
|
+
return { error: `connection.target "${target}" must be an absolute URL for kind "mcp"` };
|
|
430
|
+
}
|
|
431
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
432
|
+
return { error: `connection.target must be an http(s) URL for kind "mcp"` };
|
|
433
|
+
}
|
|
434
|
+
return { spec: { kind: "mcp", target } };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ===========================================================================
|
|
438
|
+
// GET /admin/grants?agent=<name> — list (module-auth, NO material)
|
|
439
|
+
// ===========================================================================
|
|
440
|
+
|
|
441
|
+
async function listGrants(req: Request, deps: AgentGrantsDeps): Promise<Response> {
|
|
442
|
+
const auth = await requireModuleAuth(req, deps);
|
|
443
|
+
if (auth instanceof Response) return auth;
|
|
444
|
+
|
|
445
|
+
const agent = new URL(req.url).searchParams.get("agent");
|
|
446
|
+
if (agent !== null && !AGENT_NAME_RE.test(agent)) {
|
|
447
|
+
return jsonError(400, "invalid_request", "?agent must match [a-zA-Z0-9][a-zA-Z0-9_.-]*");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Default to all grants when no agent filter is given (the approval UI lists
|
|
451
|
+
// every agent's grants); narrow to one agent for the module's status check.
|
|
452
|
+
const records = agent ? listGrantsForAgent(deps.storePath, agent) : readGrants(deps.storePath);
|
|
453
|
+
|
|
454
|
+
const grants = records.map(toListing);
|
|
455
|
+
return new Response(JSON.stringify({ grants }), {
|
|
456
|
+
status: 200,
|
|
457
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ===========================================================================
|
|
462
|
+
// GET /admin/grants/<id>/material — approved-only secret (module-auth)
|
|
463
|
+
// ===========================================================================
|
|
464
|
+
|
|
465
|
+
async function grantMaterial(req: Request, id: string, deps: AgentGrantsDeps): Promise<Response> {
|
|
466
|
+
const auth = await requireModuleAuth(req, deps);
|
|
467
|
+
if (auth instanceof Response) return auth;
|
|
468
|
+
|
|
469
|
+
if (!GRANT_ID_RE.test(id)) {
|
|
470
|
+
return jsonError(400, "invalid_request", "grant id is not a valid identifier");
|
|
471
|
+
}
|
|
472
|
+
const grant = getGrant(deps.storePath, id);
|
|
473
|
+
if (!grant) {
|
|
474
|
+
return jsonError(404, "not_found", `no grant ${id}`);
|
|
475
|
+
}
|
|
476
|
+
if (grant.status !== "approved" || !grant.material) {
|
|
477
|
+
// For a needs_consent grant, tell the operator how to revive it (re-approve
|
|
478
|
+
// re-runs the OAuth consent) so admin/agent surfaces can render an actionable
|
|
479
|
+
// message rather than a bare "not approved".
|
|
480
|
+
const hint =
|
|
481
|
+
grant.status === "needs_consent"
|
|
482
|
+
? " — re-consent (approve again) to revive this connection"
|
|
483
|
+
: " — material is available only for approved grants";
|
|
484
|
+
return jsonError(
|
|
485
|
+
409,
|
|
486
|
+
"not_approved",
|
|
487
|
+
`grant ${id} is ${grant.status}${grant.reason ? ` (${grant.reason})` : ""}${hint}`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// The injectable secret. vault → token + the vault's MCP URL (so the agent
|
|
492
|
+
// can add it as an MCP server); service → token + the inject hints; mcp →
|
|
493
|
+
// refresh-if-needed then token + the remote MCP URL.
|
|
494
|
+
let payload: Record<string, unknown>;
|
|
495
|
+
if (grant.material.kind === "vault") {
|
|
496
|
+
payload = {
|
|
497
|
+
kind: "vault",
|
|
498
|
+
token: grant.material.token,
|
|
499
|
+
mcpUrl: vaultMcpUrl(deps.hubOrigin, grant.connection.target),
|
|
500
|
+
};
|
|
501
|
+
} else if (grant.material.kind === "service") {
|
|
502
|
+
payload = {
|
|
503
|
+
kind: "service",
|
|
504
|
+
token: grant.material.token,
|
|
505
|
+
inject: grant.connection.kind === "service" ? (grant.connection.inject ?? []) : [],
|
|
506
|
+
};
|
|
507
|
+
} else {
|
|
508
|
+
// mcp — refresh first if it's an OAuth grant near/past expiry. A refresh
|
|
509
|
+
// FAILURE flips the grant to needs_consent (material dropped) and 409s.
|
|
510
|
+
const resolved = await resolveMcpMaterial(grant, deps);
|
|
511
|
+
if (resolved instanceof Response) return resolved;
|
|
512
|
+
payload = {
|
|
513
|
+
kind: "mcp",
|
|
514
|
+
// Field-name seam: store field is `access_token`; wire field is `token`.
|
|
515
|
+
token: resolved.access_token,
|
|
516
|
+
mcpUrl: resolved.mcpUrl,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return new Response(JSON.stringify(payload), {
|
|
521
|
+
status: 200,
|
|
522
|
+
headers: {
|
|
523
|
+
"content-type": "application/json",
|
|
524
|
+
// The body carries a live secret — never cache it.
|
|
525
|
+
"cache-control": "no-store",
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Resolve an approved mcp grant's live access token, refreshing first if it's an
|
|
532
|
+
* OAuth grant that's expired or within the skew window. A static-bearer grant (no
|
|
533
|
+
* refresh_token / no expiresAt) returns its stored token unchanged. A refresh
|
|
534
|
+
* FAILURE flips the grant to `needs_consent` (material dropped) and returns a 409
|
|
535
|
+
* Response — the connection is simply absent next spawn until the operator
|
|
536
|
+
* re-consents.
|
|
537
|
+
*
|
|
538
|
+
* Returns the live mcp material on success, or a `Response` (the 409) on failure.
|
|
539
|
+
*/
|
|
540
|
+
async function resolveMcpMaterial(
|
|
541
|
+
grant: GrantRecord,
|
|
542
|
+
deps: AgentGrantsDeps,
|
|
543
|
+
): Promise<Extract<GrantMaterial, { kind: "mcp" }> | Response> {
|
|
544
|
+
const mat = grant.material;
|
|
545
|
+
if (!mat || mat.kind !== "mcp") {
|
|
546
|
+
return jsonError(409, "not_approved", `grant ${grant.id} has no mcp material`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Static bearer — no refresh token / no expiry → return as-is.
|
|
550
|
+
if (!mat.refresh_token || !mat.expiresAt || !mat.tokenEndpoint || !mat.clientId) {
|
|
551
|
+
return mat;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const now = deps.now?.() ?? new Date();
|
|
555
|
+
const expiresMs = new Date(mat.expiresAt).getTime();
|
|
556
|
+
const needsRefresh = !Number.isFinite(expiresMs) || expiresMs - now.getTime() <= REFRESH_SKEW_MS;
|
|
557
|
+
if (!needsRefresh) return mat;
|
|
558
|
+
|
|
559
|
+
// Refresh.
|
|
560
|
+
try {
|
|
561
|
+
const refreshed = await oauth(deps).refreshToken(
|
|
562
|
+
{
|
|
563
|
+
tokenEndpoint: mat.tokenEndpoint,
|
|
564
|
+
refreshToken: mat.refresh_token,
|
|
565
|
+
clientId: mat.clientId,
|
|
566
|
+
now: deps.now ?? (() => new Date()),
|
|
567
|
+
},
|
|
568
|
+
undefined,
|
|
569
|
+
);
|
|
570
|
+
const updatedMaterial: GrantMaterial = {
|
|
571
|
+
kind: "mcp",
|
|
572
|
+
access_token: refreshed.access_token,
|
|
573
|
+
// Rotated refresh, if returned; else keep the existing one.
|
|
574
|
+
refresh_token: refreshed.refresh_token ?? mat.refresh_token,
|
|
575
|
+
...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}),
|
|
576
|
+
...(mat.issuer ? { issuer: mat.issuer } : {}),
|
|
577
|
+
clientId: mat.clientId,
|
|
578
|
+
tokenEndpoint: mat.tokenEndpoint,
|
|
579
|
+
...(mat.revocationEndpoint ? { revocationEndpoint: mat.revocationEndpoint } : {}),
|
|
580
|
+
mcpUrl: mat.mcpUrl,
|
|
581
|
+
};
|
|
582
|
+
const updated: GrantRecord = {
|
|
583
|
+
id: grant.id,
|
|
584
|
+
agent: grant.agent,
|
|
585
|
+
connection: grant.connection,
|
|
586
|
+
status: "approved",
|
|
587
|
+
createdAt: grant.createdAt,
|
|
588
|
+
...(grant.approvedAt ? { approvedAt: grant.approvedAt } : {}),
|
|
589
|
+
material: updatedMaterial,
|
|
590
|
+
};
|
|
591
|
+
putGrant(deps.storePath, updated);
|
|
592
|
+
return updatedMaterial;
|
|
593
|
+
} catch (err) {
|
|
594
|
+
// Refresh died (refresh token revoked/expired). Flip to needs_consent + drop
|
|
595
|
+
// material — the operator re-consents to revive. NEVER log the token/error
|
|
596
|
+
// detail that could carry one; the reason is the error message only.
|
|
597
|
+
const reason = `refresh failed: ${err instanceof Error ? err.message : "unknown error"}`;
|
|
598
|
+
const downgraded: GrantRecord = {
|
|
599
|
+
id: grant.id,
|
|
600
|
+
agent: grant.agent,
|
|
601
|
+
connection: grant.connection,
|
|
602
|
+
status: "needs_consent",
|
|
603
|
+
reason,
|
|
604
|
+
createdAt: grant.createdAt,
|
|
605
|
+
...(grant.approvedAt ? { approvedAt: grant.approvedAt } : {}),
|
|
606
|
+
// material dropped.
|
|
607
|
+
};
|
|
608
|
+
putGrant(deps.storePath, downgraded);
|
|
609
|
+
console.log(`agent grant needs re-consent: id=${grant.id} agent=${grant.agent} kind=mcp`);
|
|
610
|
+
return jsonError(409, "not_approved", `grant ${grant.id} ${reason} — re-consent required`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** `<hub-origin>/vault/<name>/mcp` — the MCP endpoint a client connects to. */
|
|
615
|
+
function vaultMcpUrl(hubOrigin: string, vaultName: string): string {
|
|
616
|
+
return `${hubOrigin.replace(/\/+$/, "")}/vault/${vaultName}/mcp`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ===========================================================================
|
|
620
|
+
// POST /admin/grants/<id>/approve — operator approves (operator-auth)
|
|
621
|
+
// ===========================================================================
|
|
622
|
+
|
|
623
|
+
async function approveGrant(req: Request, id: string, deps: AgentGrantsDeps): Promise<Response> {
|
|
624
|
+
const op = requireOperator(req, deps);
|
|
625
|
+
if (op instanceof Response) return op;
|
|
626
|
+
|
|
627
|
+
if (!GRANT_ID_RE.test(id)) {
|
|
628
|
+
return jsonError(400, "invalid_request", "grant id is not a valid identifier");
|
|
629
|
+
}
|
|
630
|
+
const grant = getGrant(deps.storePath, id);
|
|
631
|
+
if (!grant) return jsonError(404, "not_found", `no grant ${id}`);
|
|
632
|
+
|
|
633
|
+
let body: { token?: unknown } = {};
|
|
634
|
+
try {
|
|
635
|
+
const raw = await req.text();
|
|
636
|
+
if (raw.trim().length > 0) body = JSON.parse(raw) as { token?: unknown };
|
|
637
|
+
} catch {
|
|
638
|
+
return jsonError(400, "invalid_request", "body must be JSON when present");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const conn = grant.connection;
|
|
642
|
+
const now = deps.now?.() ?? new Date();
|
|
643
|
+
const approvedAt = now.toISOString();
|
|
644
|
+
|
|
645
|
+
if (conn.kind === "mcp") {
|
|
646
|
+
return approveMcpGrant(grant, body, deps, approvedAt);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (conn.kind === "vault") {
|
|
650
|
+
// Re-approval of an already-approved vault grant: revoke the prior minted
|
|
651
|
+
// token first so exactly one live token exists per grant.
|
|
652
|
+
if (grant.material?.kind === "vault") {
|
|
653
|
+
try {
|
|
654
|
+
revokeTokenByJti(deps.db, grant.material.jti, now);
|
|
655
|
+
} catch {
|
|
656
|
+
// Best-effort — a missing registry row leaves nothing to revoke.
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Approve-time existence check: the vault must still be installed.
|
|
660
|
+
if (deps.resolveVaultOrigin(conn.target) === null) {
|
|
661
|
+
return jsonError(400, "unknown_vault", `no vault named "${conn.target}" in this hub`);
|
|
662
|
+
}
|
|
663
|
+
const access = conn.access ?? "read";
|
|
664
|
+
const scope = `vault:${conn.target}:${access}`;
|
|
665
|
+
let minted: { token: string; jti: string; expiresAt: string };
|
|
666
|
+
try {
|
|
667
|
+
minted = await mintVaultGrant(deps, op.userId, conn.target, scope, conn.tags ?? []);
|
|
668
|
+
} catch (err) {
|
|
669
|
+
return jsonError(
|
|
670
|
+
500,
|
|
671
|
+
"mint_failed",
|
|
672
|
+
`failed to mint vault grant: ${err instanceof Error ? err.message : String(err)}`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
// Rebuild from the identity fields rather than spreading `grant` — that
|
|
676
|
+
// drops any pending `reason` cleanly (no mutate-after-spread).
|
|
677
|
+
const updated: GrantRecord = {
|
|
678
|
+
id: grant.id,
|
|
679
|
+
agent: grant.agent,
|
|
680
|
+
connection: grant.connection,
|
|
681
|
+
status: "approved",
|
|
682
|
+
createdAt: grant.createdAt,
|
|
683
|
+
approvedAt,
|
|
684
|
+
material: {
|
|
685
|
+
kind: "vault",
|
|
686
|
+
token: minted.token,
|
|
687
|
+
jti: minted.jti,
|
|
688
|
+
expiresAt: minted.expiresAt,
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
putGrant(deps.storePath, updated);
|
|
692
|
+
console.log(`agent grant approved: id=${id} agent=${grant.agent} kind=vault scope=${scope}`);
|
|
693
|
+
return grantResponse(200, updated);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// service — store the operator-pasted API token.
|
|
697
|
+
// Trim — a pasted " tok " must not inject whitespace into the eventual
|
|
698
|
+
// `Authorization: Bearer` header (drive-by correctness fix; the mcp
|
|
699
|
+
// static-bearer path has the same trim).
|
|
700
|
+
const token = typeof body.token === "string" ? body.token.trim() : "";
|
|
701
|
+
if (token.length === 0) {
|
|
702
|
+
return jsonError(
|
|
703
|
+
400,
|
|
704
|
+
"token_required",
|
|
705
|
+
"approving a service grant requires a non-empty `token` (the API credential to store)",
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
if (token.length > MAX_TOKEN_LEN) {
|
|
709
|
+
return jsonError(400, "invalid_request", `token exceeds the ${MAX_TOKEN_LEN}-char limit`);
|
|
710
|
+
}
|
|
711
|
+
// Rebuild from identity fields (drops any pending `reason`).
|
|
712
|
+
const updated: GrantRecord = {
|
|
713
|
+
id: grant.id,
|
|
714
|
+
agent: grant.agent,
|
|
715
|
+
connection: grant.connection,
|
|
716
|
+
status: "approved",
|
|
717
|
+
createdAt: grant.createdAt,
|
|
718
|
+
approvedAt,
|
|
719
|
+
material: { kind: "service", token },
|
|
720
|
+
};
|
|
721
|
+
putGrant(deps.storePath, updated);
|
|
722
|
+
// NEVER log the token. Identity fields only.
|
|
723
|
+
console.log(
|
|
724
|
+
`agent grant approved: id=${id} agent=${grant.agent} kind=service target=${conn.target}`,
|
|
725
|
+
);
|
|
726
|
+
return grantResponse(200, updated);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ===========================================================================
|
|
730
|
+
// approve(mcp) — static bearer OR start the OAuth consent flow (4b-2)
|
|
731
|
+
// ===========================================================================
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Approve a `kind:mcp` grant. Two paths:
|
|
735
|
+
*
|
|
736
|
+
* - body `{ token }` (a pasted static bearer) → store
|
|
737
|
+
* `material:{kind:"mcp", access_token, mcpUrl}` (no refresh) + status
|
|
738
|
+
* `approved` immediately. No discovery. Weaker lifecycle (no expiry, no
|
|
739
|
+
* refresh, no issuer-side revoke).
|
|
740
|
+
* - body `{}` (no token) → START OAuth: discover → DCR (reuse a stored clientId
|
|
741
|
+
* for the SAME issuer, else register) → mint PKCE + state → persist a pending
|
|
742
|
+
* flow → return a `GrantListing` with `authorizeUrl` (status stays pending,
|
|
743
|
+
* reason "awaiting oauth consent"). The operator's browser follows the URL;
|
|
744
|
+
* the callback completes the flow.
|
|
745
|
+
*
|
|
746
|
+
* A re-approve of an already-approved mcp grant starts a FRESH flow; the callback
|
|
747
|
+
* replaces the material + best-effort revokes the old refresh token.
|
|
748
|
+
*/
|
|
749
|
+
async function approveMcpGrant(
|
|
750
|
+
grant: GrantRecord,
|
|
751
|
+
body: { token?: unknown },
|
|
752
|
+
deps: AgentGrantsDeps,
|
|
753
|
+
approvedAt: string,
|
|
754
|
+
): Promise<Response> {
|
|
755
|
+
const mcpUrl = grant.connection.target;
|
|
756
|
+
|
|
757
|
+
// --- Static-bearer path: a pasted token short-circuits discovery. ---
|
|
758
|
+
if (typeof body.token === "string" && body.token.trim().length > 0) {
|
|
759
|
+
// Trim — a pasted " tok " must not inject whitespace into the eventual
|
|
760
|
+
// `Authorization: Bearer` header.
|
|
761
|
+
const token = body.token.trim();
|
|
762
|
+
if (token.length > MAX_TOKEN_LEN) {
|
|
763
|
+
return jsonError(400, "invalid_request", `token exceeds the ${MAX_TOKEN_LEN}-char limit`);
|
|
764
|
+
}
|
|
765
|
+
const updated: GrantRecord = {
|
|
766
|
+
id: grant.id,
|
|
767
|
+
agent: grant.agent,
|
|
768
|
+
connection: grant.connection,
|
|
769
|
+
status: "approved",
|
|
770
|
+
createdAt: grant.createdAt,
|
|
771
|
+
approvedAt,
|
|
772
|
+
material: { kind: "mcp", access_token: token, mcpUrl },
|
|
773
|
+
};
|
|
774
|
+
putGrant(deps.storePath, updated);
|
|
775
|
+
// NEVER log the token.
|
|
776
|
+
console.log(
|
|
777
|
+
`agent grant approved: id=${grant.id} agent=${grant.agent} kind=mcp mode=static-bearer`,
|
|
778
|
+
);
|
|
779
|
+
return grantResponse(200, updated);
|
|
780
|
+
}
|
|
781
|
+
// A non-empty-but-non-string token, or an over-long one, was handled above; a
|
|
782
|
+
// present-but-empty token falls through to the OAuth path (treated as "no token").
|
|
783
|
+
if (body.token !== undefined && typeof body.token !== "string") {
|
|
784
|
+
return jsonError(400, "invalid_request", "token must be a string when present");
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// --- OAuth path: discover → DCR → PKCE/state → persist flow → authorizeUrl. ---
|
|
788
|
+
const client = oauth(deps);
|
|
789
|
+
const redirectUri = callbackUrl(deps.hubOrigin);
|
|
790
|
+
|
|
791
|
+
let discovery: Awaited<ReturnType<OAuthClient["discover"]>>;
|
|
792
|
+
try {
|
|
793
|
+
discovery = await client.discover(mcpUrl);
|
|
794
|
+
} catch (err) {
|
|
795
|
+
return jsonError(
|
|
796
|
+
502,
|
|
797
|
+
"discovery_failed",
|
|
798
|
+
`could not discover OAuth metadata for ${mcpUrl}: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// DCR client reuse: if the grant already has mcp material with a clientId for
|
|
803
|
+
// the SAME issuer (a re-consent), reuse it; else register a fresh client.
|
|
804
|
+
// ACCEPTED (per design): if the issuer rotates its registration (so the stored
|
|
805
|
+
// clientId is no longer valid), a fresh register orphans the old DCR client at
|
|
806
|
+
// the issuer. There is no cross-issuer client GC — orphans accrue only on issuer
|
|
807
|
+
// rotation, which is rare; noted, not built.
|
|
808
|
+
let clientId: string | undefined;
|
|
809
|
+
if (
|
|
810
|
+
grant.material?.kind === "mcp" &&
|
|
811
|
+
grant.material.clientId &&
|
|
812
|
+
grant.material.issuer === discovery.issuer
|
|
813
|
+
) {
|
|
814
|
+
clientId = grant.material.clientId;
|
|
815
|
+
}
|
|
816
|
+
if (!clientId) {
|
|
817
|
+
if (!discovery.registrationEndpoint) {
|
|
818
|
+
return jsonError(
|
|
819
|
+
502,
|
|
820
|
+
"registration_unsupported",
|
|
821
|
+
`the issuer ${discovery.issuer} does not advertise a registration_endpoint (RFC 7591 DCR) — paste a static bearer token instead`,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
const reg = await client.registerClient(discovery.registrationEndpoint, redirectUri);
|
|
826
|
+
clientId = reg.clientId;
|
|
827
|
+
} catch (err) {
|
|
828
|
+
return jsonError(
|
|
829
|
+
502,
|
|
830
|
+
"registration_failed",
|
|
831
|
+
`dynamic client registration failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const verifier = client.generateCodeVerifier();
|
|
837
|
+
const challenge = client.generateCodeChallenge(verifier);
|
|
838
|
+
const state = client.generateState();
|
|
839
|
+
// Scope (#671). When the target is a Parachute vault MCP (`…/vault/<name>/mcp`),
|
|
840
|
+
// request a single least-privilege `vault:<name>:read` scope rather than the
|
|
841
|
+
// resource's full advertised set (which for a vault includes hub:admin,
|
|
842
|
+
// vault:<name>:write, … — wildly over-privileged for a read-only agent).
|
|
843
|
+
// Write is a deliberate future knob, not the default. For any non-vault MCP
|
|
844
|
+
// URL, fall back to the resource's advertised scopes (9728→8414), space-joined;
|
|
845
|
+
// omit if none.
|
|
846
|
+
const vaultScope = deriveVaultScopeFromMcpUrl(mcpUrl);
|
|
847
|
+
const scope = vaultScope
|
|
848
|
+
? vaultScope
|
|
849
|
+
: discovery.scopesSupported?.length
|
|
850
|
+
? discovery.scopesSupported.join(" ")
|
|
851
|
+
: undefined;
|
|
852
|
+
|
|
853
|
+
const flow: PendingFlow = {
|
|
854
|
+
state,
|
|
855
|
+
grantId: grant.id,
|
|
856
|
+
issuer: discovery.issuer,
|
|
857
|
+
clientId,
|
|
858
|
+
tokenEndpoint: discovery.tokenEndpoint,
|
|
859
|
+
...(discovery.revocationEndpoint ? { revocationEndpoint: discovery.revocationEndpoint } : {}),
|
|
860
|
+
verifier,
|
|
861
|
+
mcpUrl,
|
|
862
|
+
...(scope ? { scope } : {}),
|
|
863
|
+
redirectUri,
|
|
864
|
+
createdAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
865
|
+
};
|
|
866
|
+
putFlow(deps.flowsStorePath, flow, (deps.now?.() ?? new Date()).getTime());
|
|
867
|
+
|
|
868
|
+
const authorizeUrl = client.buildAuthorizeUrl({
|
|
869
|
+
authorizationEndpoint: discovery.authorizationEndpoint,
|
|
870
|
+
clientId,
|
|
871
|
+
redirectUri,
|
|
872
|
+
...(scope ? { scope } : {}),
|
|
873
|
+
state,
|
|
874
|
+
codeChallenge: challenge,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// Keep the grant pending with the slice-2 reason; surface authorizeUrl so the
|
|
878
|
+
// admin UI can redirect the browser. NEVER log the verifier/state.
|
|
879
|
+
console.log(
|
|
880
|
+
`agent grant oauth flow started: id=${grant.id} agent=${grant.agent} issuer=${discovery.issuer}`,
|
|
881
|
+
);
|
|
882
|
+
const listing: GrantListing & { authorizeUrl: string } = {
|
|
883
|
+
id: grant.id,
|
|
884
|
+
agent: grant.agent,
|
|
885
|
+
connection: grant.connection,
|
|
886
|
+
status: "pending",
|
|
887
|
+
reason: "awaiting oauth consent",
|
|
888
|
+
authorizeUrl,
|
|
889
|
+
};
|
|
890
|
+
return new Response(JSON.stringify(listing), {
|
|
891
|
+
status: 200,
|
|
892
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ===========================================================================
|
|
897
|
+
// GET /oauth/agent-grant/callback — the operator's browser redirect target (4b-2)
|
|
898
|
+
// ===========================================================================
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* The OAuth-client callback. The remote issuer redirects the operator's browser
|
|
902
|
+
* here with `?code&state` (success) or `?error&state` (RFC 6749 §4.1.2.1 — e.g.
|
|
903
|
+
* the operator clicked Deny). GET, no Bearer; the single-use `state` is the CSRF
|
|
904
|
+
* defense (this is a cross-site redirect IN — same-origin is NOT required).
|
|
905
|
+
*
|
|
906
|
+
* On success: look up the flow by `state` (delete-on-use), exchange the code,
|
|
907
|
+
* store the mcp material, flip the grant `approved`. If the grant previously had
|
|
908
|
+
* mcp material with a refresh_token + revocationEndpoint, best-effort revoke the
|
|
909
|
+
* OLD one first (one live credential per grant).
|
|
910
|
+
*
|
|
911
|
+
* Renders minimal HTML — NEVER a token. On any error, an HTML error page; the
|
|
912
|
+
* grant stays pending/needs_consent.
|
|
913
|
+
*/
|
|
914
|
+
export async function handleOAuthGrantCallback(
|
|
915
|
+
req: Request,
|
|
916
|
+
deps: AgentGrantsDeps,
|
|
917
|
+
): Promise<Response> {
|
|
918
|
+
const url = new URL(req.url);
|
|
919
|
+
const state = url.searchParams.get("state") ?? "";
|
|
920
|
+
const code = url.searchParams.get("code");
|
|
921
|
+
const errorParam = url.searchParams.get("error");
|
|
922
|
+
|
|
923
|
+
if (!state) {
|
|
924
|
+
return htmlPage(400, "Connection failed", "The authorization response was missing its state.");
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Single-use: look up, then delete the flow (delete-on-use). The get→delete
|
|
928
|
+
// pair is NOT atomic, but the race is benign: the auth `code` is single-use at
|
|
929
|
+
// the ISSUER (RFC 6749 §10.5), so a concurrent second callback with the same
|
|
930
|
+
// `state` exchanges the same code and the issuer rejects the second exchange.
|
|
931
|
+
// The design's concurrency posture is first-wins; the loser sees a token-error
|
|
932
|
+
// page, the grant ends approved exactly once.
|
|
933
|
+
const flow = getFlowByState(deps.flowsStorePath, state, (deps.now?.() ?? new Date()).getTime());
|
|
934
|
+
if (!flow) {
|
|
935
|
+
// Unknown / replayed / expired state — never mint anything.
|
|
936
|
+
return htmlPage(
|
|
937
|
+
400,
|
|
938
|
+
"Connection failed",
|
|
939
|
+
"This authorization link is unknown, already used, or expired. Start the connection again from the admin page.",
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
deleteFlow(deps.flowsStorePath, state);
|
|
943
|
+
|
|
944
|
+
// The operator clicked Deny (or the issuer returned an error). Leave the grant
|
|
945
|
+
// pending, but record WHY so admin can distinguish "not yet tried" from
|
|
946
|
+
// "tried + denied". (htmlPage single-escapes its args — pass the RAW string.)
|
|
947
|
+
if (errorParam) {
|
|
948
|
+
const denied = getGrant(deps.storePath, flow.grantId);
|
|
949
|
+
if (denied && denied.status === "pending") {
|
|
950
|
+
putGrant(deps.storePath, {
|
|
951
|
+
id: denied.id,
|
|
952
|
+
agent: denied.agent,
|
|
953
|
+
connection: denied.connection,
|
|
954
|
+
status: "pending",
|
|
955
|
+
reason: "operator declined",
|
|
956
|
+
createdAt: denied.createdAt,
|
|
957
|
+
...(denied.approvedAt ? { approvedAt: denied.approvedAt } : {}),
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
return htmlPage(
|
|
961
|
+
400,
|
|
962
|
+
"Connection not authorized",
|
|
963
|
+
`The remote service did not grant access (${errorParam}). The connection stays pending — you can try again from the admin page.`,
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (!code) {
|
|
968
|
+
return htmlPage(
|
|
969
|
+
400,
|
|
970
|
+
"Connection failed",
|
|
971
|
+
"The authorization response was missing its code. Start the connection again from the admin page.",
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const grant = getGrant(deps.storePath, flow.grantId);
|
|
976
|
+
if (!grant) {
|
|
977
|
+
// The grant was removed mid-consent — nothing to populate.
|
|
978
|
+
return htmlPage(
|
|
979
|
+
404,
|
|
980
|
+
"Connection failed",
|
|
981
|
+
"The grant this authorization belonged to no longer exists.",
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const client = oauth(deps);
|
|
986
|
+
let tokens: Awaited<ReturnType<OAuthClient["exchangeCode"]>>;
|
|
987
|
+
try {
|
|
988
|
+
tokens = await client.exchangeCode({
|
|
989
|
+
tokenEndpoint: flow.tokenEndpoint,
|
|
990
|
+
code,
|
|
991
|
+
redirectUri: flow.redirectUri,
|
|
992
|
+
codeVerifier: flow.verifier,
|
|
993
|
+
clientId: flow.clientId,
|
|
994
|
+
now: deps.now ?? (() => new Date()),
|
|
995
|
+
});
|
|
996
|
+
} catch (err) {
|
|
997
|
+
// Token exchange failed — grant stays pending (the operator can retry).
|
|
998
|
+
// htmlPage single-escapes its args — pass the RAW message.
|
|
999
|
+
return htmlPage(
|
|
1000
|
+
502,
|
|
1001
|
+
"Connection failed",
|
|
1002
|
+
`The token exchange with the remote service failed: ${err instanceof Error ? err.message : "unknown error"}. The connection stays pending — try again from the admin page.`,
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Best-effort revoke a prior refresh token (re-consent replaces material).
|
|
1007
|
+
if (
|
|
1008
|
+
grant.material?.kind === "mcp" &&
|
|
1009
|
+
grant.material.refresh_token &&
|
|
1010
|
+
grant.material.revocationEndpoint
|
|
1011
|
+
) {
|
|
1012
|
+
await client.revokeRemote({
|
|
1013
|
+
revocationEndpoint: grant.material.revocationEndpoint,
|
|
1014
|
+
refreshToken: grant.material.refresh_token,
|
|
1015
|
+
clientId: flow.clientId,
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const material: GrantMaterial = {
|
|
1020
|
+
kind: "mcp",
|
|
1021
|
+
access_token: tokens.access_token,
|
|
1022
|
+
...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}),
|
|
1023
|
+
...(tokens.expiresAt ? { expiresAt: tokens.expiresAt } : {}),
|
|
1024
|
+
issuer: flow.issuer,
|
|
1025
|
+
clientId: flow.clientId,
|
|
1026
|
+
tokenEndpoint: flow.tokenEndpoint,
|
|
1027
|
+
...(flow.revocationEndpoint ? { revocationEndpoint: flow.revocationEndpoint } : {}),
|
|
1028
|
+
mcpUrl: flow.mcpUrl,
|
|
1029
|
+
};
|
|
1030
|
+
const updated: GrantRecord = {
|
|
1031
|
+
id: grant.id,
|
|
1032
|
+
agent: grant.agent,
|
|
1033
|
+
connection: grant.connection,
|
|
1034
|
+
status: "approved",
|
|
1035
|
+
createdAt: grant.createdAt,
|
|
1036
|
+
approvedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
1037
|
+
material,
|
|
1038
|
+
};
|
|
1039
|
+
putGrant(deps.storePath, updated);
|
|
1040
|
+
// NEVER log a token.
|
|
1041
|
+
console.log(`agent grant approved: id=${grant.id} agent=${grant.agent} kind=mcp mode=oauth`);
|
|
1042
|
+
|
|
1043
|
+
return htmlPage(
|
|
1044
|
+
200,
|
|
1045
|
+
"Connected",
|
|
1046
|
+
"The connection is authorized. You can close this tab — the agent will use it on its next run.",
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Minimal server-rendered HTML — NEVER carries a token. */
|
|
1051
|
+
function htmlPage(status: number, heading: string, message: string): Response {
|
|
1052
|
+
const html = `<!doctype html>
|
|
1053
|
+
<html lang="en">
|
|
1054
|
+
<head>
|
|
1055
|
+
<meta charset="utf-8">
|
|
1056
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1057
|
+
<title>${escapeHtml(heading)} — Parachute</title>
|
|
1058
|
+
<style>
|
|
1059
|
+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 32rem; margin: 4rem auto; padding: 0 1.5rem; color: #1a1a1a; }
|
|
1060
|
+
h1 { font-size: 1.5rem; }
|
|
1061
|
+
p { line-height: 1.6; color: #444; }
|
|
1062
|
+
</style>
|
|
1063
|
+
</head>
|
|
1064
|
+
<body>
|
|
1065
|
+
<h1>${escapeHtml(heading)}</h1>
|
|
1066
|
+
<p>${escapeHtml(message)}</p>
|
|
1067
|
+
</body>
|
|
1068
|
+
</html>
|
|
1069
|
+
`;
|
|
1070
|
+
return new Response(html, {
|
|
1071
|
+
status,
|
|
1072
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function escapeHtml(s: string): string {
|
|
1077
|
+
return s
|
|
1078
|
+
.replace(/&/g, "&")
|
|
1079
|
+
.replace(/</g, "<")
|
|
1080
|
+
.replace(/>/g, ">")
|
|
1081
|
+
.replace(/"/g, """)
|
|
1082
|
+
.replace(/'/g, "'");
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ===========================================================================
|
|
1086
|
+
// POST /admin/grants/<id>/revoke — operator revokes (operator-auth)
|
|
1087
|
+
// ===========================================================================
|
|
1088
|
+
|
|
1089
|
+
async function revokeGrant(req: Request, id: string, deps: AgentGrantsDeps): Promise<Response> {
|
|
1090
|
+
const op = requireOperator(req, deps);
|
|
1091
|
+
if (op instanceof Response) return op;
|
|
1092
|
+
|
|
1093
|
+
if (!GRANT_ID_RE.test(id)) {
|
|
1094
|
+
return jsonError(400, "invalid_request", "grant id is not a valid identifier");
|
|
1095
|
+
}
|
|
1096
|
+
const grant = getGrant(deps.storePath, id);
|
|
1097
|
+
if (!grant) return jsonError(404, "not_found", `no grant ${id}`);
|
|
1098
|
+
|
|
1099
|
+
const now = deps.now?.() ?? new Date();
|
|
1100
|
+
await tearDownGrantMaterial(grant, deps, now);
|
|
1101
|
+
|
|
1102
|
+
const updated: GrantRecord = {
|
|
1103
|
+
id: grant.id,
|
|
1104
|
+
agent: grant.agent,
|
|
1105
|
+
connection: grant.connection,
|
|
1106
|
+
status: "revoked",
|
|
1107
|
+
createdAt: grant.createdAt,
|
|
1108
|
+
...(grant.approvedAt ? { approvedAt: grant.approvedAt } : {}),
|
|
1109
|
+
// material + reason intentionally dropped — the secret leaves the store.
|
|
1110
|
+
};
|
|
1111
|
+
putGrant(deps.storePath, updated);
|
|
1112
|
+
console.log(`agent grant revoked: id=${id} agent=${grant.agent} kind=${grant.connection.kind}`);
|
|
1113
|
+
return grantResponse(200, updated);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Tear down a grant's credential material — shared by `revoke` (operator intent,
|
|
1118
|
+
* keeps the row at status:revoked) and `reconcile` (the holder is gone, the row is
|
|
1119
|
+
* then removed entirely). Idempotent + best-effort: a missing registry row or a
|
|
1120
|
+
* failed remote revoke must not throw.
|
|
1121
|
+
*
|
|
1122
|
+
* - vault → revoke the minted token in the registry so it's dead immediately,
|
|
1123
|
+
* not just absent from the next fetch.
|
|
1124
|
+
* - mcp → best-effort revoke the refresh token at the issuer so the remote
|
|
1125
|
+
* credential dies, not just our local copy. A static bearer (no
|
|
1126
|
+
* refresh/revocation endpoint) is a no-op (operator rotates upstream).
|
|
1127
|
+
* - service → operator-owned external cred; nothing to revoke remotely — the
|
|
1128
|
+
* caller drops our copy (the operator rotates upstream if needed).
|
|
1129
|
+
*/
|
|
1130
|
+
async function tearDownGrantMaterial(
|
|
1131
|
+
grant: GrantRecord,
|
|
1132
|
+
deps: AgentGrantsDeps,
|
|
1133
|
+
now: Date,
|
|
1134
|
+
): Promise<void> {
|
|
1135
|
+
if (grant.material?.kind === "vault") {
|
|
1136
|
+
try {
|
|
1137
|
+
revokeTokenByJti(deps.db, grant.material.jti, now);
|
|
1138
|
+
} catch {
|
|
1139
|
+
// Best-effort — a missing registry row leaves nothing to revoke.
|
|
1140
|
+
}
|
|
1141
|
+
} else if (
|
|
1142
|
+
grant.material?.kind === "mcp" &&
|
|
1143
|
+
grant.material.refresh_token &&
|
|
1144
|
+
grant.material.revocationEndpoint &&
|
|
1145
|
+
grant.material.clientId
|
|
1146
|
+
) {
|
|
1147
|
+
// Best-effort + locally guarded (reviewer NIT2): `revokeRemote` swallows its
|
|
1148
|
+
// own errors today, but don't rely on that — a throwing impl/test-double must
|
|
1149
|
+
// not abort a reconcile mid-loop and leave the remaining grants un-pruned.
|
|
1150
|
+
try {
|
|
1151
|
+
await oauth(deps).revokeRemote({
|
|
1152
|
+
revocationEndpoint: grant.material.revocationEndpoint,
|
|
1153
|
+
refreshToken: grant.material.refresh_token,
|
|
1154
|
+
clientId: grant.material.clientId,
|
|
1155
|
+
});
|
|
1156
|
+
} catch {
|
|
1157
|
+
// best-effort — the local material is dropped by the caller regardless.
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// ===========================================================================
|
|
1163
|
+
// POST /admin/grants/reconcile — grant-GC for a holder (#96; module-auth)
|
|
1164
|
+
// ===========================================================================
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Reconcile (garbage-collect) one holder's grants against its CURRENTLY-declared
|
|
1168
|
+
* connections. The agent module POSTs the live connection SPECS a holder still
|
|
1169
|
+
* wants; the hub re-derives each key with its OWN `connectionKey()` and prunes
|
|
1170
|
+
* every grant for that holder whose key is NOT in the live set — tearing down its
|
|
1171
|
+
* material exactly like `revoke`, then REMOVING the row (the holder is gone, so a
|
|
1172
|
+
* lingering status:revoked row is just cruft).
|
|
1173
|
+
*
|
|
1174
|
+
* Module-auth (host-admin Bearer), mirroring PUT/GET — NOT operator-cookie-gated.
|
|
1175
|
+
* Pruning only ever REMOVES access (never escalates), so the "a note can only
|
|
1176
|
+
* REQUEST, never GRANT" invariant is untouched: this can't mint or approve.
|
|
1177
|
+
*
|
|
1178
|
+
* Body: `{ agent: "<name>", liveConnections: [<ConnectionSpec>, ...] }`.
|
|
1179
|
+
* `liveConnections: []` (the agent/def is gone) prunes ALL of that holder's grants.
|
|
1180
|
+
* Sending SPECS (not pre-computed keys) is deliberate: the hub re-derives keys with
|
|
1181
|
+
* the same normalization it stored them under, so there is NO dependency on the
|
|
1182
|
+
* agent module's separate connectionKey() impl (which diverges for service /
|
|
1183
|
+
* tagged-vault / mixed-case-mcp grants — a still-wanted grant would otherwise be
|
|
1184
|
+
* wrongly pruned; caught by live verification 2026-06-18).
|
|
1185
|
+
*
|
|
1186
|
+
* Returns `{ pruned: <number>, prunedIds: ["<id>", ...] }`.
|
|
1187
|
+
*/
|
|
1188
|
+
async function reconcileGrants(req: Request, deps: AgentGrantsDeps): Promise<Response> {
|
|
1189
|
+
const auth = await requireModuleAuth(req, deps);
|
|
1190
|
+
if (auth instanceof Response) return auth;
|
|
1191
|
+
|
|
1192
|
+
let body: unknown;
|
|
1193
|
+
try {
|
|
1194
|
+
body = await req.json();
|
|
1195
|
+
} catch {
|
|
1196
|
+
return jsonError(400, "invalid_request", "body must be JSON");
|
|
1197
|
+
}
|
|
1198
|
+
if (!body || typeof body !== "object") {
|
|
1199
|
+
return jsonError(400, "invalid_request", "body must be a JSON object");
|
|
1200
|
+
}
|
|
1201
|
+
const b = body as Record<string, unknown>;
|
|
1202
|
+
|
|
1203
|
+
const agent = typeof b.agent === "string" ? b.agent.trim() : "";
|
|
1204
|
+
if (!agent || agent.length > MAX_AGENT_LEN || !AGENT_NAME_RE.test(agent)) {
|
|
1205
|
+
return jsonError(
|
|
1206
|
+
400,
|
|
1207
|
+
"invalid_request",
|
|
1208
|
+
`agent is required and must match [a-zA-Z0-9][a-zA-Z0-9_.-]* (max ${MAX_AGENT_LEN} chars)`,
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// The caller sends the live CONNECTION SPECS (not pre-computed keys): the hub
|
|
1213
|
+
// re-derives each key with its OWN connectionKey() — the same normalization +
|
|
1214
|
+
// function it used to store/grantId the grants — so the keep-set is guaranteed
|
|
1215
|
+
// to match the stored keys. (Sending agent-computed keys would couple to the
|
|
1216
|
+
// agent module's separate connectionKey() impl, which diverges for service /
|
|
1217
|
+
// tagged-vault / mixed-case-mcp grants — caught live 2026-06-18.)
|
|
1218
|
+
if (!Array.isArray(b.liveConnections)) {
|
|
1219
|
+
return jsonError(
|
|
1220
|
+
400,
|
|
1221
|
+
"invalid_request",
|
|
1222
|
+
"liveConnections is required and must be an array of connection specs",
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
if (b.liveConnections.length > MAX_LIVE_KEYS) {
|
|
1226
|
+
return jsonError(
|
|
1227
|
+
400,
|
|
1228
|
+
"invalid_request",
|
|
1229
|
+
`liveConnections exceeds the ${MAX_LIVE_KEYS}-entry limit`,
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
const liveKeys = new Set<string>();
|
|
1233
|
+
for (const raw of b.liveConnections) {
|
|
1234
|
+
const parsed = parseConnectionSpec(raw);
|
|
1235
|
+
if ("error" in parsed) {
|
|
1236
|
+
return jsonError(400, "invalid_request", `liveConnections entry invalid: ${parsed.error}`);
|
|
1237
|
+
}
|
|
1238
|
+
liveKeys.add(connectionKey(parsed.spec));
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const now = deps.now?.() ?? new Date();
|
|
1242
|
+
const held = listGrantsForAgent(deps.storePath, agent);
|
|
1243
|
+
const prunedIds: string[] = [];
|
|
1244
|
+
for (const grant of held) {
|
|
1245
|
+
// Both sides of this comparison use the HUB's connectionKey (stored grant +
|
|
1246
|
+
// re-derived live spec) — no cross-repo key-format dependency.
|
|
1247
|
+
if (liveKeys.has(connectionKey(grant.connection))) continue;
|
|
1248
|
+
await tearDownGrantMaterial(grant, deps, now);
|
|
1249
|
+
removeGrant(deps.storePath, grant.id);
|
|
1250
|
+
prunedIds.push(grant.id);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
if (prunedIds.length > 0) {
|
|
1254
|
+
// Identity fields + count only — NEVER a token.
|
|
1255
|
+
console.log(`agent grants reconciled: agent=${agent} pruned=${prunedIds.length}`);
|
|
1256
|
+
}
|
|
1257
|
+
return new Response(JSON.stringify({ pruned: prunedIds.length, prunedIds }), {
|
|
1258
|
+
status: 200,
|
|
1259
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// ===========================================================================
|
|
1264
|
+
// Mint
|
|
1265
|
+
// ===========================================================================
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Mint the vault token for an approved vault grant: a REGISTERED
|
|
1269
|
+
* (created_via "agent_grant") `vault:<target>:<access>` JWT, audience-bound +
|
|
1270
|
+
* vault_scope-pinned to the target, carrying `permissions.scoped_tags` when
|
|
1271
|
+
* tags were declared (the vault's tag-scope enforcement reads them). Mirrors
|
|
1272
|
+
* `admin-connections.ts:mintCredential`.
|
|
1273
|
+
*/
|
|
1274
|
+
async function mintVaultGrant(
|
|
1275
|
+
deps: AgentGrantsDeps,
|
|
1276
|
+
userId: string,
|
|
1277
|
+
vault: string,
|
|
1278
|
+
scope: string,
|
|
1279
|
+
scopedTags: readonly string[],
|
|
1280
|
+
): Promise<{ token: string; jti: string; expiresAt: string }> {
|
|
1281
|
+
const sign = deps.signToken ?? signAccessToken;
|
|
1282
|
+
const signed = await sign(deps.db, {
|
|
1283
|
+
sub: userId || "agent-grant",
|
|
1284
|
+
scopes: [scope],
|
|
1285
|
+
audience: `vault.${vault}`,
|
|
1286
|
+
clientId: GRANT_CLIENT_ID,
|
|
1287
|
+
issuer: deps.hubOrigin,
|
|
1288
|
+
ttlSeconds: VAULT_GRANT_TTL_SECONDS,
|
|
1289
|
+
vaultScope: [vault],
|
|
1290
|
+
...(scopedTags.length > 0
|
|
1291
|
+
? { extraClaims: { permissions: { scoped_tags: [...scopedTags] } } }
|
|
1292
|
+
: {}),
|
|
1293
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
1294
|
+
});
|
|
1295
|
+
// Register the long-lived mint so revoke can drop it (hub-module-boundary
|
|
1296
|
+
// registered-mint rule — an unregistered long-lived token is unrevocable).
|
|
1297
|
+
recordTokenMint(deps.db, {
|
|
1298
|
+
jti: signed.jti,
|
|
1299
|
+
createdVia: "agent_grant",
|
|
1300
|
+
subject: "agent-grant",
|
|
1301
|
+
...(userId ? { userId } : {}),
|
|
1302
|
+
clientId: GRANT_CLIENT_ID,
|
|
1303
|
+
scopes: [scope],
|
|
1304
|
+
expiresAt: signed.expiresAt,
|
|
1305
|
+
...(scopedTags.length > 0
|
|
1306
|
+
? { permissions: JSON.stringify({ scoped_tags: [...scopedTags] }) }
|
|
1307
|
+
: {}),
|
|
1308
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
1309
|
+
});
|
|
1310
|
+
return { token: signed.token, jti: signed.jti, expiresAt: signed.expiresAt };
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// ===========================================================================
|
|
1314
|
+
// Wire shapes
|
|
1315
|
+
// ===========================================================================
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* The list/echo wire shape for a grant — id, agent, connection, status,
|
|
1319
|
+
* optional reason + approvedAt. **Carries NO `material`** (the secret never
|
|
1320
|
+
* leaves via this shape). This is what PUT, GET-list, approve, and revoke all
|
|
1321
|
+
* return.
|
|
1322
|
+
*/
|
|
1323
|
+
export interface GrantListing {
|
|
1324
|
+
id: string;
|
|
1325
|
+
agent: string;
|
|
1326
|
+
connection: ConnectionSpec;
|
|
1327
|
+
status: GrantRecord["status"];
|
|
1328
|
+
reason?: string;
|
|
1329
|
+
approvedAt?: string;
|
|
1330
|
+
/**
|
|
1331
|
+
* Present ONLY on a fresh `approve(mcp)` that started an OAuth flow — the URL
|
|
1332
|
+
* the admin UI redirects the operator's browser to. A superset of the 4b-1
|
|
1333
|
+
* listing shape; the UI ignores it for vault/service approves (absent there).
|
|
1334
|
+
* Never persisted; `toListing` never emits it (it's a response-only field).
|
|
1335
|
+
*/
|
|
1336
|
+
authorizeUrl?: string;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function toListing(g: GrantRecord): GrantListing {
|
|
1340
|
+
return {
|
|
1341
|
+
id: g.id,
|
|
1342
|
+
agent: g.agent,
|
|
1343
|
+
connection: g.connection,
|
|
1344
|
+
status: g.status,
|
|
1345
|
+
...(g.reason ? { reason: g.reason } : {}),
|
|
1346
|
+
...(g.approvedAt ? { approvedAt: g.approvedAt } : {}),
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function grantResponse(status: number, g: GrantRecord): Response {
|
|
1351
|
+
return new Response(JSON.stringify(toListing(g)), {
|
|
1352
|
+
status,
|
|
1353
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
1358
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
1359
|
+
status,
|
|
1360
|
+
headers: { "content-type": "application/json" },
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** Re-exported so callers/tests can reuse the stable key derivation. */
|
|
1365
|
+
export { connectionKey, grantId };
|