@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.
@@ -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,
@@ -66,6 +75,10 @@ import {
66
75
  type ImportResult,
67
76
  } from "./mirror-import.ts";
68
77
  import { redactToken } from "./export-watch.ts";
78
+ import {
79
+ findConflictingVault,
80
+ remoteConflictMessage,
81
+ } from "./mirror-remote-guard.ts";
69
82
  import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
70
83
  import { getVaultStore } from "./vault-store.ts";
71
84
  import { assetsDir } from "./routes.ts";
@@ -298,9 +311,10 @@ export function buildMirrorGetResponse(
298
311
  }
299
312
 
300
313
  // ---------------------------------------------------------------------------
301
- // Credential routes — Cut 3 of the UI-configurable push credentials work.
314
+ // Credential routes — Cut 3 of the UI-configurable push credentials work,
315
+ // reshaped by the GitHub-App installation semantics (vault#480).
302
316
  //
303
- // Six surfaces, all `vault:<name>:admin`-gated upstream:
317
+ // Surfaces, all `vault:<name>:admin`-gated upstream:
304
318
  //
305
319
  // POST /.parachute/mirror/auth/github/device-code — start GitHub Device
306
320
  // Flow; returns { polling_id, user_code, verification_uri, expires_in,
@@ -308,19 +322,29 @@ export function buildMirrorGetResponse(
308
322
  // by polling_id (a short opaque token) so the device_code doesn't
309
323
  // land on the wire twice.
310
324
  // 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.
325
+ // { polling_id }. On `granted`: fetch user, save credentials, enable
326
+ // history for a never-configured vault (vault#483), return
327
+ // { state: "granted", user, history_enabled }. Other states surface
328
+ // verbatim.
314
329
  // POST /.parachute/mirror/auth/pat — validate + store a
315
330
  // PAT (token + remote_url + label). Validates via `git ls-remote`.
331
+ // Same history-on-link behavior as the poll grant (vault#483).
316
332
  // GET /.parachute/mirror/auth — current connection
317
- // status (NO secrets). Returns the sanitized public shape.
333
+ // status (NO secrets, NO network). Returns the sanitized public shape.
318
334
  // DELETE /.parachute/mirror/auth — wipe credentials,
319
335
  // unset embedded-credential remote URL.
320
- // GET /.parachute/mirror/auth/github/repos list operator's
321
- // GitHub repos via stored OAuth token.
336
+ // GET /.parachute/mirror/auth/github/installations install state
337
+ // (vault#480): which app, whether it's installed anywhere, the
338
+ // install link, and the per-account installations. The one
339
+ // explicitly-network status endpoint — `GET /auth` stays offline.
340
+ // GET /.parachute/mirror/auth/github/repos — list the repos the
341
+ // operator's app INSTALLATIONS grant (user + org accounts), via the
342
+ // stored OAuth token. Returns { installed: false, install_url,
343
+ // repos: [] } when the app isn't installed anywhere yet.
322
344
  // POST /.parachute/mirror/auth/github/create-repo — create a new private
323
- // repo on behalf of the operator.
345
+ // repo on behalf of the operator. 403s with the shared Contents-only
346
+ // app (mapped to error_type "app_lacks_admin_permission" + the
347
+ // guided-manual path); works for BYO apps with Administration:write.
324
348
  //
325
349
  // ---------------------------------------------------------------------------
326
350
 
@@ -363,16 +387,18 @@ export function _resetDeviceFlowSessionsForTest(): void {
363
387
  }
364
388
 
365
389
  /**
366
- * Errors out cleanly when the operator hasn't replaced the placeholder
367
- * client_id. The user-facing message explains the next step.
390
+ * Errors out cleanly when PARACHUTE_GITHUB_CLIENT_ID is set to a
391
+ * placeholder-shaped value. A real default ships in the build (the shared
392
+ * Parachute GitHub App — see github-device-flow.ts), so this is only
393
+ * reachable via a misconfigured override.
368
394
  */
369
395
  function placeholderClientIdResponse(): Response {
370
396
  return Response.json(
371
397
  {
372
- error: "GitHub OAuth not configured",
398
+ error: "GitHub auth misconfigured",
373
399
  error_type: "placeholder_client_id",
374
400
  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.",
401
+ "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
402
  },
377
403
  { status: 503 },
378
404
  );
@@ -515,12 +541,12 @@ export async function handleAuthGithubPoll(
515
541
  }
516
542
  // Clean up the polling session.
517
543
  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.
544
+ // vault#483: linking implies backup intent enable history for a
545
+ // never-configured vault (consent-respecting; see the helper). No
546
+ // remote URL is set here we don't have an owner/repo yet (the
547
+ // operator hasn't picked a repo); that wiring happens in select-repo,
548
+ // which is also where auto_push flips on (Cut 3).
549
+ const history_enabled = await maybeEnableHistoryOnLink(manager);
524
550
  return Response.json(
525
551
  {
526
552
  state: "granted",
@@ -530,6 +556,7 @@ export async function handleAuthGithubPoll(
530
556
  name: user.name,
531
557
  avatar_url: user.avatar_url,
532
558
  },
559
+ history_enabled,
533
560
  },
534
561
  { headers: { "Access-Control-Allow-Origin": "*" } },
535
562
  );
@@ -563,8 +590,16 @@ export async function handleAuthGithubPoll(
563
590
  export async function handleAuthPat(
564
591
  req: Request,
565
592
  manager: MirrorManager,
593
+ // Test seam for the `git ls-remote` validation probe (default
594
+ // `probeGitLsRemote`, which spawns real git against the supplied remote).
595
+ // Inject a fn returning `{ok: true}` to exercise the post-validation save
596
+ // path (credential persist + history-on-link, vault#483) hermetically.
597
+ probeOverride?: (
598
+ url: string,
599
+ timeoutMs: number,
600
+ ) => Promise<{ ok: boolean; error?: string }>,
566
601
  ): Promise<Response> {
567
- let body: { token?: unknown; remote_url?: unknown; label?: unknown };
602
+ let body: { token?: unknown; remote_url?: unknown; label?: unknown; override?: unknown };
568
603
  try {
569
604
  body = (await req.json()) as Record<string, unknown>;
570
605
  } catch (err) {
@@ -573,6 +608,7 @@ export async function handleAuthPat(
573
608
  { status: 400 },
574
609
  );
575
610
  }
611
+ const override = body.override === true;
576
612
  const token = typeof body.token === "string" ? body.token.trim() : "";
577
613
  const remote_url = typeof body.remote_url === "string" ? body.remote_url.trim() : "";
578
614
  const label =
@@ -657,7 +693,7 @@ export async function handleAuthPat(
657
693
  u.password = token;
658
694
  return u.toString();
659
695
  })();
660
- const probeResult = await probeGitLsRemote(authedUrl, 10_000);
696
+ const probeResult = await (probeOverride ?? probeGitLsRemote)(authedUrl, 10_000);
661
697
  if (!probeResult.ok) {
662
698
  return Response.json(
663
699
  {
@@ -668,11 +704,33 @@ export async function handleAuthPat(
668
704
  );
669
705
  }
670
706
 
707
+ // vault#482: refuse if ANOTHER vault on this server already pushes to this
708
+ // repo. Two vaults sharing one remote force-push over each other's backups
709
+ // (silent data loss). Compare against the operator-supplied (un-authed)
710
+ // remote_url — the normalizer strips userinfo so the token doesn't matter.
711
+ // Re-pointing THIS vault at its own remote is fine (the scan excludes it).
712
+ // `override=true` is the explicit escape hatch.
713
+ const vaultName = manager.getVaultName();
714
+ if (!override) {
715
+ const conflict = findConflictingVault(vaultName, remote_url);
716
+ if (conflict) {
717
+ return Response.json(
718
+ {
719
+ error: "Remote already in use by another vault",
720
+ error_type: "remote_conflict",
721
+ conflicting_vault: conflict.conflictingVault,
722
+ remote: conflict.remoteIdentity,
723
+ message: remoteConflictMessage(conflict),
724
+ },
725
+ { status: 409 },
726
+ );
727
+ }
728
+ }
729
+
671
730
  // Save the userinfo'd URL — that's what gets embedded as `origin` so
672
731
  // bare `git push` works without needing GIT_ASKPASS etc. Per-vault
673
732
  // (vault#399): the PAT + remote_url land in this vault's own file, not a
674
733
  // server-wide one — so configuring vault B never reuses vault A's remote.
675
- const vaultName = manager.getVaultName();
676
734
  const next: MirrorCredentials = {
677
735
  ...(readCredentials(vaultName) ?? emptyCredentials()),
678
736
  active_method: "pat",
@@ -694,6 +752,15 @@ export async function handleAuthPat(
694
752
  );
695
753
  }
696
754
 
755
+ // vault#483: linking implies backup intent — enable history for a
756
+ // never-configured vault BEFORE the Cut-3/Cut-6 steps below, so a fresh
757
+ // vault gets the whole intended flow in one save: history on (the reload's
758
+ // start() applies the just-written PAT remote to `origin`), then Cut 3
759
+ // sees an enabled mirror and flips auto_push on, then Cut 6 fires the
760
+ // initial push. An explicitly-disabled mirror short-circuits all of it
761
+ // (history stays off → maybeEnableAutoPush no-ops on disabled).
762
+ const history_enabled = await maybeEnableHistoryOnLink(manager);
763
+
697
764
  // Push the new URL onto the mirror's git remote if it's currently
698
765
  // resolved + on disk. Non-fatal if the mirror isn't running.
699
766
  await applyCredentialsToMirror(manager);
@@ -713,6 +780,7 @@ export async function handleAuthPat(
713
780
  return Response.json(
714
781
  {
715
782
  ...sanitizeCredentials(next),
783
+ history_enabled,
716
784
  auto_push_was_already_enabled: autoPushChange.was_already_enabled,
717
785
  auto_push_enabled: autoPushChange.auto_push_now_enabled,
718
786
  initial_push: initialPush,
@@ -811,8 +879,102 @@ export async function handleAuthDelete(manager: MirrorManager): Promise<Response
811
879
  }
812
880
 
813
881
  /**
814
- * `GET /.parachute/mirror/auth/github/repos` — list operator's repos via
815
- * the stored OAuth token. Requires `active_method === "github_oauth"`.
882
+ * `GET /.parachute/mirror/auth/github/installations` — install state for
883
+ * the connect flow (vault#480). Answers three UI questions in one call:
884
+ * which app is in play (shared default vs BYO), is it installed ANYWHERE
885
+ * for this operator, and which accounts (user/orgs) carry installations.
886
+ *
887
+ * Deliberately a separate, explicitly-network endpoint: `GET /auth` stays
888
+ * a pure local read of the stored credential. The SPA calls this when it
889
+ * renders the connect flow / repo picker, not on every status poll.
890
+ *
891
+ * Response:
892
+ * 200 {
893
+ * app: { client_id, slug, is_shared_default },
894
+ * installed: boolean, // installations.length > 0
895
+ * install_url: string, // github.com/apps/<slug>/installations/new
896
+ * installations: [{ id, account_login, account_type, repository_selection }]
897
+ * }
898
+ * 400 { error, error_type: "github_not_connected", message } — no stored
899
+ * github_oauth credential; the device flow hasn't been run (or a PAT
900
+ * is active instead — install state is a GitHub-App concept). 400,
901
+ * not 401, matching the sibling repos handler: a 401 here would trip
902
+ * the SPA's authedFetch token-refresh machinery and clear a
903
+ * perfectly valid cached admin token over a non-auth condition.
904
+ * 502 { error, message } — GitHub unreachable / API error.
905
+ */
906
+ export async function handleAuthGithubInstallations(
907
+ manager: MirrorManager,
908
+ fetchImpl?: FetchLike,
909
+ ): Promise<Response> {
910
+ const creds = readCredentials(manager.getVaultName());
911
+ if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
912
+ return Response.json(
913
+ {
914
+ error: "Not connected to GitHub",
915
+ error_type: "github_not_connected",
916
+ message:
917
+ "No GitHub sign-in is stored for this vault. Run the device flow first (POST /.parachute/mirror/auth/github/device-code).",
918
+ },
919
+ { status: 400 },
920
+ );
921
+ }
922
+ const clientId = getGithubClientId();
923
+ const slug = getGithubAppSlug();
924
+ let installations;
925
+ try {
926
+ installations = await listInstallations(creds.github_oauth.access_token, fetchImpl);
927
+ } catch (err) {
928
+ return Response.json(
929
+ {
930
+ error: "Installation check failed",
931
+ message: (err as Error).message ?? String(err),
932
+ },
933
+ { status: 502 },
934
+ );
935
+ }
936
+ return Response.json(
937
+ {
938
+ app: {
939
+ client_id: clientId,
940
+ slug,
941
+ is_shared_default:
942
+ clientId === GITHUB_CLIENT_ID_DEFAULT && slug === GITHUB_APP_SLUG_DEFAULT,
943
+ },
944
+ installed: installations.length > 0,
945
+ install_url: installUrlForSlug(slug),
946
+ installations: installations.map((i) => ({
947
+ id: i.id,
948
+ account_login: i.account.login,
949
+ account_type: i.account.type,
950
+ repository_selection: i.repository_selection,
951
+ })),
952
+ },
953
+ { headers: { "Access-Control-Allow-Origin": "*" } },
954
+ );
955
+ }
956
+
957
+ /**
958
+ * `GET /.parachute/mirror/auth/github/repos` — list the repos the
959
+ * operator's app installations grant, via the stored OAuth token. Requires
960
+ * `active_method === "github_oauth"`.
961
+ *
962
+ * Source (vault#480): `GET /user/installations` → per-installation
963
+ * `GET /user/installations/{id}/repositories`, unioned. This replaces the
964
+ * old `GET /user/repos?type=owner` source, which (a) excluded org-owned
965
+ * repos by construction and (b) showed all-public-repos when the app wasn't
966
+ * installed at all (every GitHub App reads public repos) — Aaron walked
967
+ * into exactly that "looks connected, shows the wrong repos" state live.
968
+ *
969
+ * Response:
970
+ * 200 installed: { installed: true, repos: [{ ...GitHubRepoInfo,
971
+ * account_login, installation_id }], truncated }
972
+ * 200 not installed: { installed: false, install_url, repos: [],
973
+ * truncated: false } — machine-readable
974
+ * authorized-but-not-installed state; the UI shows
975
+ * the guided-install step, no string-matching needed.
976
+ * 400 { error, message } — no github_oauth credential stored.
977
+ * 502 { error, message } — GitHub unreachable / API error.
816
978
  */
817
979
  export async function handleAuthGithubRepos(
818
980
  manager: MirrorManager,
@@ -828,9 +990,58 @@ export async function handleAuthGithubRepos(
828
990
  { status: 400 },
829
991
  );
830
992
  }
831
- let result;
993
+ const token = creds.github_oauth.access_token;
994
+ let installations;
832
995
  try {
833
- result = await listRepos(creds.github_oauth.access_token, {}, fetchImpl);
996
+ installations = await listInstallations(token, fetchImpl);
997
+ } catch (err) {
998
+ return Response.json(
999
+ {
1000
+ error: "Repo list failed",
1001
+ message: (err as Error).message ?? String(err),
1002
+ },
1003
+ { status: 502 },
1004
+ );
1005
+ }
1006
+
1007
+ if (installations.length === 0) {
1008
+ // Authorized but not installed — the device-flow grant alone reaches no
1009
+ // repos. Distinct, machine-readable state (NOT an empty repo list that
1010
+ // looks like "you have no repos").
1011
+ return Response.json(
1012
+ {
1013
+ installed: false,
1014
+ install_url: installUrlForSlug(getGithubAppSlug()),
1015
+ repos: [],
1016
+ truncated: false,
1017
+ },
1018
+ { headers: { "Access-Control-Allow-Origin": "*" } },
1019
+ );
1020
+ }
1021
+
1022
+ const repos: Array<GitHubRepoInfo & { account_login: string; installation_id: number }> = [];
1023
+ let truncated = false;
1024
+ try {
1025
+ for (const installation of installations) {
1026
+ const result = await listInstallationRepos(token, installation.id, {}, fetchImpl);
1027
+ // `GET /user/installations/{id}/repositories` takes no `sort` param
1028
+ // (unlike the old `GET /user/repos?sort=updated` picker source), so
1029
+ // order within each account group ourselves: most-recently-updated
1030
+ // first, so the repo the operator probably wants sits near the top.
1031
+ // ISO-8601 timestamps compare lexicographically. Per-group (not
1032
+ // across the union) so the SPA's account grouping stays contiguous.
1033
+ const sorted = [...result.repos].sort((a, b) =>
1034
+ b.updated_at.localeCompare(a.updated_at),
1035
+ );
1036
+ for (const repo of sorted) {
1037
+ repos.push({
1038
+ ...repo,
1039
+ account_login: installation.account.login,
1040
+ installation_id: installation.id,
1041
+ });
1042
+ }
1043
+ if (result.truncated) truncated = true;
1044
+ }
834
1045
  } catch (err) {
835
1046
  return Response.json(
836
1047
  {
@@ -841,7 +1052,7 @@ export async function handleAuthGithubRepos(
841
1052
  );
842
1053
  }
843
1054
  return Response.json(
844
- { repos: result.repos, truncated: result.truncated },
1055
+ { installed: true, repos, truncated },
845
1056
  { headers: { "Access-Control-Allow-Origin": "*" } },
846
1057
  );
847
1058
  }
@@ -850,6 +1061,15 @@ export async function handleAuthGithubRepos(
850
1061
  * `POST /.parachute/mirror/auth/github/create-repo` — create a new repo on
851
1062
  * the operator's account, return the new RepoInfo. The SPA flows straight
852
1063
  * from this into the "repo selected" state.
1064
+ *
1065
+ * With the shared Parachute app this 403s by design (vault#480):
1066
+ * `POST /user/repos` needs Administration:write; the shared app is frozen
1067
+ * at Contents-only. The 403 maps to error_type "app_lacks_admin_permission"
1068
+ * with the guided-manual path (create at github.com/new → add it to the
1069
+ * installation → refresh the picker). The endpoint stays functional for
1070
+ * BYO-app operators whose app grants Administration:write — and per the
1071
+ * install docs, app-created repos auto-join the installation, so their
1072
+ * create→push flow works end-to-end.
853
1073
  */
854
1074
  export async function handleAuthGithubCreateRepo(
855
1075
  req: Request,
@@ -892,6 +1112,20 @@ export async function handleAuthGithubCreateRepo(
892
1112
  fetchImpl,
893
1113
  );
894
1114
  } catch (err) {
1115
+ if (err instanceof GitHubApiError && err.status === 403) {
1116
+ // Expected with the shared Contents-only app: POST /user/repos needs
1117
+ // Administration:write. Actionable, machine-readable mapping — the UI
1118
+ // renders the guided-manual checklist off error_type, not a string.
1119
+ return Response.json(
1120
+ {
1121
+ error: "Create not permitted for this GitHub App",
1122
+ error_type: "app_lacks_admin_permission",
1123
+ message:
1124
+ "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.",
1125
+ },
1126
+ { status: 403 },
1127
+ );
1128
+ }
895
1129
  return Response.json(
896
1130
  { error: "Create failed", message: (err as Error).message ?? String(err) },
897
1131
  { status: 502 },
@@ -919,7 +1153,7 @@ export async function handleAuthGithubSelectRepo(
919
1153
  { status: 400 },
920
1154
  );
921
1155
  }
922
- let body: { owner?: unknown; name?: unknown };
1156
+ let body: { owner?: unknown; name?: unknown; override?: unknown };
923
1157
  try {
924
1158
  body = (await req.json()) as Record<string, unknown>;
925
1159
  } catch (err) {
@@ -928,6 +1162,7 @@ export async function handleAuthGithubSelectRepo(
928
1162
  { status: 400 },
929
1163
  );
930
1164
  }
1165
+ const override = body.override === true;
931
1166
  const owner = typeof body.owner === "string" ? body.owner.trim() : "";
932
1167
  const name = typeof body.name === "string" ? body.name.trim() : "";
933
1168
  if (!owner || !name) {
@@ -939,6 +1174,32 @@ export async function handleAuthGithubSelectRepo(
939
1174
  { status: 400 },
940
1175
  );
941
1176
  }
1177
+
1178
+ // vault#482: refuse if ANOTHER vault on this server already pushes to this
1179
+ // repo. The clobber path for the OAuth flow: select-repo writes
1180
+ // `github.com/<owner>/<name>` onto this vault's mirror `origin`; if a sibling
1181
+ // vault already has that same origin, both force-push the same branch and the
1182
+ // loser's backup is overwritten. Compare against the public github URL (no
1183
+ // token — the normalizer keys off host/owner/repo). The scan excludes THIS
1184
+ // vault, so re-picking the same repo (token rotation, re-entry) is fine.
1185
+ // `override=true` is the explicit escape hatch.
1186
+ if (!override) {
1187
+ const candidate = `https://github.com/${owner}/${name}.git`;
1188
+ const conflict = findConflictingVault(manager.getVaultName(), candidate);
1189
+ if (conflict) {
1190
+ return Response.json(
1191
+ {
1192
+ error: "Remote already in use by another vault",
1193
+ error_type: "remote_conflict",
1194
+ conflicting_vault: conflict.conflictingVault,
1195
+ remote: conflict.remoteIdentity,
1196
+ message: remoteConflictMessage(conflict),
1197
+ },
1198
+ { status: 409 },
1199
+ );
1200
+ }
1201
+ }
1202
+
942
1203
  // Reach into mirror-credentials.ts for the authed URL builder.
943
1204
  const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
944
1205
  const authedUrl = githubAuthedRemoteUrl(
@@ -947,6 +1208,18 @@ export async function handleAuthGithubSelectRepo(
947
1208
  name,
948
1209
  );
949
1210
 
1211
+ // vault#483: linking implies backup intent — the choose-repo re-entry can
1212
+ // be the FIRST credential-linked action for this vault (a credential saved
1213
+ // before history-on-link existed, on a never-configured vault: the #483
1214
+ // "linked but silently inert" state). Run history-on-link here too, BEFORE
1215
+ // the Cut-3/Cut-6 steps below and mirroring handleAuthPat's ordering:
1216
+ // history on (the reload's start() resolves mirror_path so the remote
1217
+ // write below actually lands), then Cut 3 sees an enabled mirror and flips
1218
+ // auto_push, then Cut 6 fires the initial push. An explicitly-disabled
1219
+ // mirror short-circuits all of it (history stays off → maybeEnableAutoPush
1220
+ // no-ops on disabled).
1221
+ const history_enabled = await maybeEnableHistoryOnLink(manager);
1222
+
950
1223
  // Apply to the mirror dir if running. If the mirror isn't running (no
951
1224
  // mirror_path), we still consider this a success — the credentials are
952
1225
  // stored, and the URL will get applied next time the mirror starts.
@@ -980,6 +1253,7 @@ export async function handleAuthGithubSelectRepo(
980
1253
  // Echo the redacted form back so the SPA can show "pushing to <repo>".
981
1254
  // No raw token in the response.
982
1255
  remote: `https://github.com/${owner}/${name}.git`,
1256
+ history_enabled,
983
1257
  auto_push_was_already_enabled: autoPushChange.was_already_enabled,
984
1258
  auto_push_enabled: autoPushChange.auto_push_now_enabled,
985
1259
  initial_push: initialPush,
@@ -988,6 +1262,73 @@ export async function handleAuthGithubSelectRepo(
988
1262
  );
989
1263
  }
990
1264
 
1265
+ /**
1266
+ * vault#483 fix 1 — linking implies backup intent. Outcome flag for the
1267
+ * credential-save responses:
1268
+ * - `true` — history (the mirror) is on: either this link just enabled it
1269
+ * (never-configured vault) or it was already enabled.
1270
+ * - `"left_disabled"` — a mirror config exists with `enabled: false`; we
1271
+ * refuse to silently flip an explicit operator choice. The UI should
1272
+ * offer the one-click "Turn on history now?" enable.
1273
+ * - `false` — we tried to enable history but the mirror didn't come up
1274
+ * (e.g. git missing). Config intent is persisted (PUT semantics); the
1275
+ * mirror status carries the actionable error.
1276
+ */
1277
+ export type HistoryOnLink = true | false | "left_disabled";
1278
+
1279
+ /**
1280
+ * When a GitHub credential lands (device-flow grant or PAT save), turn
1281
+ * history on for a vault that has NEVER had a mirror configured — nobody
1282
+ * links GitHub to a vault for any reason other than backing it up. Aaron hit
1283
+ * this live (vault#483): pre-#440 vaults have no mirror-config file, so
1284
+ * linking stored a credential and then... nothing, with the only path out
1285
+ * buried in advanced settings.
1286
+ *
1287
+ * Consent-respecting by design:
1288
+ * - **No config file** (never configured) → enable the internal mirror
1289
+ * with the standard defaults (enabled, internal, events, auto_commit on,
1290
+ * auto_push off — the same shape #440 gives new vaults). Writes through
1291
+ * `manager.reload()` — the exact path the PUT handler uses — so persist +
1292
+ * lifecycle-start stay one code path.
1293
+ * - **Config file exists with enabled:false** → explicit operator choice;
1294
+ * do NOT flip it. Return `"left_disabled"` so the UI can offer one-click
1295
+ * enable instead.
1296
+ * - **Config file exists with enabled:true** → nothing to do.
1297
+ *
1298
+ * The file-existence check (not just the parsed read) is the load-bearing
1299
+ * distinction: an existing-but-malformed file parses to `undefined`, same as
1300
+ * absent — but it still represents operator-touched state, so we treat it as
1301
+ * "exists, not known-enabled" and leave it alone.
1302
+ */
1303
+ async function maybeEnableHistoryOnLink(
1304
+ manager: MirrorManager,
1305
+ ): Promise<HistoryOnLink> {
1306
+ const vaultName = manager.getVaultName();
1307
+ if (!existsSync(mirrorConfigPath(vaultName))) {
1308
+ try {
1309
+ const status = await manager.reload({
1310
+ ...defaultMirrorConfig(),
1311
+ enabled: true,
1312
+ });
1313
+ if (!status.enabled) {
1314
+ console.warn(
1315
+ `[mirror-auth] history-on-link: enabled the mirror config for vault "${vaultName}" but it didn't start: ${status.last_error ?? "unknown error"}`,
1316
+ );
1317
+ return false;
1318
+ }
1319
+ return true;
1320
+ } catch (err) {
1321
+ console.warn(
1322
+ `[mirror-auth] history-on-link: could not enable the mirror for vault "${vaultName}" (non-fatal): ${(err as Error).message ?? err}`,
1323
+ );
1324
+ return false;
1325
+ }
1326
+ }
1327
+ const persisted = readMirrorConfigForVault(vaultName);
1328
+ if (persisted?.enabled) return true;
1329
+ return "left_disabled";
1330
+ }
1331
+
991
1332
  /**
992
1333
  * Cut 3: when credentials save, flip `auto_push` from false → true on
993
1334
  * the persisted config. Operators wiring credentials almost certainly
@@ -1176,6 +1517,7 @@ export async function handleMirrorImport(
1176
1517
  mode?: unknown;
1177
1518
  credentials?: unknown;
1178
1519
  enable_sync?: unknown;
1520
+ override?: unknown;
1179
1521
  };
1180
1522
  try {
1181
1523
  body = (await req.json()) as Record<string, unknown>;
@@ -1287,6 +1629,13 @@ export async function handleMirrorImport(
1287
1629
  enableSync = body.enable_sync;
1288
1630
  }
1289
1631
 
1632
+ // vault#482: cross-vault clobber override for the sync-enable step. Default
1633
+ // off; a literal `true` lets the operator deliberately point this vault's
1634
+ // sync at a repo a SIBLING vault already backs up to (e.g. they moved the
1635
+ // repo between vaults). The import itself never refuses on this — only the
1636
+ // optional sync-enable does.
1637
+ const override = body.override === true;
1638
+
1290
1639
  // Resolve the target vault's store + assets dir. The route is gated on
1291
1640
  // `vault:<name>:admin`, so we trust vaultName is real by the time we
1292
1641
  // reach this code path; defensively re-resolve in case the vault was
@@ -1374,6 +1723,7 @@ export async function handleMirrorImport(
1374
1723
  remoteUrl: remote_url,
1375
1724
  auth,
1376
1725
  manager,
1726
+ override,
1377
1727
  });
1378
1728
  result.sync_enabled = outcome.sync_enabled;
1379
1729
  if (outcome.warning) result.sync_warning = outcome.warning;
@@ -1444,8 +1794,14 @@ export async function enableSyncToImportedRepo(opts: {
1444
1794
  * rather than persisting a half-configured mirror with no live manager.
1445
1795
  */
1446
1796
  manager: MirrorManager | undefined;
1797
+ /**
1798
+ * vault#482 escape hatch — skip the cross-vault clobber guard. Default
1799
+ * false: refuse (warn, don't enable sync) when a SIBLING vault already
1800
+ * backs up to this repo.
1801
+ */
1802
+ override?: boolean;
1447
1803
  }): Promise<{ sync_enabled: boolean; warning?: string }> {
1448
- const { vaultName, remoteUrl, auth, manager } = opts;
1804
+ const { vaultName, remoteUrl, auth, manager, override = false } = opts;
1449
1805
 
1450
1806
  if (!manager) {
1451
1807
  return {
@@ -1455,6 +1811,21 @@ export async function enableSyncToImportedRepo(opts: {
1455
1811
  };
1456
1812
  }
1457
1813
 
1814
+ // vault#482: don't enable sync to a repo a DIFFERENT vault on this server
1815
+ // already backs up to — both would force-push the same branch and clobber
1816
+ // each other. Import success is never lost; we just decline the optional
1817
+ // sync-enable + warn. The existing same-vault "different remote" check below
1818
+ // handles THIS vault's own prior target; this one covers SIBLING vaults.
1819
+ if (!override) {
1820
+ const conflict = findConflictingVault(vaultName, remoteUrl);
1821
+ if (conflict) {
1822
+ return {
1823
+ sync_enabled: false,
1824
+ warning: `Import succeeded, but Sync was not enabled — ${remoteConflictMessage(conflict)}`,
1825
+ };
1826
+ }
1827
+ }
1828
+
1458
1829
  // --- Resolve the push credential we'll persist for this remote. ----------
1459
1830
  // We need a credential that can PUSH to `remoteUrl`. The clone-time auth
1460
1831
  // shapes map onto push credentials as follows:
@@ -1696,14 +2067,16 @@ function effectiveRemoteOf(creds: MirrorCredentials): string | null {
1696
2067
  * (SSH shorthand, local paths).
1697
2068
  */
1698
2069
  function sameRemote(a: string, b: string): boolean {
2070
+ // Lower-cased whole identity: GitHub owner/repo is case-insensitive, so a
2071
+ // clobber guard must treat Aaron/Repo == aaron/repo (matches normalizeRemoteIdentity).
1699
2072
  const norm = (u: string): string => {
1700
2073
  try {
1701
2074
  const url = new URL(u);
1702
2075
  const host = url.host.toLowerCase();
1703
2076
  const path = url.pathname.replace(/\.git$/, "").replace(/\/+$/, "");
1704
- return `${url.protocol}//${host}${path}`;
2077
+ return `${url.protocol}//${host}${path}`.toLowerCase();
1705
2078
  } catch {
1706
- return u.trim().replace(/\.git$/, "").replace(/\/+$/, "");
2079
+ return u.trim().replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
1707
2080
  }
1708
2081
  };
1709
2082
  return norm(a) === norm(b);