@openparachute/vault 0.5.0-rc.2 → 0.5.0-rc.3

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.
@@ -26,16 +26,20 @@
26
26
 
27
27
  import {
28
28
  defaultMirrorConfig,
29
+ readMirrorConfigForVault,
29
30
  validateExternalPath,
30
31
  validateMirrorConfigShape,
31
32
  type MirrorConfig,
32
33
  } from "./mirror-config.ts";
33
34
  import type { MirrorManager } from "./mirror-manager.ts";
35
+ import { getMirrorManager } from "./mirror-registry.ts";
34
36
  import {
35
37
  applyToGitRemote,
36
38
  deleteCredentials,
37
39
  emptyCredentials,
40
+ githubAuthedRemoteUrl,
38
41
  readCredentials,
42
+ redactRemoteUrl,
39
43
  sanitizeCredentials,
40
44
  unsetGitRemote,
41
45
  writeCredentials,
@@ -1106,15 +1110,32 @@ export async function applyCredentialsToMirror(
1106
1110
  // "mode": "merge" | "replace",
1107
1111
  // "credentials": null
1108
1112
  // | { "kind": "pat", "token": "ghp_..." }
1109
- // | { "kind": "none" }
1113
+ // | { "kind": "none" },
1114
+ // "enable_sync": true // optional, DEFAULT TRUE
1110
1115
  // }
1111
1116
  //
1112
1117
  // `credentials: null` means "use the stored mirror credentials." Passing
1113
- // `{kind: "pat", token}` is the one-shot path — token doesn't get persisted.
1118
+ // `{kind: "pat", token}` is the one-shot path — token doesn't get persisted
1119
+ // for the CLONE, but IS persisted when sync is enabled (it's the push
1120
+ // credential for the now-configured mirror).
1121
+ //
1122
+ // `enable_sync` (vault#416) — DEFAULT TRUE when omitted. After a successful
1123
+ // import, auto-enable mirror push-back to the SAME repo, reusing the import's
1124
+ // credentials. Makes "import a repo" and "back up to that repo going forward"
1125
+ // one fluid flow. The UI ships a checked-by-default checkbox the operator can
1126
+ // uncheck. Edge cases (handled in `enableSyncToImportedRepo`, never fail the
1127
+ // whole import):
1128
+ // - `auth: none` (public repo, no push creds) → skip + warn (can't push
1129
+ // without a credential).
1130
+ // - A mirror already points at a DIFFERENT remote → skip + warn (don't
1131
+ // clobber the operator's existing backup target).
1132
+ // - A mirror already points at the SAME remote → no-op success.
1133
+ // - Sync setup throws → import result still returned (success);
1134
+ // `sync_enabled: false` + a warning. Import success is never lost.
1114
1135
  //
1115
1136
  // Response:
1116
1137
  // 200 { notes_imported, tags_imported, attachments_imported,
1117
- // notes_deleted?, warnings }
1138
+ // notes_deleted?, warnings, sync_enabled, sync_warning? }
1118
1139
  // 400 { error, error_type, message } — validation / not-a-vault-export
1119
1140
  // 409 { error, error_type, message } — concurrent import for this vault
1120
1141
  // 502 { error, message } — clone failed (auth, network, …)
@@ -1136,17 +1157,25 @@ export async function applyCredentialsToMirror(
1136
1157
  * `whichOverride` is a test seam for the git-presence preflight (default
1137
1158
  * `Bun.which` inside `cloneAndImport`). Inject a fn returning `null` to
1138
1159
  * exercise the git_not_installed 503 path without uninstalling git.
1160
+ *
1161
+ * `managerOverride` is a test seam for the post-import sync-enable step
1162
+ * (vault#416). Production callers omit it; the route resolves the per-vault
1163
+ * manager via `getMirrorManager(vaultName)` (same registry the auth /
1164
+ * run-now / push-now routes use). Tests inject a manager so they can assert
1165
+ * on the config it ends up with without standing up the full registry.
1139
1166
  */
1140
1167
  export async function handleMirrorImport(
1141
1168
  req: Request,
1142
1169
  vaultName: string,
1143
1170
  spawnOverride?: GitSpawn,
1144
1171
  whichOverride?: (cmd: string) => string | null,
1172
+ managerOverride?: MirrorManager,
1145
1173
  ): Promise<Response> {
1146
1174
  let body: {
1147
1175
  remote_url?: unknown;
1148
1176
  mode?: unknown;
1149
1177
  credentials?: unknown;
1178
+ enable_sync?: unknown;
1150
1179
  };
1151
1180
  try {
1152
1181
  body = (await req.json()) as Record<string, unknown>;
@@ -1239,6 +1268,25 @@ export async function handleMirrorImport(
1239
1268
  );
1240
1269
  }
1241
1270
 
1271
+ // vault#416: `enable_sync` defaults TRUE when omitted — the default-on UX.
1272
+ // Only a literal `false` opts out; any other type is a validation error so
1273
+ // a malformed body never silently flips the default.
1274
+ let enableSync = true;
1275
+ if ("enable_sync" in body && body.enable_sync !== undefined) {
1276
+ if (typeof body.enable_sync !== "boolean") {
1277
+ return Response.json(
1278
+ {
1279
+ error: "enable_sync invalid",
1280
+ error_type: "validation",
1281
+ field: "enable_sync",
1282
+ message: "enable_sync must be a boolean (defaults to true when omitted).",
1283
+ },
1284
+ { status: 400 },
1285
+ );
1286
+ }
1287
+ enableSync = body.enable_sync;
1288
+ }
1289
+
1242
1290
  // Resolve the target vault's store + assets dir. The route is gated on
1243
1291
  // `vault:<name>:admin`, so we trust vaultName is real by the time we
1244
1292
  // reach this code path; defensively re-resolve in case the vault was
@@ -1311,7 +1359,368 @@ export async function handleMirrorImport(
1311
1359
  );
1312
1360
  }
1313
1361
 
1362
+ // ---- vault#416: auto-enable sync to the imported repo (default-on) -------
1363
+ //
1364
+ // The import SUCCEEDED above. From here on, every failure is non-fatal —
1365
+ // we never lose a successful import to a sync-setup error. `result` already
1366
+ // carries `sync_enabled: false` (set by importResultFromStats); we flip it
1367
+ // true only when sync is actually wired (or already wired to this remote).
1368
+ if (enableSync) {
1369
+ try {
1370
+ const manager =
1371
+ managerOverride ?? getMirrorManager(vaultName) ?? undefined;
1372
+ const outcome = await enableSyncToImportedRepo({
1373
+ vaultName,
1374
+ remoteUrl: remote_url,
1375
+ auth,
1376
+ manager,
1377
+ });
1378
+ result.sync_enabled = outcome.sync_enabled;
1379
+ if (outcome.warning) result.sync_warning = outcome.warning;
1380
+ } catch (err) {
1381
+ // Defense-in-depth: enableSyncToImportedRepo is written to not throw
1382
+ // (it catches its own write/credential/reload errors), but a future
1383
+ // edit or an unexpected throw must NOT take down a successful import.
1384
+ const msg = redactToken((err as Error).message ?? String(err));
1385
+ console.warn(
1386
+ `[mirror-import] sync-enable threw after a successful import (non-fatal): ${msg}`,
1387
+ );
1388
+ result.sync_enabled = false;
1389
+ result.sync_warning =
1390
+ "Import succeeded, but enabling Sync failed. Set up Sync separately from the Git remote section.";
1391
+ }
1392
+ }
1393
+
1314
1394
  return Response.json(result, {
1315
1395
  headers: { "Access-Control-Allow-Origin": "*" },
1316
1396
  });
1317
1397
  }
1398
+
1399
+ // ---------------------------------------------------------------------------
1400
+ // vault#416 — sync-enable after a successful import.
1401
+ // ---------------------------------------------------------------------------
1402
+
1403
+ /**
1404
+ * After a successful import, turn the imported-from repo into a configured,
1405
+ * credential-backed, auto-pushing mirror — reusing the import's credentials.
1406
+ * This is the back half of the default-on "import → also sync back" flow.
1407
+ *
1408
+ * Reuses the SAME machinery the manual mirror-setup flow uses:
1409
+ * - `writeCredentials` to persist the push credential (per-vault, vault#399).
1410
+ * - `MirrorConfig` + `manager.reload()` to enable `auto_push` and restart
1411
+ * the lifecycle — which calls `applyCredentialsToRemote` to set `origin`
1412
+ * from the stored credentials (exactly like handleAuthPat /
1413
+ * handleAuthGithubSelectRepo do).
1414
+ *
1415
+ * Mirror location is left `internal` (vault-managed dir under the vault's
1416
+ * data dir) — the import didn't ask the operator to pick an external path,
1417
+ * and `auto_push + internal + credentials` is the supported "push to a
1418
+ * GitHub/GitLab remote" shape (see mirror-config.ts validation note). The
1419
+ * remote lives in credentials, not the config; that's why we persist
1420
+ * credentials AND flip auto_push.
1421
+ *
1422
+ * Never throws — every failure path returns `{ sync_enabled: false, warning }`.
1423
+ * The import already succeeded by the time this runs; a sync-setup error must
1424
+ * not be surfaced as an import failure.
1425
+ *
1426
+ * Edge cases (returns `sync_enabled: false` + a warning, no broken mirror
1427
+ * left behind):
1428
+ * - **No push-capable credential** — `auth: none`, OR `credentialsFile`
1429
+ * with no stored credential that covers this remote's host. Can't push
1430
+ * without a credential; skip rather than configure a mirror that would
1431
+ * just fail every push.
1432
+ * - **A mirror already targets a DIFFERENT remote** — don't clobber the
1433
+ * operator's existing backup target. Detected by comparing the existing
1434
+ * stored credential's remote host/path against the import remote.
1435
+ * - **A mirror already targets the SAME remote** — no-op success.
1436
+ */
1437
+ export async function enableSyncToImportedRepo(opts: {
1438
+ vaultName: string;
1439
+ remoteUrl: string;
1440
+ auth: ImportAuth;
1441
+ /**
1442
+ * The per-vault mirror manager. Undefined only in the boot-race window
1443
+ * where the registry factory hasn't been installed; we then skip (warn)
1444
+ * rather than persisting a half-configured mirror with no live manager.
1445
+ */
1446
+ manager: MirrorManager | undefined;
1447
+ }): Promise<{ sync_enabled: boolean; warning?: string }> {
1448
+ const { vaultName, remoteUrl, auth, manager } = opts;
1449
+
1450
+ if (!manager) {
1451
+ return {
1452
+ sync_enabled: false,
1453
+ warning:
1454
+ "Sync not enabled — the vault's mirror manager wasn't ready. Set up Sync from the Git remote section.",
1455
+ };
1456
+ }
1457
+
1458
+ // --- Resolve the push credential we'll persist for this remote. ----------
1459
+ // We need a credential that can PUSH to `remoteUrl`. The clone-time auth
1460
+ // shapes map onto push credentials as follows:
1461
+ // - pat → persist the supplied PAT against this remote.
1462
+ // - none → no push credential; cannot enable a working sync.
1463
+ // - credentialsFile → reuse the already-stored credential, but only if it
1464
+ // actually covers this remote's host (a stored PAT for
1465
+ // a different host can't push here).
1466
+ let credentialToWrite: MirrorCredentials | null = null;
1467
+
1468
+ if (auth.kind === "none") {
1469
+ return {
1470
+ sync_enabled: false,
1471
+ warning:
1472
+ "Sync not enabled — pushing changes back needs write credentials (a PAT or GitHub sign-in). Set up Sync separately to enable it.",
1473
+ };
1474
+ }
1475
+
1476
+ if (auth.kind === "pat") {
1477
+ // Persist the supplied PAT against this remote — the same x-access-token
1478
+ // embedding the manual PAT route stores, so bare `git push` works.
1479
+ const embedded = embedTokenInRemoteUrl(remoteUrl, auth.token);
1480
+ if (!embedded) {
1481
+ return {
1482
+ sync_enabled: false,
1483
+ warning:
1484
+ "Sync not enabled — the remote URL couldn't be parsed to embed the access token. Set up Sync separately from the Git remote section.",
1485
+ };
1486
+ }
1487
+ credentialToWrite = {
1488
+ ...(readCredentials(vaultName) ?? emptyCredentials()),
1489
+ active_method: "pat",
1490
+ pat: {
1491
+ token: auth.token,
1492
+ remote_url: embedded,
1493
+ label: "Imported-repo sync",
1494
+ },
1495
+ };
1496
+ }
1497
+
1498
+ // For credentialsFile we DON'T overwrite — we reuse what's already stored,
1499
+ // but must verify it covers this remote (host match). Resolve the existing
1500
+ // credential's effective remote so the conflict check below has something
1501
+ // to compare against.
1502
+ const existing = readCredentials(vaultName);
1503
+
1504
+ // --- Conflict / idempotency check against any already-configured mirror. --
1505
+ // The mirror's remote lives in credentials, not in MirrorConfig. Compare
1506
+ // the existing stored credential's remote against the import remote.
1507
+ const persistedConfig = readMirrorConfigForVault(vaultName);
1508
+ const mirrorAlreadyConfigured = !!persistedConfig && persistedConfig.enabled;
1509
+ const existingRemote = existing ? effectiveRemoteOf(existing) : null;
1510
+
1511
+ if (mirrorAlreadyConfigured && existingRemote) {
1512
+ if (sameRemote(existingRemote, remoteUrl)) {
1513
+ // Same remote — make sure auto_push is on, then no-op success. We don't
1514
+ // need to rewrite credentials (credentialsFile) or can refresh the PAT
1515
+ // (pat path) — either way the mirror already points here.
1516
+ if (auth.kind === "pat" && credentialToWrite) {
1517
+ try {
1518
+ writeCredentials(vaultName, credentialToWrite);
1519
+ } catch (err) {
1520
+ return {
1521
+ sync_enabled: false,
1522
+ warning: `Import succeeded; mirror already targets this repo but the credential refresh failed: ${redactToken((err as Error).message ?? String(err))}`,
1523
+ };
1524
+ }
1525
+ }
1526
+ return await applyEnabledAutoPush(manager);
1527
+ }
1528
+ // Different remote — don't clobber the operator's existing backup target.
1529
+ return {
1530
+ sync_enabled: false,
1531
+ warning: `Sync not enabled — this vault already syncs to a different repo (${redactRemoteUrl(existingRemote)}). Leaving your existing backup target untouched. Change it from the Git remote section if you want to switch.`,
1532
+ };
1533
+ }
1534
+
1535
+ // A mirror is already configured + enabled with an active credential, but we
1536
+ // couldn't read its remote to compare (github_oauth stores no owner/repo at
1537
+ // the credential level — its remote lives on the mirror dir's `origin`). To
1538
+ // avoid clobbering an existing GitHub-connected backup target by switching
1539
+ // `active_method` to a PAT, refuse unless the existing credential is OAuth
1540
+ // and this is the same GitHub host (then the manual select-repo flow already
1541
+ // points origin where it should; we treat that as "already set up — leave
1542
+ // it"). Conservative: don't auto-switch an existing connected mirror.
1543
+ if (
1544
+ mirrorAlreadyConfigured &&
1545
+ existing?.active_method === "github_oauth" &&
1546
+ existing.github_oauth
1547
+ ) {
1548
+ return {
1549
+ sync_enabled: false,
1550
+ warning:
1551
+ "Sync not enabled — this vault already syncs via a connected GitHub account. Leaving your existing backup target untouched. Switch it from the Git remote section if you want to point sync at this repo instead.",
1552
+ };
1553
+ }
1554
+
1555
+ // --- No conflicting mirror. Wire it up. ----------------------------------
1556
+ if (auth.kind === "credentialsFile") {
1557
+ // Reuse the stored credential — but only if it can push to this remote.
1558
+ const covers = existing && existingRemote && sameRemote(existingRemote, remoteUrl);
1559
+ const hasOauthForGithub =
1560
+ existing?.active_method === "github_oauth" &&
1561
+ !!existing.github_oauth &&
1562
+ isGithubRemote(remoteUrl);
1563
+ if (!covers && !hasOauthForGithub) {
1564
+ return {
1565
+ sync_enabled: false,
1566
+ warning:
1567
+ "Sync not enabled — no saved credential can push to this repo. Connect GitHub or paste a Personal Access Token in the Git remote section to enable Sync.",
1568
+ };
1569
+ }
1570
+ if (existing?.active_method === "github_oauth" && hasOauthForGithub) {
1571
+ // OAuth path: persist origin via the github authed URL, same as the
1572
+ // select-repo flow. Parse owner/repo from the import remote.
1573
+ const ownerRepo = parseGithubOwnerRepo(remoteUrl);
1574
+ if (!ownerRepo) {
1575
+ return {
1576
+ sync_enabled: false,
1577
+ warning:
1578
+ "Sync not enabled — couldn't parse owner/repo from the GitHub URL. Pick the repo from the Git remote section to enable Sync.",
1579
+ };
1580
+ }
1581
+ const status = manager.getStatus();
1582
+ const authedUrl = githubAuthedRemoteUrl(
1583
+ existing.github_oauth!.access_token,
1584
+ ownerRepo.owner,
1585
+ ownerRepo.repo,
1586
+ );
1587
+ if (status.mirror_path) {
1588
+ await applyToGitRemote(status.mirror_path, authedUrl);
1589
+ }
1590
+ // Stash a PAT-shaped credential so a restart re-applies the right
1591
+ // origin without needing the operator to re-pick the repo. (The OAuth
1592
+ // token works through the same x-access-token shape.) This mirrors how
1593
+ // applyCredentialsToRemote refreshes github origins on restart.
1594
+ credentialToWrite = {
1595
+ ...existing,
1596
+ active_method: "github_oauth",
1597
+ };
1598
+ }
1599
+ // covers === true → existing PAT already targets this remote; nothing to
1600
+ // re-persist. Fall through to enabling auto_push.
1601
+ }
1602
+
1603
+ if (credentialToWrite) {
1604
+ try {
1605
+ writeCredentials(vaultName, credentialToWrite);
1606
+ } catch (err) {
1607
+ return {
1608
+ sync_enabled: false,
1609
+ warning: `Import succeeded, but saving the sync credential failed: ${redactToken((err as Error).message ?? String(err))}. Set up Sync separately from the Git remote section.`,
1610
+ };
1611
+ }
1612
+ }
1613
+
1614
+ return await applyEnabledAutoPush(manager);
1615
+ }
1616
+
1617
+ /**
1618
+ * Flip the mirror config to `enabled: true, auto_push: true` (internal
1619
+ * location) and reload the manager — which applies the just-persisted
1620
+ * credentials to `origin` and starts the event-driven export loop. Returns
1621
+ * the sync outcome; reload failures are non-fatal (import already succeeded).
1622
+ */
1623
+ async function applyEnabledAutoPush(
1624
+ manager: MirrorManager,
1625
+ ): Promise<{ sync_enabled: boolean; warning?: string }> {
1626
+ const current = manager.getEffectiveConfig();
1627
+ const next: MirrorConfig = {
1628
+ ...current,
1629
+ enabled: true,
1630
+ // Keep the operator's existing location if they'd set external; default
1631
+ // internal for the fresh case. Internal + credentials is the supported
1632
+ // "push to a hosted remote" shape.
1633
+ location: current.location,
1634
+ auto_push: true,
1635
+ };
1636
+ try {
1637
+ await manager.reload(next);
1638
+ } catch (err) {
1639
+ return {
1640
+ sync_enabled: false,
1641
+ warning: `Import succeeded, but enabling Sync failed: ${redactToken((err as Error).message ?? String(err))}. Set up Sync separately from the Git remote section.`,
1642
+ };
1643
+ }
1644
+ // reload → start sets status.enabled iff bootstrap succeeded. If bootstrap
1645
+ // failed (e.g. git-less host, though import would've 503'd earlier), surface
1646
+ // that rather than claiming sync is on.
1647
+ const status = manager.getStatus();
1648
+ if (!status.enabled) {
1649
+ return {
1650
+ sync_enabled: false,
1651
+ warning: status.last_error
1652
+ ? `Import succeeded, but the mirror couldn't start: ${redactToken(status.last_error)}`
1653
+ : "Import succeeded, but the mirror couldn't start. Check the Git remote section.",
1654
+ };
1655
+ }
1656
+ return { sync_enabled: true };
1657
+ }
1658
+
1659
+ /**
1660
+ * Embed an x-access-token credential into an HTTPS remote URL, the same shape
1661
+ * the manual PAT route persists. Returns null when the URL can't be parsed or
1662
+ * isn't http(s) (SSH / local-path remotes can't carry a token in userinfo —
1663
+ * those rely on the operator's ssh-agent, so there's no push credential for
1664
+ * us to embed). When the URL already carries userinfo, trust it verbatim.
1665
+ */
1666
+ function embedTokenInRemoteUrl(remoteUrl: string, token: string): string | null {
1667
+ let parsed: URL;
1668
+ try {
1669
+ parsed = new URL(remoteUrl);
1670
+ } catch {
1671
+ return null;
1672
+ }
1673
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
1674
+ if (parsed.username || parsed.password) return remoteUrl;
1675
+ parsed.username = "x-access-token";
1676
+ parsed.password = token;
1677
+ return parsed.toString();
1678
+ }
1679
+
1680
+ /**
1681
+ * The remote a stored credential effectively pushes to:
1682
+ * - pat → its `remote_url` (userinfo-embedded; comparison strips userinfo).
1683
+ * - github_oauth → null here (no owner/repo stored at the credential level;
1684
+ * the origin is set when a repo is picked). Callers treat null as "no
1685
+ * known remote to conflict with."
1686
+ */
1687
+ function effectiveRemoteOf(creds: MirrorCredentials): string | null {
1688
+ if (creds.active_method === "pat" && creds.pat) return creds.pat.remote_url;
1689
+ return null;
1690
+ }
1691
+
1692
+ /**
1693
+ * Compare two remote URLs ignoring userinfo (embedded tokens) + a trailing
1694
+ * `.git` + a trailing slash, case-insensitively on host. "Same repo" for the
1695
+ * clobber check. Falls back to a trimmed string compare for non-URL remotes
1696
+ * (SSH shorthand, local paths).
1697
+ */
1698
+ function sameRemote(a: string, b: string): boolean {
1699
+ const norm = (u: string): string => {
1700
+ try {
1701
+ const url = new URL(u);
1702
+ const host = url.host.toLowerCase();
1703
+ const path = url.pathname.replace(/\.git$/, "").replace(/\/+$/, "");
1704
+ return `${url.protocol}//${host}${path}`;
1705
+ } catch {
1706
+ return u.trim().replace(/\.git$/, "").replace(/\/+$/, "");
1707
+ }
1708
+ };
1709
+ return norm(a) === norm(b);
1710
+ }
1711
+
1712
+ /** True when a remote URL is on github.com (host-exact, case-insensitive). */
1713
+ function isGithubRemote(remoteUrl: string): boolean {
1714
+ try {
1715
+ return new URL(remoteUrl).host.toLowerCase() === "github.com";
1716
+ } catch {
1717
+ return false;
1718
+ }
1719
+ }
1720
+
1721
+ /** Parse `owner` + `repo` out of a github.com HTTPS URL. Null when it doesn't match. */
1722
+ function parseGithubOwnerRepo(remoteUrl: string): { owner: string; repo: string } | null {
1723
+ const match = remoteUrl.match(/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?(?:\/)?$/i);
1724
+ if (!match) return null;
1725
+ return { owner: match[1]!, repo: match[2]! };
1726
+ }