sealkeep 0.8.1 → 0.11.0

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 (275) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +244 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1746 -166
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.d.ts +1 -0
  150. package/dist/src/migrate.js +33 -5
  151. package/dist/src/notify.d.ts +1 -1
  152. package/dist/src/notify.js +5 -5
  153. package/dist/src/offload.d.ts +201 -14
  154. package/dist/src/offload.js +1848 -140
  155. package/dist/src/onboarding.d.ts +8 -1
  156. package/dist/src/onboarding.js +4 -4
  157. package/dist/src/packages.d.ts +2 -2
  158. package/dist/src/packages.js +10 -2
  159. package/dist/src/passkey.d.ts +0 -1
  160. package/dist/src/passkey.js +2 -7
  161. package/dist/src/password-lock.d.ts +2 -2
  162. package/dist/src/password-lock.js +6 -6
  163. package/dist/src/paths.d.ts +2 -0
  164. package/dist/src/paths.js +2 -0
  165. package/dist/src/presence.d.ts +86 -0
  166. package/dist/src/presence.js +240 -0
  167. package/dist/src/progress-deadline.d.ts +21 -0
  168. package/dist/src/progress-deadline.js +91 -0
  169. package/dist/src/project-repair.d.ts +55 -0
  170. package/dist/src/project-repair.js +131 -0
  171. package/dist/src/providers/gcs.d.ts +28 -7
  172. package/dist/src/providers/gcs.js +35 -24
  173. package/dist/src/providers/gdrive.d.ts +71 -8
  174. package/dist/src/providers/gdrive.js +223 -50
  175. package/dist/src/providers/index.d.ts +11 -3
  176. package/dist/src/providers/index.js +30 -10
  177. package/dist/src/providers/s3.d.ts +30 -8
  178. package/dist/src/providers/s3.js +41 -30
  179. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  180. package/dist/src/providers/safe-storage-fetch.js +72 -0
  181. package/dist/src/queue.d.ts +136 -19
  182. package/dist/src/queue.js +862 -96
  183. package/dist/src/reclaim-transaction.d.ts +156 -0
  184. package/dist/src/reclaim-transaction.js +1027 -0
  185. package/dist/src/recovery-codes.d.ts +32 -0
  186. package/dist/src/recovery-codes.js +338 -0
  187. package/dist/src/recovery.js +12 -9
  188. package/dist/src/rehydrate.d.ts +25 -22
  189. package/dist/src/rehydrate.js +319 -23
  190. package/dist/src/restore.d.ts +57 -4
  191. package/dist/src/restore.js +272 -36
  192. package/dist/src/resume-stub.d.ts +92 -0
  193. package/dist/src/resume-stub.js +417 -0
  194. package/dist/src/retention.d.ts +98 -7
  195. package/dist/src/retention.js +1066 -62
  196. package/dist/src/rotate.js +3 -3
  197. package/dist/src/search.d.ts +566 -8
  198. package/dist/src/search.js +5897 -290
  199. package/dist/src/secrets.d.ts +51 -7
  200. package/dist/src/secrets.js +316 -24
  201. package/dist/src/service.d.ts +49 -11
  202. package/dist/src/service.js +776 -35
  203. package/dist/src/share.js +3 -3
  204. package/dist/src/shared-spaces.d.ts +98 -0
  205. package/dist/src/shared-spaces.js +214 -0
  206. package/dist/src/source-reader.d.ts +73 -0
  207. package/dist/src/source-reader.js +715 -0
  208. package/dist/src/spool.d.ts +1 -1
  209. package/dist/src/spool.js +1 -1
  210. package/dist/src/start-tui.js +2 -1
  211. package/dist/src/start.js +2 -2
  212. package/dist/src/storage-endpoint.d.ts +21 -0
  213. package/dist/src/storage-endpoint.js +122 -0
  214. package/dist/src/storage-setup.js +12 -12
  215. package/dist/src/storage-targets.d.ts +109 -6
  216. package/dist/src/storage-targets.js +975 -67
  217. package/dist/src/stream-to-cloud.d.ts +5 -1
  218. package/dist/src/stream-to-cloud.js +34 -14
  219. package/dist/src/sync-rules.d.ts +31 -6
  220. package/dist/src/sync-rules.js +153 -14
  221. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  222. package/dist/src/team-backfill-scheduling.js +33 -0
  223. package/dist/src/team-backfill.d.ts +116 -0
  224. package/dist/src/team-backfill.js +1429 -0
  225. package/dist/src/team-index-cache.d.ts +16 -0
  226. package/dist/src/team-index-cache.js +152 -0
  227. package/dist/src/team-offboarding.d.ts +38 -0
  228. package/dist/src/team-offboarding.js +1043 -0
  229. package/dist/src/team-presence.d.ts +127 -0
  230. package/dist/src/team-presence.js +904 -0
  231. package/dist/src/team-publication-policy.d.ts +20 -0
  232. package/dist/src/team-publication-policy.js +140 -0
  233. package/dist/src/team-realtime.d.ts +68 -0
  234. package/dist/src/team-realtime.js +816 -0
  235. package/dist/src/team-source-facts-cache.d.ts +23 -0
  236. package/dist/src/team-source-facts-cache.js +255 -0
  237. package/dist/src/trash.d.ts +1 -1
  238. package/dist/src/trash.js +2 -2
  239. package/dist/src/tui.js +11 -12
  240. package/dist/src/types.d.ts +173 -7
  241. package/dist/src/types.js +20 -0
  242. package/dist/src/ui-server.d.ts +163 -35
  243. package/dist/src/ui-server.js +712 -72
  244. package/dist/src/ui.d.ts +1 -2
  245. package/dist/src/ui.js +1 -2
  246. package/dist/src/upload.d.ts +27 -0
  247. package/dist/src/upload.js +383 -43
  248. package/dist/src/vault.d.ts +235 -29
  249. package/dist/src/vault.js +1783 -190
  250. package/dist/src/watcher.d.ts +7 -1
  251. package/dist/src/watcher.js +198 -55
  252. package/dist/src/worker.d.ts +27 -3
  253. package/dist/src/worker.js +274 -55
  254. package/package.json +33 -12
  255. package/scripts/native-reboot-rehearsal.mjs +90 -0
  256. package/web/app.js +6032 -343
  257. package/web/bootstrap.js +17 -0
  258. package/web/index.html +255 -57
  259. package/web/rail.js +317 -40
  260. package/web/retention.html +2 -2
  261. package/web/rules-view.js +188 -16
  262. package/web/sessions-view.js +485 -62
  263. package/web/sessions.html +2 -2
  264. package/web/setup-api.js +152 -29
  265. package/web/setup-logic.js +68 -9
  266. package/web/setup.html +113 -44
  267. package/web/setup.js +604 -71
  268. package/web/style.css +513 -98
  269. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  275. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -1,59 +1,651 @@
1
- import { fail } from "./errors.js";
2
- import { readConfig, listArchives } from "./vault.js";
1
+ import { createHash } from "node:crypto";
2
+ import { fail, isSealkeepError } from "./errors.js";
3
+ import { readConfig, listArchives, updateConfigFields, updateConfigFieldsIf } from "./vault.js";
3
4
  import { isV2 } from "./types.js";
