@openparachute/vault 0.4.8 → 0.4.9-rc.11

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.
Files changed (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -0,0 +1,700 @@
1
+ /**
2
+ * Mirror credentials store — UI-configurable git push credentials.
3
+ *
4
+ * After vault#382 (event-driven mirror), `auto_push: true` works only if the
5
+ * operator's shell has the right git credential plumbing wired (SSH key,
6
+ * GH_TOKEN, system credential helper). For self-hosted users that pattern is
7
+ * a non-starter — most have never opened a terminal. This module owns the
8
+ * UI-configurable alternative.
9
+ *
10
+ * Two surfaces:
11
+ * - **GitHub OAuth Device Flow** — the recommended path. Operator opens a
12
+ * modal, vault calls GitHub's device-code endpoint, the operator types
13
+ * a code at github.com/login/device, vault polls until granted, the
14
+ * resulting `gho_*` token is stored and embedded in the mirror's git
15
+ * remote URL so bare `git push` works. Same UX as `gh auth login`.
16
+ * **Why Device Flow, not Web Flow:** Web Flow needs a pre-registered
17
+ * callback URL per OAuth app; self-hosted vaults have unpredictable
18
+ * origins (localhost:1940, random Tailscale FQDN, custom domain). Device
19
+ * Flow needs only a public `client_id` and works against any vault
20
+ * origin without infrastructure.
21
+ * - **Personal Access Token (PAT) fallback** — provider-agnostic. Operator
22
+ * pastes a token + a remote URL with HTTPS auth; vault stores both and
23
+ * embeds them in the mirror's remote URL. Works against GitHub, GitLab,
24
+ * Codeberg, Gitea, anything that accepts an HTTPS token in the URL.
25
+ *
26
+ * **Storage:** `<configDir>/vault/data/<vaultName>/.mirror-credentials.yaml`,
27
+ * perms `0o600`, **not encrypted at rest**. Rationale: encryption-at-rest
28
+ * with the key on the same disk doesn't add real security; OS perms ARE the
29
+ * protection. Same trust model as `~/.git-credentials` (which most operators
30
+ * already use). The file is documented as sensitive; redaction in logs is
31
+ * enforced by `sanitizeCredentials` + a discipline of "never log the raw
32
+ * token."
33
+ *
34
+ * **Per-vault (vault#399).** Credentials — both the PAT and the embedded
35
+ * `remote_url` — live under each vault's own data dir, alongside its SQLite
36
+ * DB + vault.yaml. This is the existing per-vault-state pattern. Before
37
+ * vault#399 they lived in a single SERVER-WIDE file
38
+ * (`<configDir>/vault/.mirror-credentials.yaml`); that leaked the first
39
+ * vault's remote + PAT onto every other vault that configured git sync —
40
+ * pointing vault B at vault A's GitHub repo. The legacy server-wide file is
41
+ * migrated to its owning vault on first per-vault read (see
42
+ * `migrateLegacyServerWideCredentials`).
43
+ *
44
+ * **One credential set per vault.** Multi-credential ("I want repo A pushed
45
+ * with token X, repo B with token Y") within a single vault isn't supported —
46
+ * vault#382 ships one mirror per vault; one credential set per vault matches.
47
+ */
48
+
49
+ import {
50
+ chmodSync,
51
+ existsSync,
52
+ mkdirSync,
53
+ readFileSync,
54
+ renameSync,
55
+ statSync,
56
+ unlinkSync,
57
+ writeFileSync,
58
+ } from "node:fs";
59
+ import { dirname, join } from "node:path";
60
+ import { homedir } from "node:os";
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Types
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Which credential surface is currently active. Null when none configured.
68
+ * - `github_oauth` — populated `github_oauth` block.
69
+ * - `pat` — populated `pat` block (Personal Access Token + remote URL).
70
+ */
71
+ export type ActiveMethod = "github_oauth" | "pat" | null;
72
+
73
+ /**
74
+ * GitHub OAuth Device Flow result. Stored verbatim after a successful poll
75
+ * returns `granted`. The `access_token` is what gets embedded in the git
76
+ * remote URL at push time (via `x-access-token:<TOKEN>@github.com/...`).
77
+ */
78
+ export interface GitHubOAuthCredential {
79
+ /** The `gho_*` token returned by GitHub's `/login/oauth/access_token`. */
80
+ access_token: string;
81
+ /** Scope string GitHub granted (typically "repo"). */
82
+ scope: string;
83
+ /** ISO timestamp captured at the moment we saved the token. */
84
+ authorized_at: string;
85
+ /** GitHub login (`@octocat`). */
86
+ user_login: string;
87
+ /** GitHub numeric user id — stable across login renames. */
88
+ user_id: number;
89
+ }
90
+
91
+ /**
92
+ * Personal Access Token fallback. The operator pastes both the token AND
93
+ * the remote URL — vault doesn't try to guess one from the other (GitHub
94
+ * uses `https://x-access-token:<token>@github.com/...`, GitLab uses
95
+ * `https://oauth2:<token>@gitlab.com/...`, etc., and there's no generic
96
+ * rule). The stored URL is what gets set as the mirror's remote.
97
+ */
98
+ export interface PATCredential {
99
+ /** Bearer token (ghp_*, glpat-*, etc.). */
100
+ token: string;
101
+ /**
102
+ * Full HTTPS remote URL with auth embedded, e.g.
103
+ * `https://x-access-token:ghp_abc@github.com/owner/repo.git`. The operator
104
+ * supplies this; we don't synthesize.
105
+ */
106
+ remote_url: string;
107
+ /** Operator-visible label, e.g. "GitHub PAT for backup". */
108
+ label: string;
109
+ }
110
+
111
+ /**
112
+ * The on-disk + on-the-wire shape. One file per VAULT (vault#399) — keyed by
113
+ * the vault's data dir, not the server. Each vault has its own PAT + its own
114
+ * `remote_url`, so configuring git sync for vault B never reuses vault A's
115
+ * remote.
116
+ */
117
+ export interface MirrorCredentials {
118
+ /**
119
+ * Which credential method is active. Read paths check this; if null the
120
+ * mirror runs with no embedded credentials (bare `git push` inherits
121
+ * the shell — back-compat with pre-PR operators).
122
+ */
123
+ active_method: ActiveMethod;
124
+ github_oauth: GitHubOAuthCredential | null;
125
+ pat: PATCredential | null;
126
+ }
127
+
128
+ /**
129
+ * Redacted view of credentials, safe for logs / API responses. Masks tokens
130
+ * to first-4 + last-4 chars; preserves user metadata so the operator can
131
+ * verify "yes, this is the right account/repo" without re-authenticating.
132
+ */
133
+ export interface MirrorCredentialsPublic {
134
+ active_method: ActiveMethod;
135
+ github_oauth: {
136
+ user_login: string;
137
+ user_id: number;
138
+ scope: string;
139
+ authorized_at: string;
140
+ token_preview: string;
141
+ } | null;
142
+ pat: {
143
+ label: string;
144
+ remote_url: string;
145
+ token_preview: string;
146
+ } | null;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Path resolution
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /** The vault home root — `<configDir>/vault`. Re-reads PARACHUTE_HOME per call. */
154
+ function vaultHomeRoot(): string {
155
+ const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
156
+ return join(root, "vault");
157
+ }
158
+
159
+ /**
160
+ * Path to a vault's per-vault credentials file (vault#399).
161
+ *
162
+ * `<configDir>/vault/data/<vaultName>/.mirror-credentials.yaml` — under the
163
+ * vault's own data dir, alongside its SQLite DB (`vault.db`) + config
164
+ * (`vault.yaml`). This is the canonical per-vault-state location; every
165
+ * vault carries its own PAT + remote_url so git sync for one vault never
166
+ * reuses another vault's remote.
167
+ *
168
+ * Path resolution mirrors `config.ts:vaultDir()` rather than importing it —
169
+ * mirror-config.ts imports this module and config.ts imports mirror-config.ts,
170
+ * so importing config.ts here would close an import cycle. We re-derive the
171
+ * path from `PARACHUTE_HOME` (the canonical override the rest of vault honors)
172
+ * instead, which keeps this module dependency-light.
173
+ */
174
+ export function mirrorCredentialsPath(vaultName: string): string {
175
+ return join(vaultHomeRoot(), "data", vaultName, ".mirror-credentials.yaml");
176
+ }
177
+
178
+ /**
179
+ * Path to the LEGACY server-wide credentials file
180
+ * (`<configDir>/vault/.mirror-credentials.yaml`) used before vault#399.
181
+ * Retained only so the migration can find + attribute + rename it.
182
+ */
183
+ export function legacyServerWideCredentialsPath(): string {
184
+ return join(vaultHomeRoot(), ".mirror-credentials.yaml");
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Defaults
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /** Empty credentials — what readCredentials returns when the file is absent. */
192
+ export function emptyCredentials(): MirrorCredentials {
193
+ return {
194
+ active_method: null,
195
+ github_oauth: null,
196
+ pat: null,
197
+ };
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // YAML — hand-rolled to match the pattern in mirror-config.ts. No new dep.
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Serialize credentials as YAML. Keeps the file hand-editable for operators
206
+ * who want to rotate a token by `vim`-ing the file.
207
+ *
208
+ * Format:
209
+ *
210
+ * active_method: github_oauth
211
+ * github_oauth:
212
+ * access_token: gho_...
213
+ * scope: repo
214
+ * authorized_at: 2026-05-28T03:14:15.000Z
215
+ * user_login: aaron
216
+ * user_id: 12345
217
+ * pat:
218
+ * token: ghp_...
219
+ * remote_url: https://github.com/aaron/my-vault.git
220
+ * label: "GitHub PAT"
221
+ */
222
+ export function serializeCredentials(creds: MirrorCredentials): string {
223
+ const lines: string[] = [];
224
+ lines.push(`active_method: ${creds.active_method === null ? "null" : creds.active_method}`);
225
+ if (creds.github_oauth) {
226
+ lines.push("github_oauth:");
227
+ lines.push(` access_token: ${quoteIfNeeded(creds.github_oauth.access_token)}`);
228
+ lines.push(` scope: ${quoteIfNeeded(creds.github_oauth.scope)}`);
229
+ lines.push(` authorized_at: ${quoteIfNeeded(creds.github_oauth.authorized_at)}`);
230
+ lines.push(` user_login: ${quoteIfNeeded(creds.github_oauth.user_login)}`);
231
+ lines.push(` user_id: ${creds.github_oauth.user_id}`);
232
+ } else {
233
+ lines.push("github_oauth: null");
234
+ }
235
+ if (creds.pat) {
236
+ lines.push("pat:");
237
+ lines.push(` token: ${quoteIfNeeded(creds.pat.token)}`);
238
+ lines.push(` remote_url: ${quoteIfNeeded(creds.pat.remote_url)}`);
239
+ lines.push(` label: ${quoteIfNeeded(creds.pat.label)}`);
240
+ } else {
241
+ lines.push("pat: null");
242
+ }
243
+ return lines.join("\n") + "\n";
244
+ }
245
+
246
+ /**
247
+ * Quote a YAML scalar when it contains characters that confuse parsers.
248
+ * `gho_*` / `ghp_*` tokens never carry newlines or special chars, but the
249
+ * operator-supplied `label` field has no such guarantee — a label with a
250
+ * literal `\n` would break the parser's per-line section logic. Escape
251
+ * newlines + carriage returns + backslash + quote inside the quoted form.
252
+ * Reviewer-flagged on vault#384.
253
+ */
254
+ function quoteIfNeeded(value: string): string {
255
+ if (/[:#"'\\\n\r]/.test(value) || value.trim() !== value || value.length === 0) {
256
+ return `"${value
257
+ .replace(/\\/g, "\\\\")
258
+ .replace(/"/g, '\\"')
259
+ .replace(/\n/g, "\\n")
260
+ .replace(/\r/g, "\\r")}"`;
261
+ }
262
+ return value;
263
+ }
264
+
265
+ /** Parse a YAML scalar that may be quoted; otherwise return the trimmed value. */
266
+ function parseScalar(raw: string): string {
267
+ const trimmed = raw.trim();
268
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
269
+ return trimmed
270
+ .slice(1, -1)
271
+ .replace(/\\n/g, "\n")
272
+ .replace(/\\r/g, "\r")
273
+ .replace(/\\"/g, '"')
274
+ .replace(/\\\\/g, "\\");
275
+ }
276
+ return trimmed;
277
+ }
278
+
279
+ /**
280
+ * Parse the credentials YAML file. Lenient — unknown fields ignored, missing
281
+ * blocks default to null. Returns `emptyCredentials()` if the file is empty
282
+ * or contains nothing recognized.
283
+ */
284
+ export function parseCredentials(yaml: string): MirrorCredentials {
285
+ const result = emptyCredentials();
286
+ const lines = yaml.split("\n");
287
+ let section: "github_oauth" | "pat" | null = null;
288
+ // Buffer per-section scalars so we can validate as a block before commit.
289
+ let oauth: Partial<GitHubOAuthCredential> = {};
290
+ let pat: Partial<PATCredential> = {};
291
+
292
+ const commitSection = () => {
293
+ if (section === "github_oauth") {
294
+ if (
295
+ oauth.access_token &&
296
+ oauth.scope !== undefined &&
297
+ oauth.authorized_at &&
298
+ oauth.user_login &&
299
+ typeof oauth.user_id === "number"
300
+ ) {
301
+ result.github_oauth = oauth as GitHubOAuthCredential;
302
+ }
303
+ oauth = {};
304
+ } else if (section === "pat") {
305
+ if (pat.token && pat.remote_url && pat.label !== undefined) {
306
+ result.pat = pat as PATCredential;
307
+ }
308
+ pat = {};
309
+ }
310
+ };
311
+
312
+ for (const line of lines) {
313
+ if (line.match(/^\s*$/)) continue;
314
+
315
+ // Top-level scalars / section headers.
316
+ if (line.match(/^\S/)) {
317
+ // Close the previous section before starting a new one.
318
+ commitSection();
319
+ section = null;
320
+ const activeMatch = line.match(/^active_method:\s*(.*)$/);
321
+ if (activeMatch) {
322
+ const v = parseScalar(activeMatch[1]!);
323
+ if (v === "github_oauth" || v === "pat") result.active_method = v;
324
+ else result.active_method = null;
325
+ continue;
326
+ }
327
+ if (line.match(/^github_oauth:\s*null\s*$/)) {
328
+ result.github_oauth = null;
329
+ continue;
330
+ }
331
+ if (line.match(/^pat:\s*null\s*$/)) {
332
+ result.pat = null;
333
+ continue;
334
+ }
335
+ if (line.match(/^github_oauth:\s*$/)) {
336
+ section = "github_oauth";
337
+ continue;
338
+ }
339
+ if (line.match(/^pat:\s*$/)) {
340
+ section = "pat";
341
+ continue;
342
+ }
343
+ continue;
344
+ }
345
+
346
+ // Indented section field.
347
+ const fieldMatch = line.match(/^\s+(\w+):\s*(.*)$/);
348
+ if (!fieldMatch) continue;
349
+ const [, key, rawVal] = fieldMatch;
350
+ if (section === "github_oauth") {
351
+ if (key === "access_token") oauth.access_token = parseScalar(rawVal!);
352
+ else if (key === "scope") oauth.scope = parseScalar(rawVal!);
353
+ else if (key === "authorized_at") oauth.authorized_at = parseScalar(rawVal!);
354
+ else if (key === "user_login") oauth.user_login = parseScalar(rawVal!);
355
+ else if (key === "user_id") {
356
+ const n = Number(parseScalar(rawVal!));
357
+ if (Number.isFinite(n)) oauth.user_id = n;
358
+ }
359
+ } else if (section === "pat") {
360
+ if (key === "token") pat.token = parseScalar(rawVal!);
361
+ else if (key === "remote_url") pat.remote_url = parseScalar(rawVal!);
362
+ else if (key === "label") pat.label = parseScalar(rawVal!);
363
+ }
364
+ }
365
+
366
+ commitSection();
367
+ return result;
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // File I/O
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /**
375
+ * Read a vault's credentials from disk. Returns `null` when the file doesn't
376
+ * exist (operator hasn't connected anything yet); throws when the file is
377
+ * present but unreadable (a permission error is a loud configuration problem).
378
+ *
379
+ * vault#399 migration: if the per-vault file is absent but the legacy
380
+ * server-wide file exists, `migrateLegacyServerWideCredentials` may have
381
+ * promoted it to THIS vault (when it's the migration target). The migration
382
+ * runs at boot (server.ts) and is idempotent; this read just picks up
383
+ * whatever landed on disk.
384
+ */
385
+ export function readCredentials(vaultName: string): MirrorCredentials | null {
386
+ const path = mirrorCredentialsPath(vaultName);
387
+ if (!existsSync(path)) return null;
388
+ const raw = readFileSync(path, "utf8");
389
+ return parseCredentials(raw);
390
+ }
391
+
392
+ /**
393
+ * Persist credentials atomically:
394
+ * 1. Write to `<path>.tmp` with perms 0600 (write + read by owner only).
395
+ * 2. Rename onto the final path (atomic on POSIX; mostly atomic on Windows).
396
+ * 3. Re-apply 0600 perms in case the rename clobbered them (defense in
397
+ * depth — `mv` shouldn't alter perms, but the test surface is wide).
398
+ *
399
+ * Fails loudly: any errno propagates. Callers (the route handler) catch +
400
+ * surface a 500 with the underlying message — quietly losing credentials
401
+ * would be worse than crashing the request.
402
+ */
403
+ export function writeCredentials(vaultName: string, creds: MirrorCredentials): void {
404
+ const path = mirrorCredentialsPath(vaultName);
405
+ const dir = dirname(path);
406
+ // Vault home may not exist yet (tests, fresh installs); create it.
407
+ if (!existsSync(dir)) {
408
+ mkdirSync(dir, { recursive: true });
409
+ }
410
+ const tmp = `${path}.tmp`;
411
+ const serialized = serializeCredentials(creds);
412
+ writeFileSync(tmp, serialized, { mode: 0o600 });
413
+ // Belt-and-braces: writeFileSync's `mode` is only honored on file
414
+ // CREATION. If the temp file already existed (interrupted prior write),
415
+ // the existing perms persist. Re-chmod to be sure.
416
+ chmodSync(tmp, 0o600);
417
+ renameSync(tmp, path);
418
+ // Some filesystems preserve the old file's perms across rename; force
419
+ // 0600 on the final path. No-op on the common case.
420
+ chmodSync(path, 0o600);
421
+
422
+ // Defensive verification — if the perms are wrong on disk, throw so the
423
+ // caller surfaces the misconfiguration to the operator. A world-readable
424
+ // credentials file would silently leak the OAuth token.
425
+ const stat = statSync(path);
426
+ const perms = stat.mode & 0o777;
427
+ if (perms !== 0o600) {
428
+ throw new Error(
429
+ `Mirror credentials file at ${path} has perms ${perms.toString(8)}, expected 0600. Refusing to leave a world-readable token on disk.`,
430
+ );
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Delete the credentials file. Idempotent — missing file is a no-op (the
436
+ * Disconnect UX should succeed even if the file was already removed).
437
+ */
438
+ export function deleteCredentials(vaultName: string): void {
439
+ const path = mirrorCredentialsPath(vaultName);
440
+ if (existsSync(path)) unlinkSync(path);
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Migration — legacy server-wide → per-vault (vault#399)
445
+ // ---------------------------------------------------------------------------
446
+
447
+ /**
448
+ * One-time migration of the legacy server-wide credentials file to the
449
+ * per-vault layout (vault#399).
450
+ *
451
+ * The bug: pre-vault#399, credentials (PAT + embedded `remote_url`) lived in a
452
+ * single `<configDir>/vault/.mirror-credentials.yaml` shared across ALL
453
+ * vaults. Configuring git sync for a second vault reused the first vault's
454
+ * remote, pointing it at the wrong GitHub repo.
455
+ *
456
+ * Attribution policy (SAFEST per the design): attribute the legacy creds to
457
+ * the FIRST vault — the earliest-created one, which is what
458
+ * `resolveMirrorVaultName()` already bound the single server-wide mirror to.
459
+ * That's the vault whose remote/PAT the legacy file actually corresponds to.
460
+ * We do NOT copy the same remote/PAT onto every vault — that would recreate
461
+ * the leak.
462
+ *
463
+ * Safety:
464
+ * - No-op when no legacy file exists (fresh installs, already-migrated).
465
+ * - No-op when the target vault already has a per-vault file (don't clobber
466
+ * creds the operator set post-migration).
467
+ * - Leaves the legacy file in place renamed `.bak` (never silently deleted)
468
+ * so nothing is lost if attribution was wrong — the operator can recover.
469
+ * - Logs the attribution decision clearly.
470
+ *
471
+ * @param targetVaultName the vault to attribute the legacy creds to (caller
472
+ * passes the result of `resolveMirrorVaultName()`, i.e. default-or-first).
473
+ * @returns a struct describing what happened, for logging + tests.
474
+ */
475
+ export function migrateLegacyServerWideCredentials(
476
+ targetVaultName: string | null,
477
+ ):
478
+ | { migrated: false; reason: "no_legacy_file" | "no_target_vault" | "target_already_has_creds" }
479
+ | { migrated: true; targetVaultName: string; backupPath: string } {
480
+ const legacyPath = legacyServerWideCredentialsPath();
481
+ if (!existsSync(legacyPath)) {
482
+ return { migrated: false, reason: "no_legacy_file" };
483
+ }
484
+ if (!targetVaultName) {
485
+ // Legacy file present but no vault to attribute it to. Leave it in
486
+ // place — a future boot (once a vault exists) migrates it.
487
+ return { migrated: false, reason: "no_target_vault" };
488
+ }
489
+ const targetPath = mirrorCredentialsPath(targetVaultName);
490
+ if (existsSync(targetPath)) {
491
+ // The target vault already has per-vault creds (operator configured
492
+ // them post-migration, or a prior migration ran). Don't clobber.
493
+ // Still rename the legacy file so we don't re-evaluate it forever.
494
+ const backupPath = `${legacyPath}.bak`;
495
+ try {
496
+ if (!existsSync(backupPath)) renameSync(legacyPath, backupPath);
497
+ } catch {
498
+ // Non-fatal — worst case we re-check next boot and short-circuit here.
499
+ }
500
+ return { migrated: false, reason: "target_already_has_creds" };
501
+ }
502
+
503
+ // Read the legacy creds + write them to the target vault's per-vault file.
504
+ const raw = readFileSync(legacyPath, "utf8");
505
+ const creds = parseCredentials(raw);
506
+ writeCredentials(targetVaultName, creds);
507
+
508
+ // Rename the legacy file to .bak rather than deleting — never silently
509
+ // lose an operator's only copy of a token/remote.
510
+ const backupPath = `${legacyPath}.bak`;
511
+ try {
512
+ renameSync(legacyPath, backupPath);
513
+ } catch {
514
+ // If the rename fails (e.g. .bak already exists from a partial prior
515
+ // run), fall back to unlinking the original — the creds are now safely
516
+ // in the per-vault file.
517
+ try {
518
+ unlinkSync(legacyPath);
519
+ } catch {
520
+ // Leave it; the target_already_has_creds branch short-circuits next boot.
521
+ }
522
+ }
523
+
524
+ console.log(
525
+ `[mirror] migrated legacy server-wide mirror credentials → vault "${targetVaultName}" (per-vault, vault#399). ` +
526
+ `Other vaults start with no mirror credentials (configure each separately). ` +
527
+ `Legacy file preserved at ${backupPath}.`,
528
+ );
529
+ return { migrated: true, targetVaultName, backupPath };
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Redaction
534
+ // ---------------------------------------------------------------------------
535
+
536
+ /**
537
+ * Mask a token to first-4 + last-4 chars with a fixed-width middle. Designed
538
+ * to be safe to log + display in the UI's status section (operator can verify
539
+ * "yes, this is the token I authorized" without revealing the secret).
540
+ *
541
+ * Short tokens (< 12 chars) get fully masked rather than partially revealed
542
+ * — anything that short isn't a real production token, but defense in depth
543
+ * costs nothing.
544
+ */
545
+ export function previewToken(token: string): string {
546
+ if (token.length < 12) return "***";
547
+ return `${token.slice(0, 4)}…${token.slice(-4)}`;
548
+ }
549
+
550
+ /**
551
+ * Produce a redacted view of credentials. Use this anywhere credentials
552
+ * leave the trust boundary — logs, HTTP responses, UI state. The full
553
+ * shape lives only in memory + on disk.
554
+ */
555
+ export function sanitizeCredentials(
556
+ creds: MirrorCredentials | null,
557
+ ): MirrorCredentialsPublic {
558
+ if (!creds) {
559
+ return { active_method: null, github_oauth: null, pat: null };
560
+ }
561
+ return {
562
+ active_method: creds.active_method,
563
+ github_oauth: creds.github_oauth
564
+ ? {
565
+ user_login: creds.github_oauth.user_login,
566
+ user_id: creds.github_oauth.user_id,
567
+ scope: creds.github_oauth.scope,
568
+ authorized_at: creds.github_oauth.authorized_at,
569
+ token_preview: previewToken(creds.github_oauth.access_token),
570
+ }
571
+ : null,
572
+ pat: creds.pat
573
+ ? {
574
+ label: creds.pat.label,
575
+ remote_url: redactRemoteUrl(creds.pat.remote_url),
576
+ token_preview: previewToken(creds.pat.token),
577
+ }
578
+ : null,
579
+ };
580
+ }
581
+
582
+ /**
583
+ * Mask the userinfo portion of a remote URL — the operator might have
584
+ * pasted `https://user:token@host/...` and we don't want the raw token
585
+ * leaking via the redacted view. Returns the URL with `user:***@` swapped
586
+ * in for the entire userinfo, leaving the host + path intact.
587
+ */
588
+ export function redactRemoteUrl(url: string): string {
589
+ try {
590
+ const u = new URL(url);
591
+ if (u.username || u.password) {
592
+ u.username = "***";
593
+ u.password = "";
594
+ return u.toString();
595
+ }
596
+ return url;
597
+ } catch {
598
+ // Non-URL string — return a generic placeholder.
599
+ return "***";
600
+ }
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // Git remote URL helpers
605
+ // ---------------------------------------------------------------------------
606
+
607
+ /**
608
+ * Build the HTTPS remote URL with an embedded GitHub OAuth token for `owner/repo`.
609
+ *
610
+ * https://x-access-token:<TOKEN>@github.com/<owner>/<repo>.git
611
+ *
612
+ * The `x-access-token` username convention is GitHub-specific. PATs work
613
+ * through the same shape (GitHub treats `gho_*` and `ghp_*` identically at
614
+ * the credential-helper layer); GitLab / Codeberg use different conventions,
615
+ * so the PAT path stores the full URL operator-supplied rather than
616
+ * synthesizing.
617
+ */
618
+ export function githubAuthedRemoteUrl(
619
+ token: string,
620
+ owner: string,
621
+ repo: string,
622
+ ): string {
623
+ // GitHub repo names allow `.`, `-`, `_`. URL-escape just in case
624
+ // someone has a weird name; the path-component encoder is too aggressive
625
+ // here (it would escape `.`), so we trust GitHub's naming rules.
626
+ return `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
627
+ }
628
+
629
+ /**
630
+ * Set the embedded-credential remote URL on a mirror repo's git config.
631
+ *
632
+ * Idempotent: calling on an already-configured remote replaces the URL
633
+ * (which is what we want — token rotation should "just work" when the
634
+ * stored credentials update). Adds the remote if it doesn't exist; updates
635
+ * it if it does.
636
+ *
637
+ * **Logs are scrubbed.** We never log the URL itself (it carries the
638
+ * token). We log a redacted form via `redactRemoteUrl` instead.
639
+ */
640
+ export async function applyToGitRemote(
641
+ repoDir: string,
642
+ remoteUrl: string,
643
+ ): Promise<{ ok: boolean; error?: string }> {
644
+ // Probe for an existing `origin`. `git remote get-url origin` returns
645
+ // exit 0 if it exists, non-zero otherwise.
646
+ const probe = Bun.spawn(["git", "remote", "get-url", "origin"], {
647
+ cwd: repoDir,
648
+ stdout: "pipe",
649
+ stderr: "pipe",
650
+ });
651
+ const probeCode = await probe.exited;
652
+ const verb = probeCode === 0 ? "set-url" : "add";
653
+ const cmd =
654
+ verb === "set-url"
655
+ ? ["git", "remote", "set-url", "origin", remoteUrl]
656
+ : ["git", "remote", "add", "origin", remoteUrl];
657
+ const proc = Bun.spawn(cmd, {
658
+ cwd: repoDir,
659
+ stdout: "pipe",
660
+ stderr: "pipe",
661
+ });
662
+ const exitCode = await proc.exited;
663
+ if (exitCode !== 0) {
664
+ const stderr = new TextDecoder()
665
+ .decode(await new Response(proc.stderr).arrayBuffer())
666
+ .trim();
667
+ return { ok: false, error: stderr };
668
+ }
669
+ return { ok: true };
670
+ }
671
+
672
+ /**
673
+ * Remove the embedded-credential remote (when credentials are cleared).
674
+ * Idempotent: missing remote is fine — the operator might never have had
675
+ * one set up.
676
+ */
677
+ export async function unsetGitRemote(
678
+ repoDir: string,
679
+ ): Promise<{ ok: boolean; error?: string }> {
680
+ const probe = Bun.spawn(["git", "remote", "get-url", "origin"], {
681
+ cwd: repoDir,
682
+ stdout: "pipe",
683
+ stderr: "pipe",
684
+ });
685
+ const probeCode = await probe.exited;
686
+ if (probeCode !== 0) return { ok: true }; // nothing to unset
687
+ const proc = Bun.spawn(["git", "remote", "remove", "origin"], {
688
+ cwd: repoDir,
689
+ stdout: "pipe",
690
+ stderr: "pipe",
691
+ });
692
+ const exitCode = await proc.exited;
693
+ if (exitCode !== 0) {
694
+ const stderr = new TextDecoder()
695
+ .decode(await new Response(proc.stderr).arrayBuffer())
696
+ .trim();
697
+ return { ok: false, error: stderr };
698
+ }
699
+ return { ok: true };
700
+ }