@openparachute/vault 0.6.0 → 0.6.2-rc.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.
@@ -1,66 +1,110 @@
1
1
  /**
2
- * GitHub OAuth Device Flow client + supporting API calls.
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 per OAuth app; Device Flow needs
7
- * only a public `client_id` and the operator authorizes by typing a code at
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/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
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
- * **GITHUB_CLIENT_ID setup (REQUIRED before this works in production):**
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
- * 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
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
- * 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.
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 — REPLACE BEFORE TAGGING A RELEASE.
49
+ // Client ID — the shared Parachute GitHub App (public; safe to commit).
35
50
  //
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).
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 GITHUB_CLIENT_ID_PLACEHOLDER =
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 — 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.
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 || GITHUB_CLIENT_ID_PLACEHOLDER;
66
+ return process.env.PARACHUTE_GITHUB_CLIENT_ID || GITHUB_CLIENT_ID_DEFAULT;
59
67
  }
60
68
 
61
- /** Returns true when no real client id has been configured. */
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 === GITHUB_CLIENT_ID_PLACEHOLDER || clientId.includes("PLACEHOLDER");
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 + the `repo` scope (the minimum we need to push
143
- * to the operator's private repos).
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 Error(
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 {