4
- import { envVar } from "./env.js";
5
+ import { envFlag, envVar } from "./env.js";
6
+ import { loadProviderCredentialRecord, storeProviderCredentials, } from "./secrets.js";
7
+ import { archiveCopies } from "./archive-copies.js";
8
+ import { STORAGE_PREFIX_DEFAULT } from "./branding.js";
9
+ import { assertSafeStorageEndpoint, parseStorageEndpoint } from "./storage-endpoint.js";
10
+ export { assertSafeStorageEndpoint, parseStorageEndpoint } from "./storage-endpoint.js";
11
+ async function targetPushGenerationMatches(dataDir, options) {
12
+ if (!Object.hasOwn(options, "expectedAuthGeneration"))
13
+ return true;
14
+ try {
15
+ const { cloudAuthGeneration } = await import("./cloud.js");
16
+ return await cloudAuthGeneration(dataDir) === options.expectedAuthGeneration;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function routingFingerprint(config) {
23
+ // Usage reporting must never revive a destination, cap, pin or priority
24
+ // which the person changed while its network request was in flight. The
25
+ // complete account-owned routing shapes are small and already serialisable;
26
+ // hashing them also avoids persisting another mutable revision counter.
27
+ return createHash("sha256").update(JSON.stringify({
28
+ storageTargets: config.storageTargets ?? null,
29
+ storageTargetsScope: config.storageTargetsScope ?? null,
30
+ remoteStorage: config.remoteStorage ?? null,
31
+ })).digest("hex");
32
+ }
33
+ function normalizedStorageTargetsScope(config) {
34
+ const scope = config.storageTargetsScope;
35
+ if (scope?.kind === "local")
36
+ return { kind: "local" };
37
+ if (scope?.kind === "account"
38
+ && (scope.accountScope === null || typeof scope.accountScope === "string")) {
39
+ return { kind: "account", accountScope: scope.accountScope };
40
+ }
41
+ return null;
42
+ }
43
+ const accountStorageTargetsScope = (accountScope) => ({
44
+ kind: "account",
45
+ accountScope,
46
+ });
47
+ function sameStorageTargetsScope(left, right) {
48
+ return left?.kind === right.kind
49
+ && (left.kind !== "account" || right.kind !== "account" || left.accountScope === right.accountScope);
50
+ }
51
+ /** Read-only local auth state. `signedIn: null` is an unreadable/transitioning
52
+ * credential store and therefore cannot authorise a legacy unscoped cache. */
53
+ async function localCloudAuthState(dataDir) {
54
+ const { cloudAuthGeneration, cloudToken } = await import("./cloud.js");
55
+ let accountScope;
56
+ try {
57
+ accountScope = await cloudAuthGeneration(dataDir);
58
+ }
59
+ catch {
60
+ return { accountScope: null, signedIn: null };
61
+ }
62
+ try {
63
+ await cloudToken(dataDir, undefined, { renew: false });
64
+ return { accountScope, signedIn: true };
65
+ }
66
+ catch (error) {
67
+ return {
68
+ accountScope,
69
+ signedIn: isSealkeepError(error) && error.code === "unauthorized" ? false : null,
70
+ };
71
+ }
72
+ }
73
+ async function localStorageTargetsAreUsable(dataDir, config, authenticatedAccountScope) {
74
+ const scope = normalizedStorageTargetsScope(config);
75
+ if (scope?.kind === "local")
76
+ return true;
77
+ if (authenticatedAccountScope !== undefined) {
78
+ return scope?.kind === "account" && scope.accountScope === authenticatedAccountScope;
79
+ }
80
+ const auth = await localCloudAuthState(dataDir);
81
+ if (scope?.kind === "account") {
82
+ return auth.signedIn === true && auth.accountScope === scope.accountScope;
83
+ }
84
+ // Before this field existed, storageTargets could be either a local setting
85
+ // or a Cloud cache. Preserve signed-out/local vaults, but never guess while a
86
+ // Cloud account (or an unreadable auth transition) may own the old rows.
87
+ return auth.signedIn === false;
88
+ }
89
+ export async function storageTargetAccountBaselineIsCurrent(dataDir, baseline) {
90
+ return targetPushGenerationMatches(dataDir, { expectedAuthGeneration: baseline.accountScope });
91
+ }
92
+ async function targetPushRoutingMatches(dataDir, options) {
93
+ if (!options.expectedRoutingFingerprint)
94
+ return true;
95
+ try {
96
+ return routingFingerprint(await readConfig(dataDir)) === options.expectedRoutingFingerprint;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ function normalPrefix(prefix) {
103
+ return (prefix ?? STORAGE_PREFIX_DEFAULT).trim().replace(/^\/+|\/+$/g, "");
104
+ }
105
+ function gdriveCredentialAccountId(credentials) {
106
+ if (!credentials || !("kind" in credentials) || credentials.kind !== "gdrive")
107
+ return null;
108
+ const accountId = typeof credentials.accountId === "string" ? credentials.accountId.trim() : "";
109
+ return accountId && accountId.length <= 512 && !/[\u0000-\u001f\u007f]/.test(accountId) ? accountId : null;
110
+ }
111
+ /** Stable, serialisable identity used by both credential and copy bindings. */
112
+ export function storageDestinationIdentity(target, effectiveEndpoint = target.endpoint, gdriveAccountId) {
113
+ if (target.provider === "vaultline")
114
+ fail("invalid_argument", "Managed storage does not use a local bucket destination identity.");
115
+ const bucket = (target.bucket ?? "").trim();
116
+ if (!bucket && target.provider !== "gdrive")
117
+ fail("invalid_argument", `Storage target "${target.id}" needs a bucket.`);
118
+ const endpoint = effectiveEndpoint ? parseStorageEndpoint(effectiveEndpoint).origin : null;
119
+ const hostname = endpoint ? new URL(endpoint).hostname.toLowerCase().replace(/^\[|\]$/g, "") : "";
120
+ const r2Suffix = ".r2.cloudflarestorage.com";
121
+ const account = target.provider === "gdrive"
122
+ ? (gdriveAccountId?.trim() || null)
123
+ : target.provider === "r2" && hostname.endsWith(r2Suffix)
124
+ ? hostname.slice(0, -r2Suffix.length) || null
125
+ : null;
126
+ return {
127
+ version: 1,
128
+ provider: target.provider,
129
+ bucket,
130
+ prefix: normalPrefix(target.prefix),
131
+ region: target.provider === "gcs" || target.provider === "gdrive"
132
+ ? null
133
+ : (target.region ?? "auto").trim().toLowerCase(),
134
+ endpoint,
135
+ account,
136
+ };
137
+ }
138
+ /** Hash of a fixed-order tuple, never of user-controlled object key syntax. */
139
+ export function storageDestinationFingerprint(destination) {
140
+ const canonical = JSON.stringify([
141
+ destination.version,
142
+ destination.provider,
143
+ destination.bucket,
144
+ destination.prefix,
145
+ destination.region,
146
+ destination.endpoint,
147
+ destination.account,
148
+ ]);
149
+ return `dest_${createHash("sha256").update(canonical).digest("hex")}`;
150
+ }
151
+ export async function destinationIdentityForTarget(dataDir, target, credentials) {
152
+ let accountId = target.provider === "gdrive" ? gdriveCredentialAccountId(credentials) : null;
153
+ if (target.provider === "gdrive" && !credentials) {
154
+ const config = await readConfig(dataDir);
155
+ const ids = [...new Set([target.id, ...(target.id === "primary" ? [config.vaultId] : [])].filter(Boolean))];
156
+ for (const id of ids) {
157
+ const record = await loadProviderCredentialRecord(dataDir, id).catch(() => null);
158
+ accountId = gdriveCredentialAccountId(record?.credentials);
159
+ if (accountId)
160
+ break;
161
+ }
162
+ }
163
+ return storageDestinationIdentity(target, await endpointForTarget(dataDir, target), accountId);
164
+ }
165
+ export async function destinationBindingForTarget(dataDir, target, verifiedAt = new Date().toISOString(), credentials) {
166
+ const destination = await destinationIdentityForTarget(dataDir, target, credentials);
167
+ return { destination, destinationFingerprint: storageDestinationFingerprint(destination), verifiedAt };
168
+ }
169
+ export async function storeProviderCredentialsForTarget(dataDir, target, credentials, preferred, verifiedAt = new Date().toISOString()) {
170
+ if (target.provider === "gdrive" && !gdriveCredentialAccountId(credentials)) {
171
+ fail("invalid_argument", `Google Drive target "${target.id}" has no stable account identity. Disconnect and reconnect it before use.`);
172
+ }
173
+ return storeProviderCredentials(dataDir, target.id, credentials, preferred, await destinationBindingForTarget(dataDir, target, verifiedAt, credentials));
174
+ }
175
+ function validDestinationBinding(binding) {
176
+ return binding.destinationFingerprint === storageDestinationFingerprint(binding.destination);
177
+ }
178
+ /** Provider host information is routing configuration, not a credential, so it
179
+ * travels with the account target and every machine signs against the same
180
+ * intended service. This also prevents a stale setup-wide endpoint from being
181
+ * accidentally reused for a different bucket target. */
182
+ export function endpointOverridesForTarget(target) {
183
+ if (!target.endpoint)
184
+ return {};
185
+ const parsed = parseStorageEndpoint(target.endpoint, `Storage target "${target.id}" endpoint`);
186
+ return {
187
+ host: parsed.hostname,
188
+ ...(parsed.port ? { port: Number(parsed.port) } : {}),
189
+ protocol: parsed.protocol === "http:" ? "http" : "https",
190
+ pathStyle: true,
191
+ };
192
+ }
193
+ /**
194
+ * The setup wizard predates account-wide targets and kept its endpoint in
195
+ * setup.json. That value belongs to the one legacy `remoteStorage` target only:
196
+ * letting it bleed into a named target can sign an R2/S3 request for the right
197
+ * bucket against the wrong provider host. New targets carry their endpoint in
198
+ * routing metadata; only the synthesized `primary` target may use this fallback.
199
+ */
200
+ export async function endpointForTarget(dataDir, target) {
201
+ if (target.endpoint)
202
+ return target.endpoint;
203
+ if (target.id !== "primary")
204
+ return undefined;
205
+ try {
206
+ const { readFile } = await import("node:fs/promises");
207
+ const { join } = await import("node:path");
208
+ const setup = JSON.parse(await readFile(join(dataDir, "setup.json"), "utf8"));
209
+ return typeof setup.storageEndpoint === "string" && setup.storageEndpoint.trim()
210
+ ? setup.storageEndpoint.trim()
211
+ : undefined;
212
+ }
213
+ catch {
214
+ return undefined;
215
+ }
216
+ }
217
+ /** Effective provider overrides, including the narrowly-scoped legacy fallback. */
218
+ export async function endpointOverridesForResolvedTarget(dataDir, target) {
219
+ const endpoint = await endpointForTarget(dataDir, target);
220
+ if (!endpoint)
221
+ return {};
222
+ await assertSafeStorageEndpoint(endpoint, target.provider);
223
+ return endpointOverridesForTarget({ ...target, endpoint });
224
+ }
5
225
  const defaultPriority = (target) => target.priority ?? (target.provider === "vaultline" ? 0 : 10);
226
+ function credentialMatches(target, credential) {
227
+ if (target.provider === "gdrive")
228
+ return "kind" in credential && credential.kind === "gdrive";
229
+ if (target.provider === "gcs")
230
+ return "clientEmail" in credential;
231
+ return target.provider !== "vaultline" && "accessKeyId" in credential;
232
+ }
6
233
  /**
7
- * The effective target list. Explicit `storageTargets` in config wins;
8
- * otherwise the single `remoteStorage` entry is the one target; otherwise a
9
- * vault signed into the cloud account is managed-only — the same ladder the
10
- * rest of the product climbs.
234
+ * Provider credentials belong to a machine, while routing rules belong to the
235
+ * account. New installs address a credential by target id so two BYO targets
236
+ * cannot overwrite one another; the vault id remains a read fallback only for
237
+ * the synthesized primary target created by older releases.
11
238
  */
12
- export async function resolveTargets(dataDir) {
239
+ export async function credentialConfigIdForTarget(dataDir, target) {
240
+ if (target.provider === "vaultline")
241
+ return null;
13
242
  const config = await readConfig(dataDir);
243
+ // A named account target is an isolation boundary. Two S3-style providers
244
+ // have the same credential shape, so probing the vault-wide legacy secret
245
+ // for every target would make a newly-added bucket appear connected with a
246
+ // different bucket's key. Only the synthesized legacy primary may fall back.
247
+ const ids = [...new Set([target.id, ...(target.id === "primary" ? [config.vaultId] : [])].filter(Boolean))];
248
+ const destination = target.provider === "gdrive" ? null : await destinationIdentityForTarget(dataDir, target);
249
+ const fingerprint = destination ? storageDestinationFingerprint(destination) : null;
250
+ const actualLegacyPrimary = config.remoteStorage
251
+ ? { id: "primary", ...config.remoteStorage }
252
+ : null;
253
+ const actualLegacyFingerprint = actualLegacyPrimary && actualLegacyPrimary.provider !== "vaultline" && actualLegacyPrimary.provider !== "gdrive"
254
+ ? storageDestinationFingerprint(await destinationIdentityForTarget(dataDir, actualLegacyPrimary))
255
+ : null;
256
+ const synthesizedPrimary = target.id === "primary"
257
+ && !(config.storageTargets && config.storageTargets.length > 0)
258
+ && Boolean(config.remoteStorage)
259
+ && fingerprint === actualLegacyFingerprint;
260
+ for (const id of ids) {
261
+ const record = await loadProviderCredentialRecord(dataDir, id).catch(() => null);
262
+ if (!record || !credentialMatches(target, record.credentials))
263
+ continue;
264
+ const candidateDestination = target.provider === "gdrive"
265
+ ? await destinationIdentityForTarget(dataDir, target, record.credentials)
266
+ : destination;
267
+ const candidateFingerprint = candidateDestination
268
+ ? storageDestinationFingerprint(candidateDestination)
269
+ : null;
270
+ // Pre-account-binding Drive grants are readable only so the person can
271
+ // disconnect them. Treating one as connected would let a reconnect under
272
+ // account B read/delete a copy that was actually written under account A.
273
+ if (target.provider === "gdrive" && !gdriveCredentialAccountId(record.credentials))
274
+ continue;
275
+ if (record.binding) {
276
+ if (validDestinationBinding(record.binding) && record.binding.destinationFingerprint === candidateFingerprint)
277
+ return id;
278
+ continue;
279
+ }
280
+ // A Drive credential carrying its Google-issued account id can be migrated
281
+ // without probing or guessing: the credential itself identifies the grant.
282
+ // Historical credentials without that field take the fail-closed branch
283
+ // above and require fresh consent.
284
+ if (target.provider === "gdrive" && candidateDestination && candidateFingerprint) {
285
+ await storeProviderCredentials(dataDir, id, record.credentials, undefined, {
286
+ destination: candidateDestination,
287
+ destinationFingerprint: candidateFingerprint,
288
+ verifiedAt: null,
289
+ });
290
+ return id;
291
+ }
292
+ // A raw named credential has no evidence of which account endpoint it was
293
+ // tested against. Silently blessing today's route would recreate the
294
+ // target-id repoint vulnerability. The one safe migration is the old
295
+ // machine-owned `remoteStorage` target: its routing and secret predate the
296
+ // account-wide mutable target list and were stored together locally.
297
+ if (synthesizedPrimary && destination) {
298
+ const binding = {
299
+ destination,
300
+ destinationFingerprint: fingerprint,
301
+ // Unknown is represented honestly; this migration does not pretend a
302
+ // historical probe happened now.
303
+ verifiedAt: null,
304
+ };
305
+ await storeProviderCredentials(dataDir, id, record.credentials, undefined, binding);
306
+ return id;
307
+ }
308
+ }
309
+ return null;
310
+ }
311
+ /**
312
+ * Exact-copy routing guard. New records must match the immutable fingerprint.
313
+ * Legacy records are accepted only for globally addressed providers without a
314
+ * custom endpoint; R2/custom S3/Drive account identity is ambiguous and fails
315
+ * closed until a verified migration can stamp the copy.
316
+ */
317
+ export async function remoteCopyMatchesTarget(dataDir, target, copy) {
318
+ if (target.provider === "vaultline")
319
+ return copy.provider === "vaultline";
320
+ if (copy.provider !== target.provider || (target.bucket ?? "") !== copy.bucket)
321
+ return false;
322
+ const destination = await destinationIdentityForTarget(dataDir, target);
323
+ const fingerprint = storageDestinationFingerprint(destination);
324
+ if (copy.destination || copy.destinationFingerprint) {
325
+ return Boolean(copy.destination && copy.destinationFingerprint
326
+ && copy.destinationFingerprint === storageDestinationFingerprint(copy.destination)
327
+ && copy.destinationFingerprint === fingerprint);
328
+ }
329
+ const endpoint = await endpointForTarget(dataDir, target);
330
+ return !endpoint && target.provider !== "r2" && target.provider !== "gdrive";
331
+ }
332
+ /** Whether this particular machine can use an account-wide destination now. */
333
+ export async function targetConnectedOnThisMachine(dataDir, target, options = {}) {
334
+ options.signal?.throwIfAborted();
335
+ if (target.provider === "vaultline") {
336
+ try {
337
+ const { cloudToken } = await import("./cloud.js");
338
+ await cloudToken(dataDir, undefined, {
339
+ renew: options.renewCloudToken !== false,
340
+ signal: options.signal,
341
+ });
342
+ return true;
343
+ }
344
+ catch (error) {
345
+ if (options.signal?.aborted)
346
+ throw options.signal.reason ?? error;
347
+ return false;
348
+ }
349
+ }
350
+ const endpoint = await endpointForTarget(dataDir, target);
351
+ if (target.provider === "r2" && !endpoint)
352
+ return false;
353
+ try {
354
+ if (endpoint)
355
+ await assertSafeStorageEndpoint(endpoint, target.provider);
356
+ }
357
+ catch {
358
+ return false;
359
+ }
360
+ return (await credentialConfigIdForTarget(dataDir, target)) !== null;
361
+ }
362
+ export async function targetConnectionEvidence(dataDir, target, options = {}) {
363
+ if (target.provider === "vaultline") {
364
+ const connected = await targetConnectedOnThisMachine(dataDir, target, options);
365
+ return { connected, status: connected ? "account_session" : "credentials_missing", verifiedAt: null };
366
+ }
367
+ const credentialId = await credentialConfigIdForTarget(dataDir, target);
368
+ if (!credentialId || !(await targetConnectedOnThisMachine(dataDir, target, options))) {
369
+ return { connected: false, status: "credentials_missing", verifiedAt: null };
370
+ }
371
+ const record = await loadProviderCredentialRecord(dataDir, credentialId).catch(() => null);
372
+ const verifiedAt = record?.binding?.verifiedAt ?? null;
373
+ return {
374
+ connected: true,
375
+ status: verifiedAt ? "credentials_verified" : "credentials_saved",
376
+ verifiedAt,
377
+ };
378
+ }
379
+ async function resolveTargetsState(dataDir, options = {}) {
380
+ let config = await readConfig(dataDir);
381
+ const expectedLocalRoutingFingerprint = routingFingerprint(config);
14
382
  // The account is the source of truth: every machine of the vault syncs the
15
383
  // SAME rules down, so a cap set on the laptop holds on the desktop too.
16
384
  // The local config is the offline cache; an explicit local list still wins
17
385
  // when the account cannot be reached.
18
- const fromCloud = await pullTargetsFromCloud(dataDir).catch(() => null);
19
- if (fromCloud && fromCloud.length > 0) {
20
- if (JSON.stringify(fromCloud) !== JSON.stringify(config.storageTargets ?? null)) {
21
- await setStorageTargets(dataDir, fromCloud, { push: false }).catch(() => undefined);
386
+ const pulled = options.pullFromCloud === false
387
+ ? null
388
+ // This boundary is an account-routing read backed by an authenticated
389
+ // local cache. A timeout makes the remote read unavailable; it does not
390
+ // invalidate that cache. `pullTargetsFromCloud` itself still propagates
391
+ // aborts, so write paths which call it directly retain cancellation.
392
+ : await pullTargetsFromCloud(dataDir, options).catch(() => null);
393
+ // The Cloud read may be slow. If a person saved a newer route while it was
394
+ // in flight, neither use nor cache the older reply. Re-resolve strictly from
395
+ // the latest local config so the action which awaited this read also sees
396
+ // the newer choice.
397
+ if (options.pullFromCloud !== false) {
398
+ const localStillCurrent = await targetPushRoutingMatches(dataDir, {
399
+ expectedRoutingFingerprint: expectedLocalRoutingFingerprint,
400
+ });
401
+ if (!localStillCurrent)
402
+ return resolveTargetsState(dataDir, { ...options, pullFromCloud: false });
403
+ }
404
+ // A target reply is account-owned configuration. If login/logout raced the
405
+ // request, it must not overwrite this machine's cached routes or be used by
406
+ // the action which happened to await it.
407
+ const currentAccountScope = pulled
408
+ ? await import("./cloud.js").then(({ cloudAuthGeneration }) => cloudAuthGeneration(dataDir)).catch(() => null)
409
+ : null;
410
+ const validPulled = pulled && currentAccountScope === pulled.accountScope ? pulled : null;
411
+ const accountBaseline = validPulled ? {
412
+ targetsJson: validPulled.rawTargetsJson,
413
+ updatedAt: validPulled.updatedAt,
414
+ accountScope: validPulled.accountScope,
415
+ } : null;
416
+ const fromCloud = validPulled && validPulled.rawTargetsJson !== null
417
+ ? validPulled.targets
418
+ : null;
419
+ if (fromCloud) {
420
+ const desired = fromCloud.length > 0 ? fromCloud : undefined;
421
+ const desiredScope = accountStorageTargetsScope(validPulled.accountScope);
422
+ if (JSON.stringify(desired ?? null) !== JSON.stringify(config.storageTargets ?? null)
423
+ || !sameStorageTargetsScope(normalizedStorageTargetsScope(config), desiredScope)) {
424
+ const cached = await updateConfigFieldsIf(dataDir, (current) => routingFingerprint(current) === expectedLocalRoutingFingerprint, { storageTargets: desired, storageTargetsScope: desiredScope }).catch(() => null);
425
+ if (!cached?.matched)
426
+ return resolveTargetsState(dataDir, { ...options, pullFromCloud: false });
427
+ config = cached.config;
22
428
  }
23
- return fromCloud;
429
+ if (fromCloud.length > 0)
430
+ return { targets: fromCloud, accountBaseline };
431
+ }
432
+ if (validPulled && validPulled.rawTargetsJson === null) {
433
+ const existingScope = normalizedStorageTargetsScope(config);
434
+ // An authenticated "no row" answer must retire a prior account's cache.
435
+ // Keep only an explicit machine-local route, or this same account's local
436
+ // first-save draft whose create request previously failed offline.
437
+ const keepLocalDraft = Boolean(config.storageTargets?.length)
438
+ && (existingScope?.kind === "local"
439
+ || (existingScope?.kind === "account" && existingScope.accountScope === validPulled.accountScope));
440
+ const desiredScope = accountStorageTargetsScope(validPulled.accountScope);
441
+ if (!keepLocalDraft && (config.storageTargets !== undefined
442
+ || !sameStorageTargetsScope(existingScope, desiredScope))) {
443
+ const cached = await updateConfigFieldsIf(dataDir, (current) => routingFingerprint(current) === expectedLocalRoutingFingerprint, { storageTargets: undefined, storageTargetsScope: desiredScope }).catch(() => null);
444
+ if (!cached?.matched)
445
+ return resolveTargetsState(dataDir, { ...options, pullFromCloud: false });
446
+ config = cached.config;
447
+ }
448
+ }
449
+ // `[]` is an authoritative account reset, distinct from an unavailable
450
+ // account response (`null`). Do not resurrect this machine's stale cached
451
+ // routes after another machine removes the explicit list; continue through
452
+ // the ordinary legacy/managed fallback as if the local setting were absent.
453
+ const declared = fromCloud === null
454
+ && await localStorageTargetsAreUsable(dataDir, config, validPulled ? validPulled.accountScope : undefined)
455
+ ? config.storageTargets
456
+ : undefined;
457
+ if (declared && declared.length > 0) {
458
+ const sanitized = sanitizedStorageTargets(declared);
459
+ if (!sanitized)
460
+ fail("invalid_argument", "Stored storage targets are malformed. Review and save the account's destination rules again.");
461
+ return { targets: sanitized, accountBaseline };
24
462
  }
25
- const declared = config.storageTargets;
26
- if (declared && declared.length > 0)
27
- return declared;
28
463
  if (config.remoteStorage) {
29
- return [{ id: "primary", provider: config.remoteStorage.provider, bucket: config.remoteStorage.bucket, prefix: config.remoteStorage.prefix, region: config.remoteStorage.region }];
464
+ return {
465
+ targets: [{ id: "primary", provider: config.remoteStorage.provider, bucket: config.remoteStorage.bucket, prefix: config.remoteStorage.prefix, region: config.remoteStorage.region }],
466
+ accountBaseline,
467
+ };
30
468
  }
469
+ if (validPulled)
470
+ return { targets: [{ id: "managed", provider: "vaultline" }], accountBaseline };
31
471
  try {
32
472
  const { cloudToken } = await import("./cloud.js");
33
- await cloudToken(dataDir);
34
- return [{ id: "managed", provider: "vaultline" }];
473
+ await cloudToken(dataDir, undefined, {
474
+ renew: options.renewCloudToken !== false,
475
+ signal: options.signal,
476
+ });
477
+ return { targets: [{ id: "managed", provider: "vaultline" }], accountBaseline };
35
478
  }
36
- catch {
37
- return [];
479
+ catch (error) {
480
+ if (options.signal?.aborted)
481
+ throw options.signal.reason ?? error;
482
+ return { targets: [], accountBaseline };
38
483
  }
39
484
  }
485
+ export async function resolveTargets(dataDir, options = {}) {
486
+ return (await resolveTargetsState(dataDir, options)).targets;
487
+ }
488
+ /** One coherent edit view: the effective rows and the exact account revision
489
+ * they came from. Callers must carry `accountBaseline` unchanged into
490
+ * `setStorageTargets`; a fresh read at save time would authorise a stale UI. */
491
+ export async function storageTargetsForEditing(dataDir, options = {}) {
492
+ // A first explicit account edit may also be the first operation on this
493
+ // machine. Enrol before sampling the baseline so enrolment cannot advance
494
+ // the auth generation between the edit read and its eventual CAS write.
495
+ const { machineIdentity } = await import("./cloud.js");
496
+ await machineIdentity(dataDir).catch(() => null);
497
+ return resolveTargetsState(dataDir, options);
498
+ }
40
499
  /** The account's rules, or null when not signed in / none saved. */
41
- async function pullTargetsFromCloud(dataDir) {
42
- const { cloudToken } = await import("./cloud.js");
43
- const token = await cloudToken(dataDir).catch(() => null);
500
+ function sanitizedStorageTarget(value) {
501
+ if (typeof value !== "object" || value === null || Array.isArray(value))
502
+ return null;
503
+ const raw = value;
504
+ const providers = new Set(["vaultline", "s3", "r2", "b2", "gcs", "gdrive"]);
505
+ if (typeof raw.id !== "string" || !/^[A-Za-z0-9._-]{1,100}$/.test(raw.id))
506
+ return null;
507
+ if (typeof raw.provider !== "string" || !providers.has(raw.provider))
508
+ return null;
509
+ const provider = raw.provider;
510
+ if (raw.enabled !== undefined && typeof raw.enabled !== "boolean")
511
+ return null;
512
+ const stringField = (name, max) => {
513
+ const found = raw[name];
514
+ if (found === undefined || found === null)
515
+ return undefined;
516
+ return typeof found === "string" && found.length > 0 && found.length <= max ? found : null;
517
+ };
518
+ const bucket = stringField("bucket", 1024);
519
+ const prefix = stringField("prefix", 2048);
520
+ const region = stringField("region", 200);
521
+ const endpoint = stringField("endpoint", 2048);
522
+ if (bucket === null || prefix === null || region === null || endpoint === null)
523
+ return null;
524
+ if (provider !== "vaultline" && provider !== "gdrive" && !bucket)
525
+ return null;
526
+ if (provider === "r2" && !endpoint)
527
+ return null;
528
+ if (endpoint) {
529
+ let parsed;
530
+ try {
531
+ parsed = parseStorageEndpoint(endpoint, `Storage target "${raw.id}" endpoint`);
532
+ }
533
+ catch {
534
+ return null;
535
+ }
536
+ const hostname = parsed.hostname.toLowerCase().replace(/\.$/, "");
537
+ if (provider === "r2" && !hostname.endsWith(".r2.cloudflarestorage.com") && !envFlag("ALLOW_PRIVATE_STORAGE_ENDPOINT"))
538
+ return null;
539
+ }
540
+ const numberField = (name, minimum) => {
541
+ const found = raw[name];
542
+ if (found === undefined || found === null)
543
+ return undefined;
544
+ return typeof found === "number" && Number.isFinite(found) && found >= minimum ? found : null;
545
+ };
546
+ const maxGb = numberField("maxGb", 0);
547
+ const priority = numberField("priority", Number.NEGATIVE_INFINITY);
548
+ const usedBytes = numberField("usedBytes", 0);
549
+ if (maxGb === null || priority === null || usedBytes === null)
550
+ return null;
551
+ const projects = raw.projects === undefined ? undefined : Array.isArray(raw.projects)
552
+ ? raw.projects.filter((item) => typeof item === "string" && item.length > 0 && item.length < 200).slice(0, 64)
553
+ : null;
554
+ if (projects === null)
555
+ return null;
556
+ const target = {
557
+ id: raw.id,
558
+ provider,
559
+ ...(raw.enabled === false ? { enabled: false } : {}),
560
+ ...(bucket ? { bucket } : {}),
561
+ ...(prefix ? { prefix } : {}),
562
+ ...(region ? { region } : {}),
563
+ ...(endpoint ? { endpoint: parseStorageEndpoint(endpoint).origin } : {}),
564
+ ...(maxGb !== undefined ? { maxGb } : {}),
565
+ ...(projects && projects.length > 0 ? { projects } : {}),
566
+ ...(priority !== undefined ? { priority } : {}),
567
+ ...(usedBytes !== undefined ? { usedBytes } : {}),
568
+ ...(typeof raw.usedAt === "string" ? { usedAt: raw.usedAt } : {}),
569
+ };
570
+ if (typeof raw.connectionHealth === "object" && raw.connectionHealth !== null && !Array.isArray(raw.connectionHealth)) {
571
+ const reports = {};
572
+ for (const [device, report] of Object.entries(raw.connectionHealth).slice(0, 128)) {
573
+ if (!/^[A-Za-z0-9._:-]{1,200}$/.test(device) || typeof report !== "object" || report === null || Array.isArray(report))
574
+ continue;
575
+ const entry = report;
576
+ if (typeof entry.connected !== "boolean" || typeof entry.checkedAt !== "string")
577
+ continue;
578
+ const status = entry.status === "credentials_verified" || entry.status === "credentials_saved"
579
+ || entry.status === "credentials_missing" || entry.status === "account_session"
580
+ ? entry.status : undefined;
581
+ reports[device] = {
582
+ connected: entry.connected,
583
+ checkedAt: entry.checkedAt,
584
+ ...(status ? { status } : {}),
585
+ ...(entry.verifiedAt === null || typeof entry.verifiedAt === "string" ? { verifiedAt: entry.verifiedAt } : {}),
586
+ ...(typeof entry.label === "string" ? { label: entry.label.slice(0, 200) } : {}),
587
+ };
588
+ }
589
+ if (Object.keys(reports).length > 0)
590
+ target.connectionHealth = reports;
591
+ }
592
+ return target;
593
+ }
594
+ function sanitizedStorageTargets(values) {
595
+ if (!Array.isArray(values) || values.length > 100)
596
+ return null;
597
+ const targets = values.map(sanitizedStorageTarget);
598
+ if (targets.some((target) => target === null))
599
+ return null;
600
+ const result = targets;
601
+ if (new Set(result.map((target) => target.id)).size !== result.length)
602
+ return null;
603
+ return result;
604
+ }
605
+ async function pullTargetsFromCloud(dataDir, options = {}) {
606
+ const { cloudAuthGeneration, cloudToken } = await import("./cloud.js");
607
+ const token = await cloudToken(dataDir, undefined, {
608
+ renew: options.renewCloudToken !== false,
609
+ signal: options.signal,
610
+ }).catch((error) => {
611
+ if (options.signal?.aborted)
612
+ throw options.signal.reason ?? error;
613
+ return null;
614
+ });
44
615
  if (!token)
45
616
  return null;
617
+ // Sample after cloudToken: an action read may renew a near-expiry token, and
618
+ // renewal legitimately advances the generation before this request begins.
619
+ const accountScope = await cloudAuthGeneration(dataDir).catch(() => null);
46
620
  const { DEFAULT_CLOUD_URL } = await import("./cloud.js");
47
621
  const base = (envVar("CLOUD_URL") ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
48
- const response = await fetch(`${base}/v1/cloud/storage-targets`, { headers: { authorization: `Bearer ${token}` } });
622
+ const response = await fetch(`${base}/v1/cloud/storage-targets`, {
623
+ headers: { authorization: `Bearer ${token}` },
624
+ signal: options.signal,
625
+ });
49
626
  if (!response.ok)
50
627
  return null;
51
628
  const body = await response.json();
52
- if (!body.targets_json)
629
+ if (body.targets_json === null || body.targets_json === undefined)
630
+ return {
631
+ targets: [],
632
+ accountScope,
633
+ rawTargetsJson: null,
634
+ updatedAt: typeof body.updated_at === "string" ? body.updated_at : null,
635
+ };
636
+ if (typeof body.targets_json !== "string")
53
637
  return null;
54
638
  try {
55
- const parsed = JSON.parse(body.targets_json);
56
- return Array.isArray(parsed) ? parsed : null;
639
+ const targets = sanitizedStorageTargets(JSON.parse(body.targets_json));
640
+ return targets ? {
641
+ targets,
642
+ accountScope,
643
+ // CAS compares the exact value which the server returned. Re-encoding
644
+ // the parsed array would make harmless whitespace/key-order differences
645
+ // look like a concurrent update and could never safely authorise PATCH.
646
+ rawTargetsJson: body.targets_json,
647
+ updatedAt: typeof body.updated_at === "string" ? body.updated_at : null,
648
+ } : null;
57
649
  }
58
650
  catch {
59
651
  return null;
@@ -62,51 +654,269 @@ async function pullTargetsFromCloud(dataDir) {
62
654
  /** Pushes the rules to the account so every other machine syncs them. Advisory: offline edits still apply locally. */
63
655
  /** Re-pushes the current rules with fresh usage stamps — machines are the only
64
656
  * ones who can see Drive and BYO bytes, so the account learns them from here. */
65
- export async function pushTargetUsage(dataDir) {
657
+ export async function pushTargetUsage(dataDir, options = {}) {
66
658
  try {
67
- const targets = await resolveTargets(dataDir);
68
- if (!targets.length)
659
+ const { cloudAuthGeneration } = await import("./cloud.js");
660
+ const expectedAuthGeneration = await cloudAuthGeneration(dataDir);
661
+ const expectedRoutingFingerprint = routingFingerprint(await readConfig(dataDir));
662
+ // Telemetry is never the operation which renews a session or claims a
663
+ // machine. Besides being advisory, doing either here advances auth state
664
+ // and can invalidate a destructive archive action on another lane. The
665
+ // local fingerprint is only a cancellation guard; routing itself comes
666
+ // from the account row inside pushTargetsToCloud.
667
+ if (await cloudAuthGeneration(dataDir) !== expectedAuthGeneration)
668
+ return;
669
+ if (routingFingerprint(await readConfig(dataDir)) !== expectedRoutingFingerprint)
69
670
  return;
70
- await pushTargetsToCloud(dataDir, targets);
671
+ await pushTargetsToCloud(dataDir, [], {
672
+ signal: options.signal,
673
+ renewCloudToken: false,
674
+ claimMachineIdentity: false,
675
+ advisoryUsageOnly: true,
676
+ expectedAuthGeneration,
677
+ expectedRoutingFingerprint,
678
+ });
71
679
  }
72
680
  catch { /* advisory */ }
73
681
  }
74
- export async function pushTargetsToCloud(dataDir, targets) {
682
+ async function pushTargetsToCloudOutcome(dataDir, targets, options = {}) {
75
683
  try {
684
+ if (!await targetPushGenerationMatches(dataDir, options))
685
+ return "unavailable";
686
+ if (!await targetPushRoutingMatches(dataDir, options))
687
+ return "unavailable";
688
+ // Explicit saves may enrol the machine so its connection evidence has an
689
+ // account device key. Do that before reading the routing row: enrolment
690
+ // legitimately advances the local auth generation, and the subsequent
691
+ // GET must sample the post-enrolment account scope. Advisory telemetry
692
+ // uses the read-only peek and therefore never mutates auth state.
693
+ const { machineIdentity, peekMachineCredential } = await import("./cloud.js");
694
+ const machine = options.claimMachineIdentity === false
695
+ ? await peekMachineCredential(dataDir).catch(() => null)
696
+ : await machineIdentity(dataDir).catch(() => null);
697
+ if (!await targetPushGenerationMatches(dataDir, options))
698
+ return "unavailable";
699
+ if (!await targetPushRoutingMatches(dataDir, options))
700
+ return "unavailable";
701
+ // A routing write can originate on any signed-in machine. Preserve the
702
+ // account's newest non-secret telemetry before adding this machine's
703
+ // observation: a fresh laptop with no local sidecars must never replace a
704
+ // known positive provider total with zero or erase another device's
705
+ // connection report. Usage is intentionally monotonic here. Lowering it
706
+ // requires a provider/account-authoritative reconciliation; one machine's
707
+ // partial catalog is not enough evidence that shared remote bytes vanished.
708
+ const pulled = options.advisoryUsageOnly
709
+ ? await pullTargetsFromCloud(dataDir, options).catch(() => null)
710
+ : options.accountBaseline
711
+ ? (() => {
712
+ const rawTargetsJson = options.accountBaseline.targetsJson;
713
+ if (rawTargetsJson === null)
714
+ return {
715
+ targets: [], rawTargetsJson: null,
716
+ updatedAt: options.accountBaseline.updatedAt,
717
+ accountScope: options.accountBaseline.accountScope,
718
+ };
719
+ try {
720
+ const parsed = sanitizedStorageTargets(JSON.parse(rawTargetsJson));
721
+ return parsed ? {
722
+ targets: parsed, rawTargetsJson,
723
+ updatedAt: options.accountBaseline.updatedAt,
724
+ accountScope: options.accountBaseline.accountScope,
725
+ } : null;
726
+ }
727
+ catch {
728
+ return null;
729
+ }
730
+ })()
731
+ : null;
732
+ if (!await targetPushGenerationMatches(dataDir, options))
733
+ return "unavailable";
734
+ if (!await targetPushRoutingMatches(dataDir, options))
735
+ return "unavailable";
736
+ // PUT is permitted only after a successful authenticated GET proved that
737
+ // this account has no routing row. A timeout, 5xx, malformed reply, or
738
+ // missing token is not evidence of absence and must produce no write.
739
+ if (!pulled)
740
+ return "unavailable";
741
+ if (Object.hasOwn(options, "expectedAuthGeneration") && pulled.accountScope !== options.expectedAuthGeneration)
742
+ return "unavailable";
743
+ const currentAccountScope = await import("./cloud.js")
744
+ .then(({ cloudAuthGeneration }) => cloudAuthGeneration(dataDir)).catch(() => null);
745
+ if (currentAccountScope !== pulled.accountScope)
746
+ return "unavailable";
747
+ if (options.advisoryUsageOnly) {
748
+ // Telemetry is not configuration. It may enrich the exact routing row
749
+ // it read, but a stale machine must never publish its cached providers,
750
+ // priority, caps, or project pins. Nor may telemetry create the first
751
+ // row: that is reserved for an explicit setStorageTargets action.
752
+ if (pulled.rawTargetsJson === null)
753
+ return "unavailable";
754
+ targets = pulled.targets;
755
+ }
756
+ else if (pulled.rawTargetsJson !== null) {
757
+ const remoteById = new Map(pulled.targets.map((target) => [target.id, target]));
758
+ targets = targets.map((target) => {
759
+ const remote = remoteById.get(target.id);
760
+ if (!remote || !sameUsageDestination(target, remote))
761
+ return target;
762
+ const remoteUsed = Number.isFinite(remote.usedBytes) && remote.usedBytes >= 0 ? remote.usedBytes : undefined;
763
+ const localUsed = Number.isFinite(target.usedBytes) && target.usedBytes >= 0 ? target.usedBytes : undefined;
764
+ const usedBytes = remoteUsed === undefined ? localUsed : localUsed === undefined ? remoteUsed : Math.max(remoteUsed, localUsed);
765
+ const usedAt = usedBytes === remoteUsed && remoteUsed !== undefined ? remote.usedAt : target.usedAt;
766
+ return {
767
+ ...target,
768
+ ...(usedBytes !== undefined ? { usedBytes } : {}),
769
+ ...(usedAt ? { usedAt } : {}),
770
+ connectionHealth: {
771
+ ...(remote.connectionHealth ?? {}),
772
+ ...(target.connectionHealth ?? {}),
773
+ },
774
+ };
775
+ });
776
+ }
76
777
  const usage = await targetUsage(dataDir, targets).catch(() => null);
778
+ if (!await targetPushGenerationMatches(dataDir, options))
779
+ return "unavailable";
780
+ if (!await targetPushRoutingMatches(dataDir, options))
781
+ return "unavailable";
77
782
  if (usage) {
78
783
  const now = new Date().toISOString();
79
784
  targets = targets.map((t) => {
80
785
  const used = usage.get(t.id);
81
- return used && used > 0 ? { ...t, usedBytes: used, usedAt: now } : t;
786
+ // A local zero is not proof that a shared/provider destination is
787
+ // empty. `targetUsage` already preserves the larger account stamp, so
788
+ // this write can raise the conservative lower bound but never make a
789
+ // fresh or sparse machine lower it.
790
+ return used !== undefined ? { ...t, usedBytes: used, usedAt: now } : t;
82
791
  });
83
792
  }
84
793
  const { cloudToken, DEFAULT_CLOUD_URL } = await import("./cloud.js");
85
- const token = await cloudToken(dataDir);
794
+ const token = await cloudToken(dataDir, undefined, { renew: options.renewCloudToken !== false });
795
+ if (!await targetPushGenerationMatches(dataDir, options))
796
+ return "unavailable";
797
+ if (!await targetPushRoutingMatches(dataDir, options))
798
+ return "unavailable";
799
+ // Routing is account-wide, but credentials are deliberately machine-local.
800
+ // Publish only non-secret credential/probe evidence keyed by the enrolled
801
+ // device. It never becomes a live-reachability claim and never contains a
802
+ // refresh token, access key, recovery phrase, or local API token.
803
+ if (!await targetPushGenerationMatches(dataDir, options))
804
+ return "unavailable";
805
+ if (!await targetPushRoutingMatches(dataDir, options))
806
+ return "unavailable";
807
+ if (machine) {
808
+ const { hostname } = await import("node:os");
809
+ const checkedAt = new Date().toISOString();
810
+ const deviceKey = String(machine.device_id);
811
+ targets = await Promise.all(targets.map(async (target) => {
812
+ const evidence = await targetConnectionEvidence(dataDir, target, { renewCloudToken: options.renewCloudToken });
813
+ return {
814
+ ...target,
815
+ connectionHealth: {
816
+ ...(target.connectionHealth ?? {}),
817
+ [deviceKey]: { ...evidence, checkedAt, label: hostname() }
818
+ }
819
+ };
820
+ }));
821
+ }
822
+ if (!await targetPushGenerationMatches(dataDir, options))
823
+ return "unavailable";
824
+ if (!await targetPushRoutingMatches(dataDir, options))
825
+ return "unavailable";
826
+ if (await import("./cloud.js").then(({ cloudAuthGeneration }) => cloudAuthGeneration(dataDir)).catch(() => null) !== pulled.accountScope)
827
+ return "unavailable";
86
828
  const base = (envVar("CLOUD_URL") ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
829
+ const targetsJson = JSON.stringify(targets);
87
830
  const response = await fetch(`${base}/v1/cloud/storage-targets`, {
88
- method: "PUT",
831
+ // An existing row is always compare-and-set. In particular, a detached
832
+ // usage report may merge an old snapshot, but it can no longer commit
833
+ // after another machine has changed that account routing row.
834
+ method: pulled.rawTargetsJson !== null ? "PATCH" : "PUT",
89
835
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
90
- body: JSON.stringify({ targets_json: JSON.stringify(targets) })
836
+ body: JSON.stringify(pulled.rawTargetsJson !== null
837
+ ? {
838
+ targets_json: targetsJson,
839
+ expected_targets_json: pulled.rawTargetsJson,
840
+ expected_updated_at: pulled.updatedAt,
841
+ }
842
+ : { targets_json: targetsJson }),
843
+ signal: options.signal,
91
844
  });
92
- return response.ok;
845
+ // The request may have committed under account A while this machine
846
+ // switched to account B (or accepted a newer local route) before fetch
847
+ // delivered the response. The remote CAS remains a valid A-side commit,
848
+ // but this caller must not report it as B's synced local state. Returning
849
+ // unavailable lets setStorageTargets apply its generation fence and
850
+ // restore the stale local draft without masking a real 409 below.
851
+ if (!await targetPushGenerationMatches(dataDir, options))
852
+ return "unavailable";
853
+ if (!await targetPushRoutingMatches(dataDir, options))
854
+ return "unavailable";
855
+ if (await import("./cloud.js").then(({ cloudAuthGeneration }) => cloudAuthGeneration(dataDir)).catch(() => null) !== pulled.accountScope) {
856
+ return "unavailable";
857
+ }
858
+ // A CAS conflict is final for this snapshot. Retrying it as a PUT (or
859
+ // silently re-reading and merging again) would let stale advisory work
860
+ // overwrite the user's newer routing choice.
861
+ if (response.status === 409)
862
+ return "conflict";
863
+ return response.ok ? "synced" : "unavailable";
93
864
  }
94
865
  catch {
95
- return false;
866
+ return "unavailable";
96
867
  }
97
868
  }
98
- /** Bytes each target currently holds, from the records: targetId when stamped, provider+bucket for older records. */
869
+ export async function pushTargetsToCloud(dataDir, targets, options = {}) {
870
+ return await pushTargetsToCloudOutcome(dataDir, targets, options) === "synced";
871
+ }
872
+ /** Non-secret physical identity sufficient for retaining conservative usage
873
+ * across an account routing write. A changed provider/bucket/prefix/endpoint
874
+ * starts a new destination instead of inheriting an old destination's total. */
875
+ function sameUsageDestination(left, right) {
876
+ return left.provider === right.provider
877
+ && (left.bucket ?? "") === (right.bucket ?? "")
878
+ && normalPrefix(left.prefix) === normalPrefix(right.prefix)
879
+ && (left.region ?? "") === (right.region ?? "")
880
+ && (left.endpoint ?? "") === (right.endpoint ?? "");
881
+ }
882
+ /** Bytes each target currently holds. A mutable display id is never enough to
883
+ * attribute a copy: the record must still name the target's immutable physical
884
+ * destination (or pass the deliberately narrow legacy compatibility rule). */
99
885
  export async function targetUsage(dataDir, targets) {
100
886
  const usage = new Map(targets.map((target) => [target.id, 0]));
101
887
  const records = await listArchives(dataDir).catch(() => []);
102
888
  for (const record of records) {
103
- if (!isV2(record) || !record.remote)
889
+ if (!isV2(record))
104
890
  continue;
105
- const stamped = record.remote.targetId;
106
- const match = (stamped && targets.find((target) => target.id === stamped))
107
- ?? targets.find((target) => target.provider === record.remote.provider && (target.provider === "vaultline" || target.bucket === record.remote.bucket));
108
- if (match)
109
- usage.set(match.id, (usage.get(match.id) ?? 0) + (record.remote.bytes ?? 0));
891
+ for (const copy of archiveCopies(record)) {
892
+ let match;
893
+ // Prefer the recorded label only as a lookup hint. The label can be
894
+ // repointed account-wide, so immutable destination matching remains the
895
+ // authority before usage affects limits or account health.
896
+ const stamped = copy.targetId ? targets.find((target) => target.id === copy.targetId) : undefined;
897
+ if (stamped && await remoteCopyMatchesTarget(dataDir, stamped, copy))
898
+ match = stamped;
899
+ if (!match) {
900
+ for (const target of targets) {
901
+ if (target === stamped)
902
+ continue;
903
+ if (await remoteCopyMatchesTarget(dataDir, target, copy)) {
904
+ match = target;
905
+ break;
906
+ }
907
+ }
908
+ }
909
+ if (match)
910
+ usage.set(match.id, (usage.get(match.id) ?? 0) + (copy.bytes ?? 0));
911
+ }
912
+ }
913
+ // Local records are a lower bound on a multi-machine/provider total. Keep a
914
+ // larger account-reported value rather than allowing a sparse/fresh machine
915
+ // to route beyond the configured cap.
916
+ for (const target of targets) {
917
+ if (Number.isFinite(target.usedBytes) && target.usedBytes >= 0) {
918
+ usage.set(target.id, Math.max(usage.get(target.id) ?? 0, target.usedBytes));
919
+ }
110
920
  }
111
921
  return usage;
112
922
  }
@@ -124,44 +934,142 @@ export async function chooseTarget(dataDir, input) {
124
934
  const named = targets.find((target) => target.id === input.targetId);
125
935
  if (!named)
126
936
  fail("invalid_argument", `No storage target named "${input.targetId}". Targets: ${targets.map((target) => target.id).join(", ")}`);
937
+ if (named.enabled === false) {
938
+ fail("storage_not_configured", `Storage target "${named.id}" is paused for new archives. Resume it before copying an archive there.`);
939
+ }
940
+ if (input.requireManaged && named.provider !== "vaultline") {
941
+ fail("storage_not_configured", "Shared project memory must be stored in Sealkeep Cloud so every approved member can reach the same encrypted archive.");
942
+ }
943
+ if (input.requireChunkCapable && named.provider === "gdrive") {
944
+ fail("storage_not_configured", `Storage target "${named.id}" cannot accept streamed chunk archives.`);
945
+ }
946
+ if (!(await targetConnectedOnThisMachine(dataDir, named))) {
947
+ fail("storage_not_configured", `Storage target "${named.id}" is not connected on this machine. Reconnect it before copying an archive there.`);
948
+ }
949
+ if (named.maxGb !== undefined) {
950
+ const used = (await targetUsage(dataDir, targets)).get(named.id) ?? 0;
951
+ if (used + input.bytes > named.maxGb * 1024 ** 3) {
952
+ fail("storage_not_configured", `Storage target "${named.id}" is at its ${named.maxGb} GB limit. Choose another destination or raise its cap.`);
953
+ }
954
+ }
127
955
  return named;
128
956
  }
129
957
  const usage = await targetUsage(dataDir, targets);
958
+ const connected = new Map(await Promise.all(targets.map(async (target) => [target.id, await targetConnectedOnThisMachine(dataDir, target)])));
130
959
  const eligible = targets.filter((target) => {
960
+ if (target.enabled === false)
961
+ return false;
962
+ if (input.requireManaged && target.provider !== "vaultline")
963
+ return false;
131
964
  if (input.requireChunkCapable && target.provider === "gdrive")
132
965
  return false;
133
966
  if (target.maxGb !== undefined && (usage.get(target.id) ?? 0) + input.bytes > target.maxGb * 1024 ** 3)
134
967
  return false;
968
+ // A Drive/bucket rule can be valid account-wide but unavailable on this
969
+ // machine. Automatic routing falls through to the next connected target;
970
+ // otherwise an unattended daemon retries the same impossible first rule
971
+ // forever while a working managed destination sits underneath it.
972
+ if (!connected.get(target.id))
973
+ return false;
135
974
  return true;
136
975
  });
137
976
  if (eligible.length === 0) {
138
- fail("storage_not_configured", "Every storage target is either full (maxGb reached) or unable to take this seal. Raise a limit or add a target.");
977
+ if (input.requireManaged) {
978
+ fail("storage_not_configured", "Shared project memory needs an available Sealkeep Cloud destination. Add it to this account's storage routes or raise its limit.");
979
+ }
980
+ fail("storage_not_configured", "No destination on this machine can take this archive: each target is paused, disconnected, full, or incompatible. Resume or reconnect a destination, or change the routing order.");
139
981
  }
140
- const pinScore = (target) => (input.project && target.projects?.includes(input.project) ? 0 : 1);
982
+ const pinScore = (target) => ((input.projectKey && target.projects?.includes(input.projectKey))
983
+ || (input.project && target.projects?.includes(input.project))
984
+ ? 0
985
+ : 1);
141
986
  eligible.sort((a, b) => pinScore(a) - pinScore(b) || defaultPriority(a) - defaultPriority(b));
142
987
  return eligible[0];
143
988
  }
144
989
  /** Persist an explicit target list. Empty array removes the setting (back to single remoteStorage / managed). */
145
990
  export async function setStorageTargets(dataDir, targets, options = {}) {
146
- const { readFile, writeFile } = await import("node:fs/promises");
147
- const { join } = await import("node:path");
148
- const path = join(dataDir, "config.json");
149
- const config = JSON.parse(await readFile(path, "utf8"));
150
- if (targets.length === 0)
151
- delete config.storageTargets;
152
- else {
153
- const ids = new Set();
154
- for (const target of targets) {
155
- if (!target.id || ids.has(target.id))
156
- fail("invalid_argument", "Every storage target needs a unique id");
157
- ids.add(target.id);
158
- if (target.provider !== "vaultline" && target.provider !== "gdrive" && !target.bucket) {
159
- fail("invalid_argument", `Target "${target.id}" (${target.provider}) needs a bucket`);
160
- }
991
+ let savedTargets;
992
+ if (targets.length > 0) {
993
+ const sanitized = sanitizedStorageTargets(targets);
994
+ if (!sanitized)
995
+ fail("invalid_argument", "Storage targets must have unique ids and valid provider, bucket, endpoint, routing, and health fields.");
996
+ targets = sanitized;
997
+ savedTargets = sanitized;
998
+ }
999
+ // A dashboard/CLI draft belongs to the Cloud login which produced its edit
1000
+ // snapshot. Reject an already-stale draft before it can alter even the local
1001
+ // routing cache. A second fence after the write handles a transition which
1002
+ // begins between this read and the config-file transaction.
1003
+ if (options.accountBaseline
1004
+ && !await storageTargetAccountBaselineIsCurrent(dataDir, options.accountBaseline)) {
1005
+ return {
1006
+ localSaved: false,
1007
+ accountSynced: false,
1008
+ accountConflict: true,
1009
+ accountStale: true,
1010
+ };
1011
+ }
1012
+ const before = await readConfig(dataDir);
1013
+ const storageTargetsScope = options.accountBaseline
1014
+ ? accountStorageTargetsScope(options.accountBaseline.accountScope)
1015
+ : options.push === false
1016
+ ? { kind: "local" }
1017
+ : normalizedStorageTargetsScope(before) ?? { kind: "local" };
1018
+ const saved = await updateConfigFields(dataDir, { storageTargets: savedTargets, storageTargetsScope });
1019
+ const expectedRoutingFingerprint = routingFingerprint(saved);
1020
+ const restoreBeforeIfOwned = async () => {
1021
+ await updateConfigFieldsIf(dataDir, (current) => routingFingerprint(current) === expectedRoutingFingerprint, {
1022
+ storageTargets: before.storageTargets,
1023
+ storageTargetsScope: before.storageTargetsScope,
1024
+ }).catch(() => null);
1025
+ };
1026
+ if (options.accountBaseline
1027
+ && !await storageTargetAccountBaselineIsCurrent(dataDir, options.accountBaseline)) {
1028
+ await restoreBeforeIfOwned();
1029
+ return {
1030
+ localSaved: false,
1031
+ accountSynced: false,
1032
+ accountConflict: true,
1033
+ accountStale: true,
1034
+ };
1035
+ }
1036
+ if (options.push === false)
1037
+ return { localSaved: true, accountSynced: null };
1038
+ // No baseline means the account could not be read (or an older caller did
1039
+ // not carry the edit snapshot). Keep the useful offline local save, but do
1040
+ // not turn a fresh GET at save time into permission to overwrite somebody
1041
+ // else's newer account rules.
1042
+ if (!options.accountBaseline)
1043
+ return { localSaved: true, accountSynced: false };
1044
+ const outcome = await pushTargetsToCloudOutcome(dataDir, targets, {
1045
+ expectedRoutingFingerprint,
1046
+ expectedAuthGeneration: options.accountBaseline.accountScope,
1047
+ accountBaseline: options.accountBaseline,
1048
+ });
1049
+ if (outcome === "synced")
1050
+ return { localSaved: true, accountSynced: true };
1051
+ if (outcome === "unavailable") {
1052
+ if (options.accountBaseline
1053
+ && !await storageTargetAccountBaselineIsCurrent(dataDir, options.accountBaseline)) {
1054
+ await restoreBeforeIfOwned();
1055
+ return {
1056
+ localSaved: false,
1057
+ accountSynced: false,
1058
+ accountConflict: true,
1059
+ accountStale: true,
1060
+ };
161
1061
  }
162
- config.storageTargets = targets;
1062
+ return { localSaved: true, accountSynced: false };
163
1063
  }
164
- await writeFile(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
165
- if (options.push !== false)
166
- await pushTargetsToCloud(dataDir, targets);
1064
+ // A stale edit must not leave this machine following C while the account
1065
+ // correctly retained B. First try to load/cache B. If that read happens to
1066
+ // fail, restore the pre-edit local A only if nobody made a newer local edit
1067
+ // after C; either is safer than silently activating the rejected draft.
1068
+ const refreshed = await resolveTargetsState(dataDir).catch(() => null);
1069
+ // Content equality is not a freshness signal: another machine may have
1070
+ // legitimately changed B to the same C this stale editor attempted. A
1071
+ // non-null baseline proves that the recovery GET succeeded even in that case.
1072
+ if (!refreshed?.accountBaseline)
1073
+ await restoreBeforeIfOwned();
1074
+ return { localSaved: false, accountSynced: false, accountConflict: true };
167
1075
  }