@openparachute/vault 0.6.0 → 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.
@@ -24,8 +24,11 @@
24
24
  * hand + restarting the vault.
25
25
  */
26
26
 
27
+ import { existsSync } from "node:fs";
28
+
27
29
  import {
28
30
  defaultMirrorConfig,
31
+ mirrorConfigPath,
29
32
  readMirrorConfigForVault,
30
33
  validateExternalPath,
31
34
  validateMirrorConfigShape,
@@ -46,11 +49,17 @@ import {
46
49
  type MirrorCredentials,
47
50
  } from "./mirror-credentials.ts";
48
51
  import {
52
+ GITHUB_APP_SLUG_DEFAULT,
53
+ GITHUB_CLIENT_ID_DEFAULT,
54
+ GitHubApiError,
49
55
  createRepo,
50
56
  fetchUser,
57
+ getGithubAppSlug,
51
58
  getGithubClientId,
59
+ installUrlForSlug,
52
60
  isPlaceholderClientId,
53
- listRepos,
61
+ listInstallationRepos,
62
+ listInstallations,
54
63
  pollForToken,
55
64
  requestDeviceCode,
56
65
  type FetchLike,
@@ -298,9 +307,10 @@ export function buildMirrorGetResponse(
298
307
  }
299
308
 
300
309
  // ---------------------------------------------------------------------------
301
- // Credential routes — Cut 3 of the UI-configurable push credentials work.
310
+ // Credential routes — Cut 3 of the UI-configurable push credentials work,
311
+ // reshaped by the GitHub-App installation semantics (vault#480).
302
312
  //
303
- // Six surfaces, all `vault:<name>:admin`-gated upstream:
313
+ // Surfaces, all `vault:<name>:admin`-gated upstream:
304
314
  //
305
315
  // POST /.parachute/mirror/auth/github/device-code — start GitHub Device
306
316
  // Flow; returns { polling_id, user_code, verification_uri, expires_in,
@@ -308,19 +318,29 @@ export function buildMirrorGetResponse(
308
318
  // by polling_id (a short opaque token) so the device_code doesn't
309
319
  // land on the wire twice.
310
320
  // POST /.parachute/mirror/auth/github/poll — poll for token, body
311
- // { polling_id }. On `granted`: fetch user, save credentials, set
312
- // remote URL, return { state: "granted", user }. Other states
313
- // surface verbatim.
321
+ // { polling_id }. On `granted`: fetch user, save credentials, enable
322
+ // history for a never-configured vault (vault#483), return
323
+ // { state: "granted", user, history_enabled }. Other states surface
324
+ // verbatim.
314
325
  // POST /.parachute/mirror/auth/pat — validate + store a
315
326
  // PAT (token + remote_url + label). Validates via `git ls-remote`.
327
+ // Same history-on-link behavior as the poll grant (vault#483).
316
328
  // GET /.parachute/mirror/auth — current connection
317
- // status (NO secrets). Returns the sanitized public shape.
329
+ // status (NO secrets, NO network). Returns the sanitized public shape.
318
330
  // DELETE /.parachute/mirror/auth — wipe credentials,
319
331
  // unset embedded-credential remote URL.
320
- // GET /.parachute/mirror/auth/github/repos list operator's
321
- // GitHub repos via stored OAuth token.
332
+ // GET /.parachute/mirror/auth/github/installations install state
333
+ // (vault#480): which app, whether it's installed anywhere, the
334
+ // install link, and the per-account installations. The one
335
+ // explicitly-network status endpoint — `GET /auth` stays offline.
336
+ // GET /.parachute/mirror/auth/github/repos — list the repos the
337
+ // operator's app INSTALLATIONS grant (user + org accounts), via the
338
+ // stored OAuth token. Returns { installed: false, install_url,
339
+ // repos: [] } when the app isn't installed anywhere yet.
322
340
  // POST /.parachute/mirror/auth/github/create-repo — create a new private
323
- // repo on behalf of the operator.
341
+ // repo on behalf of the operator. 403s with the shared Contents-only
342
+ // app (mapped to error_type "app_lacks_admin_permission" + the
343
+ // guided-manual path); works for BYO apps with Administration:write.
324
344
  //
325
345
  // ---------------------------------------------------------------------------
326
346
 
@@ -363,16 +383,18 @@ export function _resetDeviceFlowSessionsForTest(): void {
363
383
  }
364
384
 
365
385
  /**
366
- * Errors out cleanly when the operator hasn't replaced the placeholder
367
- * client_id. The user-facing message explains the next step.
386
+ * Errors out cleanly when PARACHUTE_GITHUB_CLIENT_ID is set to a
387
+ * placeholder-shaped value. A real default ships in the build (the shared
388
+ * Parachute GitHub App — see github-device-flow.ts), so this is only
389
+ * reachable via a misconfigured override.
368
390
  */
369
391
  function placeholderClientIdResponse(): Response {
370
392
  return Response.json(
371
393
  {
372
- error: "GitHub OAuth not configured",
394
+ error: "GitHub auth misconfigured",
373
395
  error_type: "placeholder_client_id",
374
396
  message:
375
- "This Parachute Vault build doesn't have a registered GitHub OAuth App client_id. Set the PARACHUTE_GITHUB_CLIENT_ID environment variable (see src/github-device-flow.ts for setup steps) or use the Personal Access Token path instead.",
397
+ "PARACHUTE_GITHUB_CLIENT_ID is set to a placeholder value. Unset it to use the built-in shared Parachute GitHub App, set it to your own app's client_id, or use the Personal Access Token path instead.",
376
398
  },
377
399
  { status: 503 },
378
400
  );
@@ -515,12 +537,12 @@ export async function handleAuthGithubPoll(
515
537
  }
516
538
  // Clean up the polling session.
517
539
  deviceFlowSessions.delete(body.polling_id);
518
- // Apply to git remote if mirror is currently running on an external
519
- // path that's a git repo. The credentials become active on next push;
520
- // the operator doesn't have to restart vault. We don't have an owner/
521
- // repo yet (the operator hasn't picked a repo) that wiring happens
522
- // in the create-repo or repo-picked path. So at this point we just
523
- // store credentials; the URL gets set when the operator picks a repo.
540
+ // vault#483: linking implies backup intent enable history for a
541
+ // never-configured vault (consent-respecting; see the helper). No
542
+ // remote URL is set here we don't have an owner/repo yet (the
543
+ // operator hasn't picked a repo); that wiring happens in select-repo,
544
+ // which is also where auto_push flips on (Cut 3).
545
+ const history_enabled = await maybeEnableHistoryOnLink(manager);
524
546
  return Response.json(
525
547
  {
526
548
  state: "granted",
@@ -530,6 +552,7 @@ export async function handleAuthGithubPoll(
530
552
  name: user.name,
531
553
  avatar_url: user.avatar_url,
532
554
  },
555
+ history_enabled,
533
556
  },
534
557
  { headers: { "Access-Control-Allow-Origin": "*" } },
535
558
  );
@@ -563,6 +586,14 @@ export async function handleAuthGithubPoll(
563
586
  export async function handleAuthPat(
564
587
  req: Request,
565
588
  manager: MirrorManager,
589
+ // Test seam for the `git ls-remote` validation probe (default
590
+ // `probeGitLsRemote`, which spawns real git against the supplied remote).
591
+ // Inject a fn returning `{ok: true}` to exercise the post-validation save
592
+ // path (credential persist + history-on-link, vault#483) hermetically.
593
+ probeOverride?: (
594
+ url: string,
595
+ timeoutMs: number,
596
+ ) => Promise<{ ok: boolean; error?: string }>,
566
597
  ): Promise<Response> {
567
598
  let body: { token?: unknown; remote_url?: unknown; label?: unknown };
568
599
  try {
@@ -657,7 +688,7 @@ export async function handleAuthPat(
657
688
  u.password = token;
658
689
  return u.toString();
659
690
  })();
660
- const probeResult = await probeGitLsRemote(authedUrl, 10_000);
691
+ const probeResult = await (probeOverride ?? probeGitLsRemote)(authedUrl, 10_000);
661
692
  if (!probeResult.ok) {
662
693
  return Response.json(
663
694
  {
@@ -694,6 +725,15 @@ export async function handleAuthPat(
694
725
  );
695
726
  }
696
727
 
728
+ // vault#483: linking implies backup intent — enable history for a
729
+ // never-configured vault BEFORE the Cut-3/Cut-6 steps below, so a fresh
730
+ // vault gets the whole intended flow in one save: history on (the reload's
731
+ // start() applies the just-written PAT remote to `origin`), then Cut 3
732
+ // sees an enabled mirror and flips auto_push on, then Cut 6 fires the
733
+ // initial push. An explicitly-disabled mirror short-circuits all of it
734
+ // (history stays off → maybeEnableAutoPush no-ops on disabled).
735
+ const history_enabled = await maybeEnableHistoryOnLink(manager);
736
+
697
737
  // Push the new URL onto the mirror's git remote if it's currently
698
738
  // resolved + on disk. Non-fatal if the mirror isn't running.
699
739
  await applyCredentialsToMirror(manager);
@@ -713,6 +753,7 @@ export async function handleAuthPat(
713
753
  return Response.json(
714
754
  {
715
755
  ...sanitizeCredentials(next),
756
+ history_enabled,
716
757
  auto_push_was_already_enabled: autoPushChange.was_already_enabled,
717
758
  auto_push_enabled: autoPushChange.auto_push_now_enabled,
718
759
  initial_push: initialPush,
@@ -811,8 +852,102 @@ export async function handleAuthDelete(manager: MirrorManager): Promise<Response
811
852
  }
812
853
 
813
854
  /**
814
- * `GET /.parachute/mirror/auth/github/repos` — list operator's repos via
815
- * the stored OAuth token. Requires `active_method === "github_oauth"`.
855
+ * `GET /.parachute/mirror/auth/github/installations` — install state for
856
+ * the connect flow (vault#480). Answers three UI questions in one call:
857
+ * which app is in play (shared default vs BYO), is it installed ANYWHERE
858
+ * for this operator, and which accounts (user/orgs) carry installations.
859
+ *
860
+ * Deliberately a separate, explicitly-network endpoint: `GET /auth` stays
861
+ * a pure local read of the stored credential. The SPA calls this when it
862
+ * renders the connect flow / repo picker, not on every status poll.
863
+ *
864
+ * Response:
865
+ * 200 {
866
+ * app: { client_id, slug, is_shared_default },
867
+ * installed: boolean, // installations.length > 0
868
+ * install_url: string, // github.com/apps/<slug>/installations/new
869
+ * installations: [{ id, account_login, account_type, repository_selection }]
870
+ * }
871
+ * 400 { error, error_type: "github_not_connected", message } — no stored
872
+ * github_oauth credential; the device flow hasn't been run (or a PAT
873
+ * is active instead — install state is a GitHub-App concept). 400,
874
+ * not 401, matching the sibling repos handler: a 401 here would trip
875
+ * the SPA's authedFetch token-refresh machinery and clear a
876
+ * perfectly valid cached admin token over a non-auth condition.
877
+ * 502 { error, message } — GitHub unreachable / API error.
878
+ */
879
+ export async function handleAuthGithubInstallations(
880
+ manager: MirrorManager,
881
+ fetchImpl?: FetchLike,
882
+ ): Promise<Response> {
883
+ const creds = readCredentials(manager.getVaultName());
884
+ if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
885
+ return Response.json(
886
+ {
887
+ error: "Not connected to GitHub",
888
+ error_type: "github_not_connected",
889
+ message:
890
+ "No GitHub sign-in is stored for this vault. Run the device flow first (POST /.parachute/mirror/auth/github/device-code).",
891
+ },
892
+ { status: 400 },
893
+ );
894
+ }
895
+ const clientId = getGithubClientId();
896
+ const slug = getGithubAppSlug();
897
+ let installations;
898
+ try {
899
+ installations = await listInstallations(creds.github_oauth.access_token, fetchImpl);
900
+ } catch (err) {
901
+ return Response.json(
902
+ {
903
+ error: "Installation check failed",
904
+ message: (err as Error).message ?? String(err),
905
+ },
906
+ { status: 502 },
907
+ );
908
+ }
909
+ return Response.json(
910
+ {
911
+ app: {
912
+ client_id: clientId,
913
+ slug,
914
+ is_shared_default:
915
+ clientId === GITHUB_CLIENT_ID_DEFAULT && slug === GITHUB_APP_SLUG_DEFAULT,
916
+ },
917
+ installed: installations.length > 0,
918
+ install_url: installUrlForSlug(slug),
919
+ installations: installations.map((i) => ({
920
+ id: i.id,
921
+ account_login: i.account.login,
922
+ account_type: i.account.type,
923
+ repository_selection: i.repository_selection,
924
+ })),
925
+ },
926
+ { headers: { "Access-Control-Allow-Origin": "*" } },
927
+ );
928
+ }
929
+
930
+ /**
931
+ * `GET /.parachute/mirror/auth/github/repos` — list the repos the
932
+ * operator's app installations grant, via the stored OAuth token. Requires
933
+ * `active_method === "github_oauth"`.
934
+ *
935
+ * Source (vault#480): `GET /user/installations` → per-installation
936
+ * `GET /user/installations/{id}/repositories`, unioned. This replaces the
937
+ * old `GET /user/repos?type=owner` source, which (a) excluded org-owned
938
+ * repos by construction and (b) showed all-public-repos when the app wasn't
939
+ * installed at all (every GitHub App reads public repos) — Aaron walked
940
+ * into exactly that "looks connected, shows the wrong repos" state live.
941
+ *
942
+ * Response:
943
+ * 200 installed: { installed: true, repos: [{ ...GitHubRepoInfo,
944
+ * account_login, installation_id }], truncated }
945
+ * 200 not installed: { installed: false, install_url, repos: [],
946
+ * truncated: false } — machine-readable
947
+ * authorized-but-not-installed state; the UI shows
948
+ * the guided-install step, no string-matching needed.
949
+ * 400 { error, message } — no github_oauth credential stored.
950
+ * 502 { error, message } — GitHub unreachable / API error.
816
951
  */
817
952
  export async function handleAuthGithubRepos(
818
953
  manager: MirrorManager,
@@ -828,9 +963,58 @@ export async function handleAuthGithubRepos(
828
963
  { status: 400 },
829
964
  );
830
965
  }
831
- let result;
966
+ const token = creds.github_oauth.access_token;
967
+ let installations;
968
+ try {
969
+ installations = await listInstallations(token, fetchImpl);
970
+ } catch (err) {
971
+ return Response.json(
972
+ {
973
+ error: "Repo list failed",
974
+ message: (err as Error).message ?? String(err),
975
+ },
976
+ { status: 502 },
977
+ );
978
+ }
979
+
980
+ if (installations.length === 0) {
981
+ // Authorized but not installed — the device-flow grant alone reaches no
982
+ // repos. Distinct, machine-readable state (NOT an empty repo list that
983
+ // looks like "you have no repos").
984
+ return Response.json(
985
+ {
986
+ installed: false,
987
+ install_url: installUrlForSlug(getGithubAppSlug()),
988
+ repos: [],
989
+ truncated: false,
990
+ },
991
+ { headers: { "Access-Control-Allow-Origin": "*" } },
992
+ );
993
+ }
994
+
995
+ const repos: Array<GitHubRepoInfo & { account_login: string; installation_id: number }> = [];
996
+ let truncated = false;
832
997
  try {
833
- result = await listRepos(creds.github_oauth.access_token, {}, fetchImpl);
998
+ for (const installation of installations) {
999
+ const result = await listInstallationRepos(token, installation.id, {}, fetchImpl);
1000
+ // `GET /user/installations/{id}/repositories` takes no `sort` param
1001
+ // (unlike the old `GET /user/repos?sort=updated` picker source), so
1002
+ // order within each account group ourselves: most-recently-updated
1003
+ // first, so the repo the operator probably wants sits near the top.
1004
+ // ISO-8601 timestamps compare lexicographically. Per-group (not
1005
+ // across the union) so the SPA's account grouping stays contiguous.
1006
+ const sorted = [...result.repos].sort((a, b) =>
1007
+ b.updated_at.localeCompare(a.updated_at),
1008
+ );
1009
+ for (const repo of sorted) {
1010
+ repos.push({
1011
+ ...repo,
1012
+ account_login: installation.account.login,
1013
+ installation_id: installation.id,
1014
+ });
1015
+ }
1016
+ if (result.truncated) truncated = true;
1017
+ }
834
1018
  } catch (err) {
835
1019
  return Response.json(
836
1020
  {
@@ -841,7 +1025,7 @@ export async function handleAuthGithubRepos(
841
1025
  );
842
1026
  }
843
1027
  return Response.json(
844
- { repos: result.repos, truncated: result.truncated },
1028
+ { installed: true, repos, truncated },
845
1029
  { headers: { "Access-Control-Allow-Origin": "*" } },
846
1030
  );
847
1031
  }
@@ -850,6 +1034,15 @@ export async function handleAuthGithubRepos(
850
1034
  * `POST /.parachute/mirror/auth/github/create-repo` — create a new repo on
851
1035
  * the operator's account, return the new RepoInfo. The SPA flows straight
852
1036
  * from this into the "repo selected" state.
1037
+ *
1038
+ * With the shared Parachute app this 403s by design (vault#480):
1039
+ * `POST /user/repos` needs Administration:write; the shared app is frozen
1040
+ * at Contents-only. The 403 maps to error_type "app_lacks_admin_permission"
1041
+ * with the guided-manual path (create at github.com/new → add it to the
1042
+ * installation → refresh the picker). The endpoint stays functional for
1043
+ * BYO-app operators whose app grants Administration:write — and per the
1044
+ * install docs, app-created repos auto-join the installation, so their
1045
+ * create→push flow works end-to-end.
853
1046
  */
854
1047
  export async function handleAuthGithubCreateRepo(
855
1048
  req: Request,
@@ -892,6 +1085,20 @@ export async function handleAuthGithubCreateRepo(
892
1085
  fetchImpl,
893
1086
  );
894
1087
  } catch (err) {
1088
+ if (err instanceof GitHubApiError && err.status === 403) {
1089
+ // Expected with the shared Contents-only app: POST /user/repos needs
1090
+ // Administration:write. Actionable, machine-readable mapping — the UI
1091
+ // renders the guided-manual checklist off error_type, not a string.
1092
+ return Response.json(
1093
+ {
1094
+ error: "Create not permitted for this GitHub App",
1095
+ error_type: "app_lacks_admin_permission",
1096
+ message:
1097
+ "The Parachute GitHub App can't create repositories (it only has Contents permission — creating repos needs Administration). Create it manually instead: 1) create a private, uninitialized repo at github.com/new, 2) add it to the app installation (GitHub → Settings → Applications → Configure), 3) refresh the repo list here and pick it.",
1098
+ },
1099
+ { status: 403 },
1100
+ );
1101
+ }
895
1102
  return Response.json(
896
1103
  { error: "Create failed", message: (err as Error).message ?? String(err) },
897
1104
  { status: 502 },
@@ -947,6 +1154,18 @@ export async function handleAuthGithubSelectRepo(
947
1154
  name,
948
1155
  );
949
1156
 
1157
+ // vault#483: linking implies backup intent — the choose-repo re-entry can
1158
+ // be the FIRST credential-linked action for this vault (a credential saved
1159
+ // before history-on-link existed, on a never-configured vault: the #483
1160
+ // "linked but silently inert" state). Run history-on-link here too, BEFORE
1161
+ // the Cut-3/Cut-6 steps below and mirroring handleAuthPat's ordering:
1162
+ // history on (the reload's start() resolves mirror_path so the remote
1163
+ // write below actually lands), then Cut 3 sees an enabled mirror and flips
1164
+ // auto_push, then Cut 6 fires the initial push. An explicitly-disabled
1165
+ // mirror short-circuits all of it (history stays off → maybeEnableAutoPush
1166
+ // no-ops on disabled).
1167
+ const history_enabled = await maybeEnableHistoryOnLink(manager);
1168
+
950
1169
  // Apply to the mirror dir if running. If the mirror isn't running (no
951
1170
  // mirror_path), we still consider this a success — the credentials are
952
1171
  // stored, and the URL will get applied next time the mirror starts.
@@ -980,6 +1199,7 @@ export async function handleAuthGithubSelectRepo(
980
1199
  // Echo the redacted form back so the SPA can show "pushing to <repo>".
981
1200
  // No raw token in the response.
982
1201
  remote: `https://github.com/${owner}/${name}.git`,
1202
+ history_enabled,
983
1203
  auto_push_was_already_enabled: autoPushChange.was_already_enabled,
984
1204
  auto_push_enabled: autoPushChange.auto_push_now_enabled,
985
1205
  initial_push: initialPush,
@@ -988,6 +1208,73 @@ export async function handleAuthGithubSelectRepo(
988
1208
  );
989
1209
  }
990
1210
 
1211
+ /**
1212
+ * vault#483 fix 1 — linking implies backup intent. Outcome flag for the
1213
+ * credential-save responses:
1214
+ * - `true` — history (the mirror) is on: either this link just enabled it
1215
+ * (never-configured vault) or it was already enabled.
1216
+ * - `"left_disabled"` — a mirror config exists with `enabled: false`; we
1217
+ * refuse to silently flip an explicit operator choice. The UI should
1218
+ * offer the one-click "Turn on history now?" enable.
1219
+ * - `false` — we tried to enable history but the mirror didn't come up
1220
+ * (e.g. git missing). Config intent is persisted (PUT semantics); the
1221
+ * mirror status carries the actionable error.
1222
+ */
1223
+ export type HistoryOnLink = true | false | "left_disabled";
1224
+
1225
+ /**
1226
+ * When a GitHub credential lands (device-flow grant or PAT save), turn
1227
+ * history on for a vault that has NEVER had a mirror configured — nobody
1228
+ * links GitHub to a vault for any reason other than backing it up. Aaron hit
1229
+ * this live (vault#483): pre-#440 vaults have no mirror-config file, so
1230
+ * linking stored a credential and then... nothing, with the only path out
1231
+ * buried in advanced settings.
1232
+ *
1233
+ * Consent-respecting by design:
1234
+ * - **No config file** (never configured) → enable the internal mirror
1235
+ * with the standard defaults (enabled, internal, events, auto_commit on,
1236
+ * auto_push off — the same shape #440 gives new vaults). Writes through
1237
+ * `manager.reload()` — the exact path the PUT handler uses — so persist +
1238
+ * lifecycle-start stay one code path.
1239
+ * - **Config file exists with enabled:false** → explicit operator choice;
1240
+ * do NOT flip it. Return `"left_disabled"` so the UI can offer one-click
1241
+ * enable instead.
1242
+ * - **Config file exists with enabled:true** → nothing to do.
1243
+ *
1244
+ * The file-existence check (not just the parsed read) is the load-bearing
1245
+ * distinction: an existing-but-malformed file parses to `undefined`, same as
1246
+ * absent — but it still represents operator-touched state, so we treat it as
1247
+ * "exists, not known-enabled" and leave it alone.
1248
+ */
1249
+ async function maybeEnableHistoryOnLink(
1250
+ manager: MirrorManager,
1251
+ ): Promise<HistoryOnLink> {
1252
+ const vaultName = manager.getVaultName();
1253
+ if (!existsSync(mirrorConfigPath(vaultName))) {
1254
+ try {
1255
+ const status = await manager.reload({
1256
+ ...defaultMirrorConfig(),
1257
+ enabled: true,
1258
+ });
1259
+ if (!status.enabled) {
1260
+ console.warn(
1261
+ `[mirror-auth] history-on-link: enabled the mirror config for vault "${vaultName}" but it didn't start: ${status.last_error ?? "unknown error"}`,
1262
+ );
1263
+ return false;
1264
+ }
1265
+ return true;
1266
+ } catch (err) {
1267
+ console.warn(
1268
+ `[mirror-auth] history-on-link: could not enable the mirror for vault "${vaultName}" (non-fatal): ${(err as Error).message ?? err}`,
1269
+ );
1270
+ return false;
1271
+ }
1272
+ }
1273
+ const persisted = readMirrorConfigForVault(vaultName);
1274
+ if (persisted?.enabled) return true;
1275
+ return "left_disabled";
1276
+ }
1277
+
991
1278
  /**
992
1279
  * Cut 3: when credentials save, flip `auto_push` from false → true on
993
1280
  * the persisted config. Operators wiring credentials almost certainly
package/src/routes.ts CHANGED
@@ -15,6 +15,12 @@ import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
16
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
17
17
  import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
18
+ import {
19
+ parseContentRange,
20
+ applyContentRange,
21
+ contentRangeRequiresContent,
22
+ type ContentRange,
23
+ } from "../core/src/content-range.ts";
18
24
  import { attachValidationStatus } from "../core/src/mcp.ts";
19
25
  import * as linkOps from "../core/src/links.ts";
20
26
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
@@ -103,6 +109,37 @@ function parseQueryList(url: URL, key: string): string[] | undefined {
103
109
  return val ? val.split(",") : undefined;
104
110
  }
105
111
 
112
+ /**
113
+ * Parse `?content_offset=` / `?content_length=` (content range — bounded
114
+ * reads for large notes; unit is UTF-8 bytes, see core/src/content-range.ts
115
+ * for the codepoint-boundary slicing rules). Returns `{ range: null }` when
116
+ * neither param is present (response byte-identical to the no-pagination
117
+ * shape), or `{ error }` (400 INVALID_QUERY) on invalid values or when the
118
+ * response shape excludes content — range params on a content-less shape
119
+ * error loudly rather than silently no-op, same policy as `?expand=`.
120
+ */
121
+ function parseContentRangeQuery(
122
+ url: URL,
123
+ includeContent: boolean,
124
+ ): { range: ContentRange | null; error?: Response } {
125
+ try {
126
+ const range = parseContentRange(
127
+ parseQuery(url, "content_offset") ?? undefined,
128
+ parseQuery(url, "content_length") ?? undefined,
129
+ );
130
+ if (range && !includeContent) throw contentRangeRequiresContent();
131
+ return { range };
132
+ } catch (e: any) {
133
+ // Duck-type on `name` — core is a separate module, so `instanceof`
134
+ // is fragile across bundling boundaries (same note as the QueryError
135
+ // handling in the structured-query path below).
136
+ if (e && e.name === "QueryError") {
137
+ return { range: null, error: json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400) };
138
+ }
139
+ throw e;
140
+ }
141
+ }
142
+
106
143
  /**
107
144
  * Parse the extension query parameter (vault#328). Two accepted shapes:
108
145
  * - `?extension=csv` (single value → string)
@@ -706,12 +743,18 @@ async function handleNotesInner(
706
743
  return json({ error: "Note not found", id }, 404);
707
744
  }
708
745
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
746
+ const contentRange = parseContentRangeQuery(url, includeContent);
747
+ if (contentRange.error) return contentRange.error;
709
748
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
710
749
  const expand = parseExpandParams(url, db, tagScope);
711
750
  if (expand && includeContent && typeof result.content === "string") {
712
751
  expand.ctx.expanded.add(note.id);
713
752
  result.content = expandContent(result.content, expand.ctx, expand.depth);
714
753
  }
754
+ // Content range applies to the FINAL returned content (post-
755
+ // expansion) — the window the client pages through is the same
756
+ // document it would have received unpaged.
757
+ if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
715
758
  result = filterMetadata(result, parseIncludeMetadata(url));
716
759
  if (parseBool(parseQuery(url, "include_links"), false)) {
717
760
  // Tag-scope: drop links whose neighbor is out of scope so the
@@ -769,6 +812,8 @@ async function handleNotesInner(
769
812
  // returns 200 [] (consistent with "no matches"), not 403.
770
813
  const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
771
814
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
815
+ const contentRange = parseContentRangeQuery(url, includeContent);
816
+ if (contentRange.error) return contentRange.error;
772
817
  const inclMeta = parseIncludeMetadata(url);
773
818
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
774
819
  const expand = parseExpandParams(url, db, tagScope);
@@ -780,6 +825,10 @@ async function handleNotesInner(
780
825
  }
781
826
  }
782
827
  }
828
+ // Content range — per-note, post-expansion (see core/src/content-range.ts).
829
+ if (contentRange.range && includeContent) {
830
+ for (const n of output) applyContentRange(n, contentRange.range);
831
+ }
783
832
  if (inclMeta !== undefined && inclMeta !== true) {
784
833
  output = output.map((n: any) => filterMetadata(n, inclMeta));
785
834
  }
@@ -910,6 +959,8 @@ async function handleNotesInner(
910
959
  results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
911
960
 
912
961
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
962
+ const contentRange = parseContentRangeQuery(url, includeContent);
963
+ if (contentRange.error) return contentRange.error;
913
964
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
914
965
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
915
966
  const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
@@ -924,6 +975,10 @@ async function handleNotesInner(
924
975
  }
925
976
  }
926
977
  }
978
+ // Content range — per-note, post-expansion (see core/src/content-range.ts).
979
+ if (contentRange.range && includeContent) {
980
+ for (const n of output) applyContentRange(n, contentRange.range);
981
+ }
927
982
  if (inclMeta !== undefined && inclMeta !== true) {
928
983
  output = output.map((n: any) => filterMetadata(n, inclMeta));
929
984
  }
@@ -952,8 +1007,9 @@ async function handleNotesInner(
952
1007
  const nodes = output.map((n: any) => ({ id: n.id, path: n.path ?? null, tags: n.tags ?? [] }));
953
1008
  const edges: { source: string; target: string; relationship: string }[] = [];
954
1009
  if (includeLinks) {
1010
+ const linksByNote = linkOps.getLinksHydratedForNotes(db, results.map((n) => n.id));
955
1011
  for (const n of results) {
956
- for (const link of linkOps.getLinksHydrated(db, n.id)) {
1012
+ for (const link of linksByNote.get(n.id) ?? []) {
957
1013
  // Only include edges where source is this note and target is in the result set
958
1014
  if (link.sourceId === n.id && resultIds.has(link.targetId)) {
959
1015
  edges.push({ source: link.sourceId, target: link.targetId, relationship: link.relationship });
@@ -965,13 +1021,19 @@ async function handleNotesInner(
965
1021
  }
966
1022
 
967
1023
  if (includeLinks || includeAttachments) {
1024
+ // Whole-page link hydration in a constant number of queries — the
1025
+ // per-note variant cost (1 link query + 1 summary query + N tag
1026
+ // queries) × page size. 2026-06-10 perf measurements.
1027
+ const linksByNote = includeLinks
1028
+ ? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
1029
+ : null;
968
1030
  const enrichedOut: any[] = [];
969
1031
  for (const n of output) {
970
1032
  const enriched: any = { ...n };
971
- if (includeLinks) {
1033
+ if (linksByNote) {
972
1034
  // Tag-scope: strip out-of-scope-neighbor links (no-op unscoped).
973
1035
  enriched.links = filterHydratedLinksByTagScope(
974
- linkOps.getLinksHydrated(db, n.id),
1036
+ linksByNote.get(n.id) ?? [],
975
1037
  tagScope.allowed,
976
1038
  tagScope.raw,
977
1039
  );
@@ -1252,12 +1314,16 @@ async function handleNotesInner(
1252
1314
  return json({ error: "Not found" }, 404);
1253
1315
  }
1254
1316
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
1317
+ const contentRange = parseContentRangeQuery(url, includeContent);
1318
+ if (contentRange.error) return contentRange.error;
1255
1319
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
1256
1320
  const expand = parseExpandParams(url, db, tagScope);
1257
1321
  if (expand && includeContent && typeof result.content === "string") {
1258
1322
  expand.ctx.expanded.add(note.id);
1259
1323
  result.content = expandContent(result.content, expand.ctx, expand.depth);
1260
1324
  }
1325
+ // Content range applies to the FINAL returned content (post-expansion).
1326
+ if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
1261
1327
  result = filterMetadata(result, parseIncludeMetadata(url));
1262
1328
  if (parseBool(parseQuery(url, "include_links"), false)) {
1263
1329
  // Tag-scope: drop out-of-scope-neighbor links (no-op unscoped).
package/src/routing.ts CHANGED
@@ -87,6 +87,7 @@ import {
87
87
  handleAuthGet,
88
88
  handleAuthGithubCreateRepo,
89
89
  handleAuthGithubDeviceCode,
90
+ handleAuthGithubInstallations,
90
91
  handleAuthGithubPoll,
91
92
  handleAuthGithubRepos,
92
93
  handleAuthGithubSelectRepo,
@@ -735,6 +736,13 @@ export async function route(
735
736
  if (req.method === "POST") return handleAuthGithubPoll(req, manager);
736
737
  return Response.json({ error: "Method not allowed" }, { status: 405 });
737
738
  }
739
+ if (subpath === "/.parachute/mirror/auth/github/installations") {
740
+ // Install state (vault#480) — which app, installed-anywhere?, install
741
+ // link, per-account installations. Explicitly-network (probes GitHub);
742
+ // the offline status read stays on GET /.parachute/mirror/auth.
743
+ if (req.method === "GET") return handleAuthGithubInstallations(manager);
744
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
745
+ }
738
746
  if (subpath === "/.parachute/mirror/auth/github/repos") {
739
747
  if (req.method === "GET") return handleAuthGithubRepos(manager);
740
748
  return Response.json({ error: "Method not allowed" }, { status: 405 });