@openparachute/vault 0.6.1 → 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.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Cross-vault remote-clobber guard (vault#482).
3
+ *
4
+ * Per-vault mirror managers (vault#400) made every vault on a server
5
+ * independently linkable to a GitHub (or any git) remote. Nothing stopped
6
+ * TWO vaults on the same server from pointing their mirror at the SAME repo —
7
+ * and two mirrors pushing the same branch of the same repo fight each other:
8
+ * each `git push` (often `--force` for a mirror) overwrites the other vault's
9
+ * snapshot. The loser's backup is silently clobbered. Realistic on a
10
+ * family/shared box where two users share one GitHub account and both pick the
11
+ * obvious repo name, and more likely once the multi-user invite flow makes
12
+ * per-user vaults common.
13
+ *
14
+ * This module is the cheap same-server guard the issue calls for: when a vault
15
+ * is about to bind a remote (PAT save, OAuth repo pick, or import-then-sync),
16
+ * scan the OTHER vaults on this server for one that already claims the same
17
+ * normalized remote and refuse — naming the conflicting vault + the repo —
18
+ * unless the caller passes an explicit override.
19
+ *
20
+ * Cross-SERVER collisions can't be detected locally; the docs cover that half
21
+ * ("one repo per vault").
22
+ *
23
+ * ## What counts as a vault's "claimed remote"
24
+ *
25
+ * A vault's mirror remote lives in two places depending on the credential
26
+ * method:
27
+ * - **PAT** — the full authed `remote_url` in the per-vault credentials file.
28
+ * - **GitHub OAuth** — NOT in credentials (the credential carries no
29
+ * owner/repo). The actual remote is written onto the mirror dir's git
30
+ * `origin` by the select-repo flow.
31
+ * So we look at BOTH: the stored PAT credential, and the mirror dir's `origin`
32
+ * read straight from `.git/config`. Reading `.git/config` is a plain file read
33
+ * (no network, no git spawn) so the scan stays fast + hermetic.
34
+ *
35
+ * ## Normalization
36
+ *
37
+ * `https://github.com/x/y`, `https://github.com/x/y.git`,
38
+ * `git@github.com:x/y.git`, and `ssh://git@github.com/x/y` all name the same
39
+ * repo. We normalize to `<host-lowercased>/<owner>/<repo>` (userinfo + auth
40
+ * token stripped, `.git` + trailing slash removed) so equivalent URLs compare
41
+ * equal regardless of protocol/case/suffix.
42
+ */
43
+
44
+ import { existsSync, readFileSync } from "node:fs";
45
+ import { join } from "node:path";
46
+ import { homedir } from "node:os";
47
+
48
+ import { listVaults } from "./config.ts";
49
+ import { readCredentials } from "./mirror-credentials.ts";
50
+ import { readMirrorConfigForVault, resolveMirrorPath } from "./mirror-config.ts";
51
+
52
+ /**
53
+ * Canonical identity for a git remote, used for "same repo?" comparison.
54
+ * Strips the auth token / userinfo, lower-cases the host, drops a trailing
55
+ * `.git` and trailing slashes. Returns `<host>/<path>` (e.g.
56
+ * `github.com/aaron/my-vault`).
57
+ *
58
+ * Recognizes the three remote shapes git accepts:
59
+ * - HTTPS/HTTP/ssh:// URLs (parse via `URL`).
60
+ * - SCP-style SSH shorthand `git@host:owner/repo(.git)`.
61
+ * - Anything else (local path, oddball) → a trimmed, suffix-stripped string
62
+ * compare, so two byte-identical local paths still match.
63
+ *
64
+ * Returns `null` for an empty/whitespace string (nothing to compare).
65
+ */
66
+ export function normalizeRemoteIdentity(remote: string): string | null {
67
+ const trimmed = remote.trim();
68
+ if (trimmed.length === 0) return null;
69
+
70
+ // The whole identity is LOWER-CASED before returning. The host is
71
+ // case-insensitive by DNS, and GitHub (the primary mirror target) treats
72
+ // owner/repo case-insensitively too — `github.com/Aaron/Vault` and
73
+ // `github.com/aaron/vault` are the SAME repo, so a data-loss guard must treat
74
+ // them as equal (else two vaults with different-case configs still clobber).
75
+ // The theoretical false-positive on a path-case-SENSITIVE Git host is
76
+ // acceptable for a clobber guard — the operator can override.
77
+
78
+ // SCP-style SSH shorthand: `user@host:owner/repo.git`. Doesn't parse as a
79
+ // URL (no scheme), so detect + rewrite to the canonical `host/path` shape
80
+ // before the URL path. Pattern: `<user>@<host>:<path>` where the char after
81
+ // the colon isn't a slash (a `//` after colon would be a real URL).
82
+ const scp = trimmed.match(/^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):(.+)$/);
83
+ if (scp && !scp[2]!.startsWith("/")) {
84
+ const host = scp[1]!;
85
+ const path = stripRepoSuffix(scp[2]!);
86
+ return `${host}/${path}`.toLowerCase();
87
+ }
88
+
89
+ try {
90
+ const url = new URL(trimmed);
91
+ // Host already excludes userinfo. `URL` keeps a leading slash on pathname —
92
+ // drop it so the join below doesn't double up.
93
+ const host = url.host;
94
+ const path = stripRepoSuffix(url.pathname.replace(/^\/+/, ""));
95
+ const identity = path.length === 0 ? host : `${host}/${path}`;
96
+ return identity.toLowerCase();
97
+ } catch {
98
+ // Non-URL, non-SCP (local path, etc.) — fall back to a trimmed,
99
+ // suffix-stripped string compare so identical local paths match.
100
+ return stripRepoSuffix(trimmed).toLowerCase();
101
+ }
102
+ }
103
+
104
+ /** Strip a trailing `.git` and any trailing slashes from a remote path. */
105
+ function stripRepoSuffix(path: string): string {
106
+ return path.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
107
+ }
108
+
109
+ /** Two remotes name the same repo iff their normalized identities match. */
110
+ export function sameRemoteIdentity(a: string, b: string): boolean {
111
+ const na = normalizeRemoteIdentity(a);
112
+ const nb = normalizeRemoteIdentity(b);
113
+ if (na === null || nb === null) return false;
114
+ return na === nb;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Per-vault claimed-remote resolution
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** The vault home root — `<configDir>/vault`. Re-reads PARACHUTE_HOME per call. */
122
+ function vaultHomeRoot(): string {
123
+ const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
124
+ return join(root, "vault");
125
+ }
126
+
127
+ /**
128
+ * The mirror dir for a vault, derived from its persisted mirror config the
129
+ * same way `MirrorManager.start()` does (`resolveMirrorPath(vaultDataDir,
130
+ * config)`). Returns null when the vault has no enabled mirror config or the
131
+ * path can't be resolved (e.g. external location without external_path).
132
+ *
133
+ * Re-derives the vault data dir from PARACHUTE_HOME rather than importing
134
+ * `config.vaultDir` — keeping the dependency surface narrow + matching the
135
+ * path resolution `mirror-credentials.ts` uses.
136
+ */
137
+ function vaultMirrorDir(vaultName: string): string | null {
138
+ const config = readMirrorConfigForVault(vaultName);
139
+ if (!config) return null;
140
+ const vaultDataDir = join(vaultHomeRoot(), "data", vaultName);
141
+ return resolveMirrorPath(vaultDataDir, config);
142
+ }
143
+
144
+ /**
145
+ * Read a vault's mirror dir `origin` URL straight from `.git/config` — no git
146
+ * spawn, no network. Returns null when there's no mirror dir, no `.git/config`,
147
+ * or no `[remote "origin"]` url line.
148
+ *
149
+ * `.git/config` is INI-ish; the origin url lives under a `[remote "origin"]`
150
+ * section header as `url = <value>`. We do a minimal section-aware scan rather
151
+ * than pull in a git-config parser — the file shape is stable + simple.
152
+ */
153
+ function readOriginFromGitConfig(mirrorDir: string): string | null {
154
+ const gitConfigPath = join(mirrorDir, ".git", "config");
155
+ if (!existsSync(gitConfigPath)) return null;
156
+ let raw: string;
157
+ try {
158
+ raw = readFileSync(gitConfigPath, "utf8");
159
+ } catch {
160
+ return null;
161
+ }
162
+ let inOrigin = false;
163
+ for (const line of raw.split("\n")) {
164
+ const section = line.match(/^\s*\[(.+?)\]\s*$/);
165
+ if (section) {
166
+ // Section headers: `[remote "origin"]` or `[core]` etc. Normalize the
167
+ // inner text + match the remote-origin shape (quoting can vary).
168
+ const header = section[1]!.trim().toLowerCase().replace(/\s+/g, " ");
169
+ inOrigin = header === 'remote "origin"';
170
+ continue;
171
+ }
172
+ if (!inOrigin) continue;
173
+ const url = line.match(/^\s*url\s*=\s*(.+?)\s*$/);
174
+ if (url) return url[1]!;
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * The remote a vault currently claims, normalized, or null when it claims
181
+ * none. Looks at both credential-backed (PAT) and mirror-dir (`origin`)
182
+ * sources, so it covers OAuth-selected repos (which live only on `origin`)
183
+ * as well as PAT remotes.
184
+ *
185
+ * Returns the FIRST resolvable normalized remote it finds. `origin` is
186
+ * authoritative when set (it's what actually gets pushed); the stored PAT
187
+ * `remote_url` is the fallback for a vault whose mirror dir hasn't been
188
+ * bootstrapped yet.
189
+ */
190
+ export function claimedRemoteOf(vaultName: string): string | null {
191
+ // 1. The live `origin` on the mirror dir — authoritative, covers OAuth.
192
+ const mirrorDir = vaultMirrorDir(vaultName);
193
+ if (mirrorDir) {
194
+ const origin = readOriginFromGitConfig(mirrorDir);
195
+ if (origin) {
196
+ const norm = normalizeRemoteIdentity(origin);
197
+ if (norm) return norm;
198
+ }
199
+ }
200
+ // 2. The stored PAT remote_url — fallback for a not-yet-bootstrapped mirror.
201
+ const creds = readCredentials(vaultName);
202
+ if (creds?.active_method === "pat" && creds.pat?.remote_url) {
203
+ const norm = normalizeRemoteIdentity(creds.pat.remote_url);
204
+ if (norm) return norm;
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /** A detected cross-vault remote collision. */
210
+ export interface RemoteConflict {
211
+ /** The other vault on this server that already targets the same repo. */
212
+ conflictingVault: string;
213
+ /** The normalized repo identity both vaults point at (`host/owner/repo`). */
214
+ remoteIdentity: string;
215
+ }
216
+
217
+ /**
218
+ * Scan every OTHER vault on this server for one that already claims the same
219
+ * remote as `candidateRemote`. Returns the first conflict found, or null when
220
+ * the repo is unclaimed.
221
+ *
222
+ * The current vault is excluded — re-pointing a vault at its OWN existing
223
+ * remote (token rotation, re-running select-repo with the same repo, re-import)
224
+ * is legitimate + idempotent and must never trip the guard.
225
+ *
226
+ * Fail-open by design: any per-vault read error is swallowed (skip that vault)
227
+ * rather than blocking a legitimate bind on an unrelated vault's broken state.
228
+ * The guard's job is to catch the obvious double-target, not to be a hard
229
+ * gate that a corrupt sibling vault can wedge.
230
+ */
231
+ export function findConflictingVault(
232
+ currentVaultName: string,
233
+ candidateRemote: string,
234
+ ): RemoteConflict | null {
235
+ const target = normalizeRemoteIdentity(candidateRemote);
236
+ if (target === null) return null;
237
+
238
+ let vaults: string[];
239
+ try {
240
+ vaults = listVaults();
241
+ } catch {
242
+ return null;
243
+ }
244
+
245
+ for (const other of vaults) {
246
+ if (other === currentVaultName) continue;
247
+ let claimed: string | null;
248
+ try {
249
+ claimed = claimedRemoteOf(other);
250
+ } catch {
251
+ continue;
252
+ }
253
+ if (claimed !== null && claimed === target) {
254
+ return { conflictingVault: other, remoteIdentity: target };
255
+ }
256
+ }
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * Build the operator-facing error message for a refused bind. Names the
262
+ * conflicting vault + the repo, and tells the operator how to proceed.
263
+ * Centralized so the three call sites (PAT, select-repo, import-sync) surface
264
+ * identical wording.
265
+ */
266
+ export function remoteConflictMessage(conflict: RemoteConflict): string {
267
+ return (
268
+ `Vault "${conflict.conflictingVault}" on this server already backs up to ${conflict.remoteIdentity}. ` +
269
+ `Two vaults pushing to the same repo overwrite each other's backups (silent data loss). ` +
270
+ `Pick a different repo for this vault, or disconnect the other vault's backup first. ` +
271
+ `If you're sure (e.g. you just moved the repo between vaults), pass override=true to proceed anyway.`
272
+ );
273
+ }
@@ -2395,6 +2395,108 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2395
2395
  expect(creds?.pat?.remote_url).toContain("OTHER-repo.git");
2396
2396
  });
2397
2397
 
2398
+ test("vault#482: import-sync to a repo a SIBLING vault already backs up → import succeeds but sync declined + conflict warning", async () => {
2399
+ home = tmp("import-sync-cross-vault-");
2400
+ await bootstrapVault(home); // creates vault "default"
2401
+ fixture = await buildExportFixture();
2402
+ manager = makeSyncManager(home);
2403
+
2404
+ // Sibling vault "family" already syncs to the repo we're importing.
2405
+ const sibDataDir = path.join(home, "vault", "data", "family");
2406
+ fs.mkdirSync(sibDataDir, { recursive: true });
2407
+ fs.writeFileSync(path.join(sibDataDir, "vault.yaml"), "name: family\n");
2408
+ writeMirrorConfigForVault("family", {
2409
+ ...defaultMirrorConfig(),
2410
+ enabled: true,
2411
+ location: "internal",
2412
+ });
2413
+ const sibGitDir = path.join(sibDataDir, "mirror", ".git");
2414
+ fs.mkdirSync(sibGitDir, { recursive: true });
2415
+ fs.writeFileSync(
2416
+ path.join(sibGitDir, "config"),
2417
+ '[remote "origin"]\n\turl = https://github.com/aaron/my-vault.git\n',
2418
+ );
2419
+
2420
+ const req = new Request("http://x/import", {
2421
+ method: "POST",
2422
+ body: JSON.stringify({
2423
+ remote_url: "https://github.com/aaron/my-vault.git",
2424
+ mode: "merge",
2425
+ credentials: { kind: "pat", token: "ghp_import_token_abc" },
2426
+ enable_sync: true,
2427
+ }),
2428
+ });
2429
+ const res = await handleMirrorImport(
2430
+ req,
2431
+ "default",
2432
+ spawnCloneSuccess(fixture),
2433
+ undefined,
2434
+ manager,
2435
+ );
2436
+ expect(res.status).toBe(200);
2437
+ const body = (await res.json()) as {
2438
+ notes_imported: number;
2439
+ sync_enabled: boolean;
2440
+ sync_warning?: string;
2441
+ };
2442
+ // Import itself never fails on the conflict.
2443
+ expect(body.notes_imported).toBe(2);
2444
+ // ...but sync is declined + the warning names the sibling vault + repo.
2445
+ expect(body.sync_enabled).toBe(false);
2446
+ expect(body.sync_warning).toContain("family");
2447
+ expect(body.sync_warning).toContain("github.com/aaron/my-vault");
2448
+
2449
+ // No sync config / credential got persisted for "default".
2450
+ expect(readMirrorConfigForVault("default")).toBeUndefined();
2451
+ expect(readCredentials("default")).toBeNull();
2452
+ });
2453
+
2454
+ test("vault#482: import-sync with override=true binds anyway despite a sibling on the same repo", async () => {
2455
+ home = tmp("import-sync-cross-override-");
2456
+ await bootstrapVault(home);
2457
+ fixture = await buildExportFixture();
2458
+ manager = makeSyncManager(home);
2459
+
2460
+ const sibDataDir = path.join(home, "vault", "data", "family");
2461
+ fs.mkdirSync(sibDataDir, { recursive: true });
2462
+ fs.writeFileSync(path.join(sibDataDir, "vault.yaml"), "name: family\n");
2463
+ writeMirrorConfigForVault("family", {
2464
+ ...defaultMirrorConfig(),
2465
+ enabled: true,
2466
+ location: "internal",
2467
+ });
2468
+ const sibGitDir = path.join(sibDataDir, "mirror", ".git");
2469
+ fs.mkdirSync(sibGitDir, { recursive: true });
2470
+ fs.writeFileSync(
2471
+ path.join(sibGitDir, "config"),
2472
+ '[remote "origin"]\n\turl = https://github.com/aaron/my-vault.git\n',
2473
+ );
2474
+
2475
+ const req = new Request("http://x/import", {
2476
+ method: "POST",
2477
+ body: JSON.stringify({
2478
+ remote_url: "https://github.com/aaron/my-vault.git",
2479
+ mode: "merge",
2480
+ credentials: { kind: "pat", token: "ghp_import_token_abc" },
2481
+ enable_sync: true,
2482
+ override: true,
2483
+ }),
2484
+ });
2485
+ const res = await handleMirrorImport(
2486
+ req,
2487
+ "default",
2488
+ spawnCloneSuccess(fixture),
2489
+ undefined,
2490
+ manager,
2491
+ );
2492
+ expect(res.status).toBe(200);
2493
+ const body = (await res.json()) as { sync_enabled: boolean };
2494
+ expect(body.sync_enabled).toBe(true);
2495
+ // Sync config + credential persisted for "default" despite the sibling.
2496
+ expect(readMirrorConfigForVault("default")?.auto_push).toBe(true);
2497
+ expect(readCredentials("default")?.pat?.token).toBe("ghp_import_token_abc");
2498
+ });
2499
+
2398
2500
  test("existing mirror to the SAME remote → no-op success (sync_enabled true)", async () => {
2399
2501
  home = tmp("import-sync-same-");
2400
2502
  await bootstrapVault(home);
@@ -2555,3 +2657,214 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2555
2657
  });
2556
2658
  });
2557
2659
 
2660
+ // ---------------------------------------------------------------------------
2661
+ // vault#482 — cross-vault remote-clobber guard at the ROUTE level.
2662
+ //
2663
+ // Two vaults on one server pointing their mirror at the same repo force-push
2664
+ // over each other's backups (silent data loss). The guard refuses a bind
2665
+ // (PAT save / OAuth repo pick / import-then-sync) when a SIBLING vault already
2666
+ // claims the same normalized remote, unless `override: true`. Re-binding a
2667
+ // vault to its OWN remote is always allowed (no false positive).
2668
+ // ---------------------------------------------------------------------------
2669
+
2670
+ /**
2671
+ * Seed a sibling vault on disk under the active PARACHUTE_HOME so
2672
+ * `listVaults()` sees it, with an enabled internal mirror whose `origin`
2673
+ * points at `originUrl`. No git spawn — we write `.git/config` directly,
2674
+ * exactly what the guard reads.
2675
+ */
2676
+ function seedSiblingVault(name: string, originUrl: string): void {
2677
+ const home = process.env.PARACHUTE_HOME!;
2678
+ const dataDir = path.join(home, "vault", "data", name);
2679
+ fs.mkdirSync(dataDir, { recursive: true });
2680
+ fs.writeFileSync(path.join(dataDir, "vault.yaml"), `name: ${name}\n`);
2681
+ writeMirrorConfigForVault(name, {
2682
+ ...defaultMirrorConfig(),
2683
+ enabled: true,
2684
+ location: "internal",
2685
+ });
2686
+ const mirrorGitDir = path.join(dataDir, "mirror", ".git");
2687
+ fs.mkdirSync(mirrorGitDir, { recursive: true });
2688
+ fs.writeFileSync(
2689
+ path.join(mirrorGitDir, "config"),
2690
+ `[remote "origin"]\n\turl = ${originUrl}\n`,
2691
+ );
2692
+ }
2693
+
2694
+ describe("cross-vault remote-clobber guard (vault#482)", () => {
2695
+ let home: string;
2696
+ afterEach(() => {
2697
+ if (home) fs.rmSync(home, { recursive: true, force: true });
2698
+ });
2699
+
2700
+ test("PAT: a second vault targeting a sibling's repo is refused with 409 + helpful error", async () => {
2701
+ home = tmp("guard-pat-refuse-");
2702
+ const { manager } = makeManager(home); // vault "default"
2703
+ // Sibling vault "family" already backs up to aaron/shared.
2704
+ seedSiblingVault("family", "https://github.com/aaron/shared.git");
2705
+
2706
+ const res = await handleAuthPat(
2707
+ new Request("http://x/pat", {
2708
+ method: "POST",
2709
+ body: JSON.stringify({
2710
+ token: "ghp_newtoken1234567890",
2711
+ remote_url: "https://github.com/aaron/shared.git",
2712
+ }),
2713
+ }),
2714
+ manager,
2715
+ // Probe stub — the conflict guard runs AFTER the probe, so the probe
2716
+ // must pass for us to reach (and assert on) the guard.
2717
+ async () => ({ ok: true }),
2718
+ );
2719
+ expect(res.status).toBe(409);
2720
+ const body = (await res.json()) as {
2721
+ error_type: string;
2722
+ conflicting_vault: string;
2723
+ remote: string;
2724
+ message: string;
2725
+ };
2726
+ expect(body.error_type).toBe("remote_conflict");
2727
+ expect(body.conflicting_vault).toBe("family");
2728
+ expect(body.remote).toBe("github.com/aaron/shared");
2729
+ // Names the vault + repo + tells the operator how to proceed.
2730
+ expect(body.message).toContain("family");
2731
+ expect(body.message).toContain("github.com/aaron/shared");
2732
+ expect(body.message).toContain("override");
2733
+ // Nothing got persisted for the refused vault.
2734
+ expect(readCredentials("default")).toBeNull();
2735
+ });
2736
+
2737
+ test("PAT: URL-normalization — sibling stored as scp-shorthand, candidate https → recognized as same repo", async () => {
2738
+ home = tmp("guard-pat-norm-");
2739
+ const { manager } = makeManager(home);
2740
+ // Sibling stored its origin as SSH scp-shorthand.
2741
+ seedSiblingVault("family", "git@github.com:aaron/shared.git");
2742
+
2743
+ const res = await handleAuthPat(
2744
+ new Request("http://x/pat", {
2745
+ method: "POST",
2746
+ body: JSON.stringify({
2747
+ token: "ghp_newtoken1234567890",
2748
+ // ...candidate arrives as HTTPS without .git.
2749
+ remote_url: "https://github.com/aaron/shared",
2750
+ }),
2751
+ }),
2752
+ manager,
2753
+ async () => ({ ok: true }),
2754
+ );
2755
+ expect(res.status).toBe(409);
2756
+ const body = (await res.json()) as { conflicting_vault: string };
2757
+ expect(body.conflicting_vault).toBe("family");
2758
+ });
2759
+
2760
+ test("PAT: re-pointing the SAME vault at its OWN remote is allowed (no false positive)", async () => {
2761
+ home = tmp("guard-pat-self-");
2762
+ const { manager } = makeManager(home); // vault "default"
2763
+ // "default" already targets aaron/mine (its own origin) + a sibling
2764
+ // targets a DIFFERENT repo — neither should block a self re-bind.
2765
+ seedSiblingVault("default", "https://github.com/aaron/mine.git");
2766
+ seedSiblingVault("family", "https://github.com/aaron/theirs.git");
2767
+
2768
+ const res = await handleAuthPat(
2769
+ new Request("http://x/pat", {
2770
+ method: "POST",
2771
+ body: JSON.stringify({
2772
+ token: "ghp_rotated1234567890",
2773
+ // Same repo "default" already points at — token rotation.
2774
+ remote_url: "https://github.com/aaron/mine.git",
2775
+ }),
2776
+ }),
2777
+ manager,
2778
+ async () => ({ ok: true }),
2779
+ );
2780
+ expect(res.status).toBe(200);
2781
+ const creds = readCredentials("default");
2782
+ expect(creds?.active_method).toBe("pat");
2783
+ expect(creds?.pat?.remote_url).toContain("github.com/aaron/mine.git");
2784
+ });
2785
+
2786
+ test("PAT: override=true bypasses the guard and saves anyway", async () => {
2787
+ home = tmp("guard-pat-override-");
2788
+ const { manager } = makeManager(home);
2789
+ seedSiblingVault("family", "https://github.com/aaron/shared.git");
2790
+
2791
+ const res = await handleAuthPat(
2792
+ new Request("http://x/pat", {
2793
+ method: "POST",
2794
+ body: JSON.stringify({
2795
+ token: "ghp_newtoken1234567890",
2796
+ remote_url: "https://github.com/aaron/shared.git",
2797
+ override: true,
2798
+ }),
2799
+ }),
2800
+ manager,
2801
+ async () => ({ ok: true }),
2802
+ );
2803
+ expect(res.status).toBe(200);
2804
+ expect(readCredentials("default")?.active_method).toBe("pat");
2805
+ });
2806
+
2807
+ test("select-repo: picking a sibling's repo is refused with 409", async () => {
2808
+ home = tmp("guard-selectrepo-refuse-");
2809
+ const { manager } = makeManager(home);
2810
+ seedSiblingVault("family", "https://github.com/aaron/shared.git");
2811
+ // "default" has an OAuth credential (select-repo requires one).
2812
+ writeCredentials("default", {
2813
+ active_method: "github_oauth",
2814
+ github_oauth: {
2815
+ access_token: "gho_fake1234567890abcd",
2816
+ scope: "repo",
2817
+ authorized_at: "2026-05-28T03:14:15.000Z",
2818
+ user_login: "aaron",
2819
+ user_id: 1,
2820
+ },
2821
+ pat: null,
2822
+ });
2823
+
2824
+ const res = await handleAuthGithubSelectRepo(
2825
+ new Request("http://x/select", {
2826
+ method: "POST",
2827
+ body: JSON.stringify({ owner: "aaron", name: "shared" }),
2828
+ }),
2829
+ manager,
2830
+ );
2831
+ expect(res.status).toBe(409);
2832
+ const body = (await res.json()) as {
2833
+ error_type: string;
2834
+ conflicting_vault: string;
2835
+ };
2836
+ expect(body.error_type).toBe("remote_conflict");
2837
+ expect(body.conflicting_vault).toBe("family");
2838
+ });
2839
+
2840
+ test("select-repo: override=true bypasses the guard", async () => {
2841
+ home = tmp("guard-selectrepo-override-");
2842
+ const { manager } = makeManager(home);
2843
+ seedSiblingVault("family", "https://github.com/aaron/shared.git");
2844
+ writeCredentials("default", {
2845
+ active_method: "github_oauth",
2846
+ github_oauth: {
2847
+ access_token: "gho_fake1234567890abcd",
2848
+ scope: "repo",
2849
+ authorized_at: "2026-05-28T03:14:15.000Z",
2850
+ user_login: "aaron",
2851
+ user_id: 1,
2852
+ },
2853
+ pat: null,
2854
+ });
2855
+ const res = await handleAuthGithubSelectRepo(
2856
+ new Request("http://x/select", {
2857
+ method: "POST",
2858
+ body: JSON.stringify({ owner: "aaron", name: "shared", override: true }),
2859
+ }),
2860
+ manager,
2861
+ );
2862
+ // 200 (or any non-409) — the guard didn't block it. (mirror_path is
2863
+ // unresolved since the manager never started, so it stores creds + returns
2864
+ // ok without applying to a remote.)
2865
+ expect(res.status).not.toBe(409);
2866
+ const body = (await res.json()) as { ok?: boolean };
2867
+ expect(body.ok).toBe(true);
2868
+ });
2869
+ });
2870
+