@openparachute/vault 0.4.8 → 0.4.9-rc.11
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/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth Device Flow client + supporting API calls.
|
|
3
|
+
*
|
|
4
|
+
* Why Device Flow (not Web Flow): self-hosted vault origins are
|
|
5
|
+
* unpredictable (localhost:1940, random Tailscale FQDN, custom domain). Web
|
|
6
|
+
* Flow needs a pre-registered callback URL per OAuth app; Device Flow needs
|
|
7
|
+
* only a public `client_id` and the operator authorizes by typing a code at
|
|
8
|
+
* github.com/login/device from any device. Same UX as `gh auth login`.
|
|
9
|
+
*
|
|
10
|
+
* Spec: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
|
|
11
|
+
*
|
|
12
|
+
* All HTTP calls accept an injectable `fetch` so tests can mock the wire
|
|
13
|
+
* without spawning a real GitHub round-trip. Production wiring uses the
|
|
14
|
+
* platform `fetch` (Bun's native).
|
|
15
|
+
*
|
|
16
|
+
* **GITHUB_CLIENT_ID setup (REQUIRED before this works in production):**
|
|
17
|
+
*
|
|
18
|
+
* 1. Visit https://github.com/settings/developers
|
|
19
|
+
* 2. "OAuth Apps" → "New OAuth App"
|
|
20
|
+
* 3. Application name: "Parachute Vault" (or operator-chosen)
|
|
21
|
+
* Homepage URL: https://parachute.computer
|
|
22
|
+
* Authorization callback URL: https://parachute.computer/oauth/github
|
|
23
|
+
* (callback URL is required by GitHub's form but unused in Device Flow)
|
|
24
|
+
* 4. After creating: open the app's settings page
|
|
25
|
+
* 5. Tick the "Enable Device Flow" checkbox
|
|
26
|
+
* 6. Copy the Client ID (looks like `Iv1.abc123...` or `Ov23li...`)
|
|
27
|
+
* 7. Set the `GITHUB_CLIENT_ID` constant below to that value
|
|
28
|
+
*
|
|
29
|
+
* The placeholder ships with this PR. Production builds are gated on a real
|
|
30
|
+
* client_id; the PR body flags Aaron as the action owner.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Client ID — REPLACE BEFORE TAGGING A RELEASE.
|
|
35
|
+
//
|
|
36
|
+
// This is the public OAuth app client_id. No secret is needed for Device
|
|
37
|
+
// Flow (the operator's typed code is the proof-of-presence factor). Safe to
|
|
38
|
+
// commit + ship in client builds. But: tied to ONE registered GitHub OAuth
|
|
39
|
+
// App, which Aaron owns under the Parachute org / his account. Set this
|
|
40
|
+
// after running the setup checklist above.
|
|
41
|
+
//
|
|
42
|
+
// TODO(aaron): replace with the real client_id from the registered OAuth
|
|
43
|
+
// app. Until then, the device-flow endpoints return a clear-error response
|
|
44
|
+
// instead of attempting GitHub's API with an invalid id (which surfaces an
|
|
45
|
+
// opaque "Not Found" that's hard to debug).
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export const GITHUB_CLIENT_ID_PLACEHOLDER =
|
|
49
|
+
"Iv1.PLACEHOLDER_REPLACE_ME_BEFORE_RELEASE" as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The active client id at runtime. Resolved from the env (preferred — lets
|
|
53
|
+
* operators override per-deploy) or the constant above (for tests +
|
|
54
|
+
* defaults). Defaults to the placeholder; the route handlers check for the
|
|
55
|
+
* placeholder and return an actionable error before hitting GitHub.
|
|
56
|
+
*/
|
|
57
|
+
export function getGithubClientId(): string {
|
|
58
|
+
return process.env.PARACHUTE_GITHUB_CLIENT_ID || GITHUB_CLIENT_ID_PLACEHOLDER;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Returns true when no real client id has been configured. */
|
|
62
|
+
export function isPlaceholderClientId(clientId: string): boolean {
|
|
63
|
+
return clientId === GITHUB_CLIENT_ID_PLACEHOLDER || clientId.includes("PLACEHOLDER");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Types
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
export interface DeviceCodeResponse {
|
|
71
|
+
device_code: string;
|
|
72
|
+
user_code: string;
|
|
73
|
+
verification_uri: string;
|
|
74
|
+
expires_in: number;
|
|
75
|
+
/** Seconds the client should wait between polls. */
|
|
76
|
+
interval: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Discriminated union of poll outcomes. */
|
|
80
|
+
export type TokenPollResult =
|
|
81
|
+
| { state: "pending" }
|
|
82
|
+
| { state: "slow_down"; interval: number }
|
|
83
|
+
| { state: "expired" }
|
|
84
|
+
| { state: "denied" }
|
|
85
|
+
| {
|
|
86
|
+
state: "granted";
|
|
87
|
+
access_token: string;
|
|
88
|
+
scope: string;
|
|
89
|
+
token_type: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export interface GitHubUser {
|
|
93
|
+
login: string;
|
|
94
|
+
id: number;
|
|
95
|
+
name: string | null;
|
|
96
|
+
avatar_url?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface GitHubRepoInfo {
|
|
100
|
+
owner: string;
|
|
101
|
+
name: string;
|
|
102
|
+
full_name: string;
|
|
103
|
+
private: boolean;
|
|
104
|
+
html_url: string;
|
|
105
|
+
description: string | null;
|
|
106
|
+
/** ISO timestamp. Used by the SPA for "last updated" sort/display. */
|
|
107
|
+
updated_at: string;
|
|
108
|
+
/** HTTPS clone URL (no auth). The mirror gets the authed shape applied
|
|
109
|
+
* separately via `applyToGitRemote`. */
|
|
110
|
+
clone_url: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ListReposResult {
|
|
114
|
+
repos: GitHubRepoInfo[];
|
|
115
|
+
/** True when the operator has more than `maxPages * per_page` repos and
|
|
116
|
+
* we stopped paginating. Signals the UI to recommend the manual-URL
|
|
117
|
+
* paste or a search filter. */
|
|
118
|
+
truncated: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Minimal fetch-like surface — injectable for tests. */
|
|
122
|
+
export type FetchLike = (
|
|
123
|
+
input: string,
|
|
124
|
+
init?: { method?: string; headers?: Record<string, string>; body?: string },
|
|
125
|
+
) => Promise<{
|
|
126
|
+
ok: boolean;
|
|
127
|
+
status: number;
|
|
128
|
+
text: () => Promise<string>;
|
|
129
|
+
json: () => Promise<unknown>;
|
|
130
|
+
}>;
|
|
131
|
+
|
|
132
|
+
function defaultFetch(): FetchLike {
|
|
133
|
+
return globalThis.fetch as FetchLike;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Device Flow endpoints
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Start a device-flow authorization. POSTs to GitHub's `/login/device/code`
|
|
142
|
+
* with the public client_id + the `repo` scope (the minimum we need to push
|
|
143
|
+
* to the operator's private repos).
|
|
144
|
+
*
|
|
145
|
+
* Throws on transport or shape error — the route handler catches + returns
|
|
146
|
+
* a 502. Successful return is the four-tuple GitHub spec calls for
|
|
147
|
+
* (device_code, user_code, verification_uri, expires_in, interval).
|
|
148
|
+
*/
|
|
149
|
+
export async function requestDeviceCode(
|
|
150
|
+
clientId: string,
|
|
151
|
+
fetchImpl: FetchLike = defaultFetch(),
|
|
152
|
+
): Promise<DeviceCodeResponse> {
|
|
153
|
+
const res = await fetchImpl("https://github.com/login/device/code", {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
accept: "application/json",
|
|
157
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
158
|
+
},
|
|
159
|
+
body: new URLSearchParams({
|
|
160
|
+
client_id: clientId,
|
|
161
|
+
// `repo` is the broad-private-repo scope. Read-only push isn't a
|
|
162
|
+
// thing on GitHub; if we want to push, we need write access to repo
|
|
163
|
+
// contents, which `repo` includes. The narrower scope `public_repo`
|
|
164
|
+
// wouldn't cover private repos — most operator vaults are private.
|
|
165
|
+
scope: "repo",
|
|
166
|
+
}).toString(),
|
|
167
|
+
});
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
const body = await res.text();
|
|
170
|
+
throw new Error(
|
|
171
|
+
`GitHub device-code request failed (${res.status}): ${body.slice(0, 200)}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const parsed = (await res.json()) as Partial<DeviceCodeResponse>;
|
|
175
|
+
if (
|
|
176
|
+
typeof parsed.device_code !== "string" ||
|
|
177
|
+
typeof parsed.user_code !== "string" ||
|
|
178
|
+
typeof parsed.verification_uri !== "string" ||
|
|
179
|
+
typeof parsed.expires_in !== "number" ||
|
|
180
|
+
typeof parsed.interval !== "number"
|
|
181
|
+
) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`GitHub device-code response missing required fields: ${JSON.stringify(parsed).slice(0, 200)}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return parsed as DeviceCodeResponse;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Poll GitHub's `/login/oauth/access_token` for a granted token. Returns a
|
|
191
|
+
* discriminated union the route handler can branch on.
|
|
192
|
+
*
|
|
193
|
+
* GitHub returns a 200 with an `error` field for the in-flight states
|
|
194
|
+
* (`authorization_pending`, `slow_down`, etc.); we map those to our `state`
|
|
195
|
+
* field. A true HTTP error (5xx) throws.
|
|
196
|
+
*/
|
|
197
|
+
export async function pollForToken(
|
|
198
|
+
clientId: string,
|
|
199
|
+
deviceCode: string,
|
|
200
|
+
fetchImpl: FetchLike = defaultFetch(),
|
|
201
|
+
): Promise<TokenPollResult> {
|
|
202
|
+
const res = await fetchImpl("https://github.com/login/oauth/access_token", {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: {
|
|
205
|
+
accept: "application/json",
|
|
206
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
207
|
+
},
|
|
208
|
+
body: new URLSearchParams({
|
|
209
|
+
client_id: clientId,
|
|
210
|
+
device_code: deviceCode,
|
|
211
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
212
|
+
}).toString(),
|
|
213
|
+
});
|
|
214
|
+
if (!res.ok) {
|
|
215
|
+
const body = await res.text();
|
|
216
|
+
throw new Error(
|
|
217
|
+
`GitHub access_token poll failed (${res.status}): ${body.slice(0, 200)}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const parsed = (await res.json()) as Record<string, unknown>;
|
|
221
|
+
|
|
222
|
+
// Success: { access_token, scope, token_type }
|
|
223
|
+
if (typeof parsed.access_token === "string") {
|
|
224
|
+
return {
|
|
225
|
+
state: "granted",
|
|
226
|
+
access_token: parsed.access_token,
|
|
227
|
+
scope: typeof parsed.scope === "string" ? parsed.scope : "",
|
|
228
|
+
token_type: typeof parsed.token_type === "string" ? parsed.token_type : "bearer",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// In-flight / failure states: { error, error_description, interval? }
|
|
233
|
+
const error = typeof parsed.error === "string" ? parsed.error : null;
|
|
234
|
+
if (error === "authorization_pending") return { state: "pending" };
|
|
235
|
+
if (error === "slow_down") {
|
|
236
|
+
const interval = typeof parsed.interval === "number" ? parsed.interval : 5;
|
|
237
|
+
return { state: "slow_down", interval };
|
|
238
|
+
}
|
|
239
|
+
if (error === "expired_token") return { state: "expired" };
|
|
240
|
+
if (error === "access_denied") return { state: "denied" };
|
|
241
|
+
// Unknown error — treat as denied so the UI surfaces a clear failure
|
|
242
|
+
// rather than hanging on pending. The actual GitHub error string is
|
|
243
|
+
// not surfaced (it'd leak via logs); the route's response carries a
|
|
244
|
+
// generic "denied" + the message via console.warn.
|
|
245
|
+
console.warn(
|
|
246
|
+
`[github-device-flow] unexpected error from access_token poll: ${error ?? JSON.stringify(parsed).slice(0, 100)}`,
|
|
247
|
+
);
|
|
248
|
+
return { state: "denied" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// User + repo APIs (used after token granted)
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Fetch the authenticated user's profile. Used immediately after a
|
|
257
|
+
* `granted` token to populate `user_login` + `user_id` in the stored
|
|
258
|
+
* credential.
|
|
259
|
+
*/
|
|
260
|
+
export async function fetchUser(
|
|
261
|
+
token: string,
|
|
262
|
+
fetchImpl: FetchLike = defaultFetch(),
|
|
263
|
+
): Promise<GitHubUser> {
|
|
264
|
+
const res = await fetchImpl("https://api.github.com/user", {
|
|
265
|
+
headers: {
|
|
266
|
+
accept: "application/vnd.github+json",
|
|
267
|
+
authorization: `token ${token}`,
|
|
268
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
const body = await res.text();
|
|
273
|
+
throw new Error(
|
|
274
|
+
`GitHub /user fetch failed (${res.status}): ${body.slice(0, 200)}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const parsed = (await res.json()) as Partial<GitHubUser>;
|
|
278
|
+
if (typeof parsed.login !== "string" || typeof parsed.id !== "number") {
|
|
279
|
+
throw new Error(`GitHub /user response missing login or id`);
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
login: parsed.login,
|
|
283
|
+
id: parsed.id,
|
|
284
|
+
name: typeof parsed.name === "string" ? parsed.name : null,
|
|
285
|
+
avatar_url: typeof parsed.avatar_url === "string" ? parsed.avatar_url : undefined,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Paginated list of repos the authenticated user owns. Sorted by most-
|
|
291
|
+
* recently-updated so the repo the operator probably wants is near the top.
|
|
292
|
+
* Truncates after `maxPages * perPage` repos (default 3 * 100 = 300) — most
|
|
293
|
+
* operators have far fewer, the truncation signals the UI to prompt for a
|
|
294
|
+
* search filter.
|
|
295
|
+
*/
|
|
296
|
+
export async function listRepos(
|
|
297
|
+
token: string,
|
|
298
|
+
opts: { maxPages?: number; perPage?: number } = {},
|
|
299
|
+
fetchImpl: FetchLike = defaultFetch(),
|
|
300
|
+
): Promise<ListReposResult> {
|
|
301
|
+
const perPage = opts.perPage ?? 100;
|
|
302
|
+
const maxPages = opts.maxPages ?? 3;
|
|
303
|
+
const repos: GitHubRepoInfo[] = [];
|
|
304
|
+
let truncated = false;
|
|
305
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
306
|
+
const res = await fetchImpl(
|
|
307
|
+
`https://api.github.com/user/repos?type=owner&sort=updated&per_page=${perPage}&page=${page}`,
|
|
308
|
+
{
|
|
309
|
+
headers: {
|
|
310
|
+
accept: "application/vnd.github+json",
|
|
311
|
+
authorization: `token ${token}`,
|
|
312
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
if (!res.ok) {
|
|
317
|
+
const body = await res.text();
|
|
318
|
+
throw new Error(
|
|
319
|
+
`GitHub /user/repos fetch failed (${res.status}, page ${page}): ${body.slice(0, 200)}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const items = (await res.json()) as Array<{
|
|
323
|
+
name: string;
|
|
324
|
+
full_name: string;
|
|
325
|
+
private: boolean;
|
|
326
|
+
html_url: string;
|
|
327
|
+
description: string | null;
|
|
328
|
+
updated_at: string;
|
|
329
|
+
clone_url: string;
|
|
330
|
+
owner: { login: string };
|
|
331
|
+
}>;
|
|
332
|
+
for (const item of items) {
|
|
333
|
+
repos.push({
|
|
334
|
+
owner: item.owner.login,
|
|
335
|
+
name: item.name,
|
|
336
|
+
full_name: item.full_name,
|
|
337
|
+
private: item.private,
|
|
338
|
+
html_url: item.html_url,
|
|
339
|
+
description: item.description,
|
|
340
|
+
updated_at: item.updated_at,
|
|
341
|
+
clone_url: item.clone_url,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// No more pages.
|
|
345
|
+
if (items.length < perPage) {
|
|
346
|
+
return { repos, truncated: false };
|
|
347
|
+
}
|
|
348
|
+
// Page filled to perPage — there might be more. If we're at the cap,
|
|
349
|
+
// mark as truncated and return.
|
|
350
|
+
if (page === maxPages) {
|
|
351
|
+
truncated = true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { repos, truncated };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Create a new repo on the authenticated user's account. Defaults to private
|
|
359
|
+
* because the operator's vault is more likely sensitive than public. The
|
|
360
|
+
* repo gets initialized empty (no README) so the first `git push` from the
|
|
361
|
+
* mirror lands the operator's vault as commit 1.
|
|
362
|
+
*/
|
|
363
|
+
export async function createRepo(
|
|
364
|
+
token: string,
|
|
365
|
+
opts: { name: string; description?: string; private?: boolean },
|
|
366
|
+
fetchImpl: FetchLike = defaultFetch(),
|
|
367
|
+
): Promise<GitHubRepoInfo> {
|
|
368
|
+
const res = await fetchImpl("https://api.github.com/user/repos", {
|
|
369
|
+
method: "POST",
|
|
370
|
+
headers: {
|
|
371
|
+
accept: "application/vnd.github+json",
|
|
372
|
+
authorization: `token ${token}`,
|
|
373
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
374
|
+
"content-type": "application/json",
|
|
375
|
+
},
|
|
376
|
+
body: JSON.stringify({
|
|
377
|
+
name: opts.name,
|
|
378
|
+
description: opts.description ?? "Parachute Vault mirror",
|
|
379
|
+
private: opts.private ?? true,
|
|
380
|
+
auto_init: false,
|
|
381
|
+
}),
|
|
382
|
+
});
|
|
383
|
+
if (!res.ok) {
|
|
384
|
+
const body = await res.text();
|
|
385
|
+
let parsed: { message?: string } = {};
|
|
386
|
+
try {
|
|
387
|
+
parsed = JSON.parse(body) as { message?: string };
|
|
388
|
+
} catch {
|
|
389
|
+
// not JSON
|
|
390
|
+
}
|
|
391
|
+
throw new Error(
|
|
392
|
+
`GitHub /user/repos create failed (${res.status}): ${parsed.message ?? body.slice(0, 200)}`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
const item = (await res.json()) as {
|
|
396
|
+
name: string;
|
|
397
|
+
full_name: string;
|
|
398
|
+
private: boolean;
|
|
399
|
+
html_url: string;
|
|
400
|
+
description: string | null;
|
|
401
|
+
updated_at: string;
|
|
402
|
+
clone_url: string;
|
|
403
|
+
owner: { login: string };
|
|
404
|
+
};
|
|
405
|
+
return {
|
|
406
|
+
owner: item.owner.login,
|
|
407
|
+
name: item.name,
|
|
408
|
+
full_name: item.full_name,
|
|
409
|
+
private: item.private,
|
|
410
|
+
html_url: item.html_url,
|
|
411
|
+
description: item.description,
|
|
412
|
+
updated_at: item.updated_at,
|
|
413
|
+
clone_url: item.clone_url,
|
|
414
|
+
};
|
|
415
|
+
}
|
package/src/hub-jwt.test.ts
CHANGED
|
@@ -87,15 +87,19 @@ interface SignOpts {
|
|
|
87
87
|
expiresAtSeconds?: number;
|
|
88
88
|
omitKid?: boolean;
|
|
89
89
|
kid?: string;
|
|
90
|
+
/** `permissions` claim (auth-unification C0). Undefined → omit. */
|
|
91
|
+
permissions?: unknown;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
|
|
93
95
|
const iat = Math.floor(Date.now() / 1000);
|
|
94
96
|
const exp = opts.expiresAtSeconds ?? iat + (opts.ttlSeconds ?? 60);
|
|
95
|
-
const
|
|
97
|
+
const claims: Record<string, unknown> = {
|
|
96
98
|
scope: opts.scope ?? "vault:read vault:write",
|
|
97
99
|
client_id: opts.clientId ?? "test-client",
|
|
98
|
-
}
|
|
100
|
+
};
|
|
101
|
+
if (opts.permissions !== undefined) claims.permissions = opts.permissions;
|
|
102
|
+
const builder = new SignJWT(claims)
|
|
99
103
|
.setProtectedHeader(opts.omitKid ? { alg: "RS256" } : { alg: "RS256", kid: opts.kid ?? kp.kid })
|
|
100
104
|
.setIssuer(opts.iss ?? "http://issuer.invalid")
|
|
101
105
|
.setSubject(opts.sub ?? "user-1")
|
|
@@ -187,6 +191,27 @@ describe("validateHubJwt — happy path", () => {
|
|
|
187
191
|
const claims = await validateHubJwt(token, { expectedAudience: "vault.work" });
|
|
188
192
|
expect(claims.aud).toBe("vault.work");
|
|
189
193
|
});
|
|
194
|
+
|
|
195
|
+
test("permissions claim surfaces on the validated result (C0)", async () => {
|
|
196
|
+
const token = await signJwt(kp, {
|
|
197
|
+
iss: fixture.origin,
|
|
198
|
+
permissions: { scoped_tags: ["health", "finance"] },
|
|
199
|
+
});
|
|
200
|
+
const claims = await validateHubJwt(token);
|
|
201
|
+
expect(claims.permissions).toEqual({ scoped_tags: ["health", "finance"] });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("no permissions claim → permissions is undefined", async () => {
|
|
205
|
+
const token = await signJwt(kp, { iss: fixture.origin });
|
|
206
|
+
const claims = await validateHubJwt(token);
|
|
207
|
+
expect(claims.permissions).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("non-object permissions claim → permissions is undefined (not surfaced)", async () => {
|
|
211
|
+
const token = await signJwt(kp, { iss: fixture.origin, permissions: "not-an-object" });
|
|
212
|
+
const claims = await validateHubJwt(token);
|
|
213
|
+
expect(claims.permissions).toBeUndefined();
|
|
214
|
+
});
|
|
190
215
|
});
|
|
191
216
|
|
|
192
217
|
describe("validateHubJwt — audience strict-check", () => {
|
package/src/hub-jwt.ts
CHANGED
|
@@ -59,6 +59,16 @@ const guard = createScopeGuard({ hubOrigin: () => getHubOrigin() });
|
|
|
59
59
|
* Scope-shape policy (e.g. "hub-issued tokens may not carry broad
|
|
60
60
|
* `vault:<verb>` scopes") is enforced one layer up in `authenticateHubJwt`,
|
|
61
61
|
* not here — this function stays focused on JWT-level concerns.
|
|
62
|
+
*
|
|
63
|
+
* The returned `HubJwtClaims` carries the native `permissions` claim
|
|
64
|
+
* (scope-guard ≥0.4.0-rc.2 parses + surfaces it; `undefined` when absent or
|
|
65
|
+
* not a JSON object). Tag-scope enforcement reads `permissions.scoped_tags`
|
|
66
|
+
* in `authenticateHubJwt` — see auth-unification arc C0.
|
|
67
|
+
*
|
|
68
|
+
* jti policy: scope-guard's `createScopeGuard` defaults `allowMissingJti:
|
|
69
|
+
* false` (per hub#218 / scope-guard #322), so a hub JWT lacking a `jti`
|
|
70
|
+
* claim is rejected here. Vault doesn't opt out — every hub mint stamps a
|
|
71
|
+
* jti, and revocation can't be enforced on tokens we can't index.
|
|
62
72
|
*/
|
|
63
73
|
export async function validateHubJwt(
|
|
64
74
|
token: string,
|
package/src/mcp-http.ts
CHANGED
|
@@ -30,38 +30,49 @@ import { hasScopeForVault } from "./scopes.ts";
|
|
|
30
30
|
import type { VaultVerb } from "./scopes.ts";
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Required verb for
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* Required verb for an MCP tool. Reads `tool.requiredVerb` from the tool
|
|
34
|
+
* metadata — every core tool stamps this (vault#376) so the filter is data,
|
|
35
|
+
* not a side-table that can drift. The discovery + dispatch paths below
|
|
36
|
+
* call this with the tool object so a future tool that forgets to stamp
|
|
37
|
+
* falls into the default-deny branch.
|
|
38
|
+
*
|
|
39
|
+
* Default-deny: unknown tools require `write`. Keeps accidental reads of
|
|
40
|
+
* a not-yet-mapped mutation tool from slipping past. (`admin` would be
|
|
41
|
+
* safer-still but would refuse vault-info-style read tools to write-scope
|
|
42
|
+
* callers; `write` is the right middle ground.)
|
|
39
43
|
*/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"list-tags": "read",
|
|
43
|
-
"find-path": "read",
|
|
44
|
-
"vault-info": "read",
|
|
45
|
-
"create-note": "write",
|
|
46
|
-
"update-note": "write",
|
|
47
|
-
"delete-note": "write",
|
|
48
|
-
"update-tag": "write",
|
|
49
|
-
"delete-tag": "write",
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
function requiredVerbForTool(toolName: string): VaultVerb {
|
|
53
|
-
// Default-deny: unknown tools require write. Keeps accidental reads of
|
|
54
|
-
// a not-yet-mapped mutation tool from slipping past.
|
|
55
|
-
return TOOL_REQUIRED_VERB[toolName] ?? "write";
|
|
44
|
+
function requiredVerbForTool(tool: { requiredVerb?: VaultVerb }): VaultVerb {
|
|
45
|
+
return tool.requiredVerb ?? "write";
|
|
56
46
|
}
|
|
57
47
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Handle scoped MCP at /vault/{name}/mcp (single vault).
|
|
50
|
+
*
|
|
51
|
+
* `callerBearer` is the RAW credential the session presented (from
|
|
52
|
+
* `extractApiKey`). It's threaded into `generateScopedMcpTools` so the
|
|
53
|
+
* manage-token tool can forward it to hub's mint-token attenuation proxy
|
|
54
|
+
* (vault#403, MGT). NULL when the request carried no bearer (auth would have
|
|
55
|
+
* already rejected) — the tool treats a missing/non-JWT bearer as
|
|
56
|
+
* non-forwardable and returns a clear error on mint.
|
|
57
|
+
*/
|
|
58
|
+
export async function handleScopedMcp(
|
|
59
|
+
req: Request,
|
|
60
|
+
vaultName: string,
|
|
61
|
+
auth: AuthResult,
|
|
62
|
+
callerBearer?: string | null,
|
|
63
|
+
): Promise<Response> {
|
|
60
64
|
// Auth flows through to getServerInstruction so the connect-time
|
|
61
65
|
// markdown brief is filtered by `scoped_tags` — symmetric with the
|
|
62
66
|
// JSON `vault-info` wrapper.
|
|
63
67
|
const instruction = await getServerInstruction(vaultName, auth);
|
|
64
|
-
return handleMcp(
|
|
68
|
+
return handleMcp(
|
|
69
|
+
req,
|
|
70
|
+
() => generateScopedMcpTools(vaultName, auth, callerBearer ?? null),
|
|
71
|
+
`parachute-vault/${vaultName}`,
|
|
72
|
+
vaultName,
|
|
73
|
+
auth,
|
|
74
|
+
instruction,
|
|
75
|
+
);
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
async function handleMcp(
|
|
@@ -90,9 +101,12 @@ async function handleMcp(
|
|
|
90
101
|
// Filter the advertised tool list to what the caller's scopes actually
|
|
91
102
|
// permit for THIS vault. Callers without write don't see mutation tools at
|
|
92
103
|
// all — matches the prior behavior of the read/full permission model but
|
|
93
|
-
// now driven by per-vault scope inheritance.
|
|
104
|
+
// now driven by per-vault scope inheritance. With manage-token (vault#376)
|
|
105
|
+
// requiring `admin`, callers without admin don't see it at all — the AI
|
|
106
|
+
// never knows it could mint child tokens, eliminating that escalation
|
|
107
|
+
// vector by listing.
|
|
94
108
|
const visibleTools = mcpTools.filter((t) =>
|
|
95
|
-
hasScopeForVault(auth.scopes, vaultName, requiredVerbForTool(t
|
|
109
|
+
hasScopeForVault(auth.scopes, vaultName, requiredVerbForTool(t)),
|
|
96
110
|
);
|
|
97
111
|
|
|
98
112
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -106,18 +120,13 @@ async function handleMcp(
|
|
|
106
120
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
107
121
|
const { name, arguments: args } = request.params;
|
|
108
122
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
isError: true,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const tool = mcpTools.find((t) => t.name === name);
|
|
123
|
+
// Dispatch against the FILTERED tool list — tools the caller can't see
|
|
124
|
+
// in `tools/list` also can't be called explicitly. This matches the
|
|
125
|
+
// user-visible contract: "excluded tools throw 'tool not found' if
|
|
126
|
+
// called explicitly" (vault#376 spec). It also avoids leaking the
|
|
127
|
+
// existence of admin-only tools (manage-token) to write-scope sessions
|
|
128
|
+
// via differential error messages.
|
|
129
|
+
const tool = visibleTools.find((t) => t.name === name);
|
|
121
130
|
if (!tool) {
|
|
122
131
|
return {
|
|
123
132
|
content: [{ type: "text" as const, text: `Unknown tool: ${name}` }],
|
|
@@ -287,22 +287,13 @@ describe("runInteractiveInstall — decision tree", () => {
|
|
|
287
287
|
expect(result.scope).toBe("vault:write");
|
|
288
288
|
});
|
|
289
289
|
|
|
290
|
-
test("typing 'admin'
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
// `
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
// `
|
|
297
|
-
//
|
|
298
|
-
// Hub mint-token rejected (HTTP 400, invalid_scope):
|
|
299
|
-
// scope vault:default:admin is not requestable via mint-token;
|
|
300
|
-
// use OAuth flow or operator rotation
|
|
301
|
-
//
|
|
302
|
-
// Fix: auto-route "admin" in the interactive prompt to legacy-pat
|
|
303
|
-
// mode (which mints a vault-DB pvt_* — the right shape for an MCP
|
|
304
|
-
// entry needing admin permissions), with a printed explanation so
|
|
305
|
-
// the switch isn't silent.
|
|
290
|
+
test("typing 'admin' mints a hub JWT with vault:admin scope (hub PR-A / hub#449)", async () => {
|
|
291
|
+
// As of hub PR-A (hub#449), `POST /api/auth/mint-token` mints
|
|
292
|
+
// `vault:<name>:admin` when the calling operator bearer carries
|
|
293
|
+
// `parachute:host:admin` (which the default operator.token does).
|
|
294
|
+
// So picking "admin" in the mint prompt now resolves to mint mode
|
|
295
|
+
// with vault:admin scope — the verb extraction downstream narrows
|
|
296
|
+
// it to `vault:<name>:admin`. No more legacy-pat auto-route.
|
|
306
297
|
const { io, state } = mockIO([
|
|
307
298
|
null, // accept install-scope default
|
|
308
299
|
"admin",
|
|
@@ -311,13 +302,11 @@ describe("runInteractiveInstall — decision tree", () => {
|
|
|
311
302
|
const result = await runInteractiveInstall(baseCtx(), io);
|
|
312
303
|
expect(result).not.toBe("abort");
|
|
313
304
|
if (result === "abort") return;
|
|
314
|
-
expect(result.mode).toBe("
|
|
305
|
+
expect(result.mode).toBe("mint");
|
|
315
306
|
expect(result.scope).toBe("vault:admin");
|
|
316
|
-
// The
|
|
317
|
-
// mislead operators who specifically want a hub JWT.
|
|
307
|
+
// The branch surfaces that admin mints a scope-narrowed hub JWT.
|
|
318
308
|
const logged = state.logs.join("\n");
|
|
319
|
-
expect(logged).toMatch(/
|
|
320
|
-
expect(logged).toMatch(/hub policy/);
|
|
309
|
+
expect(logged).toMatch(/scope-narrowed hub JWT/);
|
|
321
310
|
});
|
|
322
311
|
|
|
323
312
|
test("typing 'paste' at the auth prompt switches to token mode + asks for token", async () => {
|