@openparachute/vault 0.6.0-rc.1 → 0.6.1
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/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
|
@@ -1,66 +1,110 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub
|
|
2
|
+
* GitHub Device Flow client + supporting API calls.
|
|
3
3
|
*
|
|
4
4
|
* Why Device Flow (not Web Flow): self-hosted vault origins are
|
|
5
5
|
* unpredictable (localhost:1940, random Tailscale FQDN, custom domain). Web
|
|
6
|
-
* Flow needs a pre-registered callback URL
|
|
7
|
-
*
|
|
6
|
+
* Flow needs a pre-registered callback URL; Device Flow needs only a public
|
|
7
|
+
* `client_id` and the operator authorizes by typing a code at
|
|
8
8
|
* github.com/login/device from any device. Same UX as `gh auth login`.
|
|
9
9
|
*
|
|
10
|
-
* Spec: https://docs.github.com/en/apps/
|
|
10
|
+
* Spec: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-device-flow-to-generate-a-user-access-token
|
|
11
11
|
*
|
|
12
12
|
* All HTTP calls accept an injectable `fetch` so tests can mock the wire
|
|
13
13
|
* without spawning a real GitHub round-trip. Production wiring uses the
|
|
14
14
|
* platform `fetch` (Bun's native).
|
|
15
15
|
*
|
|
16
|
-
* **
|
|
16
|
+
* **The registered app (2026-06-10)**: the default client_id below belongs
|
|
17
|
+
* to the shared Parachute **GitHub App** (not a classic OAuth App) that
|
|
18
|
+
* every self-hosted install uses — the same model as `gh` CLI shipping its
|
|
19
|
+
* client_id in source. Registration decisions (rationale in vault#480):
|
|
20
|
+
* fine-grained permissions = Contents read/write ONLY (treat as frozen —
|
|
21
|
+
* permission changes prompt every installer to re-approve); device flow
|
|
22
|
+
* enabled; user-token expiration disabled (non-expiring `ghu_` tokens — an
|
|
23
|
+
* unattended mirror daemon can't babysit single-use refresh tokens);
|
|
24
|
+
* installable on any account; webhook inactive; no OAuth-on-install.
|
|
17
25
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* GitHub-App token semantics that shape the connect flow:
|
|
27
|
+
* - The `scope` param is IGNORED. Token abilities = app permissions ∩
|
|
28
|
+
* the user's own access ∩ the repos selected when INSTALLING the app.
|
|
29
|
+
* - Authorization (this device flow) and installation are separate,
|
|
30
|
+
* order-independent steps. A granted token reaches no repos until the
|
|
31
|
+
* operator also installs the app on their account and selects repos
|
|
32
|
+
* (github.com/apps/<app-slug>/installations/new).
|
|
33
|
+
* - Every GitHub App can read ALL public repos — never infer "installed"
|
|
34
|
+
* from repo visibility. `GET /user/installations` (which requires NO
|
|
35
|
+
* permissions) is the canonical install probe: an empty array means
|
|
36
|
+
* "authorized but not installed yet."
|
|
28
37
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
38
|
+
* **Bring-your-own-app (BYO)**: operators who want their own GitHub App
|
|
39
|
+
* (own rate-limit budget, full sovereignty) must override BOTH env vars as
|
|
40
|
+
* a pair — `PARACHUTE_GITHUB_CLIENT_ID` (their app's client_id, used for
|
|
41
|
+
* the device flow) AND `PARACHUTE_GITHUB_APP_SLUG` (their app's URL slug,
|
|
42
|
+
* used to build the install link). Overriding only one mixes two apps:
|
|
43
|
+
* tokens would be minted for one app while the install link points at the
|
|
44
|
+
* other, and `GET /user/installations` (which lists installations of the
|
|
45
|
+
* TOKEN's app) would never agree with the link.
|
|
31
46
|
*/
|
|
32
47
|
|
|
33
48
|
// ---------------------------------------------------------------------------
|
|
34
|
-
// Client ID —
|
|
49
|
+
// Client ID — the shared Parachute GitHub App (public; safe to commit).
|
|
35
50
|
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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).
|
|
51
|
+
// No secret is needed for Device Flow (the operator's typed code is the
|
|
52
|
+
// proof-of-presence factor), and a client_id is not a credential — GitHub
|
|
53
|
+
// treats public clients as trivially spoofable by design. Operators who
|
|
54
|
+
// want their own app (own rate-limit budget — the device-flow verification
|
|
55
|
+
// cap is 50/hour PER APP fleet-wide — or full sovereignty) set
|
|
56
|
+
// PARACHUTE_GITHUB_CLIENT_ID, which takes precedence. See vault#480.
|
|
46
57
|
// ---------------------------------------------------------------------------
|
|
47
58
|
|
|
48
|
-
export const
|
|
49
|
-
"Iv1.PLACEHOLDER_REPLACE_ME_BEFORE_RELEASE" as const;
|
|
59
|
+
export const GITHUB_CLIENT_ID_DEFAULT = "Iv23livaRF4VcvPhu3uB" as const;
|
|
50
60
|
|
|
51
61
|
/**
|
|
52
|
-
* The active client id at runtime. Resolved from the env (preferred —
|
|
53
|
-
*
|
|
54
|
-
* defaults). Defaults to the placeholder; the route handlers check for the
|
|
55
|
-
* placeholder and return an actionable error before hitting GitHub.
|
|
62
|
+
* The active client id at runtime. Resolved from the env (preferred — the
|
|
63
|
+
* bring-your-own-app path) or the shared-app default above.
|
|
56
64
|
*/
|
|
57
65
|
export function getGithubClientId(): string {
|
|
58
|
-
return process.env.PARACHUTE_GITHUB_CLIENT_ID ||
|
|
66
|
+
return process.env.PARACHUTE_GITHUB_CLIENT_ID || GITHUB_CLIENT_ID_DEFAULT;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Returns true when the configured client id is a placeholder rather than a
|
|
71
|
+
* real id — only reachable via a misconfigured PARACHUTE_GITHUB_CLIENT_ID
|
|
72
|
+
* override now that a real default ships. Route handlers keep the check so
|
|
73
|
+
* a junk override surfaces an actionable error instead of GitHub's opaque
|
|
74
|
+
* "Not Found".
|
|
75
|
+
*/
|
|
62
76
|
export function isPlaceholderClientId(clientId: string): boolean {
|
|
63
|
-
return clientId
|
|
77
|
+
return clientId.includes("PLACEHOLDER");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// App slug — the shared Parachute GitHub App's URL slug. Drives the install
|
|
82
|
+
// link (github.com/apps/<slug>/installations/new), which the connect flow
|
|
83
|
+
// surfaces when the operator is authorized-but-not-installed. BYO-app
|
|
84
|
+
// operators override PARACHUTE_GITHUB_APP_SLUG *together with*
|
|
85
|
+
// PARACHUTE_GITHUB_CLIENT_ID (see the header comment — the pair must come
|
|
86
|
+
// from the same app).
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export const GITHUB_APP_SLUG_DEFAULT = "parachute-computer" as const;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The active app slug at runtime. Resolved from the env (the BYO-app path,
|
|
93
|
+
* paired with PARACHUTE_GITHUB_CLIENT_ID) or the shared-app default above.
|
|
94
|
+
*/
|
|
95
|
+
export function getGithubAppSlug(): string {
|
|
96
|
+
return process.env.PARACHUTE_GITHUB_APP_SLUG || GITHUB_APP_SLUG_DEFAULT;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The "install this app / pick repos" URL for an app slug. Installation is
|
|
101
|
+
* the second, separate step of the connect flow (authorization being the
|
|
102
|
+
* first); the same URL also serves "add another account/org" and "change
|
|
103
|
+
* repo selection" — GitHub routes already-installed accounts to the
|
|
104
|
+
* configure screen.
|
|
105
|
+
*/
|
|
106
|
+
export function installUrlForSlug(slug: string): string {
|
|
107
|
+
return `https://github.com/apps/${encodeURIComponent(slug)}/installations/new`;
|
|
64
108
|
}
|
|
65
109
|
|
|
66
110
|
// ---------------------------------------------------------------------------
|
|
@@ -118,6 +162,45 @@ export interface ListReposResult {
|
|
|
118
162
|
truncated: boolean;
|
|
119
163
|
}
|
|
120
164
|
|
|
165
|
+
/**
|
|
166
|
+
* One installation of the app, as returned by `GET /user/installations`.
|
|
167
|
+
* That endpoint lists installations OF THE TOKEN'S APP that the token's
|
|
168
|
+
* user can access — user-account installs and org installs alike — and
|
|
169
|
+
* requires NO permissions, so it works with the Contents-only shared app.
|
|
170
|
+
* An empty list means "authorized but not installed yet" (the device-flow
|
|
171
|
+
* grant alone reaches no repos).
|
|
172
|
+
*/
|
|
173
|
+
export interface GitHubInstallation {
|
|
174
|
+
id: number;
|
|
175
|
+
/** The app's URL slug (e.g. "parachute-computer"). Always this app's —
|
|
176
|
+
* the endpoint is app-scoped by the token — but carried for display +
|
|
177
|
+
* defensive checks. */
|
|
178
|
+
app_slug: string;
|
|
179
|
+
account: {
|
|
180
|
+
login: string;
|
|
181
|
+
/** "User" or "Organization". */
|
|
182
|
+
type: string;
|
|
183
|
+
};
|
|
184
|
+
/** "all" or "selected" — whether the installation covers every repo on
|
|
185
|
+
* the account or an operator-picked subset. */
|
|
186
|
+
repository_selection: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Error thrown by the GitHub API helpers when GitHub returns a non-2xx
|
|
191
|
+
* response. Carries the HTTP status so route handlers can branch on
|
|
192
|
+
* specific failure classes (e.g. createRepo's 403 = the app lacks
|
|
193
|
+
* Administration:write) without string-matching the message.
|
|
194
|
+
*/
|
|
195
|
+
export class GitHubApiError extends Error {
|
|
196
|
+
readonly status: number;
|
|
197
|
+
constructor(message: string, status: number) {
|
|
198
|
+
super(message);
|
|
199
|
+
this.name = "GitHubApiError";
|
|
200
|
+
this.status = status;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
121
204
|
/** Minimal fetch-like surface — injectable for tests. */
|
|
122
205
|
export type FetchLike = (
|
|
123
206
|
input: string,
|
|
@@ -139,8 +222,12 @@ function defaultFetch(): FetchLike {
|
|
|
139
222
|
|
|
140
223
|
/**
|
|
141
224
|
* Start a device-flow authorization. POSTs to GitHub's `/login/device/code`
|
|
142
|
-
* with the public client_id
|
|
143
|
-
*
|
|
225
|
+
* with the public client_id.
|
|
226
|
+
*
|
|
227
|
+
* No `scope` param: GitHub Apps ignore it entirely. Token abilities come
|
|
228
|
+
* from the app's fine-grained permissions (Contents read/write) intersected
|
|
229
|
+
* with the repos the operator selects when installing the app — push access
|
|
230
|
+
* to private repos comes from that install-time selection, not a scope.
|
|
144
231
|
*
|
|
145
232
|
* Throws on transport or shape error — the route handler catches + returns
|
|
146
233
|
* a 502. Successful return is the four-tuple GitHub spec calls for
|
|
@@ -158,11 +245,6 @@ export async function requestDeviceCode(
|
|
|
158
245
|
},
|
|
159
246
|
body: new URLSearchParams({
|
|
160
247
|
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
248
|
}).toString(),
|
|
167
249
|
});
|
|
168
250
|
if (!res.ok) {
|
|
@@ -292,6 +374,15 @@ export async function fetchUser(
|
|
|
292
374
|
* Truncates after `maxPages * perPage` repos (default 3 * 100 = 300) — most
|
|
293
375
|
* operators have far fewer, the truncation signals the UI to prompt for a
|
|
294
376
|
* search filter.
|
|
377
|
+
*
|
|
378
|
+
* **No longer the repo-picker source** (vault#480): `type=owner` excludes
|
|
379
|
+
* org-owned repos by construction, and with a GitHub-App token an
|
|
380
|
+
* uninstalled app still sees all PUBLIC repos — so this list silently
|
|
381
|
+
* misleads ("looks connected, shows the wrong repos"). The picker now goes
|
|
382
|
+
* `listInstallations` → `listInstallationRepos`, which enumerates exactly
|
|
383
|
+
* what the operator granted. No production callers remain — kept exported
|
|
384
|
+
* for its test coverage and as a building block for potential external /
|
|
385
|
+
* non-App callers.
|
|
295
386
|
*/
|
|
296
387
|
export async function listRepos(
|
|
297
388
|
token: string,
|
|
@@ -354,11 +445,171 @@ export async function listRepos(
|
|
|
354
445
|
return { repos, truncated };
|
|
355
446
|
}
|
|
356
447
|
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Installation APIs — the honest sources for the connect flow (vault#480).
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* List the token-user's installations of this app — `GET /user/installations`.
|
|
454
|
+
*
|
|
455
|
+
* Requires NO permissions (works with the Contents-only shared app), and is
|
|
456
|
+
* the canonical "is the app installed?" probe: an empty array means the
|
|
457
|
+
* operator authorized via device flow but hasn't installed the app on any
|
|
458
|
+
* account yet, so the token reaches no repos. Items cover both user-account
|
|
459
|
+
* and org installs, which is how org-owned mirror repos become reachable.
|
|
460
|
+
*
|
|
461
|
+
* Single page at per_page=100 — more than 100 installations of one app for
|
|
462
|
+
* one user is beyond any plausible operator; truncating there is acceptable.
|
|
463
|
+
*
|
|
464
|
+
* Throws `GitHubApiError` on a non-2xx response, plain Error on a bad shape.
|
|
465
|
+
*/
|
|
466
|
+
export async function listInstallations(
|
|
467
|
+
token: string,
|
|
468
|
+
fetchImpl: FetchLike = defaultFetch(),
|
|
469
|
+
): Promise<GitHubInstallation[]> {
|
|
470
|
+
const res = await fetchImpl("https://api.github.com/user/installations?per_page=100", {
|
|
471
|
+
headers: {
|
|
472
|
+
accept: "application/vnd.github+json",
|
|
473
|
+
authorization: `token ${token}`,
|
|
474
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
if (!res.ok) {
|
|
478
|
+
const body = await res.text();
|
|
479
|
+
throw new GitHubApiError(
|
|
480
|
+
`GitHub /user/installations fetch failed (${res.status}): ${body.slice(0, 200)}`,
|
|
481
|
+
res.status,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
const parsed = (await res.json()) as { installations?: unknown };
|
|
485
|
+
if (!parsed || !Array.isArray(parsed.installations)) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
`GitHub /user/installations response missing installations array: ${JSON.stringify(parsed).slice(0, 200)}`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
const installations: GitHubInstallation[] = [];
|
|
491
|
+
for (const item of parsed.installations as Array<Record<string, unknown>>) {
|
|
492
|
+
const account = item.account as Record<string, unknown> | null | undefined;
|
|
493
|
+
if (
|
|
494
|
+
typeof item.id !== "number" ||
|
|
495
|
+
!account ||
|
|
496
|
+
typeof account.login !== "string" ||
|
|
497
|
+
typeof account.type !== "string"
|
|
498
|
+
) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`GitHub /user/installations item missing required fields: ${JSON.stringify(item).slice(0, 200)}`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
installations.push({
|
|
504
|
+
id: item.id,
|
|
505
|
+
app_slug: typeof item.app_slug === "string" ? item.app_slug : "",
|
|
506
|
+
account: { login: account.login, type: account.type },
|
|
507
|
+
repository_selection:
|
|
508
|
+
typeof item.repository_selection === "string" ? item.repository_selection : "selected",
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return installations;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Paginated list of the repos one installation grants access to —
|
|
516
|
+
* `GET /user/installations/{id}/repositories`. Metadata-read suffices (our
|
|
517
|
+
* Contents permission implies it); private repos within the installation
|
|
518
|
+
* are included, which is exactly the set the repo picker should show.
|
|
519
|
+
* Pagination + truncation semantics match `listRepos` (per_page=100,
|
|
520
|
+
* `maxPages` cap, `truncated` flag for the UI).
|
|
521
|
+
*
|
|
522
|
+
* Throws `GitHubApiError` on a non-2xx response, plain Error on a bad shape.
|
|
523
|
+
*/
|
|
524
|
+
export async function listInstallationRepos(
|
|
525
|
+
token: string,
|
|
526
|
+
installationId: number,
|
|
527
|
+
opts: { maxPages?: number; perPage?: number } = {},
|
|
528
|
+
fetchImpl: FetchLike = defaultFetch(),
|
|
529
|
+
): Promise<ListReposResult> {
|
|
530
|
+
const perPage = opts.perPage ?? 100;
|
|
531
|
+
const maxPages = opts.maxPages ?? 3;
|
|
532
|
+
const repos: GitHubRepoInfo[] = [];
|
|
533
|
+
let truncated = false;
|
|
534
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
535
|
+
const res = await fetchImpl(
|
|
536
|
+
`https://api.github.com/user/installations/${installationId}/repositories?per_page=${perPage}&page=${page}`,
|
|
537
|
+
{
|
|
538
|
+
headers: {
|
|
539
|
+
accept: "application/vnd.github+json",
|
|
540
|
+
authorization: `token ${token}`,
|
|
541
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
);
|
|
545
|
+
if (!res.ok) {
|
|
546
|
+
const body = await res.text();
|
|
547
|
+
throw new GitHubApiError(
|
|
548
|
+
`GitHub /user/installations/${installationId}/repositories fetch failed (${res.status}, page ${page}): ${body.slice(0, 200)}`,
|
|
549
|
+
res.status,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
const parsed = (await res.json()) as { repositories?: unknown };
|
|
553
|
+
if (!parsed || !Array.isArray(parsed.repositories)) {
|
|
554
|
+
throw new Error(
|
|
555
|
+
`GitHub /user/installations/${installationId}/repositories response missing repositories array: ${JSON.stringify(parsed).slice(0, 200)}`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
const items = parsed.repositories as Array<{
|
|
559
|
+
name: string;
|
|
560
|
+
full_name: string;
|
|
561
|
+
private: boolean;
|
|
562
|
+
html_url: string;
|
|
563
|
+
description: string | null;
|
|
564
|
+
updated_at: string;
|
|
565
|
+
clone_url: string;
|
|
566
|
+
owner: { login: string };
|
|
567
|
+
}>;
|
|
568
|
+
for (const item of items) {
|
|
569
|
+
if (
|
|
570
|
+
typeof item.name !== "string" ||
|
|
571
|
+
typeof item.full_name !== "string" ||
|
|
572
|
+
!item.owner ||
|
|
573
|
+
typeof item.owner.login !== "string"
|
|
574
|
+
) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
`GitHub /user/installations/${installationId}/repositories item missing required fields: ${JSON.stringify(item).slice(0, 200)}`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
repos.push({
|
|
580
|
+
owner: item.owner.login,
|
|
581
|
+
name: item.name,
|
|
582
|
+
full_name: item.full_name,
|
|
583
|
+
private: item.private,
|
|
584
|
+
html_url: item.html_url,
|
|
585
|
+
description: item.description,
|
|
586
|
+
updated_at: item.updated_at,
|
|
587
|
+
clone_url: item.clone_url,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
// No more pages.
|
|
591
|
+
if (items.length < perPage) {
|
|
592
|
+
return { repos, truncated: false };
|
|
593
|
+
}
|
|
594
|
+
if (page === maxPages) {
|
|
595
|
+
truncated = true;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return { repos, truncated };
|
|
599
|
+
}
|
|
600
|
+
|
|
357
601
|
/**
|
|
358
602
|
* Create a new repo on the authenticated user's account. Defaults to private
|
|
359
603
|
* because the operator's vault is more likely sensitive than public. The
|
|
360
604
|
* repo gets initialized empty (no README) so the first `git push` from the
|
|
361
605
|
* mirror lands the operator's vault as commit 1.
|
|
606
|
+
*
|
|
607
|
+
* **403 with the shared app is EXPECTED** (vault#480): `POST /user/repos`
|
|
608
|
+
* requires the Administration repository permission (write); the shared
|
|
609
|
+
* Parachute app is frozen at Contents-only, so this call only succeeds for
|
|
610
|
+
* BYO-app operators whose app grants Administration:write. Throws
|
|
611
|
+
* `GitHubApiError` carrying the status so the route can map the 403 to the
|
|
612
|
+
* guided-manual-creation error rather than a generic failure.
|
|
362
613
|
*/
|
|
363
614
|
export async function createRepo(
|
|
364
615
|
token: string,
|
|
@@ -388,8 +639,9 @@ export async function createRepo(
|
|
|
388
639
|
} catch {
|
|
389
640
|
// not JSON
|
|
390
641
|
}
|
|
391
|
-
throw new
|
|
642
|
+
throw new GitHubApiError(
|
|
392
643
|
`GitHub /user/repos create failed (${res.status}): ${parsed.message ?? body.slice(0, 200)}`,
|
|
644
|
+
res.status,
|
|
393
645
|
);
|
|
394
646
|
}
|
|
395
647
|
const item = (await res.json()) as {
|
package/src/hub-jwt.test.ts
CHANGED
|
@@ -14,7 +14,14 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
|
|
16
16
|
import { generateKeyPair, exportJWK, SignJWT } from "jose";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
resetJwksCache,
|
|
19
|
+
resetRevocationCache,
|
|
20
|
+
validateHubJwt,
|
|
21
|
+
looksLikeJwt,
|
|
22
|
+
getHubOrigin,
|
|
23
|
+
getJwksOrigin,
|
|
24
|
+
} from "./hub-jwt.ts";
|
|
18
25
|
|
|
19
26
|
interface Keypair {
|
|
20
27
|
privateKey: CryptoKey;
|
|
@@ -113,6 +120,7 @@ async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
|
|
|
113
120
|
let fixture: JwksFixture;
|
|
114
121
|
let kp: Keypair;
|
|
115
122
|
let prevHubOrigin: string | undefined;
|
|
123
|
+
let prevJwksOrigin: string | undefined;
|
|
116
124
|
|
|
117
125
|
beforeAll(async () => {
|
|
118
126
|
fixture = startJwksFixture();
|
|
@@ -124,12 +132,19 @@ afterAll(() => {
|
|
|
124
132
|
fixture.stop();
|
|
125
133
|
if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
126
134
|
else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
|
|
135
|
+
if (prevJwksOrigin === undefined) delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
136
|
+
else process.env.PARACHUTE_HUB_JWKS_ORIGIN = prevJwksOrigin;
|
|
127
137
|
});
|
|
128
138
|
|
|
129
139
|
beforeEach(() => {
|
|
130
|
-
// Each test sets its own origin for clarity.
|
|
140
|
+
// Each test sets its own origin for clarity. Post-vault#464 the JWKS *fetch*
|
|
141
|
+
// origin is resolved separately from the iss-validation origin, so point the
|
|
142
|
+
// JWKS fetch at the fixture too — otherwise the guard would read keys from
|
|
143
|
+
// the loopback default (no JWKS server there) and every case would fail.
|
|
131
144
|
prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
145
|
+
prevJwksOrigin = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
132
146
|
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
147
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = fixture.origin;
|
|
133
148
|
fixture.setUnreachable(false);
|
|
134
149
|
fixture.setKeys([kp]);
|
|
135
150
|
resetJwksCache();
|
|
@@ -153,6 +168,64 @@ describe("looksLikeJwt", () => {
|
|
|
153
168
|
});
|
|
154
169
|
});
|
|
155
170
|
|
|
171
|
+
describe("origin resolvers — iss/jwks split (vault#464)", () => {
|
|
172
|
+
test("getHubOrigin honors PARACHUTE_HUB_ORIGIN (iss-validation origin)", () => {
|
|
173
|
+
process.env.PARACHUTE_HUB_ORIGIN = "https://vault.example.com/";
|
|
174
|
+
expect(getHubOrigin()).toBe("https://vault.example.com");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("getHubOrigin falls back to loopback when unset", () => {
|
|
178
|
+
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
179
|
+
expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("getJwksOrigin defaults to loopback (no env override)", () => {
|
|
183
|
+
delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
184
|
+
expect(getJwksOrigin()).toBe("http://127.0.0.1:1939");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("getJwksOrigin honors PARACHUTE_HUB_JWKS_ORIGIN and strips trailing slash", () => {
|
|
188
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = "http://10.0.0.5:1939/";
|
|
189
|
+
expect(getJwksOrigin()).toBe("http://10.0.0.5:1939");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("jwks fetch is decoupled from the iss origin: keys served ONLY at the jwks origin still validate a token whose iss is the (separate) public origin", async () => {
|
|
193
|
+
// Mirrors the vault#464 deploy shape: iss + revocation live at the public
|
|
194
|
+
// origin (the default `fixture` here), while the JWKS is reachable only at
|
|
195
|
+
// a SEPARATE jwks origin (a second fixture standing in for loopback). The
|
|
196
|
+
// guard must fetch keys from the jwks origin, not the iss origin.
|
|
197
|
+
const jwksOnly = startJwksFixture();
|
|
198
|
+
jwksOnly.setKeys([kp]);
|
|
199
|
+
// The public iss origin (default fixture) serves revocation but NOT keys —
|
|
200
|
+
// if the guard fetched JWKS from here, verification would fail "no key".
|
|
201
|
+
fixture.setKeys([]);
|
|
202
|
+
try {
|
|
203
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin; // iss + revocation
|
|
204
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = jwksOnly.origin; // keys only
|
|
205
|
+
resetJwksCache();
|
|
206
|
+
const token = await signJwt(kp, { iss: fixture.origin });
|
|
207
|
+
const claims = await validateHubJwt(token);
|
|
208
|
+
expect(claims.sub).toBe("user-1");
|
|
209
|
+
} finally {
|
|
210
|
+
jwksOnly.stop();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("token whose iss does NOT match the iss origin is rejected even when keys resolve at the jwks origin", async () => {
|
|
215
|
+
const jwksOnly = startJwksFixture();
|
|
216
|
+
jwksOnly.setKeys([kp]);
|
|
217
|
+
try {
|
|
218
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
219
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = jwksOnly.origin;
|
|
220
|
+
resetJwksCache();
|
|
221
|
+
const token = await signJwt(kp, { iss: "https://attacker.example" });
|
|
222
|
+
await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
|
|
223
|
+
} finally {
|
|
224
|
+
jwksOnly.stop();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
156
229
|
describe("validateHubJwt — happy path", () => {
|
|
157
230
|
test("valid JWT with correct iss → claims surface", async () => {
|
|
158
231
|
const token = await signJwt(kp, { iss: fixture.origin, scope: "vault:work:read vault:work:write" });
|
package/src/hub-jwt.ts
CHANGED
|
@@ -23,13 +23,18 @@ import {
|
|
|
23
23
|
const DEFAULT_HUB_LOOPBACK = "http://127.0.0.1:1939";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Resolve the hub origin used to
|
|
26
|
+
* Resolve the hub origin used to validate the token's `iss` claim. Strips a
|
|
27
27
|
* trailing slash so we get a single canonical form.
|
|
28
28
|
*
|
|
29
29
|
* Order: env var → loopback fallback. We deliberately don't read
|
|
30
30
|
* `~/.parachute/services.json` — the hub is the dispatcher, not a registered
|
|
31
31
|
* service in that file. If a deployment exposes the hub on a non-default
|
|
32
32
|
* origin, the env var is the contract.
|
|
33
|
+
*
|
|
34
|
+
* `parachute expose` pins `PARACHUTE_HUB_ORIGIN` to the PUBLIC FQDN so the
|
|
35
|
+
* `iss` we validate against matches what the hub stamps on the tokens it
|
|
36
|
+
* mints — keep using this origin for iss-validation. The JWKS *fetch* origin
|
|
37
|
+
* is resolved separately by `getJwksOrigin()`; see vault#464.
|
|
33
38
|
*/
|
|
34
39
|
export function getHubOrigin(): string {
|
|
35
40
|
const env = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
|
|
@@ -37,12 +42,44 @@ export function getHubOrigin(): string {
|
|
|
37
42
|
return DEFAULT_HUB_LOOPBACK;
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the origin used to FETCH the hub's JWKS — kept distinct from
|
|
47
|
+
* `getHubOrigin()` (the iss-validation origin) per vault#464.
|
|
48
|
+
*
|
|
49
|
+
* Vault is co-located with its hub (the hub supervises vault on the same box,
|
|
50
|
+
* the common deploy). After `parachute expose --cloudflare`, `getHubOrigin()`
|
|
51
|
+
* is the public Cloudflare FQDN. If we fetched JWKS from that public origin we
|
|
52
|
+
* would hairpin out through the tunnel and back to the same box — a round-trip
|
|
53
|
+
* that times out (hard fail under Docker NAT-loopback, slow/flaky on a real
|
|
54
|
+
* VPS) and 401s the first MCP connect after expose. So we always read keys
|
|
55
|
+
* from the LOCAL hub on loopback instead.
|
|
56
|
+
*
|
|
57
|
+
* Order: `PARACHUTE_HUB_JWKS_ORIGIN` override → loopback default. The override
|
|
58
|
+
* exists for the rare non-co-located case — a vault running on a DIFFERENT box
|
|
59
|
+
* than its hub sets `PARACHUTE_HUB_JWKS_ORIGIN` to the hub's reachable
|
|
60
|
+
* internal address. Trailing-slash-stripped, matching `getHubOrigin()`.
|
|
61
|
+
*/
|
|
62
|
+
export function getJwksOrigin(): string {
|
|
63
|
+
const env = process.env.PARACHUTE_HUB_JWKS_ORIGIN?.replace(/\/$/, "");
|
|
64
|
+
if (env && env.length > 0) return env;
|
|
65
|
+
return DEFAULT_HUB_LOOPBACK;
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
// Process-wide guard. The resolver form lets tests flip
|
|
41
|
-
// `PARACHUTE_HUB_ORIGIN` between cases — the lib
|
|
42
|
-
// `validateHubJwt` and `resetJwksCache` call so the
|
|
43
|
-
// without a server restart. JWKS cache (5min/30s
|
|
44
|
-
// guard, shared across requests.
|
|
45
|
-
|
|
69
|
+
// `PARACHUTE_HUB_ORIGIN` / `PARACHUTE_HUB_JWKS_ORIGIN` between cases — the lib
|
|
70
|
+
// re-resolves on every `validateHubJwt` and `resetJwksCache` call so the
|
|
71
|
+
// env-var change picks up without a server restart. JWKS cache (5min/30s
|
|
72
|
+
// defaults) lives inside the guard, shared across requests.
|
|
73
|
+
//
|
|
74
|
+
// The iss/jwks split (vault#464): `hubOrigin` validates the token's `iss`
|
|
75
|
+
// (public FQDN post-expose, via PARACHUTE_HUB_ORIGIN); `jwksOrigin` fetches
|
|
76
|
+
// the keys from the local hub (loopback by default, via
|
|
77
|
+
// PARACHUTE_HUB_JWKS_ORIGIN). Co-located vault never egresses to read its own
|
|
78
|
+
// hub's keys, so no tunnel hairpin.
|
|
79
|
+
const guard = createScopeGuard({
|
|
80
|
+
hubOrigin: () => getHubOrigin(),
|
|
81
|
+
jwksOrigin: () => getJwksOrigin(),
|
|
82
|
+
});
|
|
46
83
|
|
|
47
84
|
/**
|
|
48
85
|
* Verify a presented JWT against the hub's JWKS. Throws `HubJwtError` on any
|