sealkeep 0.9.0 → 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 (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -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 +1698 -163
  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.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/dist/src/cloud.js CHANGED
@@ -1,12 +1,17 @@
1
- import { createHash } from "node:crypto";
2
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
3
4
  import { chooseBackend } from "./secrets.js";
4
- import { encryptArchive as sealEnvelope, decryptArchive as openEnvelope, x25519PrivateKeyFromRaw, x25519PublicKeyFromRaw } from "../packages/vaultline-crypto/src/index.js";
5
+ import { encryptArchive as sealEnvelope, decryptArchive as openEnvelope, x25519PrivateKeyFromRaw, x25519PublicKeyFromRaw } from "../packages/sealkeep-crypto/src/index.js";
5
6
  import { canonicalPhrase } from "./mnemonic.js";
6
- import { fail, isVaultlineError, VaultlineError } from "./errors.js";
7
+ import { fail, isSealkeepError, SealkeepError } from "./errors.js";
7
8
  import { sha256 } from "./crypto.js";
8
- import { listArchives, readConfig, writeRecord } from "./vault.js";
9
+ import { listArchives, mutateArchiveRecord, readConfig } from "./vault.js";
9
10
  import { envVar } from "./env.js";
11
+ import { appendArchiveCopy } from "./archive-copies.js";
12
+ import { collectChunks, bufferChunks, concatStreams, digestChunks, fileChunks, fileHandleChunks, LEGACY_BUFFER_LIMIT_BYTES, MAX_FRAME_METADATA_BYTES, oneChunk, requestBody, responseBodyChunks, tapDigest, unframeChunks, } from "./byte-stream.js";
13
+ import { paceBackgroundByteStream } from "./background-bandwidth.js";
14
+ import { readTeamIndexCache, writeTeamIndexCache } from "./team-index-cache.js";
10
15
  /**
11
16
  * Sealkeep Cloud: the managed tier.
12
17
  *
@@ -17,7 +22,300 @@ import { envVar } from "./env.js";
17
22
  * control plane is never sent a key that opens it.
18
23
  */
19
24
  export const DEFAULT_CLOUD_URL = "https://shared.spala.ai/p04946/api";
20
- const cloudUrl = (env = process.env) => (envVar("CLOUD_URL", env) ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
25
+ export const cloudUrl = (env = process.env) => (envVar("CLOUD_URL", env) ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
26
+ const authGenerationPath = (dataDir) => join(dataDir, "runtime", "cloud-auth-generation");
27
+ const AUTH_TRANSITION_PREFIX = "transition:";
28
+ const cloudAuthMutationTails = new Map();
29
+ const AUTH_LOCK_WAIT_MS = 30_000;
30
+ const AUTH_LOCK_OWNER_GRACE_MS = 2_000;
31
+ const authLockPath = (dataDir) => join(dataDir, "runtime", "cloud-auth-transition.lock");
32
+ const authLockOwnerPath = (lockPath) => join(lockPath, "owner.json");
33
+ const abortReason = (signal, fallback) => {
34
+ if (signal.reason !== undefined)
35
+ return signal.reason;
36
+ const error = new Error(fallback);
37
+ error.name = "AbortError";
38
+ return error;
39
+ };
40
+ const pause = async (milliseconds, signal) => {
41
+ if (!signal)
42
+ return new Promise((resolvePause) => setTimeout(resolvePause, milliseconds));
43
+ const activeSignal = signal;
44
+ activeSignal.throwIfAborted();
45
+ await new Promise((resolvePause, rejectPause) => {
46
+ const timer = setTimeout(done, milliseconds);
47
+ const aborted = () => {
48
+ clearTimeout(timer);
49
+ activeSignal.removeEventListener("abort", aborted);
50
+ rejectPause(abortReason(activeSignal, "The Sealkeep Cloud request was cancelled"));
51
+ };
52
+ function done() {
53
+ activeSignal.removeEventListener("abort", aborted);
54
+ resolvePause();
55
+ }
56
+ activeSignal.addEventListener("abort", aborted, { once: true });
57
+ });
58
+ };
59
+ /**
60
+ * Share a control-plane mutation without making one caller's timeout cancel
61
+ * siblings which are still waiting for it. When every waiter has left, the
62
+ * underlying fetch is aborted too, so a dead request cannot poison the
63
+ * single-flight slot forever.
64
+ */
65
+ async function awaitSharedAbortable(entry, signal) {
66
+ signal?.throwIfAborted();
67
+ entry.waiters += 1;
68
+ let onAbort;
69
+ const cancelled = signal
70
+ ? new Promise((_resolve, reject) => {
71
+ onAbort = () => reject(abortReason(signal, "The Sealkeep Cloud request was cancelled"));
72
+ signal.addEventListener("abort", onAbort, { once: true });
73
+ })
74
+ : null;
75
+ try {
76
+ return await (cancelled ? Promise.race([entry.promise, cancelled]) : entry.promise);
77
+ }
78
+ finally {
79
+ if (signal && onAbort)
80
+ signal.removeEventListener("abort", onAbort);
81
+ entry.waiters -= 1;
82
+ if (!entry.settled && entry.waiters === 0 && !entry.controller.signal.aborted) {
83
+ const error = new Error("The Sealkeep Cloud request no longer has an active caller");
84
+ error.name = "AbortError";
85
+ entry.controller.abort(error);
86
+ }
87
+ }
88
+ }
89
+ async function readCloudAuthLockOwner(lockPath) {
90
+ try {
91
+ const parsed = JSON.parse(await readFile(authLockOwnerPath(lockPath), "utf8"));
92
+ if (parsed.version !== 1 || !Number.isSafeInteger(parsed.pid) || Number(parsed.pid) < 1
93
+ || typeof parsed.nonce !== "string" || !parsed.nonce || typeof parsed.acquiredAt !== "string")
94
+ return null;
95
+ return parsed;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ function processIsAlive(pid) {
102
+ try {
103
+ process.kill(pid, 0);
104
+ return true;
105
+ }
106
+ catch (error) {
107
+ // EPERM proves the process exists even though this user cannot signal it.
108
+ return error.code === "EPERM";
109
+ }
110
+ }
111
+ async function retireStaleCloudAuthLock(lockPath) {
112
+ const [owner, info] = await Promise.all([
113
+ readCloudAuthLockOwner(lockPath),
114
+ stat(lockPath).catch(() => null),
115
+ ]);
116
+ // mkdir is atomic but owner.json is a second write. Give the winning process
117
+ // time to publish it before treating an ownerless directory as abandoned.
118
+ if (!owner && info && Date.now() - info.mtimeMs < AUTH_LOCK_OWNER_GRACE_MS)
119
+ return false;
120
+ if (owner && processIsAlive(owner.pid))
121
+ return false;
122
+ const retired = `${lockPath}.${randomUUID()}.stale`;
123
+ try {
124
+ await rename(lockPath, retired);
125
+ }
126
+ catch (error) {
127
+ if (error.code === "ENOENT")
128
+ return true;
129
+ return false;
130
+ }
131
+ // The rename already released the well-known lock. Cleanup is deliberately
132
+ // narrow: never recursively remove an unexpected directory in a vault.
133
+ await unlink(authLockOwnerPath(retired)).catch(() => undefined);
134
+ await rmdir(retired).catch(() => undefined);
135
+ return true;
136
+ }
137
+ async function acquireCloudAuthLock(dataDir) {
138
+ const lockPath = authLockPath(dataDir);
139
+ await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
140
+ const deadline = Date.now() + AUTH_LOCK_WAIT_MS;
141
+ const owner = {
142
+ version: 1,
143
+ pid: process.pid,
144
+ nonce: randomUUID(),
145
+ acquiredAt: new Date().toISOString(),
146
+ };
147
+ while (true) {
148
+ try {
149
+ await mkdir(lockPath, { mode: 0o700 });
150
+ try {
151
+ await writeFile(authLockOwnerPath(lockPath), JSON.stringify(owner) + "\n", { flag: "wx", mode: 0o600 });
152
+ }
153
+ catch (error) {
154
+ await rmdir(lockPath).catch(() => undefined);
155
+ throw error;
156
+ }
157
+ return async () => {
158
+ const current = await readCloudAuthLockOwner(lockPath);
159
+ if (current?.nonce !== owner.nonce)
160
+ return;
161
+ await unlink(authLockOwnerPath(lockPath)).catch(() => undefined);
162
+ await rmdir(lockPath).catch(() => undefined);
163
+ };
164
+ }
165
+ catch (error) {
166
+ if (error.code !== "EEXIST")
167
+ throw error;
168
+ if (await retireStaleCloudAuthLock(lockPath))
169
+ continue;
170
+ if (Date.now() >= deadline) {
171
+ fail("cloud_account_unavailable", "Another Sealkeep process is still changing this machine's Cloud sign-in. Retry in a moment.");
172
+ }
173
+ await pause(25 + Math.floor(Math.random() * 50));
174
+ }
175
+ }
176
+ }
177
+ async function writeCloudAuthGeneration(dataDir, value) {
178
+ // Drop process-held credentials before publishing either edge of a
179
+ // transition. Cross-process readers observe the generation file; readers in
180
+ // this process additionally release their old secret references immediately.
181
+ invalidateCloudAuthReadCache(dataDir);
182
+ const path = authGenerationPath(dataDir);
183
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
184
+ const temporary = `${path}.${randomUUID()}.tmp`;
185
+ await writeFile(temporary, value + "\n", { mode: 0o600 });
186
+ await rename(temporary, path);
187
+ }
188
+ async function serializeCloudAuthMutation(dataDir, work) {
189
+ const key = resolve(dataDir);
190
+ const previous = cloudAuthMutationTails.get(key) ?? Promise.resolve();
191
+ let release;
192
+ const gate = new Promise((resolveGate) => { release = resolveGate; });
193
+ const tail = previous.catch(() => undefined).then(() => gate);
194
+ cloudAuthMutationTails.set(key, tail);
195
+ await previous.catch(() => undefined);
196
+ let releaseCrossProcess;
197
+ try {
198
+ releaseCrossProcess = await acquireCloudAuthLock(dataDir);
199
+ return await work();
200
+ }
201
+ finally {
202
+ await releaseCrossProcess?.();
203
+ release();
204
+ if (cloudAuthMutationTails.get(key) === tail)
205
+ cloudAuthMutationTails.delete(key);
206
+ }
207
+ }
208
+ export async function signalCloudAuthChange(dataDir) {
209
+ await serializeCloudAuthMutation(dataDir, () => writeCloudAuthGeneration(dataDir, randomUUID()));
210
+ }
211
+ /** Cheap generation probe used by long-running services; contains no token. */
212
+ export async function cloudAuthGeneration(dataDir) {
213
+ try {
214
+ return (await readFile(authGenerationPath(dataDir), "utf8")).trim() || null;
215
+ }
216
+ catch (error) {
217
+ // Missing is the legacy, pre-generation state. Permission, I/O and corrupt
218
+ // path failures are not equivalent: treating those as the reusable null
219
+ // generation could let a process cache survive a credential transition it
220
+ // was unable to observe.
221
+ if (error.code === "ENOENT")
222
+ return null;
223
+ throw error;
224
+ }
225
+ }
226
+ const sameSecretRef = (left, right) => (left.service === right.service && left.account === right.account);
227
+ async function settleCredential(backend, mutation) {
228
+ if (mutation.value === null)
229
+ await backend.delete(mutation.ref);
230
+ else
231
+ await backend.set(mutation.ref, mutation.value);
232
+ const settled = await backend.get(mutation.ref);
233
+ if (settled !== mutation.value) {
234
+ throw new Error(`The ${mutation.ref.service} credential change did not settle in the native secret backend`);
235
+ }
236
+ }
237
+ /**
238
+ * Changes account credentials without ever publishing them under an old cache
239
+ * scope. The transition marker is durable and written before the first secret:
240
+ * readers refuse it. A second atomic write publishes the new stable scope only
241
+ * after every secret has been read back successfully.
242
+ *
243
+ * If a secret or the final generation write fails, every old secret is restored
244
+ * and a fresh rollback scope is published. If even that last write cannot land
245
+ * (for example a completely full disk), the transition marker deliberately
246
+ * remains: Cloud reads stay closed instead of guessing which account owns the
247
+ * credentials. A later successful login/logout can replace that marker.
248
+ */
249
+ export async function commitCloudAuthCredentials(dataDir, backend, requested, dependencies = {}) {
250
+ const mutations = [];
251
+ for (const mutation of requested) {
252
+ const at = mutations.findIndex((existing) => sameSecretRef(existing.ref, mutation.ref));
253
+ if (at >= 0)
254
+ mutations[at] = mutation;
255
+ else
256
+ mutations.push(mutation);
257
+ }
258
+ if (!mutations.length)
259
+ return;
260
+ await serializeCloudAuthMutation(dataDir, async () => {
261
+ const writeGeneration = dependencies.writeGeneration ?? writeCloudAuthGeneration;
262
+ if (Object.hasOwn(dependencies, "expectedAuthGeneration")) {
263
+ const observed = await cloudAuthGeneration(dataDir);
264
+ if (authTransitionInProgress(observed) || observed !== dependencies.expectedAuthGeneration) {
265
+ fail("cloud_account_unavailable", "Sealkeep Cloud sign-in changed while this credential update was in flight. The older update was discarded.");
266
+ }
267
+ }
268
+ invalidateCloudAuthReadCache(dataDir);
269
+ const previous = await Promise.all(mutations.map(async ({ ref }) => ({ ref, value: await backend.get(ref) })));
270
+ const transitionId = randomUUID();
271
+ // This is the transaction's prepare record. If it cannot be made durable,
272
+ // no credential is touched — important on a nearly full machine.
273
+ await writeGeneration(dataDir, `${AUTH_TRANSITION_PREFIX}${transitionId}`);
274
+ try {
275
+ for (const mutation of mutations)
276
+ await settleCredential(backend, mutation);
277
+ await writeGeneration(dataDir, transitionId);
278
+ }
279
+ catch (error) {
280
+ let rollbackFailed = false;
281
+ for (const old of [...previous].reverse()) {
282
+ try {
283
+ await settleCredential(backend, old);
284
+ }
285
+ catch {
286
+ rollbackFailed = true;
287
+ }
288
+ }
289
+ if (!rollbackFailed) {
290
+ // Never restore the generation from before the transaction: a reader
291
+ // may have observed a transient secret. A new scope invalidates it.
292
+ await writeGeneration(dataDir, randomUUID()).catch(() => undefined);
293
+ }
294
+ throw error;
295
+ }
296
+ });
297
+ }
298
+ function authTransitionInProgress(generation) {
299
+ return generation?.startsWith(AUTH_TRANSITION_PREFIX) ?? false;
300
+ }
301
+ /**
302
+ * Seqlock for credential readers. A reader which overlaps either edge of an
303
+ * auth transaction must retry; otherwise an old token can be paired with the
304
+ * new generation (or the reverse) even though both individual writes are
305
+ * atomic. Exported as a narrow test seam so the overlap is deterministic.
306
+ */
307
+ export async function readCloudAuthSnapshot(dataDir, read) {
308
+ const before = await cloudAuthGeneration(dataDir);
309
+ if (authTransitionInProgress(before)) {
310
+ fail("cloud_account_unavailable", "Sealkeep Cloud sign-in is changing on this machine. Retry in a moment.");
311
+ }
312
+ const value = await read();
313
+ const after = await cloudAuthGeneration(dataDir);
314
+ if (authTransitionInProgress(after) || after !== before) {
315
+ fail("cloud_account_unavailable", "Sealkeep Cloud sign-in changed while this request was starting. Retry the request.");
316
+ }
317
+ return value;
318
+ }
21
319
  /**
22
320
  * Where this vault's cloud token lives in the keystore.
23
321
  *
@@ -35,41 +333,740 @@ const cloudUrl = (env = process.env) => (envVar("CLOUD_URL", env) ?? DEFAULT_CLO
35
333
  * definition, imported, so a change here cannot leave the other behind.
36
334
  */
37
335
  export function tokenRefFor(vaultId, env = process.env) {
38
- return { service: "vaultline-cloud", account: `${vaultId}@${cloudUrl(env)}` };
336
+ return { service: "sealkeep-cloud", account: `${vaultId}@${cloudUrl(env)}` };
337
+ }
338
+ /**
339
+ * A fresh install has no vault id yet. Its Cloud pairing therefore waits in
340
+ * the native secret backend under a data-directory-scoped bootstrap key until
341
+ * this machine either creates a new vault or adopts an approved existing one.
342
+ * The hash prevents two independent SealKeep homes on the same OS account from
343
+ * inheriting one another's login without putting the local path in keychain UI.
344
+ */
345
+ export function bootstrapTokenRefFor(dataDir, env = process.env) {
346
+ const scope = createHash("sha256").update(resolve(dataDir)).digest("hex");
347
+ return { service: "sealkeep-cloud-bootstrap", account: `${scope}@${cloudUrl(env)}` };
39
348
  }
40
349
  async function tokenRef(dataDir) {
41
350
  const { readConfig } = await import("./vault.js");
42
- const { vaultId } = await readConfig(dataDir);
43
- return tokenRefFor(vaultId);
351
+ const config = await readConfig(dataDir).catch((error) => {
352
+ if (isSealkeepError(error) && error.code === "vault_not_initialized")
353
+ return null;
354
+ throw error;
355
+ });
356
+ return config ? tokenRefFor(config.vaultId) : bootstrapTokenRefFor(dataDir);
357
+ }
358
+ /**
359
+ * Where this machine's own credential lives, beside the account token.
360
+ *
361
+ * The account token says *which account* is calling and nothing more — every
362
+ * laptop paired to a company account presents an identical one. That is fine
363
+ * for reading and for adding, and wrong for destroying: without a second fact
364
+ * the control plane cannot tell one machine's delete from another's, so any
365
+ * laptop could erase a colleague's sealed history and the audit row would name
366
+ * only the account. The machine key is that second fact. It is minted once at
367
+ * pairing, the server keeps only a bcrypt of it, and it rides in the request
368
+ * body rather than a header because this control plane strips request headers
369
+ * before endpoint logic ever sees them — proven, not assumed.
370
+ */
371
+ export function machineRefFor(vaultId, env = process.env) {
372
+ return { service: "sealkeep-machine", account: `${vaultId}@${cloudUrl(env)}` };
373
+ }
374
+ export function bootstrapMachineRefFor(dataDir, env = process.env) {
375
+ const scope = createHash("sha256").update(resolve(dataDir)).digest("hex");
376
+ return { service: "sealkeep-machine-bootstrap", account: `${scope}@${cloudUrl(env)}` };
377
+ }
378
+ async function cloudCredentialRefs(dataDir) {
379
+ const config = await readConfig(dataDir).catch((error) => {
380
+ if (isSealkeepError(error) && error.code === "vault_not_initialized")
381
+ return null;
382
+ throw error;
383
+ });
384
+ return {
385
+ vaultId: config?.vaultId ?? null,
386
+ token: config ? tokenRefFor(config.vaultId) : bootstrapTokenRefFor(dataDir),
387
+ bootstrapToken: bootstrapTokenRefFor(dataDir),
388
+ machine: config ? machineRefFor(config.vaultId) : bootstrapMachineRefFor(dataDir),
389
+ bootstrapMachine: bootstrapMachineRefFor(dataDir),
390
+ };
391
+ }
392
+ // A daemon asks for the same two secrets from several independent lanes. Keep
393
+ // the successful values for long enough to remove native-process churn, but
394
+ // periodically re-probe backend availability. A missing or malformed value is
395
+ // only a short backoff because native helpers cannot always distinguish a
396
+ // locked store from an absent item.
397
+ const CLOUD_AUTH_POSITIVE_CACHE_MS = 60_000;
398
+ const CLOUD_AUTH_NEGATIVE_CACHE_MS = 2_000;
399
+ const CLOUD_AUTH_CACHE_MAX_SCOPES = 64;
400
+ // A completed generation change discards the first snapshot and gets one fresh
401
+ // attempt. This keeps a machine-registration publish from spuriously failing
402
+ // sibling daemon lanes. Transitional and unreadable generations throw from
403
+ // stableCloudAuthGeneration and are never converted into a retryable mismatch.
404
+ const CLOUD_AUTH_SNAPSHOT_ATTEMPTS = 2;
405
+ const cloudAuthReadCache = new Map();
406
+ function sameCloudCredentialRefs(left, right) {
407
+ return left.vaultId === right.vaultId
408
+ && sameSecretRef(left.token, right.token)
409
+ && sameSecretRef(left.bootstrapToken, right.bootstrapToken)
410
+ && sameSecretRef(left.machine, right.machine)
411
+ && sameSecretRef(left.bootstrapMachine, right.bootstrapMachine);
412
+ }
413
+ function invalidateCloudAuthReadCache(dataDir) {
414
+ if (dataDir === undefined) {
415
+ cloudAuthReadCache.clear();
416
+ return;
417
+ }
418
+ const canonical = resolve(dataDir);
419
+ for (const [key, scope] of cloudAuthReadCache) {
420
+ if (scope.dataDir === canonical)
421
+ cloudAuthReadCache.delete(key);
422
+ }
423
+ }
424
+ async function stableCloudAuthGeneration(dataDir) {
425
+ const key = resolve(dataDir);
426
+ for (;;) {
427
+ const localMutation = cloudAuthMutationTails.get(key);
428
+ if (localMutation)
429
+ await localMutation.catch(() => undefined);
430
+ const generation = await cloudAuthGeneration(dataDir);
431
+ if (!authTransitionInProgress(generation))
432
+ return generation;
433
+ // A mutation can begin after the first tail lookup and publish its
434
+ // transition marker before the generation read. Join that known local
435
+ // writer, then probe again. An unowned marker is cross-process (or
436
+ // abandoned), so it must remain fail-closed instead of being waited out.
437
+ const transitionOwner = cloudAuthMutationTails.get(key);
438
+ if (!transitionOwner) {
439
+ fail("cloud_account_unavailable", "Sealkeep Cloud sign-in is changing on this machine. Retry in a moment.");
440
+ }
441
+ await transitionOwner.catch(() => undefined);
442
+ }
443
+ }
444
+ function cloudAuthScopeKey(dataDir, generation, backendSelector, refs) {
445
+ // No credential value is ever part of this key. The exact native refs carry
446
+ // the vault and Cloud URL; the generation carries the account session.
447
+ return JSON.stringify([
448
+ dataDir, generation, backendSelector,
449
+ refs.token.service, refs.token.account,
450
+ refs.machine.service, refs.machine.account,
451
+ ]);
452
+ }
453
+ function boundedCloudAuthCacheInsert(scope) {
454
+ while (cloudAuthReadCache.size >= CLOUD_AUTH_CACHE_MAX_SCOPES) {
455
+ const oldest = cloudAuthReadCache.keys().next().value;
456
+ if (oldest === undefined)
457
+ break;
458
+ cloudAuthReadCache.delete(oldest);
459
+ }
460
+ cloudAuthReadCache.set(scope.key, scope);
461
+ }
462
+ async function cloudAuthCacheScope(dataDir, preferred) {
463
+ const canonical = resolve(dataDir);
464
+ const generation = await stableCloudAuthGeneration(dataDir);
465
+ const refs = await cloudCredentialRefs(dataDir);
466
+ const backendSelector = preferred ?? envVar("SECRET_BACKEND") ?? "<automatic>";
467
+ const key = cloudAuthScopeKey(canonical, generation, backendSelector, refs);
468
+ const now = Date.now();
469
+ let scope = cloudAuthReadCache.get(key);
470
+ if (scope && scope.hardExpiresAt <= now) {
471
+ cloudAuthReadCache.delete(key);
472
+ scope = undefined;
473
+ }
474
+ if (!scope) {
475
+ const backend = chooseBackend(dataDir, preferred);
476
+ scope = {
477
+ key, dataDir: canonical, generation, backendSelector, refs, backend,
478
+ hardExpiresAt: now + CLOUD_AUTH_POSITIVE_CACHE_MS,
479
+ };
480
+ const owned = scope;
481
+ void backend.catch(() => { if (cloudAuthReadCache.get(key) === owned)
482
+ cloudAuthReadCache.delete(key); });
483
+ // A stable generation supersedes every older account scope for this data
484
+ // directory. Other backend selectors for the same generation stay isolated.
485
+ for (const [oldKey, old] of cloudAuthReadCache) {
486
+ if (old.dataDir === canonical && old.generation !== generation)
487
+ cloudAuthReadCache.delete(oldKey);
488
+ }
489
+ boundedCloudAuthCacheInsert(scope);
490
+ }
491
+ else {
492
+ // Touch for bounded LRU eviction without allocating another entry.
493
+ cloudAuthReadCache.delete(key);
494
+ cloudAuthReadCache.set(key, scope);
495
+ }
496
+ return { scope, generation, refs };
497
+ }
498
+ async function validateCloudAuthCacheRead(dataDir, scope, generation, refs) {
499
+ const refsAfter = await cloudCredentialRefs(dataDir);
500
+ const generationAfter = await stableCloudAuthGeneration(dataDir);
501
+ if (generationAfter !== generation || !sameCloudCredentialRefs(refsAfter, refs)) {
502
+ if (cloudAuthReadCache.get(scope.key) === scope)
503
+ cloudAuthReadCache.delete(scope.key);
504
+ return false;
505
+ }
506
+ return true;
507
+ }
508
+ async function cachedCloudTokenRead(dataDir, preferred) {
509
+ for (let attempt = 0; attempt < CLOUD_AUTH_SNAPSHOT_ATTEMPTS; attempt += 1) {
510
+ const { scope, generation, refs } = await cloudAuthCacheScope(dataDir, preferred);
511
+ const now = Date.now();
512
+ let cached = scope.token;
513
+ if (!cached || cached.expiresAt <= now) {
514
+ const promise = (async () => {
515
+ const backend = await scope.backend;
516
+ let found = await backend.get(refs.token);
517
+ if (!found && refs.token.account !== refs.bootstrapToken.account) {
518
+ // As before, the token alias is read-only here. Moving it can overwrite
519
+ // another process's newly selected account.
520
+ found = await backend.get(refs.bootstrapToken);
521
+ }
522
+ return found || null;
523
+ })();
524
+ cached = { promise, expiresAt: Number.POSITIVE_INFINITY };
525
+ scope.token = cached;
526
+ const owned = cached;
527
+ void promise.then((value) => { owned.expiresAt = Date.now() + (value ? CLOUD_AUTH_POSITIVE_CACHE_MS : CLOUD_AUTH_NEGATIVE_CACHE_MS); }, () => { if (scope.token === owned)
528
+ scope.token = undefined; });
529
+ }
530
+ const token = await cached.promise;
531
+ const backend = await scope.backend;
532
+ if (await validateCloudAuthCacheRead(dataDir, scope, generation, refs)) {
533
+ return { token, generation, refs, backend };
534
+ }
535
+ }
536
+ return fail("cloud_account_unavailable", "Sealkeep Cloud sign-in or vault identity kept changing while this request was starting. Retry the request.");
537
+ }
538
+ function parseMachineCredential(raw) {
539
+ if (!raw)
540
+ return null;
541
+ try {
542
+ const parsed = JSON.parse(raw);
543
+ if (typeof parsed.device_id !== "number" || typeof parsed.machine_key !== "string")
544
+ return null;
545
+ return { device_id: parsed.device_id, machine_key: parsed.machine_key };
546
+ }
547
+ catch {
548
+ return null;
549
+ }
550
+ }
551
+ async function cachedMachineCredentialRead(dataDir, preferred) {
552
+ for (let attempt = 0; attempt < CLOUD_AUTH_SNAPSHOT_ATTEMPTS; attempt += 1) {
553
+ const { scope, generation, refs } = await cloudAuthCacheScope(dataDir, preferred);
554
+ const now = Date.now();
555
+ let cached = scope.machine;
556
+ if (!cached || cached.expiresAt <= now) {
557
+ const promise = (async () => {
558
+ const backend = await scope.backend;
559
+ let raw = await backend.get(refs.machine);
560
+ let fromBootstrap = false;
561
+ if (!raw && refs.machine.account !== refs.bootstrapMachine.account) {
562
+ raw = await backend.get(refs.bootstrapMachine);
563
+ fromBootstrap = Boolean(raw);
564
+ }
565
+ return { credential: parseMachineCredential(raw), raw, fromBootstrap };
566
+ })();
567
+ cached = { promise, expiresAt: Number.POSITIVE_INFINITY };
568
+ scope.machine = cached;
569
+ const owned = cached;
570
+ void promise.then((value) => { owned.expiresAt = Date.now() + (value.credential ? CLOUD_AUTH_POSITIVE_CACHE_MS : CLOUD_AUTH_NEGATIVE_CACHE_MS); }, () => { if (scope.machine === owned)
571
+ scope.machine = undefined; });
572
+ }
573
+ const read = await cached.promise;
574
+ const backend = await scope.backend;
575
+ if (await validateCloudAuthCacheRead(dataDir, scope, generation, refs)) {
576
+ return { read, generation, refs, backend };
577
+ }
578
+ }
579
+ return fail("cloud_account_unavailable", "Sealkeep Cloud sign-in or vault identity kept changing while this request was starting. Retry the request.");
580
+ }
581
+ async function machineRef(dataDir) {
582
+ return (await cloudCredentialRefs(dataDir)).machine;
583
+ }
584
+ /**
585
+ * This machine's credential, claiming one if it does not have it yet.
586
+ *
587
+ * Machines that paired before machine keys existed hold only an account token.
588
+ * Left alone they would keep sealing archives nothing can attribute, and the
589
+ * protection would silently never switch on for them — the worst kind of
590
+ * security change, the sort that upgrades cleanly and does nothing. So the
591
+ * first call that needs an identity claims one. `claim` is false on paths
592
+ * where a network round trip would be a surprise.
593
+ */
594
+ export async function machineIdentity(dataDir, preferred, options = {}) {
595
+ options.signal?.throwIfAborted();
596
+ const existing = await machineCredential(dataDir, preferred);
597
+ if (existing)
598
+ return existing;
599
+ try {
600
+ const token = await cloudToken(dataDir, preferred, { signal: options.signal });
601
+ return await registerMachine(dataDir, token, preferred, options);
602
+ }
603
+ catch (error) {
604
+ if (options.signal?.aborted)
605
+ throw abortReason(options.signal, "Machine registration was cancelled");
606
+ return null;
607
+ }
608
+ }
609
+ /** This machine's credential, or null when it paired before machine keys existed. */
610
+ export async function machineCredential(dataDir, preferred) {
611
+ try {
612
+ const snapshot = await cachedMachineCredentialRead(dataDir, preferred);
613
+ if (snapshot.read.fromBootstrap && snapshot.read.raw) {
614
+ // Alias healing is a credential mutation too. Publish it through the same
615
+ // generation transaction so another process never retains the old slot.
616
+ await commitCloudAuthCredentials(dataDir, snapshot.backend, [
617
+ { ref: snapshot.refs.machine, value: snapshot.read.raw },
618
+ { ref: snapshot.refs.bootstrapMachine, value: null },
619
+ ], { expectedAuthGeneration: snapshot.generation });
620
+ }
621
+ return snapshot.read.credential;
622
+ }
623
+ catch {
624
+ return null;
625
+ }
626
+ }
627
+ /**
628
+ * This machine's credential as a read-only snapshot. Unlike
629
+ * `machineCredential`, this never migrates a pre-vault bootstrap alias and
630
+ * therefore cannot advance the Cloud auth generation. Advisory telemetry uses
631
+ * this path; operations which need a durable machine identity keep the normal
632
+ * healing behaviour above.
633
+ */
634
+ export async function peekMachineCredential(dataDir, preferred) {
635
+ try {
636
+ return (await cachedMachineCredentialRead(dataDir, preferred)).read.credential;
637
+ }
638
+ catch {
639
+ return null;
640
+ }
641
+ }
642
+ const cloudMachineRegistrations = new Map();
643
+ async function rememberMachine(dataDir, credential, backend, ref, expectedAuthGeneration) {
644
+ await commitCloudAuthCredentials(dataDir, backend, [{ ref, value: JSON.stringify(credential) }], { expectedAuthGeneration });
645
+ }
646
+ /**
647
+ * Announce this machine and keep the key it is handed.
648
+ *
649
+ * Best-effort on purpose: a machine that cannot register is still signed in and
650
+ * still archives. It is merely anonymous to the control plane, which is exactly
651
+ * what every machine was before this existed — so failing here must never fail
652
+ * a sign-in.
653
+ */
654
+ export async function registerMachine(dataDir, token, preferred, options = {}) {
655
+ options.signal?.throwIfAborted();
656
+ try {
657
+ // Bind the network result to the exact locally stored login that authorized
658
+ // it. A slow registration from account A must not install A's machine key
659
+ // after logout or a later login selected account B.
660
+ const account = await cachedCloudTokenRead(dataDir, preferred);
661
+ if (account.token !== token)
662
+ return null;
663
+ const key = JSON.stringify([
664
+ resolve(dataDir), account.generation, account.backend.name,
665
+ account.refs.machine.service, account.refs.machine.account,
666
+ createHash("sha256").update(token).digest("hex"),
667
+ ]);
668
+ let running = cloudMachineRegistrations.get(key);
669
+ if (running?.controller.signal.aborted) {
670
+ cloudMachineRegistrations.delete(key);
671
+ running = undefined;
672
+ }
673
+ if (!running) {
674
+ const controller = new AbortController();
675
+ const entry = {
676
+ controller,
677
+ promise: Promise.resolve(null),
678
+ waiters: 0,
679
+ settled: false,
680
+ };
681
+ entry.promise = (async () => {
682
+ const { hostname } = await import("node:os");
683
+ const result = await call("/v1/cloud/devices/register", {
684
+ method: "POST", token, body: JSON.stringify({ label: hostname() }), signal: controller.signal,
685
+ });
686
+ if (typeof result?.device_id !== "number" || typeof result?.machine_key !== "string")
687
+ return null;
688
+ controller.signal.throwIfAborted();
689
+ const credential = { device_id: result.device_id, machine_key: result.machine_key };
690
+ await rememberMachine(dataDir, credential, account.backend, account.refs.machine, account.generation);
691
+ return credential;
692
+ })().finally(() => {
693
+ entry.settled = true;
694
+ if (cloudMachineRegistrations.get(key) === entry)
695
+ cloudMachineRegistrations.delete(key);
696
+ });
697
+ cloudMachineRegistrations.set(key, entry);
698
+ running = entry;
699
+ }
700
+ return await awaitSharedAbortable(running, options.signal);
701
+ }
702
+ catch (error) {
703
+ if (options.signal?.aborted)
704
+ throw abortReason(options.signal, "Machine registration was cancelled");
705
+ return null;
706
+ }
707
+ }
708
+ export async function createCloudDeviceEnrollment(dataDir, input, preferred) {
709
+ const machine = await machineCredential(dataDir, preferred);
710
+ if (!machine)
711
+ fail("forbidden", "This machine must have an active Cloud device identity before it can request preserved memory");
712
+ return call("/v1/cloud/device-enrollments", {
713
+ method: "POST",
714
+ token: await cloudToken(dataDir, preferred),
715
+ body: JSON.stringify({
716
+ label: input.label,
717
+ public_key: input.publicKey,
718
+ request_secret: input.requestSecret,
719
+ target_device_id: machine.device_id,
720
+ machine_key: machine.machine_key,
721
+ }),
722
+ });
723
+ }
724
+ export async function listPendingCloudDeviceEnrollments(dataDir, preferred) {
725
+ const answer = await call("/v1/cloud/device-enrollments", {
726
+ token: await cloudToken(dataDir, preferred),
727
+ });
728
+ return Array.isArray(answer) ? answer : answer.requests ?? [];
729
+ }
730
+ export async function approveCloudDeviceEnrollment(dataDir, requestId, bundle, preferred) {
731
+ const machine = await machineCredential(dataDir, preferred);
732
+ if (!machine)
733
+ fail("forbidden", "This machine has no active Cloud device identity and cannot approve another machine");
734
+ return call(`/v1/cloud/device-enrollments/${encodeURIComponent(requestId)}/approve`, {
735
+ method: "POST",
736
+ token: await cloudToken(dataDir, preferred),
737
+ body: JSON.stringify({
738
+ device_id: machine.device_id,
739
+ machine_key: machine.machine_key,
740
+ envelope: JSON.stringify(bundle.envelope),
741
+ sealed: bundle.sealed,
742
+ }),
743
+ });
744
+ }
745
+ export async function claimCloudDeviceEnrollment(dataDir, requestId, requestSecret, preferred) {
746
+ const machine = await machineCredential(dataDir, preferred);
747
+ if (!machine)
748
+ fail("forbidden", "This machine no longer has the active device identity that requested enrollment");
749
+ return call(`/v1/cloud/device-enrollments/${encodeURIComponent(requestId)}/claim`, {
750
+ method: "POST",
751
+ token: await cloudToken(dataDir, preferred),
752
+ body: JSON.stringify({ request_secret: requestSecret, target_device_id: machine.device_id, machine_key: machine.machine_key }),
753
+ });
754
+ }
755
+ export async function completeCloudDeviceEnrollment(dataDir, requestId, requestSecret, preferred) {
756
+ const machine = await machineCredential(dataDir, preferred);
757
+ if (!machine)
758
+ fail("forbidden", "This machine no longer has the active device identity that requested enrollment");
759
+ return call(`/v1/cloud/device-enrollments/${encodeURIComponent(requestId)}/complete`, {
760
+ method: "POST",
761
+ token: await cloudToken(dataDir, preferred),
762
+ body: JSON.stringify({ request_secret: requestSecret, target_device_id: machine.device_id, machine_key: machine.machine_key }),
763
+ });
764
+ }
765
+ export async function startCloudDeviceAuthorization(input) {
766
+ return call("/v1/cloud/device-authorizations", {
767
+ method: "POST",
768
+ body: JSON.stringify({
769
+ challenge: input.challenge,
770
+ label: input.label,
771
+ os_family: input.osFamily,
772
+ agents: input.agents,
773
+ public_key: input.publicKey,
774
+ }),
775
+ });
776
+ }
777
+ export async function claimCloudDeviceAuthorization(input) {
778
+ return call(`/v1/cloud/device-authorizations/${encodeURIComponent(input.requestId)}/claim`, {
779
+ method: "POST",
780
+ body: JSON.stringify({ verifier: input.verifier }),
781
+ });
782
+ }
783
+ /**
784
+ * Commits an approved hosted authorization to the same native secret backend
785
+ * used by ordinary Cloud login. Neither credential is ever written to config,
786
+ * returned to the browser, or put in a URL. If the second write fails, the
787
+ * first is rolled back so setup never reports a half-paired machine.
788
+ */
789
+ export async function rememberCloudDeviceAuthorization(dataDir, input, preferred) {
790
+ const backend = await chooseBackend(dataDir, preferred);
791
+ const accountRef = await tokenRef(dataDir);
792
+ const deviceRef = await machineRef(dataDir);
793
+ await commitCloudAuthCredentials(dataDir, backend, [
794
+ { ref: accountRef, value: input.accountToken },
795
+ { ref: deviceRef, value: JSON.stringify(input.machine) },
796
+ ]);
797
+ await bootstrapTeamIdentity(dataDir, preferred);
798
+ }
799
+ async function bootstrapTeamIdentity(dataDir, preferred) {
800
+ try {
801
+ const { readConfig } = await import("./vault.js");
802
+ const { recallRecoveryPhrase } = await import("./secrets.js");
803
+ const config = await readConfig(dataDir);
804
+ const phrase = await recallRecoveryPhrase(dataDir, config.vaultId, preferred);
805
+ if (!phrase)
806
+ return;
807
+ const { ensureTeamIdentity } = await import("./team-presence.js");
808
+ await ensureTeamIdentity(dataDir, phrase, preferred);
809
+ }
810
+ catch { /* sign-in succeeds even when team bootstrap is offline/rate-limited */ }
811
+ }
812
+ const processBackoff = new Map();
813
+ const processBackoffObserved = new Map();
814
+ const cloudBackoffAcknowledgement = (dataDir) => join(dataDir, 'runtime', 'cloud-backoff-rechecked.json');
815
+ async function backoffRecheckedBefore(dataDir) {
816
+ try {
817
+ const value = JSON.parse(await readFile(cloudBackoffAcknowledgement(dataDir), 'utf8'));
818
+ return Number(value.before) || 0;
819
+ }
820
+ catch {
821
+ return 0;
822
+ }
823
+ }
824
+ const cloudBackoffPath = (dataDir) => join(dataDir, "runtime", "cloud-backoff.json");
825
+ const CLOUD_BACKOFF_MARKER_RE = /^cloud-backoff-until-([0-9]{13})\.marker$/;
826
+ async function readCloudBackoff(dataDir) {
827
+ try {
828
+ const row = JSON.parse(await readFile(cloudBackoffPath(dataDir), "utf8"));
829
+ if (row.version !== 1 || typeof row.until !== "number" || typeof row.retryAfterMs !== "number")
830
+ return null;
831
+ return row;
832
+ }
833
+ catch {
834
+ return null;
835
+ }
836
+ }
837
+ async function writeCloudBackoff(dataDir, until) {
838
+ const state = {
839
+ version: 1,
840
+ until,
841
+ retryAfterMs: Math.max(0, until - Date.now()),
842
+ updatedAt: new Date().toISOString(),
843
+ scope: "control-plane",
844
+ };
845
+ const path = cloudBackoffPath(dataDir);
846
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
847
+ const temporary = `${path}.${randomUUID()}.tmp`;
848
+ await writeFile(temporary, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
849
+ await rename(temporary, path);
850
+ return state;
851
+ }
852
+ async function durableCloudBackoffUntil(dataDir) {
853
+ const receipt = await readCloudBackoff(dataDir);
854
+ const recheckedBefore = await backoffRecheckedBefore(dataDir);
855
+ const dir = dirname(cloudBackoffPath(dataDir));
856
+ const entries = await readdir(dir).catch(() => []);
857
+ const now = Date.now();
858
+ let markerUntil = 0;
859
+ const expired = [];
860
+ for (const name of entries) {
861
+ const match = CLOUD_BACKOFF_MARKER_RE.exec(name);
862
+ if (!match)
863
+ continue;
864
+ const until = Number(match[1]);
865
+ if (recheckedBefore && ((await stat(join(dir, name)).catch(() => null))?.mtimeMs ?? Infinity) < recheckedBefore)
866
+ continue;
867
+ if (until <= now)
868
+ expired.push(join(dir, name));
869
+ else
870
+ markerUntil = Math.max(markerUntil, until);
871
+ }
872
+ void Promise.all(expired.map((path) => unlink(path).catch(() => undefined)));
873
+ return Math.max(receipt && Date.parse(receipt.updatedAt) >= recheckedBefore ? receipt.until : 0, markerUntil);
874
+ }
875
+ /**
876
+ * One cooldown shared by every transport that reaches the control plane.
877
+ *
878
+ * REST learns it from Retry-After, while the realtime listener may learn the
879
+ * same fact during a Socket.IO handshake. Keeping the high-water timestamp in
880
+ * one package-owned receipt prevents either transport (and a freshly started
881
+ * daemon) from bypassing the other one's server-requested quiet period.
882
+ */
883
+ export async function cloudBackoffUntil(dataDir, env = process.env) {
884
+ const base = cloudUrl(env);
885
+ const recheckedBefore = await backoffRecheckedBefore(dataDir);
886
+ if ((processBackoffObserved.get(base) ?? 0) < recheckedBefore)
887
+ processBackoff.delete(base);
888
+ return Math.max(processBackoff.get(base) ?? 0, await durableCloudBackoffUntil(dataDir));
889
+ }
890
+ export async function rememberCloudBackoff(dataDir, retryAfterMs, env = process.env) {
891
+ const delay = Number.isFinite(retryAfterMs) ? Math.max(1_000, Math.ceil(retryAfterMs)) : 60_000;
892
+ const base = cloudUrl(env);
893
+ const proposedUntil = Date.now() + delay;
894
+ // This assignment happens before the first await so another listener in the
895
+ // same process cannot enter the handshake window while the receipt is being
896
+ // persisted.
897
+ processBackoff.set(base, Math.max(processBackoff.get(base) ?? 0, proposedUntil));
898
+ processBackoffObserved.set(base, Date.now());
899
+ const dir = dirname(cloudBackoffPath(dataDir));
900
+ await mkdir(dir, { recursive: true, mode: 0o700 });
901
+ const mark = async (until) => {
902
+ try {
903
+ await (await open(join(dir, `cloud-backoff-until-${String(Math.ceil(until)).padStart(13, "0")}.marker`), "wx", 0o600)).close();
904
+ }
905
+ catch (error) {
906
+ if (error.code !== "EEXIST")
907
+ throw error;
908
+ }
909
+ };
910
+ // Publish this observation before reading any older receipt. A racing process
911
+ // can now see at least the proposed deadline instead of entering one more
912
+ // handshake while this process performs its high-water merge.
913
+ await mark(proposedUntil);
914
+ const until = Math.max(proposedUntil, processBackoff.get(base) ?? 0, await durableCloudBackoffUntil(dataDir));
915
+ processBackoff.set(base, until);
916
+ // An older pre-marker receipt can carry the longer deadline; promote it to a
917
+ // marker too so future concurrent writers cannot shorten it.
918
+ await mark(until);
919
+ return writeCloudBackoff(dataDir, until);
920
+ }
921
+ function retryDelay(response, body) {
922
+ const retry = response.headers.get("retry-after");
923
+ if (retry) {
924
+ const seconds = Number(retry);
925
+ if (Number.isFinite(seconds) && seconds >= 0)
926
+ return Math.max(1_000, Math.ceil(seconds * 1_000));
927
+ const at = Date.parse(retry);
928
+ if (Number.isFinite(at))
929
+ return Math.max(1_000, at - Date.now());
930
+ }
931
+ const error = body && typeof body === "object" ? body.error : null;
932
+ const bodyMs = Number(error?.retryAfterMs ?? body?.retryAfterMs);
933
+ if (Number.isFinite(bodyMs) && bodyMs >= 0)
934
+ return Math.max(1_000, bodyMs);
935
+ const bodySeconds = Number(error?.retryAfterSeconds ?? body?.retryAfterSeconds ?? error?.retryAfter ?? body?.retryAfter);
936
+ if (Number.isFinite(bodySeconds) && bodySeconds >= 0)
937
+ return Math.max(1_000, bodySeconds * 1_000);
938
+ const resetAt = error?.resetAt ?? body?.resetAt;
939
+ const resetTime = typeof resetAt === "string" ? Date.parse(resetAt) : Number(resetAt);
940
+ return Number.isFinite(resetTime) && resetTime > Date.now() ? Math.max(1_000, resetTime - Date.now()) : 60_000;
941
+ }
942
+ function rateLimitDetails(response, retryAfterMs) {
943
+ const details = { retryAfterMs, retryAt: new Date(Date.now() + retryAfterMs).toISOString() };
944
+ const limit = response.headers.get("ratelimit-limit");
945
+ const reset = response.headers.get("ratelimit-reset");
946
+ if (limit)
947
+ details.limit = Number.isFinite(Number(limit)) ? Number(limit) : limit;
948
+ if (reset)
949
+ details.resetAt = Number.isFinite(Number(reset)) ? new Date(Number(reset) * 1_000).toISOString() : reset;
950
+ return details;
951
+ }
952
+ async function assertCloudBackoff(base, dataDir) {
953
+ if (dataDir && (processBackoffObserved.get(base) ?? 0) < await backoffRecheckedBefore(dataDir))
954
+ processBackoff.delete(base);
955
+ const until = Math.max(processBackoff.get(base) ?? 0, dataDir ? await durableCloudBackoffUntil(dataDir) : 0);
956
+ if (until <= Date.now())
957
+ return;
958
+ const retryAfterMs = until - Date.now();
959
+ throw new SealkeepError("rate_limited", `Sealkeep Cloud requests are paused until ${new Date(until).toISOString()} after a service limit. This includes reconnection. Your local vault remains available.`, {
960
+ retryAfterMs,
961
+ retryAt: new Date(until).toISOString(),
962
+ });
44
963
  }
45
964
  /** A failed cloud call should say what the service said, not just "request failed". */
46
965
  async function call(path, init = {}, env = process.env) {
47
- const { token, ...rest } = init;
966
+ const { token, backoffDataDir, maxResponseBytes, manualSessionRetry, ...rest } = init;
967
+ const base = cloudUrl(env);
968
+ // A deliberate reconnect may recheck a stale quota deadline once. This is
969
+ // restricted to session renewal; server-side limits remain authoritative.
970
+ if (!(manualSessionRetry && path === '/v1/cloud/devices/session'))
971
+ await assertCloudBackoff(base, backoffDataDir);
48
972
  const headers = new Headers(rest.headers);
49
973
  if (rest.body)
50
974
  headers.set("content-type", "application/json");
51
975
  if (token)
52
976
  headers.set("authorization", `Bearer ${token}`);
53
977
  let response;
54
- try {
55
- response = await fetch(`${cloudUrl(env)}${path}`, { ...rest, headers });
978
+ let networkError;
979
+ // Reads are safe to replay and are the first calls ordinary machines make
980
+ // after a hosted rollout or brief edge reconnect. Absorb that short window
981
+ // internally; a person should not need to repeat a join or restart an agent.
982
+ const method = String(rest.method ?? "GET").toUpperCase();
983
+ const requestSignal = rest.signal ?? undefined;
984
+ const attempts = method === "GET" || method === "HEAD" ? 3 : 1;
985
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
986
+ try {
987
+ response = await fetch(`${base}${path}`, { ...rest, headers });
988
+ break;
989
+ }
990
+ catch (error) {
991
+ networkError = error;
992
+ // A caller-supplied deadline is authoritative. Retrying an already
993
+ // aborted request only burns the dashboard's entire latency budget (and
994
+ // creates two more fetches which can never succeed).
995
+ if (requestSignal?.aborted)
996
+ break;
997
+ if (attempt + 1 < attempts) {
998
+ await pause(100 * (attempt + 1), requestSignal);
999
+ }
1000
+ }
56
1001
  }
57
- catch (error) {
58
- return fail("internal", `Sealkeep Cloud is unreachable: ${error instanceof Error ? error.message : "network error"}`);
1002
+ if (!response) {
1003
+ if (requestSignal?.aborted)
1004
+ throw abortReason(requestSignal, "The Sealkeep Cloud request was cancelled");
1005
+ return fail("internal", `Sealkeep Cloud is unreachable: ${networkError instanceof Error ? networkError.message : "network error"}`);
59
1006
  }
60
- const text = await response.text();
1007
+ const text = maxResponseBytes === undefined ? await response.text()
1008
+ : (await collectChunks(responseBodyChunks(response), maxResponseBytes)).toString("utf8");
61
1009
  const body = text ? safeJson(text) : null;
62
1010
  if (!response.ok) {
63
- const message = body?.error ?? `${response.status} ${response.statusText}`;
1011
+ // The control plane prefixes a refused precondition with the name of the
1012
+ // step that refused it — "Error at step Precondition: ...". That is the
1013
+ // plane's internal shape, not something a person can act on, and it buries
1014
+ // the sentence that actually tells them what to do. The panel already
1015
+ // strips it; so does the CLI, so both surfaces say the same thing.
1016
+ const errorBody = body && typeof body === "object" ? body.error : null;
1017
+ const remoteCode = typeof errorBody?.code === "string" ? errorBody.code.toUpperCase() : "";
1018
+ const raw = typeof errorBody === "string"
1019
+ ? errorBody
1020
+ : typeof errorBody?.message === "string"
1021
+ ? errorBody.message
1022
+ : typeof body?.message === "string"
1023
+ ? body.message
1024
+ : `${response.status} ${response.statusText}`;
1025
+ const message = raw.replace(/^Error at step [^:]*:\s*/i, "");
1026
+ if (response.status === 429) {
1027
+ const retryAfterMs = retryDelay(response, body);
1028
+ processBackoffObserved.set(base, Date.now());
1029
+ if (backoffDataDir)
1030
+ await rememberCloudBackoff(backoffDataDir, retryAfterMs, env).catch(() => {
1031
+ processBackoff.set(base, Math.max(processBackoff.get(base) ?? 0, Date.now() + retryAfterMs));
1032
+ });
1033
+ else
1034
+ processBackoff.set(base, Math.max(processBackoff.get(base) ?? 0, Date.now() + retryAfterMs));
1035
+ throw new SealkeepError("rate_limited", `Sealkeep Cloud rate limit reached: ${message}`, rateLimitDetails(response, retryAfterMs));
1036
+ }
64
1037
  if (response.status === 401)
65
1038
  fail("unauthorized", `Sealkeep Cloud rejected the credentials: ${message}`);
66
1039
  if (response.status === 402)
67
- fail("forbidden", message);
1040
+ fail("payment_required", message);
1041
+ if (response.status === 403 && remoteCode === "TEAM_MEMBERSHIP_REVOKED") {
1042
+ fail("team_membership_revoked", message);
1043
+ }
68
1044
  if (response.status === 403)
69
1045
  fail("forbidden", message);
1046
+ // Exact compatibility with the native precondition, not a substring or a
1047
+ // general 409/lease-expiry rule. Only this authenticated personal GET says
1048
+ // there is an existing row whose upload has not completed.
1049
+ if (response.status === 409 && method === "GET" && /^\/v1\/cloud\/archives\/[^/?]+\/download$/.test(path)
1050
+ && (remoteCode === "UPLOAD_INCOMPLETE" || (!remoteCode && message === "That archive never finished uploading, so there is nothing to fetch"))) {
1051
+ fail("upload_incomplete", message, { httpStatus: 409, remoteCode });
1052
+ }
1053
+ const plainTeamStateConflict = path.startsWith("/v1/cloud/team/") && (/\bteam (?:key|membership|state) changed\b/i.test(message)
1054
+ || /member encryption identity changed/i.test(message)
1055
+ || /remaining member needs one current wrapped key/i.test(message)
1056
+ || /owner key wrap is missing/i.test(message));
1057
+ if (response.status === 409 && (remoteCode === "TEAM_STATE_CHANGED" || plainTeamStateConflict)) {
1058
+ const current = Number(errorBody?.current_key_version ?? body?.current_key_version);
1059
+ const currentMembership = Number(errorBody?.current_membership_version ?? body?.current_membership_version);
1060
+ const details = {};
1061
+ if (Number.isSafeInteger(current) && current > 0)
1062
+ details.currentKeyVersion = current;
1063
+ if (Number.isSafeInteger(currentMembership) && currentMembership > 0)
1064
+ details.currentMembershipVersion = currentMembership;
1065
+ fail("team_state_changed", message, Object.keys(details).length ? details : undefined);
1066
+ }
70
1067
  if (response.status === 409)
71
- fail("lease_expired", message);
72
- fail("internal", `Sealkeep Cloud returned ${response.status}: ${message}`);
1068
+ fail("lease_expired", message, { httpStatus: response.status, remoteCode });
1069
+ fail("internal", `Sealkeep Cloud returned ${response.status}: ${message}`, { httpStatus: response.status, remoteCode });
73
1070
  }
74
1071
  return body;
75
1072
  }
@@ -87,9 +1084,18 @@ export async function register(dataDir, input, preferred) {
87
1084
  }
88
1085
  /** The token lives in the OS keychain, never in a config file on disk. */
89
1086
  export async function login(dataDir, input, preferred) {
90
- const result = await call("/login", { method: "POST", body: JSON.stringify(input) });
91
1087
  const backend = await chooseBackend(dataDir, preferred);
92
- await backend.set(await tokenRef(dataDir), result.token);
1088
+ const refs = await cloudCredentialRefs(dataDir);
1089
+ const expectedAuthGeneration = await stableCloudAuthGeneration(dataDir);
1090
+ const result = await call("/login", { method: "POST", body: JSON.stringify(input) });
1091
+ await commitCloudAuthCredentials(dataDir, backend, [
1092
+ { ref: refs.token, value: result.token },
1093
+ ...(sameSecretRef(refs.bootstrapToken, refs.token) ? [] : [{ ref: refs.bootstrapToken, value: null }]),
1094
+ { ref: refs.machine, value: null },
1095
+ ...(sameSecretRef(refs.bootstrapMachine, refs.machine) ? [] : [{ ref: refs.bootstrapMachine, value: null }]),
1096
+ ], { expectedAuthGeneration });
1097
+ await registerMachine(dataDir, result.token, preferred);
1098
+ await bootstrapTeamIdentity(dataDir, preferred);
93
1099
  return result.user;
94
1100
  }
95
1101
  /**
@@ -106,35 +1112,618 @@ export async function loginWithCode(dataDir, input, preferred) {
106
1112
  // used to burn the code on the way to a "no vault" error — the person then
107
1113
  // stood at a panel whose displayed code was already dead, with no hint why.
108
1114
  const backend = await chooseBackend(dataDir, preferred);
109
- const ref = await tokenRef(dataDir);
1115
+ const refs = await cloudCredentialRefs(dataDir);
1116
+ const expectedAuthGeneration = await stableCloudAuthGeneration(dataDir);
110
1117
  const result = await call("/cli/exchange", { method: "POST", body: JSON.stringify(input) });
111
- await backend.set(ref, result.token);
1118
+ await commitCloudAuthCredentials(dataDir, backend, [
1119
+ { ref: refs.token, value: result.token },
1120
+ ...(sameSecretRef(refs.bootstrapToken, refs.token) ? [] : [{ ref: refs.bootstrapToken, value: null }]),
1121
+ { ref: refs.machine, value: null },
1122
+ ...(sameSecretRef(refs.bootstrapMachine, refs.machine) ? [] : [{ ref: refs.bootstrapMachine, value: null }]),
1123
+ ], { expectedAuthGeneration });
112
1124
  // Announce this machine to the account, so the panel's device list shows it
113
1125
  // and Revoke has a row to act on. The exchange endpoint itself cannot write
114
1126
  // the row (it is public; the control plane only accepts tenant writes from an
115
1127
  // authenticated caller), so the freshly minted token does it. Best-effort: a
116
1128
  // machine that fails to register is still signed in — it is merely invisible,
117
1129
  // which was previously true of every machine.
118
- try {
119
- const { hostname } = await import("node:os");
120
- await call("/v1/cloud/devices/register", { method: "POST", token: result.token, body: JSON.stringify({ label: hostname() }) });
121
- }
122
- catch { /* visibility is not worth failing a successful sign-in over */ }
1130
+ await registerMachine(dataDir, result.token, preferred);
1131
+ await bootstrapTeamIdentity(dataDir, preferred);
123
1132
  return result.user;
124
1133
  }
125
1134
  export async function logout(dataDir, preferred) {
126
1135
  const backend = await chooseBackend(dataDir, preferred);
127
- await backend.delete(await tokenRef(dataDir));
1136
+ const refs = await cloudCredentialRefs(dataDir);
1137
+ await commitCloudAuthCredentials(dataDir, backend, [
1138
+ { ref: refs.token, value: null },
1139
+ ...(sameSecretRef(refs.bootstrapToken, refs.token) ? [] : [{ ref: refs.bootstrapToken, value: null }]),
1140
+ { ref: refs.machine, value: null },
1141
+ ...(sameSecretRef(refs.bootstrapMachine, refs.machine) ? [] : [{ ref: refs.bootstrapMachine, value: null }]),
1142
+ ]);
128
1143
  }
129
- export async function cloudToken(dataDir, preferred) {
130
- const backend = await chooseBackend(dataDir, preferred);
131
- const token = await backend.get(await tokenRef(dataDir));
1144
+ /**
1145
+ * Read only the expiry claim from our own stored JWT. This is scheduling, not
1146
+ * trust: the server still verifies every token and the machine credential used
1147
+ * to renew it. An opaque/self-hosted token simply keeps its existing behavior.
1148
+ */
1149
+ function tokenNeedsRenewal(token, withinSeconds = 300) {
1150
+ try {
1151
+ const parts = token.split(".");
1152
+ if (parts.length !== 3)
1153
+ return false;
1154
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
1155
+ return typeof payload.exp === "number" && payload.exp * 1000 <= Date.now() + withinSeconds * 1000;
1156
+ }
1157
+ catch {
1158
+ return false;
1159
+ }
1160
+ }
1161
+ const cloudRenewals = new Map();
1162
+ async function renewCloudSession(dataDir, backend, ref, token, authGeneration, preferred, signal, explicit = false) {
1163
+ const key = JSON.stringify([
1164
+ resolve(dataDir), authGeneration, backend.name, ref.service, ref.account,
1165
+ createHash("sha256").update(token).digest("hex"),
1166
+ explicit,
1167
+ ]);
1168
+ let running = cloudRenewals.get(key);
1169
+ if (running?.controller.signal.aborted) {
1170
+ cloudRenewals.delete(key);
1171
+ running = undefined;
1172
+ }
1173
+ if (!running) {
1174
+ const controller = new AbortController();
1175
+ const entry = {
1176
+ controller,
1177
+ promise: Promise.resolve(null),
1178
+ waiters: 0,
1179
+ settled: false,
1180
+ };
1181
+ entry.promise = (async () => {
1182
+ const machine = await machineCredential(dataDir, preferred);
1183
+ if (!machine)
1184
+ return null;
1185
+ let fresh;
1186
+ try {
1187
+ fresh = await call("/v1/cloud/devices/session", {
1188
+ manualSessionRetry: explicit,
1189
+ method: "POST",
1190
+ body: JSON.stringify({ device_id: machine.device_id, machine_key: machine.machine_key }),
1191
+ signal: controller.signal,
1192
+ backoffDataDir: dataDir,
1193
+ });
1194
+ }
1195
+ catch (error) {
1196
+ if (controller.signal.aborted)
1197
+ throw abortReason(controller.signal, "Sealkeep Cloud session renewal was cancelled");
1198
+ // Older/self-hosted planes may not have renewal yet. Preserve their
1199
+ // existing response path so the caller receives the real auth failure.
1200
+ if (explicit)
1201
+ throw error;
1202
+ return null;
1203
+ }
1204
+ if (typeof fresh.token !== "string" || !fresh.token) {
1205
+ if (explicit)
1206
+ fail("internal", "Sealkeep Cloud returned no renewed session; this machine was not re-registered.");
1207
+ return null;
1208
+ }
1209
+ controller.signal.throwIfAborted();
1210
+ // Local commit failures are deliberately outside the compatibility catch:
1211
+ // returning the old token after a failed generation write would conceal a
1212
+ // credential transition the cache boundary could not publish safely.
1213
+ await commitCloudAuthCredentials(dataDir, backend, [{ ref, value: fresh.token }], { expectedAuthGeneration: authGeneration });
1214
+ return fresh.token;
1215
+ })().finally(() => {
1216
+ entry.settled = true;
1217
+ if (cloudRenewals.get(key) === entry)
1218
+ cloudRenewals.delete(key);
1219
+ });
1220
+ cloudRenewals.set(key, entry);
1221
+ running = entry;
1222
+ }
1223
+ return awaitSharedAbortable(running, signal);
1224
+ }
1225
+ export async function cloudToken(dataDir, preferred, options = {}) {
1226
+ options.signal?.throwIfAborted();
1227
+ const snapshot = await cachedCloudTokenRead(dataDir, preferred);
1228
+ options.signal?.throwIfAborted();
1229
+ let token = snapshot.token;
132
1230
  if (!token)
133
1231
  fail("unauthorized", "Not signed in to Sealkeep Cloud. Run: sealkeep cloud login");
1232
+ // Display-only reads deliberately use the stored token as a snapshot. They
1233
+ // must never be the caller which starts a shared network renewal and leaves
1234
+ // an action waiting behind a hung control plane. The server still validates
1235
+ // the token; a stale snapshot simply fails closed and the UI keeps its last
1236
+ // authenticated local facts.
1237
+ if (options.renew !== false && tokenNeedsRenewal(token)) {
1238
+ const renewed = await renewCloudSession(dataDir, snapshot.backend, snapshot.refs.token, token, snapshot.generation, preferred, options.signal);
1239
+ if (renewed)
1240
+ token = renewed;
1241
+ else if (await stableCloudAuthGeneration(dataDir) !== snapshot.generation) {
1242
+ // A compatibility/no-machine renewal may return null. Do not fall back
1243
+ // to the old token if logout or another login completed while that
1244
+ // attempt was in flight.
1245
+ fail("cloud_account_unavailable", "Sealkeep Cloud sign-in changed while the session renewal was in flight. Retry the request.");
1246
+ }
1247
+ }
134
1248
  return token;
135
1249
  }
136
- export async function accountStatus(dataDir, preferred) {
137
- return call("/me", { token: await cloudToken(dataDir, preferred) });
1250
+ export async function accountStatus(dataDir, preferred, options = {}) {
1251
+ return call("/me", {
1252
+ token: await cloudToken(dataDir, preferred, { renew: options.renewToken !== false, signal: options.signal }),
1253
+ signal: options.signal,
1254
+ backoffDataDir: dataDir,
1255
+ });
1256
+ }
1257
+ /** Explicit user reconnection reuses the existing device, never a new seat. */
1258
+ export async function reconnectCloudSession(dataDir, preferred) {
1259
+ const before = Date.now();
1260
+ const snapshot = await cachedCloudTokenRead(dataDir, preferred);
1261
+ if (!await machineCredential(dataDir, preferred))
1262
+ return { reconnected: false };
1263
+ const renewed = await renewCloudSession(dataDir, snapshot.backend, snapshot.refs.token, snapshot.token || "", snapshot.generation, preferred, undefined, true);
1264
+ if (renewed) {
1265
+ const target = cloudBackoffAcknowledgement(dataDir);
1266
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
1267
+ const temporary = `${target}.${randomUUID()}.tmp`;
1268
+ await writeFile(temporary, JSON.stringify({ before, checkedAt: new Date().toISOString() }), { mode: 0o600 });
1269
+ await rename(temporary, target);
1270
+ }
1271
+ return { reconnected: Boolean(renewed) };
1272
+ }
1273
+ function rowsOf(value, keys = []) {
1274
+ if (Array.isArray(value))
1275
+ return value;
1276
+ if (!value || typeof value !== "object")
1277
+ return [];
1278
+ for (const key of keys) {
1279
+ const nested = value[key];
1280
+ if (Array.isArray(nested))
1281
+ return nested;
1282
+ if (nested && typeof nested === "object")
1283
+ return [nested];
1284
+ }
1285
+ return [value];
1286
+ }
1287
+ function teamCall(dataDir, path, token, init = {}) {
1288
+ return call(path, { ...init, token, backoffDataDir: dataDir });
1289
+ }
1290
+ export async function getTeamIdentity(dataDir, preferred) {
1291
+ const answer = await teamCall(dataDir, "/v1/cloud/team/identity", await cloudToken(dataDir, preferred));
1292
+ return rowsOf(answer, ["identity", "items", "data"])[0] ?? null;
1293
+ }
1294
+ export async function putTeamIdentity(dataDir, identity, preferred) {
1295
+ const answer = await teamCall(dataDir, "/v1/cloud/team/identity", await cloudToken(dataDir, preferred), {
1296
+ method: "PUT", body: JSON.stringify(identity),
1297
+ });
1298
+ return rowsOf(answer, ["identity", "item", "data"])[0] ?? identity;
1299
+ }
1300
+ export async function createTeamSpace(dataDir, input, preferred) {
1301
+ const answer = await teamCall(dataDir, "/v1/cloud/team/spaces", await cloudToken(dataDir, preferred), {
1302
+ method: "POST", body: JSON.stringify(input),
1303
+ });
1304
+ return rowsOf(answer, ["space", "membership", "item", "data"])[0] ?? { space_key: input.space_key, key_version: 1 };
1305
+ }
1306
+ export async function listTeamSpaces(dataDir, preferred) {
1307
+ const answer = await teamCall(dataDir, "/v1/cloud/team/spaces", await cloudToken(dataDir, preferred));
1308
+ return rowsOf(answer, ["spaces", "memberships", "items", "data"]);
1309
+ }
1310
+ /** Cloud-owned intent; a connected machine creates and seals the project key. */
1311
+ export async function listTeamProjectRequests(dataDir, preferred) {
1312
+ const answer = await teamCall(dataDir, "/v1/cloud/team/project-requests", await cloudToken(dataDir, preferred));
1313
+ return rowsOf(answer, ["requests", "items", "data"]);
1314
+ }
1315
+ /** Create cloud-owned project intent; key creation still happens on a connected machine. */
1316
+ export async function createTeamProjectRequest(dataDir, projectName, preferred) {
1317
+ const answer = await teamCall(dataDir, "/v1/cloud/team/project-requests", await cloudToken(dataDir, preferred), {
1318
+ method: "POST", body: JSON.stringify({ project_name: projectName }),
1319
+ });
1320
+ const request = rowsOf(answer, ["request", "item", "data"])[0];
1321
+ if (!request)
1322
+ return fail("internal", "Sealkeep Cloud created the project request but returned no request");
1323
+ return request;
1324
+ }
1325
+ /** Closes a project intent only after this account owns the prepared space. */
1326
+ export async function completeTeamProjectRequest(dataDir, requestId, spaceKey, preferred) {
1327
+ const answer = await teamCall(dataDir, `/v1/cloud/team/project-requests/${encodeURIComponent(String(requestId))}/complete`, await cloudToken(dataDir, preferred), {
1328
+ method: "POST", body: JSON.stringify({ space_key: spaceKey }),
1329
+ });
1330
+ const request = rowsOf(answer, ["request", "item", "data"])[0];
1331
+ if (!request)
1332
+ return fail("internal", "Sealkeep Cloud completed the project request but returned no request");
1333
+ return request;
1334
+ }
1335
+ export async function getTeamMembership(dataDir, spaceKey, preferred) {
1336
+ try {
1337
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/membership`, await cloudToken(dataDir, preferred));
1338
+ return rowsOf(answer, ["membership", "member", "item", "data"])[0] ?? null;
1339
+ }
1340
+ catch (error) {
1341
+ // This endpoint has exactly one authorization boundary after sign-in:
1342
+ // current membership. Older hosted workflows return a plain 403 instead
1343
+ // of the newer structured TEAM_MEMBERSHIP_REVOKED code.
1344
+ if (isSealkeepError(error) && error.code === "forbidden")
1345
+ return null;
1346
+ throw error;
1347
+ }
1348
+ }
1349
+ function rethrowTeamMembership(error) {
1350
+ if (isSealkeepError(error) && error.code === "forbidden") {
1351
+ return fail("team_membership_revoked", "This account is no longer a member of that team space");
1352
+ }
1353
+ throw error;
1354
+ }
1355
+ export async function resolveTeamInvitee(dataDir, spaceKey, email, preferred) {
1356
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/invitee?email=${encodeURIComponent(email)}`, await cloudToken(dataDir, preferred));
1357
+ const found = rowsOf(answer, ["invitee", "account", "item", "data"])[0];
1358
+ if (!found || typeof found.account_id !== "number" || typeof found.public_key !== "string") {
1359
+ return fail("archive_not_found", `No team identity is ready for ${email}. They need to sign in to Sealkeep on one machine first.`);
1360
+ }
1361
+ return found;
1362
+ }
1363
+ export async function addTeamMember(dataDir, spaceKey, input, preferred) {
1364
+ void dataDir;
1365
+ void spaceKey;
1366
+ void input;
1367
+ void preferred;
1368
+ return fail("invalid_argument", "Direct team membership is disabled; create an invitation and let the teammate accept it");
1369
+ }
1370
+ /** Creates a pending invitation; membership is not granted until acceptance. */
1371
+ export async function createTeamInvitation(dataDir, spaceKey, input, preferred) {
1372
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/invitations`, await cloudToken(dataDir, preferred), {
1373
+ method: "POST", body: JSON.stringify({ email: input.email }),
1374
+ });
1375
+ const invitation = rowsOf(answer, ["invitation", "item", "data"])[0];
1376
+ if (!invitation || !Number.isSafeInteger(Number(invitation.id)))
1377
+ return fail("internal", "Sealkeep Cloud created the invitation but returned no invitation id");
1378
+ if (input.wrapped_key && input.expected_key_version) {
1379
+ if (!Number.isSafeInteger(input.expected_membership_version) || !Number.isSafeInteger(input.expected_index_revision)
1380
+ || typeof input.expected_archive_refs !== "string") {
1381
+ fail("invalid_argument", "Encrypted invitations require a captured team history fence");
1382
+ }
1383
+ return prepareTeamInvitation(dataDir, invitation.id, {
1384
+ wrapped_key: input.wrapped_key,
1385
+ expected_key_version: input.expected_key_version,
1386
+ expected_membership_version: input.expected_membership_version,
1387
+ expected_index_revision: input.expected_index_revision,
1388
+ expected_archive_refs: input.expected_archive_refs,
1389
+ }, preferred);
1390
+ }
1391
+ // Pending is represented by the row's existence on the hosted plane. Keep
1392
+ // the wire status explicit for the dashboard and compatibility join path;
1393
+ // accepting or declining removes the row atomically.
1394
+ return { ...invitation, status: "pending" };
1395
+ }
1396
+ /** Invitations created in the hosted account that this signed-in account owns. */
1397
+ export async function listOutgoingTeamInvitations(dataDir, preferred) {
1398
+ const answer = await teamCall(dataDir, "/v1/cloud/team/invitations/outgoing", await cloudToken(dataDir, preferred));
1399
+ return rowsOf(answer, ["invitations", "items", "data"]).map((invitation) => ({
1400
+ ...invitation,
1401
+ status: "pending",
1402
+ state: invitation.wrapped_key ? "ready" : invitation.state ?? "preparing",
1403
+ }));
1404
+ }
1405
+ /** Submit ciphertext produced by the installed owner machine; plaintext keys never reach the browser. */
1406
+ export async function prepareTeamInvitation(dataDir, invitationId, input, preferred) {
1407
+ const answer = await teamCall(dataDir, `/v1/cloud/team/invitations/${encodeURIComponent(String(invitationId))}/prepare`, await cloudToken(dataDir, preferred), {
1408
+ method: "POST", body: JSON.stringify(input),
1409
+ });
1410
+ const invitation = rowsOf(answer, ["invitation", "item", "data"])[0];
1411
+ if (!invitation)
1412
+ return fail("internal", "Sealkeep Cloud prepared the invitation but returned no invitation");
1413
+ if (invitation.history_fenced !== true
1414
+ || Number(invitation.history_membership_version) !== input.expected_membership_version
1415
+ || Number(invitation.history_index_revision) !== input.expected_index_revision
1416
+ || invitation.history_archive_refs !== input.expected_archive_refs) {
1417
+ fail("provider_unsupported", "This backend did not attest the invitation's complete history fence");
1418
+ }
1419
+ return { ...invitation, status: "pending", state: "ready" };
1420
+ }
1421
+ export async function listTeamInvitations(dataDir, preferred) {
1422
+ const answer = await teamCall(dataDir, "/v1/cloud/team/invitations", await cloudToken(dataDir, preferred));
1423
+ return rowsOf(answer, ["invitations", "items", "data"]).map((invitation) => ({
1424
+ ...invitation,
1425
+ status: "pending",
1426
+ state: invitation.wrapped_key ? "ready" : invitation.state ?? "preparing",
1427
+ }));
1428
+ }
1429
+ export async function acceptTeamInvitation(dataDir, invitationId, preferred) {
1430
+ const answer = await teamCall(dataDir, `/v1/cloud/team/invitations/${encodeURIComponent(String(invitationId))}/accept`, await cloudToken(dataDir, preferred), { method: "POST", body: "{}" });
1431
+ if (!isRecord(answer) || answer.history_fenced !== true) {
1432
+ fail("provider_unsupported", "This backend did not attest the accepted invitation's history fence");
1433
+ }
1434
+ const member = rowsOf(answer, ["membership", "member", "item", "data"])[0];
1435
+ if (!member)
1436
+ return fail("internal", "Sealkeep Cloud accepted the invitation but returned no membership");
1437
+ return member;
1438
+ }
1439
+ export async function declineTeamInvitation(dataDir, invitationId, preferred) {
1440
+ const answer = await teamCall(dataDir, `/v1/cloud/team/invitations/${encodeURIComponent(String(invitationId))}/decline`, await cloudToken(dataDir, preferred), { method: "POST", body: "{}" });
1441
+ return rowsOf(answer, ["invitation", "item", "data"])[0] ?? { id: invitationId, status: "declined" };
1442
+ }
1443
+ export async function listTeamMembers(dataDir, spaceKey, preferred) {
1444
+ try {
1445
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/members`, await cloudToken(dataDir, preferred));
1446
+ return rowsOf(answer, ["members", "memberships", "items", "data"]);
1447
+ }
1448
+ catch (error) {
1449
+ return rethrowTeamMembership(error);
1450
+ }
1451
+ }
1452
+ /** One server-consistent roster snapshot for CAS-sensitive sharing work. */
1453
+ export async function getTeamRoster(dataDir, spaceKey, preferred, options = {}) {
1454
+ try {
1455
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/members`, await cloudToken(dataDir, preferred, { renew: options.renewToken !== false, signal: options.signal }), { signal: options.signal });
1456
+ const top = answer && typeof answer === "object" && !Array.isArray(answer) ? answer : null;
1457
+ const nested = top?.data && typeof top.data === "object" && !Array.isArray(top.data) ? top.data : top;
1458
+ const members = Array.isArray(nested?.members) ? nested.members : [];
1459
+ const membershipVersion = Number(nested?.membership_version);
1460
+ const keyVersion = Number(nested?.key_version);
1461
+ const accountIds = new Set();
1462
+ const publicKeys = new Set();
1463
+ const validMembers = members.every((member) => {
1464
+ const raw = typeof member.public_key === "string" ? Buffer.from(member.public_key, "base64") : Buffer.alloc(0);
1465
+ if (!Number.isSafeInteger(member.account_id) || member.account_id <= 0
1466
+ || typeof member.public_key !== "string" || raw.length !== 32
1467
+ || !Number.isSafeInteger(member.key_version) || member.key_version <= 0
1468
+ || typeof member.role !== "string" || !member.role)
1469
+ return false;
1470
+ accountIds.add(member.account_id);
1471
+ publicKeys.add(member.public_key);
1472
+ return true;
1473
+ });
1474
+ if (!members.length || !validMembers || accountIds.size !== members.length || publicKeys.size !== members.length
1475
+ || !Number.isSafeInteger(membershipVersion) || membershipVersion <= 0) {
1476
+ return fail("internal", "Sealkeep Cloud did not return a versioned team roster; archive sharing was withheld");
1477
+ }
1478
+ return {
1479
+ members,
1480
+ membership_version: membershipVersion,
1481
+ ...(Number.isSafeInteger(keyVersion) && keyVersion > 0 ? { key_version: keyVersion } : {}),
1482
+ };
1483
+ }
1484
+ catch (error) {
1485
+ return rethrowTeamMembership(error);
1486
+ }
1487
+ }
1488
+ /** Cloud-owned intent; a connected owner machine performs the key rotation. */
1489
+ export async function createTeamRemovalRequest(dataDir, spaceKey, targetAccountId, preferred) {
1490
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/members/${encodeURIComponent(String(targetAccountId))}/removal-requests`, await cloudToken(dataDir, preferred), {
1491
+ method: "POST", body: "{}",
1492
+ });
1493
+ const request = rowsOf(answer, ["request", "item", "data"])[0];
1494
+ if (!request)
1495
+ return fail("internal", "Sealkeep Cloud created the removal request but returned no request");
1496
+ return request;
1497
+ }
1498
+ export async function listOutgoingTeamAccessRequests(dataDir, preferred) {
1499
+ const answer = await teamCall(dataDir, "/v1/cloud/team/access-requests/outgoing", await cloudToken(dataDir, preferred));
1500
+ return rowsOf(answer, ["requests", "items", "data"]);
1501
+ }
1502
+ export async function completeTeamAccessRequest(dataDir, requestId, preferred) {
1503
+ const answer = await teamCall(dataDir, `/v1/cloud/team/access-requests/${encodeURIComponent(String(requestId))}/complete`, await cloudToken(dataDir, preferred), {
1504
+ method: "POST", body: "{}",
1505
+ });
1506
+ const request = rowsOf(answer, ["request", "item", "data"])[0];
1507
+ if (!request)
1508
+ return fail("internal", "Sealkeep Cloud completed the access request but returned no request");
1509
+ return request;
1510
+ }
1511
+ export async function registerTeamArchive(dataDir, spaceKey, vaultRef, expectedMembershipVersion, preferred, options = {}) {
1512
+ if (!Number.isSafeInteger(expectedMembershipVersion) || expectedMembershipVersion <= 0) {
1513
+ return fail("invalid_argument", "A current team membership version is required before an archive can be shared");
1514
+ }
1515
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/archives`, await cloudToken(dataDir, preferred, { signal: options.signal }), {
1516
+ method: "POST",
1517
+ body: JSON.stringify({ vault_ref: vaultRef, expected_membership_version: expectedMembershipVersion }),
1518
+ signal: options.signal,
1519
+ });
1520
+ const top = answer && typeof answer === "object" && !Array.isArray(answer) ? answer : null;
1521
+ const receiptVersion = Number(top?.membership_version);
1522
+ const row = rowsOf(answer, ["archive", "item", "data"])[0];
1523
+ if (!row)
1524
+ return fail("internal", "Sealkeep Cloud registered the archive but returned no archive row");
1525
+ if (receiptVersion !== expectedMembershipVersion || row.membership_version !== expectedMembershipVersion) {
1526
+ return fail("team_state_changed", "Sealkeep Cloud did not attest the membership version used for this archive grant");
1527
+ }
1528
+ return row;
1529
+ }
1530
+ export async function unregisterTeamArchive(dataDir, spaceKey, vaultRef, expectedMembershipVersion, preferred, options = {}) {
1531
+ if (!Number.isSafeInteger(expectedMembershipVersion) || expectedMembershipVersion <= 0) {
1532
+ return fail("invalid_argument", "A current team membership version is required before an archive grant can be removed");
1533
+ }
1534
+ await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/archives/${encodeURIComponent(vaultRef)}`, await cloudToken(dataDir, preferred, { signal: options.signal }), {
1535
+ method: "DELETE",
1536
+ body: JSON.stringify({ expected_membership_version: expectedMembershipVersion }),
1537
+ signal: options.signal,
1538
+ });
1539
+ }
1540
+ export async function listTeamArchives(dataDir, spaceKey, preferred, options = {}) {
1541
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/archives`, await cloudToken(dataDir, preferred, { renew: options.renewToken !== false, signal: options.signal }), { signal: options.signal });
1542
+ return rowsOf(answer, ["archives", "items", "data"]);
1543
+ }
1544
+ export async function teamArchiveDownload(dataDir, spaceKey, vaultRef, preferred, options = {}) {
1545
+ return teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/archives/${encodeURIComponent(vaultRef)}/download`, await cloudToken(dataDir, preferred, options), options);
1546
+ }
1547
+ const teamReadGuardBindings = new WeakMap();
1548
+ /** Pins local account identity while rechecking live membership and every exact grant.
1549
+ * This guard carries no reusable permission: each assertion calls the Team plane. */
1550
+ export async function createTeamReadGuard(dataDir, spaceKey, refs, membershipVersion, options = {}) {
1551
+ if (!Number.isSafeInteger(membershipVersion) || membershipVersion <= 0) {
1552
+ fail("invalid_argument", "A Team read requires the exact membership version");
1553
+ }
1554
+ const origin = cloudUrl();
1555
+ const token = await cloudToken(dataDir, options.preferred, { signal: options.signal });
1556
+ const account = await cachedCloudTokenRead(dataDir, options.preferred);
1557
+ if (account.token !== token)
1558
+ fail("unauthorized", "The account changed before the Team read");
1559
+ const required = new Set(refs);
1560
+ const assertIdentity = async () => {
1561
+ options.signal?.throwIfAborted();
1562
+ const current = await cachedCloudTokenRead(dataDir, options.preferred);
1563
+ if (origin !== cloudUrl() || current.token !== token || current.generation !== account.generation
1564
+ || !sameCloudCredentialRefs(current.refs, account.refs)) {
1565
+ fail("unauthorized", "The account changed during the Team read");
1566
+ }
1567
+ };
1568
+ const guard = {
1569
+ async assertCurrent(additionalRefs = []) {
1570
+ for (const ref of additionalRefs)
1571
+ required.add(ref);
1572
+ await assertIdentity();
1573
+ const [roster, grants] = await Promise.all([
1574
+ getTeamRoster(dataDir, spaceKey, options.preferred, { signal: options.signal, renewToken: false }),
1575
+ listTeamArchives(dataDir, spaceKey, options.preferred, { signal: options.signal, renewToken: false }),
1576
+ ]);
1577
+ await assertIdentity();
1578
+ if (roster.membership_version !== membershipVersion) {
1579
+ fail("team_membership_revoked", "Team membership changed during the read");
1580
+ }
1581
+ const allowed = new Set(grants.filter((grant) => grant.membership_version === membershipVersion).map((grant) => grant.vault_ref));
1582
+ if ([...required].some((ref) => !allowed.has(ref))) {
1583
+ fail("forbidden", "The exact archive is no longer granted to this Team membership");
1584
+ }
1585
+ },
1586
+ };
1587
+ teamReadGuardBindings.set(guard, { dataDir: resolve(dataDir), spaceKey, membershipVersion, refs: required, assertIdentity });
1588
+ await guard.assertCurrent();
1589
+ return guard;
1590
+ }
1591
+ /** Strict framed-object range transport. Integrity of a slice additionally
1592
+ * requires the caller's authenticated envelope and per-chunk AEAD checks. */
1593
+ export async function pullTeamCiphertextRange(dataDir, spaceKey, vaultRef, storedOffset, storedLength, preferred, options = {}) {
1594
+ const maxBytes = options.maxBytes ?? 8 * 1024 * 1024;
1595
+ if (!Number.isSafeInteger(storedOffset) || storedOffset < 0
1596
+ || !Number.isSafeInteger(storedLength) || storedLength <= 0
1597
+ || !Number.isSafeInteger(storedOffset + storedLength)
1598
+ || !Number.isSafeInteger(maxBytes) || maxBytes <= 0 || storedLength > maxBytes) {
1599
+ fail("invalid_argument", "The Team range must fit its bounded byte limit");
1600
+ }
1601
+ const binding = options.readGuard ? teamReadGuardBindings.get(options.readGuard) : undefined;
1602
+ if (options.readGuard && (!binding || binding.dataDir !== resolve(dataDir) || binding.spaceKey !== spaceKey
1603
+ || (options.expectedMembershipVersion !== undefined && binding.membershipVersion !== options.expectedMembershipVersion))) {
1604
+ fail("invalid_argument", "The Team range guard belongs to a different read scope");
1605
+ }
1606
+ const epoch = binding?.membershipVersion ?? options.expectedMembershipVersion
1607
+ ?? (await getTeamRoster(dataDir, spaceKey, preferred, { signal: options.signal })).membership_version;
1608
+ const guard = options.readGuard ?? await createTeamReadGuard(dataDir, spaceKey, [vaultRef], epoch, { preferred, signal: options.signal });
1609
+ // The fresh exact-ref lease below still authorizes each network request.
1610
+ // A shared guard accumulates every dependency for its final live grant proof.
1611
+ binding?.refs.add(vaultRef);
1612
+ await binding?.assertIdentity();
1613
+ const lease = await teamArchiveDownload(dataDir, spaceKey, vaultRef, preferred, options);
1614
+ const total = Number(lease.bytes);
1615
+ if (lease.vault_ref !== vaultRef || !Number.isSafeInteger(total) || total <= 0 || storedOffset + storedLength > total
1616
+ || !/^[a-f0-9]{64}$/i.test(lease.ciphertext_sha256 ?? "")) {
1617
+ fail("ciphertext_integrity_failed", "The Team range has no valid object descriptor");
1618
+ }
1619
+ if (!lease.download?.url || (lease.download.method && lease.download.method.toUpperCase() !== "GET")) {
1620
+ fail("provider_unsupported", "This Team archive has no bounded range download transport");
1621
+ }
1622
+ const end = storedOffset + storedLength - 1;
1623
+ const headers = new Headers(lease.download.headers);
1624
+ headers.set("range", `bytes=${storedOffset}-${end}`);
1625
+ const response = await fetch(lease.download.url, { method: "GET", headers, signal: options.signal });
1626
+ const contentLength = response.headers.get("content-length");
1627
+ if (response.status !== 206 || response.headers.get("content-range") !== `bytes ${storedOffset}-${end}/${total}`
1628
+ || (contentLength !== null && Number(contentLength) !== storedLength)
1629
+ || (response.headers.get("content-encoding") && response.headers.get("content-encoding") !== "identity")) {
1630
+ await response.body?.cancel().catch(() => undefined);
1631
+ fail("ciphertext_integrity_failed", "Storage did not return the exact requested Team byte range");
1632
+ }
1633
+ const body = await collectChunks(responseBodyChunks(response), storedLength);
1634
+ if (body.length !== storedLength)
1635
+ fail("ciphertext_integrity_failed", "The Team byte range was truncated");
1636
+ if (binding)
1637
+ await binding.assertIdentity();
1638
+ else
1639
+ await guard.assertCurrent();
1640
+ return body;
1641
+ }
1642
+ /** Streaming, integrity-checked read through the current project membership gate.
1643
+ * Consumers must exhaust the stream before publishing any downloaded bytes. */
1644
+ export async function pullTeamCiphertextStream(dataDir, spaceKey, vaultRef, preferred, options = {}) {
1645
+ const lease = await teamArchiveDownload(dataDir, spaceKey, vaultRef, preferred, options);
1646
+ if (!/^[a-f0-9]{64}$/i.test(lease.ciphertext_sha256 ?? "")
1647
+ || !Number.isSafeInteger(Number(lease.bytes)) || Number(lease.bytes) < 0) {
1648
+ return fail("ciphertext_integrity_failed", "Shared archive download has no valid integrity proof");
1649
+ }
1650
+ if (options.maxBytes !== undefined
1651
+ && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)) {
1652
+ return fail("invalid_argument", "The shared archive read limit must be a non-negative safe integer");
1653
+ }
1654
+ if (options.maxBytes !== undefined && Number(lease.bytes) > options.maxBytes) {
1655
+ return fail("provider_unsupported", "The shared archive exceeds this bounded read's byte limit");
1656
+ }
1657
+ const source = await fetchLeasedObjectStream(lease, options);
1658
+ const checked = (async function* () {
1659
+ const hash = createHash("sha256");
1660
+ let bytes = 0;
1661
+ for await (const chunk of source) {
1662
+ options.signal?.throwIfAborted();
1663
+ bytes += chunk.length;
1664
+ if (bytes > Number(lease.bytes))
1665
+ fail("ciphertext_integrity_failed", "Shared archive exceeds its recorded size");
1666
+ hash.update(chunk);
1667
+ yield chunk;
1668
+ }
1669
+ if (bytes !== Number(lease.bytes) || hash.digest("hex") !== lease.ciphertext_sha256) {
1670
+ fail("ciphertext_integrity_failed", "Downloaded shared archive does not match its recorded integrity proof");
1671
+ }
1672
+ })();
1673
+ return options.unframe ? unframeChunks(checked, OBJECT_MAGIC) : checked;
1674
+ }
1675
+ /** Download and verify a shared archive through the current membership gate. */
1676
+ export async function pullTeamCiphertext(dataDir, spaceKey, vaultRef, preferred) {
1677
+ const lease = await teamArchiveDownload(dataDir, spaceKey, vaultRef, preferred);
1678
+ const ciphertext = await fetchLeasedObject(lease);
1679
+ const digest = createHash("sha256").update(ciphertext).digest("hex");
1680
+ if (lease.ciphertext_sha256 && digest !== lease.ciphertext_sha256) {
1681
+ fail("ciphertext_integrity_failed", "Downloaded team archive does not match the hash recorded at upload; refusing it");
1682
+ }
1683
+ return { ciphertext, bytes: ciphertext.length };
1684
+ }
1685
+ /**
1686
+ * One owner-authorized, compare-and-swap epoch transition. The server removes
1687
+ * the target, replaces every remaining wrap, advances the room capability and
1688
+ * appends the encrypted discovery marker as one transaction.
1689
+ */
1690
+ export async function removeTeamMemberAndRotate(dataDir, spaceKey, targetAccountId, input, preferred, options = {}) {
1691
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/members/${encodeURIComponent(String(targetAccountId))}/remove`, await cloudToken(dataDir, preferred, { signal: options.signal }), { method: "POST", body: JSON.stringify(input), signal: options.signal });
1692
+ const value = rowsOf(answer, ["rotation", "space", "item", "data"])[0];
1693
+ if (!value || !Number.isSafeInteger(Number(value.key_version)) || typeof value.realtime_key !== "string") {
1694
+ return fail("internal", "Sealkeep Cloud rotated the team space but returned no epoch receipt");
1695
+ }
1696
+ return value;
1697
+ }
1698
+ export async function publishTeamPresence(dataDir, spaceKey, input, preferred) {
1699
+ try {
1700
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/presence`, await cloudToken(dataDir, preferred), {
1701
+ method: "POST", body: JSON.stringify(input),
1702
+ });
1703
+ const row = rowsOf(answer, ["event", "presence", "item", "data"])[0];
1704
+ return row ?? { id: 0, ...input };
1705
+ }
1706
+ catch (error) {
1707
+ return rethrowTeamMembership(error);
1708
+ }
1709
+ }
1710
+ export async function pollTeamPresence(dataDir, spaceKey, after, preferred) {
1711
+ try {
1712
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/presence?after=${Math.max(0, Math.trunc(after))}`, await cloudToken(dataDir, preferred));
1713
+ return rowsOf(answer, ["events", "presence", "items", "data"]);
1714
+ }
1715
+ catch (error) {
1716
+ return rethrowTeamMembership(error);
1717
+ }
1718
+ }
1719
+ export async function recentTeamPresence(dataDir, spaceKey, preferred) {
1720
+ try {
1721
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/presence/recent`, await cloudToken(dataDir, preferred));
1722
+ return rowsOf(answer, ["events", "presence", "items", "data"]);
1723
+ }
1724
+ catch (error) {
1725
+ return rethrowTeamMembership(error);
1726
+ }
138
1727
  }
139
1728
  /**
140
1729
  * Refuses an escrow password that is also the account password.
@@ -181,7 +1770,7 @@ export function sealEscrow(secretMaterial, rawPhrase, also = []) {
181
1770
  recipients.push({ kind: "x25519", publicKey: x25519PublicKeyFromRaw(raw) });
182
1771
  }
183
1772
  }
184
- return sealEnvelope(Buffer.from(JSON.stringify(secretMaterial)), { recipients, adapter: { agent: "vaultline-escrow", version: "1" } });
1773
+ return sealEnvelope(Buffer.from(JSON.stringify(secretMaterial)), { recipients, adapter: { agent: "sealkeep-escrow", version: "1" } });
185
1774
  }
186
1775
  export function openEscrow(envelope, ciphertext, rawPhrase) {
187
1776
  const phrase = canonicalPhrase(rawPhrase);
@@ -224,6 +1813,15 @@ export function openEscrowWithKey(envelope, ciphertext, privateKeyRaw) {
224
1813
  return fail("recovery_phrase_mismatch", "That key does not open this escrow blob");
225
1814
  }
226
1815
  }
1816
+ /** Upload an already-sealed escrow envelope without ever reconstructing it server-side. */
1817
+ export async function pushEscrowBlob(dataDir, envelope, ciphertext, preferred) {
1818
+ await call("/v1/cloud/escrow", {
1819
+ method: "PUT",
1820
+ token: await cloudToken(dataDir, preferred),
1821
+ body: JSON.stringify({ envelope: JSON.stringify(envelope), sealed: ciphertext.toString("base64") })
1822
+ });
1823
+ return { stored: true };
1824
+ }
227
1825
  /**
228
1826
  * Hand the sealed blob to the control plane.
229
1827
  *
@@ -233,18 +1831,90 @@ export function openEscrowWithKey(envelope, ciphertext, privateKeyRaw) {
233
1831
  */
234
1832
  export async function pushEscrow(dataDir, secretMaterial, rawPhrase, preferred, also = []) {
235
1833
  const { envelope, ciphertext } = sealEscrow(secretMaterial, rawPhrase, also);
236
- await call("/v1/cloud/escrow", {
1834
+ return pushEscrowBlob(dataDir, envelope, ciphertext, preferred);
1835
+ }
1836
+ /** Fetch and open the escrow blob. Requires the phrase; nothing else will do. */
1837
+ /** Whether a sealed copy exists, without needing the phrase to find out. */
1838
+ export async function hasSealedCopy(dataDir, preferred) {
1839
+ const answer = await call("/v1/cloud/escrow", { token: await cloudToken(dataDir, preferred) });
1840
+ return (Array.isArray(answer) ? answer : answer ? [answer] : []).some((row) => Boolean(row?.sealed));
1841
+ }
1842
+ /** Latest append-only escrow revision; still ciphertext to every server involved. */
1843
+ export async function pullEscrowBlob(dataDir, preferred) {
1844
+ const answer = await call("/v1/cloud/escrow", { token: await cloudToken(dataDir, preferred) });
1845
+ const rows = (Array.isArray(answer) ? answer : answer ? [answer] : []).filter((row) => row?.sealed);
1846
+ return rows.sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")) || Number(b.id ?? 0) - Number(a.id ?? 0))[0] ?? null;
1847
+ }
1848
+ /**
1849
+ * Atomically replaces the phrase-only escrow copy and all one-time recovery
1850
+ * capsules. Each capsule contains one opaque key wrap; Cloud never receives a
1851
+ * code or phrase and normal escrow reads never expose a code-decryptable wrap.
1852
+ */
1853
+ export async function replaceCloudRecoveryKit(dataDir, master, capsules, preferred) {
1854
+ if (capsules.length !== 5)
1855
+ fail("invalid_argument", "A Recovery Kit needs exactly five encrypted capsules");
1856
+ return call("/v1/cloud/recovery-kit", {
237
1857
  method: "PUT",
238
1858
  token: await cloudToken(dataDir, preferred),
239
- body: JSON.stringify({ envelope: JSON.stringify(envelope), sealed: ciphertext.toString("base64") })
1859
+ body: JSON.stringify({
1860
+ envelope: JSON.stringify(master.envelope),
1861
+ sealed: master.ciphertext.toString("base64"),
1862
+ capsules: capsules.map((capsule) => ({
1863
+ recipient_id: capsule.recipient_id,
1864
+ envelope: typeof capsule.envelope === "string" ? capsule.envelope : JSON.stringify(capsule.envelope),
1865
+ sealed: capsule.sealed,
1866
+ })),
1867
+ }),
1868
+ });
1869
+ }
1870
+ export async function cloudRecoveryKitStatus(dataDir, preferred) {
1871
+ return call("/v1/cloud/recovery-kit/status", {
1872
+ token: await cloudToken(dataDir, preferred),
1873
+ });
1874
+ }
1875
+ /**
1876
+ * Atomically awards exactly one encrypted capsule to one random machine claim.
1877
+ * A retry with the same claim is idempotent; a different claim gets no capsule.
1878
+ */
1879
+ export async function redeemCloudRecoveryCode(dataDir, recipientId, claimId, preferred) {
1880
+ if (!/^[a-f0-9]{32}$/.test(recipientId))
1881
+ fail("invalid_argument", "That Recovery Kit recipient is malformed");
1882
+ if (!/^[a-f0-9]{32}$/.test(claimId))
1883
+ fail("invalid_argument", "That Recovery Kit claim is malformed");
1884
+ return call(`/v1/cloud/recovery-codes/${encodeURIComponent(recipientId)}/redeem`, {
1885
+ method: "POST",
1886
+ token: await cloudToken(dataDir, preferred),
1887
+ body: JSON.stringify({ claim_id: claimId }),
1888
+ });
1889
+ }
1890
+ /** Atomically spends one recovery recipient for this account. The code never crosses the wire. */
1891
+ export async function consumeCloudRecoveryCode(dataDir, recipientId, claimId, preferred) {
1892
+ if (!/^[a-f0-9]{32}$/.test(recipientId))
1893
+ fail("invalid_argument", "That Recovery Kit recipient is malformed");
1894
+ if (!/^[a-f0-9]{32}$/.test(claimId))
1895
+ fail("invalid_argument", "That Recovery Kit claim is malformed");
1896
+ return call(`/v1/cloud/recovery-codes/${encodeURIComponent(recipientId)}/consume`, {
1897
+ method: "POST",
1898
+ token: await cloudToken(dataDir, preferred),
1899
+ body: JSON.stringify({ claim_id: claimId }),
1900
+ });
1901
+ }
1902
+ /**
1903
+ * Resolves an ambiguous consume response without making a spent code reusable.
1904
+ * Only the same random claim made by this machine is acknowledged.
1905
+ */
1906
+ export async function cloudRecoveryCodeClaimStatus(dataDir, recipientId, claimId, preferred) {
1907
+ if (!/^[a-f0-9]{32}$/.test(recipientId))
1908
+ fail("invalid_argument", "That Recovery Kit recipient is malformed");
1909
+ if (!/^[a-f0-9]{32}$/.test(claimId))
1910
+ fail("invalid_argument", "That Recovery Kit claim is malformed");
1911
+ return call(`/v1/cloud/recovery-codes/${encodeURIComponent(recipientId)}/claims/${encodeURIComponent(claimId)}`, {
1912
+ token: await cloudToken(dataDir, preferred),
240
1913
  });
241
- return { stored: true };
242
1914
  }
243
- /** Fetch and open the escrow blob. Requires the phrase; nothing else will do. */
244
- /** Whether a sealed copy exists, without needing the phrase to find out. */
245
- export async function hasSealedCopy(dataDir, preferred) {
246
- const answer = await call("/v1/cloud/escrow", { token: await cloudToken(dataDir, preferred) });
247
- return (Array.isArray(answer) ? answer : answer ? [answer] : []).some((row) => Boolean(row?.sealed));
1915
+ /** Returns the awarded capsule only to the exact claim that spent the code. */
1916
+ export async function claimCloudRecoveryCodeCapsule(dataDir, recipientId, claimId, preferred) {
1917
+ return cloudRecoveryCodeClaimStatus(dataDir, recipientId, claimId, preferred);
248
1918
  }
249
1919
  export async function pullEscrow(dataDir, rawPhrase, preferred) {
250
1920
  // The control plane answers with a list, even though sealing replaces rather
@@ -252,22 +1922,121 @@ export async function pullEscrow(dataDir, rawPhrase, preferred) {
252
1922
  // list itself, which is always undefined, so the escrow was unreadable no
253
1923
  // matter what had been stored — the paid tier's whole safety net.
254
1924
  // Newest-first is belt and braces in case replacement ever becomes an append.
255
- const answer = await call("/v1/cloud/escrow", { token: await cloudToken(dataDir, preferred) });
256
- const rows = (Array.isArray(answer) ? answer : answer ? [answer] : []).filter((row) => row?.sealed);
257
- const blob = rows.sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")) || Number(b.id ?? 0) - Number(a.id ?? 0))[0];
1925
+ const blob = await pullEscrowBlob(dataDir, preferred);
258
1926
  if (!blob?.sealed)
259
1927
  fail("archive_not_found", "No sealed copy is stored for this account. Store one with `sealkeep recovery seal`.");
260
1928
  const envelope = typeof blob.envelope === "string" ? JSON.parse(blob.envelope) : blob.envelope;
261
1929
  return openEscrow(envelope, Buffer.from(blob.sealed, "base64"), rawPhrase);
262
1930
  }
263
- /* ------------------------------------------------------------------ uploads */
1931
+ const incompleteUploads = new WeakMap();
1932
+ function incompleteUploadBinding(receipt, dataDir, vaultRef) {
1933
+ const binding = incompleteUploads.get(receipt);
1934
+ if (!binding || binding.dataDir !== resolve(dataDir) || binding.vaultRef !== vaultRef) {
1935
+ fail("invalid_argument", "An incomplete upload retry requires its original account-scoped native receipt");
1936
+ }
1937
+ return binding;
1938
+ }
1939
+ async function pendingUploadDescriptor(binding, input, signal) {
1940
+ await binding.assertCurrent();
1941
+ // Do not use the listing's legacy fallback: an auth/outage failure is not
1942
+ // permission to write. Walk bounded, monotonic pages and stop at the exact
1943
+ // row. The server scopes every page to the same pinned account token.
1944
+ let before;
1945
+ for (let page = 0; page < 500; page++) {
1946
+ signal?.throwIfAborted();
1947
+ const rows = await call(`/v1/cloud/archives/page${before === undefined ? "" : `?before=${before}`}`, {
1948
+ token: binding.token, signal, maxResponseBytes: 1024 * 1024,
1949
+ });
1950
+ await binding.assertCurrent();
1951
+ if (!Array.isArray(rows) || rows.length > 200)
1952
+ fail("ciphertext_integrity_failed", "The pending archive inventory is malformed");
1953
+ let cursor = before ?? Number.MAX_SAFE_INTEGER;
1954
+ for (const row of rows) {
1955
+ if (!row || !Number.isSafeInteger(row.id) || row.id <= 0 || row.id >= cursor) {
1956
+ fail("ciphertext_integrity_failed", "The pending archive inventory has an invalid cursor");
1957
+ }
1958
+ cursor = row.id;
1959
+ }
1960
+ const matches = rows.filter((row) => row.vault_ref === input.vaultRef);
1961
+ if (matches.length > 1)
1962
+ fail("ciphertext_integrity_failed", "The pending archive ref is ambiguous");
1963
+ if (matches.length === 1) {
1964
+ const row = matches[0];
1965
+ if (row.state !== "pending")
1966
+ fail("lease_expired", "The incomplete archive changed state; retry its current storage proof before uploading");
1967
+ if (!Number.isSafeInteger(input.bytes) || input.bytes <= 0 || row.bytes !== input.bytes
1968
+ || typeof row.ciphertext_sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(row.ciphertext_sha256)
1969
+ || row.ciphertext_sha256.toLowerCase() !== input.ciphertextSha256.toLowerCase()
1970
+ || typeof row.object_key !== "string" || !row.object_key || row.object_key.length > 4096) {
1971
+ fail("ciphertext_integrity_failed", "The incomplete archive does not describe this exact immutable frame");
1972
+ }
1973
+ return { objectKey: row.object_key };
1974
+ }
1975
+ if (rows.length < 200)
1976
+ break;
1977
+ before = cursor;
1978
+ }
1979
+ fail("archive_not_found", "The incomplete archive's exact pending descriptor could not be verified; no upload was attempted");
1980
+ }
264
1981
  /** Asks for permission to write exactly one object. The key is chosen server-side. */
265
- export async function requestUpload(dataDir, input, preferred) {
266
- return call("/v1/cloud/uploads", {
1982
+ export async function requestUpload(dataDir, input, preferred, options = {}) {
1983
+ options.signal?.throwIfAborted();
1984
+ const resumed = options.incompleteUpload && incompleteUploadBinding(options.incompleteUpload, dataDir, input.vaultRef);
1985
+ if (resumed && (typeof input.ciphertextSha256 !== "string" || !/^[a-f0-9]{64}$/i.test(input.ciphertextSha256)
1986
+ || !Number.isSafeInteger(input.bytes) || input.bytes <= 0)) {
1987
+ fail("invalid_argument", "An incomplete archive retry requires an exact positive size and SHA-256 digest");
1988
+ }
1989
+ if (resumed)
1990
+ await resumed.assertCurrent();
1991
+ // Deliberately `machineCredential` and NOT `machineIdentity`: this must not
1992
+ // claim a fresh identity. A machine the account holder revoked would
1993
+ // otherwise answer its own refusal by enrolling again under a new id, and
1994
+ // Revoke would mean nothing. A machine that has never had a key sends none
1995
+ // and is served as before.
1996
+ const machine = await machineCredential(dataDir, preferred);
1997
+ const pending = resumed && await pendingUploadDescriptor(resumed, input, options.signal);
1998
+ const token = resumed?.token ?? await cloudToken(dataDir, preferred, { signal: options.signal });
1999
+ if (resumed)
2000
+ await resumed.assertCurrent();
2001
+ const lease = await call("/v1/cloud/uploads", {
267
2002
  method: "POST",
268
- token: await cloudToken(dataDir, preferred),
269
- body: JSON.stringify({ vault_ref: input.vaultRef, ciphertext_sha256: input.ciphertextSha256, bytes: input.bytes })
2003
+ token,
2004
+ body: JSON.stringify({ vault_ref: input.vaultRef, ciphertext_sha256: input.ciphertextSha256, bytes: input.bytes, ...(machine ?? {}) }),
2005
+ signal: options.signal,
270
2006
  });
2007
+ if (resumed && pending) {
2008
+ await resumed.assertCurrent();
2009
+ if (lease.vault_ref !== input.vaultRef || lease.object_key !== pending.objectKey
2010
+ || !Number.isSafeInteger(lease.lease_id) || lease.lease_id <= 0
2011
+ || typeof lease.expires_at !== "string" || !Number.isFinite(Date.parse(lease.expires_at)) || Date.parse(lease.expires_at) <= Date.now()) {
2012
+ fail("ciphertext_integrity_failed", "The retry lease does not bind the original pending archive");
2013
+ }
2014
+ // A concurrently completed object must not be overwritten, even with the
2015
+ // same bytes. Unsupported delegations fail closed on this resume path.
2016
+ const upload = lease.upload_url;
2017
+ const method = upload?.method === undefined ? "PUT" : upload.method;
2018
+ let validUrl = false;
2019
+ if (upload && typeof upload === "object" && typeof upload.url === "string") {
2020
+ try {
2021
+ const parsed = new URL(upload.url);
2022
+ validUrl = ["http:", "https:"].includes(parsed.protocol) && !parsed.username && !parsed.password && !parsed.hash;
2023
+ }
2024
+ catch { /* A malformed URL is not a supported native delegation. */ }
2025
+ }
2026
+ // The native signed-upload action returns {url} (no method or headers).
2027
+ // Only omission gets the existing PUT default; explicit unknown values
2028
+ // are not silently reinterpreted as an immutable write delegation.
2029
+ if (lease.vended || !upload || !validUrl || method !== "PUT") {
2030
+ fail("provider_unsupported", "An incomplete archive retry requires a create-only signed PUT");
2031
+ }
2032
+ const headers = new Headers(upload.headers);
2033
+ if (headers.has("if-none-match") && headers.get("if-none-match") !== "*") {
2034
+ fail("provider_unsupported", "The retry lease conflicts with create-only storage");
2035
+ }
2036
+ headers.set("if-none-match", "*");
2037
+ lease.upload_url = { ...upload, method: "PUT", headers: Object.fromEntries(headers) };
2038
+ }
2039
+ return lease;
271
2040
  }
272
2041
  /**
273
2042
  * What this build can actually honor, checked at run time because a lease
@@ -314,18 +2083,15 @@ function assertVendedSignable(v) {
314
2083
  * rather than two. A lease naming a delegation this build does not know is
315
2084
  * refused before a single byte or signature leaves the machine.
316
2085
  */
317
- export async function putCiphertext(lease, ciphertext, signal) {
2086
+ export async function putCiphertextStream(lease, chunks, bytes, signal) {
318
2087
  if (lease.vended) {
319
2088
  assertVendedSignable(lease.vended);
320
2089
  assertVendedFresh(lease.vended, lease);
321
2090
  const { S3UploadClient } = await import("./providers/s3.js");
322
2091
  const v = lease.vended;
323
2092
  const client = new S3UploadClient(v.provider, { provider: v.provider, region: v.region, bucket: v.bucket, host: v.host, port: v.port, protocol: v.protocol }, { accessKeyId: v.akid, secretAccessKey: v.secret_access_key, sessionToken: v.session_token });
324
- await client.upload({
325
- archiveId: lease.vault_ref, provider: v.provider, objectKey: lease.object_key, expiresAt: lease.expires_at,
326
- method: "MULTIPART", uploadUrl: "", requiredHeaders: {}, status: "active", uploadable: true
327
- }, ciphertext);
328
- return;
2093
+ const uploaded = await client.uploadStream(lease.object_key, chunks, { ...(signal ? { signal } : {}) });
2094
+ return { bytes: uploaded.bytes, remoteChecksum: uploaded.remoteChecksum };
329
2095
  }
330
2096
  if (!lease.upload_url?.url)
331
2097
  fail("internal", "Sealkeep Cloud returned a lease with nowhere to upload to");
@@ -336,9 +2102,53 @@ export async function putCiphertext(lease, ciphertext, signal) {
336
2102
  if (!/^https?:\/\//i.test(url)) {
337
2103
  fail("internal", "Sealkeep Cloud returned an upload address that is not an http(s) URL; refusing to send the archive anywhere else.");
338
2104
  }
339
- const response = await fetch(url, { method: method || "PUT", headers, body: new Uint8Array(ciphertext), ...(signal ? { signal } : {}) });
2105
+ const streamedHeaders = { ...headers };
2106
+ if (!Object.keys(streamedHeaders).some((name) => name.toLowerCase() === "content-length")) {
2107
+ streamedHeaders["content-length"] = String(bytes);
2108
+ }
2109
+ const body = requestBody(chunks);
2110
+ let response;
2111
+ try {
2112
+ response = await fetch(url, {
2113
+ method: method || "PUT",
2114
+ headers: streamedHeaders,
2115
+ body: body,
2116
+ duplex: "half",
2117
+ ...(signal ? { signal } : {}),
2118
+ });
2119
+ }
2120
+ finally {
2121
+ body.destroy();
2122
+ }
340
2123
  if (!response.ok)
341
2124
  fail("internal", `Storage rejected the upload: ${response.status} ${(await response.text()).slice(0, 200)}`);
2125
+ return { bytes, remoteChecksum: null };
2126
+ }
2127
+ /** Small-object compatibility (managed chunks/index); archives use the stream door. */
2128
+ export async function putCiphertext(lease, ciphertext, signal) {
2129
+ await putCiphertextStream(lease, oneChunk(ciphertext), ciphertext.length, signal);
2130
+ }
2131
+ /** Background-only opt-in; foreground reads retain their existing behavior.
2132
+ * Pace before consumption and checkpoint after the consumer hashes/stages the
2133
+ * chunk, so large framed metadata and payloads share the same wire budget. */
2134
+ function backgroundObjectReadStream(dataDir, source, options) {
2135
+ if (!options.maxBytesPerSecond && !options.onProgress && !options.onYield)
2136
+ return source;
2137
+ const paced = options.maxBytesPerSecond
2138
+ ? paceBackgroundByteStream(dataDir, source, options.maxBytesPerSecond, {
2139
+ signal: options.signal, now: options.paceNow, sleep: options.paceSleep,
2140
+ }) : source;
2141
+ return (async function* () {
2142
+ let bytes = 0;
2143
+ for await (const chunk of paced) {
2144
+ options.signal?.throwIfAborted();
2145
+ yield chunk;
2146
+ bytes += chunk.length;
2147
+ options.onProgress?.(bytes);
2148
+ await options.onYield?.();
2149
+ options.signal?.throwIfAborted();
2150
+ }
2151
+ })();
342
2152
  }
343
2153
  /**
344
2154
  * Proves managed storage is holding the bytes we sent, before we tell retention
@@ -353,7 +2163,35 @@ export async function putCiphertext(lease, ciphertext, signal) {
353
2163
  * customer could push to storage that kept nothing and then have retention
354
2164
  * trash the only copy.
355
2165
  */
356
- async function verifyStored(dataDir, lease, object, digest, preferred) {
2166
+ async function verifyStored(dataDir, lease, object, preferred, options = {}) {
2167
+ // Read-back is part of the same background transfer, including retry
2168
+ // adoption. A paced PUT must not be followed by an unpaced full GET.
2169
+ const readback = (chunks) => backgroundObjectReadStream(dataDir, chunks, options);
2170
+ if (options.incompleteUpload) {
2171
+ const binding = incompleteUploadBinding(options.incompleteUpload, dataDir, lease.vault_ref);
2172
+ await binding.assertCurrent();
2173
+ const descriptor = await call(`/v1/cloud/archives/${encodeURIComponent(lease.vault_ref)}/download`, {
2174
+ token: binding.token, signal: options.signal, maxResponseBytes: 64 * 1024,
2175
+ });
2176
+ await binding.assertCurrent();
2177
+ if (descriptor.vault_ref !== lease.vault_ref || descriptor.object_key !== lease.object_key
2178
+ || descriptor.bytes !== object.bytes || descriptor.ciphertext_sha256 !== object.digest) {
2179
+ fail("ciphertext_integrity_failed", "The completed retry does not describe the original immutable frame");
2180
+ }
2181
+ const hash = createHash("sha256");
2182
+ let bytes = 0;
2183
+ for await (const chunk of readback(await fetchLeasedObjectStream(descriptor, { signal: options.signal }))) {
2184
+ bytes += chunk.length;
2185
+ if (bytes > object.bytes)
2186
+ fail("ciphertext_integrity_failed", "The resumed archive read-back exceeded its immutable size");
2187
+ hash.update(chunk);
2188
+ }
2189
+ await binding.assertCurrent();
2190
+ if (bytes !== object.bytes || hash.digest("hex") !== object.digest) {
2191
+ fail("ciphertext_integrity_failed", "The resumed archive read-back does not match its immutable size and digest");
2192
+ }
2193
+ return null;
2194
+ }
357
2195
  if (lease.vended) {
358
2196
  assertVendedSignable(lease.vended);
359
2197
  assertVendedFresh(lease.vended, lease);
@@ -368,26 +2206,35 @@ async function verifyStored(dataDir, lease, object, digest, preferred) {
368
2206
  });
369
2207
  if (!head.exists)
370
2208
  return "the provider does not report the object as present";
371
- if (head.bytes !== object.length)
372
- return `the provider stored ${head.bytes} bytes; ${object.length} were sent`;
2209
+ if (head.bytes !== object.bytes)
2210
+ return `the provider stored ${head.bytes} bytes; ${object.bytes} were sent`;
2211
+ if (object.remoteChecksum && head.checksum !== object.remoteChecksum) {
2212
+ return `the provider reported checksum ${head.checksum ?? "none"}; the upload reported ${object.remoteChecksum}`;
2213
+ }
373
2214
  return null;
374
2215
  }
375
2216
  // A presigned PUT URL cannot be reused for a HEAD, so the only proof
376
2217
  // available on this path is reading the object back and hashing it. That
377
2218
  // costs egress on every push, which is a real reason to finish moving
378
2219
  // managed storage onto a provider that vends credentials instead.
379
- const roundTrip = await pullCiphertext(dataDir, lease.vault_ref, preferred);
380
- if (roundTrip.bytes !== object.length)
381
- return `storage returned ${roundTrip.bytes} bytes; ${object.length} were sent`;
382
- if (sha256(roundTrip.ciphertext) !== digest)
2220
+ const roundTrip = await digestChunks(readback(await pullCiphertextStream(dataDir, lease.vault_ref, preferred, { signal: options.signal })));
2221
+ if (roundTrip.bytes !== object.bytes)
2222
+ return `storage returned ${roundTrip.bytes} bytes; ${object.bytes} were sent`;
2223
+ if (roundTrip.sha256Hex !== object.digest)
383
2224
  return "the bytes read back do not match what was uploaded";
384
2225
  return null;
385
2226
  }
386
- export async function completeUpload(dataDir, leaseId, bytes, preferred) {
2227
+ export async function completeUpload(dataDir, leaseId, bytes, preferred, options = {}) {
2228
+ // The machine key is what stamps this archive as ours. Sending it here is the
2229
+ // whole reason a later delete can be refused to anybody else: attribution has
2230
+ // to happen at the moment of writing, because afterwards nothing in the row
2231
+ // remembers who put it there.
2232
+ const machine = await machineIdentity(dataDir, preferred, options);
387
2233
  return call(`/v1/cloud/uploads/${leaseId}/complete`, {
388
2234
  method: "POST",
389
- token: await cloudToken(dataDir, preferred),
390
- body: JSON.stringify({ bytes })
2235
+ token: await cloudToken(dataDir, preferred, { signal: options.signal }),
2236
+ body: JSON.stringify({ bytes, ...(machine ?? {}) }),
2237
+ signal: options.signal,
391
2238
  });
392
2239
  }
393
2240
  /** What managed storage is holding for this account. The ref is what `sealkeep open` takes. */
@@ -420,147 +2267,815 @@ export async function gdriveRefresh(dataDir, refreshToken, preferred) {
420
2267
  method: "POST", token: await cloudToken(dataDir, preferred),
421
2268
  body: JSON.stringify({ refresh_token: refreshToken })
422
2269
  });
423
- return { accessToken: fresh.access_token, expiresIn: fresh.expires_in ?? 3600 };
2270
+ return { accessToken: fresh.access_token, expiresIn: fresh.expires_in ?? 3600 };
2271
+ }
2272
+ export async function gdriveClaim(dataDir, state, preferred) {
2273
+ try {
2274
+ return await call(`/v1/cloud/gdrive/claim`, {
2275
+ method: "POST", token: await cloudToken(dataDir, preferred),
2276
+ body: JSON.stringify({ state })
2277
+ });
2278
+ }
2279
+ catch (error) {
2280
+ if (isSealkeepError(error) && /returned 404|No pending/i.test(error.message))
2281
+ return null;
2282
+ throw error;
2283
+ }
2284
+ }
2285
+ /**
2286
+ * The consent flow for the shared client. Two shapes, decided by the server's
2287
+ * registered redirect: a REMOTE https callback (the plane exchanges and parks
2288
+ * the tokens; this machine polls its one-time state — works even when the
2289
+ * browser lives on another device), or a loopback listener that catches the
2290
+ * code here and exchanges through the authed endpoint.
2291
+ */
2292
+ export async function connectGdriveViaCloud(dataDir, options = {}) {
2293
+ const { createServer } = await import("node:http");
2294
+ const { randomBytes } = await import("node:crypto");
2295
+ const config = await gdriveOauthConfig(dataDir, options.preferred);
2296
+ const redirect = new URL(config.redirect_uri);
2297
+ const state = randomBytes(16).toString("base64url");
2298
+ const resolveAccountId = options.accountIdResolver
2299
+ ?? (async (accessToken) => (await import("./providers/gdrive.js")).gdriveAccountId(accessToken));
2300
+ if (redirect.protocol === "https:") {
2301
+ const consent = new URL("https://accounts.google.com/o/oauth2/v2/auth");
2302
+ consent.searchParams.set("client_id", config.client_id);
2303
+ consent.searchParams.set("redirect_uri", config.redirect_uri);
2304
+ consent.searchParams.set("response_type", "code");
2305
+ consent.searchParams.set("scope", config.scope);
2306
+ consent.searchParams.set("access_type", "offline");
2307
+ consent.searchParams.set("prompt", "consent");
2308
+ consent.searchParams.set("state", state);
2309
+ options.onConsentUrl?.(consent.toString());
2310
+ const { launchBrowser } = await import("./providers/gdrive.js");
2311
+ await Promise.resolve((options.opener ?? launchBrowser)(consent.toString())).catch(() => undefined);
2312
+ const deadline = Date.now() + (options.timeoutMs ?? 300_000);
2313
+ while (Date.now() < deadline) {
2314
+ const parcel = await gdriveClaim(dataDir, state, options.preferred);
2315
+ if (parcel) {
2316
+ const accountId = await resolveAccountId(parcel.access_token);
2317
+ return { kind: "gdrive", refreshToken: parcel.refresh_token, clientId: config.client_id, exchange: "cloud", accountId };
2318
+ }
2319
+ await new Promise((rest) => setTimeout(rest, options.pollMs ?? 2000));
2320
+ }
2321
+ throw new SealkeepError("unauthorized", "No consent arrived in time. Run the connect again.");
2322
+ }
2323
+ const server = createServer();
2324
+ await new Promise((ready, refuse) => { server.once("error", refuse); server.listen(Number(redirect.port), "127.0.0.1", ready); });
2325
+ let timer;
2326
+ const code = new Promise((resolve, reject) => {
2327
+ timer = setTimeout(() => reject(new SealkeepError("unauthorized", "No response from the browser in time. Run the connect again.")), options.timeoutMs ?? 300_000);
2328
+ server.on("request", (request, response) => {
2329
+ const url = new URL(request.url ?? "/", config.redirect_uri);
2330
+ if (url.pathname !== redirect.pathname) {
2331
+ response.writeHead(404);
2332
+ response.end();
2333
+ return;
2334
+ }
2335
+ const deny = (message) => {
2336
+ response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
2337
+ response.end("<p>Sealkeep could not finish connecting. Return to the terminal.</p>");
2338
+ reject(new SealkeepError("unauthorized", message));
2339
+ };
2340
+ if (url.searchParams.get("error"))
2341
+ return deny(`Google reported: ${url.searchParams.get("error")}`);
2342
+ if (url.searchParams.get("state") !== state)
2343
+ return deny("The browser reply did not match this connection attempt (state mismatch)");
2344
+ const received = url.searchParams.get("code");
2345
+ if (!received)
2346
+ return deny("The browser reply carried no authorization code");
2347
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
2348
+ response.end("<p>Sealkeep is connected to Google Drive. You can close this tab.</p>");
2349
+ resolve(received);
2350
+ });
2351
+ });
2352
+ code.catch(() => { });
2353
+ const consent = new URL("https://accounts.google.com/o/oauth2/v2/auth");
2354
+ consent.searchParams.set("client_id", config.client_id);
2355
+ consent.searchParams.set("redirect_uri", config.redirect_uri);
2356
+ consent.searchParams.set("response_type", "code");
2357
+ consent.searchParams.set("scope", config.scope);
2358
+ consent.searchParams.set("access_type", "offline");
2359
+ consent.searchParams.set("prompt", "consent");
2360
+ consent.searchParams.set("state", state);
2361
+ try {
2362
+ options.onConsentUrl?.(consent.toString());
2363
+ const { launchBrowser } = await import("./providers/gdrive.js");
2364
+ await Promise.resolve((options.opener ?? launchBrowser)(consent.toString())).catch(() => undefined);
2365
+ const authorizationCode = await code;
2366
+ const tokens = await gdriveExchange(dataDir, { code: authorizationCode, redirectUri: config.redirect_uri }, options.preferred);
2367
+ const accountId = await resolveAccountId(tokens.access_token);
2368
+ return { kind: "gdrive", refreshToken: tokens.refresh_token, clientId: config.client_id, exchange: "cloud", accountId };
2369
+ }
2370
+ finally {
2371
+ if (timer)
2372
+ clearTimeout(timer);
2373
+ server.close();
2374
+ }
2375
+ }
2376
+ export async function pushIndexObject(dataDir, kind, body, preferred, options = {}) {
2377
+ const token = await cloudToken(dataDir, preferred, { signal: options.signal });
2378
+ const lease = await call(`/v1/cloud/index/upload`, { method: "POST", body: JSON.stringify({ kind, bytes: body.length }), token, signal: options.signal });
2379
+ const target = lease.upload_url ?? fail("internal", "The control plane vended no upload URL for the index slot");
2380
+ const stream = requestBody(options.maxBytesPerSecond
2381
+ ? paceBackgroundByteStream(dataDir, bufferChunks(body), options.maxBytesPerSecond, { signal: options.signal, onProgress: options.onProgress })
2382
+ : bufferChunks(body));
2383
+ const headers = { ...(target.headers ?? {}) };
2384
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-length"))
2385
+ headers["content-length"] = String(body.length);
2386
+ let response;
2387
+ try {
2388
+ response = await fetch(target.url, { method: target.method || "PUT", headers, body: stream, duplex: "half", signal: options.signal });
2389
+ }
2390
+ finally {
2391
+ stream.destroy();
2392
+ }
2393
+ if (!response.ok)
2394
+ fail("internal", `The index ${kind} upload was refused: ${response.status} ${response.statusText}`);
2395
+ }
2396
+ /** Publishes a large sealed index without reading the whole ciphertext back
2397
+ * into the JS heap. The caller has already atomically committed this file. */
2398
+ export async function pushIndexFile(dataDir, kind, path, preferred, options = {}) {
2399
+ const bytes = (await stat(path)).size;
2400
+ const token = await cloudToken(dataDir, preferred, { signal: options.signal });
2401
+ const lease = await call(`/v1/cloud/index/upload`, { method: "POST", body: JSON.stringify({ kind, bytes }), token, signal: options.signal });
2402
+ const target = lease.upload_url ?? fail("internal", "The control plane vended no upload URL for the index slot");
2403
+ const chunks = fileChunks(path, { signal: options.signal });
2404
+ const stream = requestBody(options.maxBytesPerSecond
2405
+ ? paceBackgroundByteStream(dataDir, chunks, options.maxBytesPerSecond, { signal: options.signal, onProgress: options.onProgress })
2406
+ : chunks);
2407
+ const headers = { ...(target.headers ?? {}) };
2408
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-length"))
2409
+ headers["content-length"] = String(bytes);
2410
+ let response;
2411
+ try {
2412
+ response = await fetch(target.url, { method: target.method || "PUT", headers, body: stream, duplex: "half", signal: options.signal });
2413
+ }
2414
+ finally {
2415
+ stream.destroy();
2416
+ }
2417
+ if (!response.ok)
2418
+ fail("internal", `The index ${kind} upload was refused: ${response.status} ${response.statusText}`);
2419
+ }
2420
+ export async function pullIndexObject(dataDir, kind, preferred, options = {}) {
2421
+ const token = await cloudToken(dataDir, preferred, { renew: options.renewToken !== false, signal: options.signal });
2422
+ const lease = await call(`/v1/cloud/index/download?kind=${kind}`, { token, signal: options.signal }).catch((error) => {
2423
+ if (isSealkeepError(error) && /not.*found|no index/i.test(error.message))
2424
+ return null;
2425
+ throw error;
2426
+ });
2427
+ if (!lease?.download?.url)
2428
+ return null;
2429
+ const response = await fetch(lease.download.url, { signal: options.signal });
2430
+ if (response.status === 404)
2431
+ return null;
2432
+ if (!response.ok)
2433
+ fail("internal", `The index ${kind} download was refused: ${response.status} ${response.statusText}`);
2434
+ const body = responseBodyChunks(response);
2435
+ return collectChunks(options.maxBytesPerSecond ? paceBackgroundByteStream(dataDir, body, options.maxBytesPerSecond, { signal: options.signal, onProgress: options.onProgress }) : body, Number.MAX_SAFE_INTEGER);
2436
+ }
2437
+ export async function pushTeamIndexObject(dataDir, spaceKey, kind, body, preferred, options = {}) {
2438
+ const lease = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/index/upload`, await cloudToken(dataDir, preferred, { signal: options.signal }), { method: "POST", body: JSON.stringify({ kind, bytes: body.length }), signal: options.signal });
2439
+ const target = lease.upload_url ?? fail("internal", "The control plane vended no upload URL for the team index slot");
2440
+ const stream = requestBody(options.maxBytesPerSecond
2441
+ ? paceBackgroundByteStream(dataDir, bufferChunks(body), options.maxBytesPerSecond, { signal: options.signal, onProgress: options.onProgress })
2442
+ : bufferChunks(body));
2443
+ const headers = { ...(target.headers ?? {}) };
2444
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-length"))
2445
+ headers["content-length"] = String(body.length);
2446
+ let response;
2447
+ try {
2448
+ response = await fetch(target.url, { method: target.method ?? "PUT", headers, body: stream, duplex: "half", signal: options.signal });
2449
+ }
2450
+ finally {
2451
+ stream.destroy();
2452
+ }
2453
+ if (!response.ok)
2454
+ fail("internal", `The team index ${kind} upload was refused: ${response.status} ${response.statusText}`);
2455
+ }
2456
+ export async function pullTeamIndexObject(dataDir, spaceKey, kind, preferred, options = {}) {
2457
+ const lease = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/index/download?kind=${kind}`, await cloudToken(dataDir, preferred, { signal: options.signal }), { signal: options.signal }).catch((error) => {
2458
+ if (isSealkeepError(error) && /not.*found|no index/i.test(error.message))
2459
+ return null;
2460
+ throw error;
2461
+ });
2462
+ if (!lease?.download?.url)
2463
+ return null;
2464
+ const response = await fetch(lease.download.url, { method: lease.download.method ?? "GET", headers: lease.download.headers, signal: options.signal });
2465
+ if (response.status === 404)
2466
+ return null;
2467
+ if (!response.ok)
2468
+ fail("internal", `The team index ${kind} download was refused: ${response.status} ${response.statusText}`);
2469
+ const body = responseBodyChunks(response);
2470
+ return collectChunks(options.maxBytesPerSecond ? paceBackgroundByteStream(dataDir, body, options.maxBytesPerSecond, { signal: options.signal, onProgress: options.onProgress }) : body, Number.MAX_SAFE_INTEGER);
2471
+ }
2472
+ const teamSnapshotCacheIdentities = new WeakMap();
2473
+ /** Only native current-head snapshots can identify a derived source-facts cache.
2474
+ * The identity contains no lease or permission and is independently copied. */
2475
+ export function teamSnapshotCacheIdentity(snapshot) {
2476
+ const identity = teamSnapshotCacheIdentities.get(snapshot);
2477
+ return identity ? structuredClone(identity) : undefined;
2478
+ }
2479
+ const V2_UNSUPPORTED = {
2480
+ status: "unsupported",
2481
+ reason: "endpoint_unsupported",
2482
+ };
2483
+ export const INDEX_REVISION_CONFLICT_REMOTE_CODE = "INDEX_REVISION_CONFLICT";
2484
+ /** The hosted v2 finalizer currently uses one S3 CopyObject (<5 GiB). */
2485
+ export const MAX_MANAGED_INDEX_V2_BLOB_BYTES = 4 * 1024 * 1024 * 1024;
2486
+ function isRecord(value) {
2487
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2488
+ }
2489
+ function v2EndpointUnsupported(error) {
2490
+ if (!isSealkeepError(error))
2491
+ return false;
2492
+ const status = Number(error.details.httpStatus);
2493
+ const remoteCode = String(error.details.remoteCode ?? "").toUpperCase();
2494
+ return status === 404 || status === 405 || status === 501
2495
+ || remoteCode === "ENDPOINT_UNSUPPORTED" || remoteCode === "NOT_IMPLEMENTED";
2496
+ }
2497
+ function v2HeadAbsent(error) {
2498
+ return isSealkeepError(error)
2499
+ && Number(error.details.httpStatus) === 404
2500
+ && /no (?:v2 )?(?:team )?(?:search )?index (?:has been )?published/i.test(error.message);
2501
+ }
2502
+ function indexV2Base(scope) {
2503
+ return scope.kind === "account"
2504
+ ? "/v1/cloud/index/v2"
2505
+ : `/v1/cloud/team/spaces/${encodeURIComponent(scope.spaceKey)}/index/v2`;
2506
+ }
2507
+ function canonicalCoverage(input) {
2508
+ if (!isRecord(input))
2509
+ fail("invalid_argument", "Index coverage must be an archive-to-entry-digest map");
2510
+ const result = {};
2511
+ for (const archiveRef of Object.keys(input).sort()) {
2512
+ const digest = input[archiveRef];
2513
+ if (!archiveRef || typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest)) {
2514
+ fail("invalid_argument", "Index coverage contains an invalid archive reference or entry digest");
2515
+ }
2516
+ result[archiveRef] = digest;
2517
+ }
2518
+ return result;
2519
+ }
2520
+ function wireCoverage(value) {
2521
+ let parsed = value;
2522
+ if (typeof parsed === "string") {
2523
+ try {
2524
+ parsed = JSON.parse(parsed);
2525
+ }
2526
+ catch {
2527
+ fail("internal", "Sealkeep Cloud returned malformed index coverage");
2528
+ }
2529
+ }
2530
+ if (!isRecord(parsed))
2531
+ fail("internal", "Sealkeep Cloud returned no committed index coverage");
2532
+ const result = {};
2533
+ for (const archiveRef of Object.keys(parsed).sort()) {
2534
+ const digest = parsed[archiveRef];
2535
+ if (!archiveRef || typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest)) {
2536
+ fail("internal", "Sealkeep Cloud returned invalid committed index coverage");
2537
+ }
2538
+ result[archiveRef] = digest;
2539
+ }
2540
+ return result;
2541
+ }
2542
+ function wireNumber(value, label, minimum = 0) {
2543
+ const number = Number(value);
2544
+ if (!Number.isSafeInteger(number) || number < minimum)
2545
+ fail("internal", `Sealkeep Cloud returned an invalid ${label}`);
2546
+ return number;
2547
+ }
2548
+ function wireText(value, label) {
2549
+ if (typeof value !== "string" || value.length === 0)
2550
+ fail("internal", `Sealkeep Cloud returned no ${label}`);
2551
+ return value;
2552
+ }
2553
+ function wireDownload(value, label) {
2554
+ if (!isRecord(value) || typeof value.url !== "string" || !value.url) {
2555
+ fail("internal", `Sealkeep Cloud returned no ${label} download URL for the committed index generation`);
2556
+ }
2557
+ const headers = isRecord(value.headers)
2558
+ ? Object.fromEntries(Object.entries(value.headers).map(([name, header]) => [name, String(header)]))
2559
+ : undefined;
2560
+ return {
2561
+ url: value.url,
2562
+ ...(typeof value.method === "string" && value.method ? { method: value.method } : {}),
2563
+ ...(headers ? { headers } : {}),
2564
+ };
2565
+ }
2566
+ function wireObject(raw, kind) {
2567
+ const nested = isRecord(raw[kind]) ? raw[kind] : {};
2568
+ const downloads = isRecord(raw.downloads) ? raw.downloads : {};
2569
+ const objectKey = nested.object_key ?? nested.objectKey ?? raw[`${kind}_object_key`] ?? raw[`${kind}ObjectKey`];
2570
+ const bytes = nested.bytes ?? raw[`${kind}_bytes`] ?? raw[`${kind}Bytes`];
2571
+ const hash = nested.sha256 ?? nested.ciphertext_sha256 ?? raw[`${kind}_sha256`] ?? raw[`${kind}Sha256`];
2572
+ const sha = wireText(hash, `${kind} SHA-256`).toLowerCase();
2573
+ if (!/^[0-9a-f]{64}$/.test(sha))
2574
+ fail("internal", `Sealkeep Cloud returned an invalid ${kind} SHA-256`);
2575
+ const download = nested.download ?? downloads[kind] ?? raw[`${kind}_download`] ?? raw[`${kind}Download`];
2576
+ return {
2577
+ receipt: {
2578
+ objectKey: wireText(objectKey, `${kind} object key`),
2579
+ bytes: wireNumber(bytes, `${kind} byte count`),
2580
+ sha256: sha,
2581
+ },
2582
+ download: wireDownload(download, kind),
2583
+ };
2584
+ }
2585
+ function wireHead(answer) {
2586
+ if (!isRecord(answer))
2587
+ fail("internal", "Sealkeep Cloud returned a malformed index head");
2588
+ const value = Object.hasOwn(answer, "head") ? answer.head : answer;
2589
+ if (value === null)
2590
+ return null;
2591
+ if (!isRecord(value))
2592
+ fail("internal", "Sealkeep Cloud returned a malformed index head");
2593
+ const blob = wireObject(value, "blob");
2594
+ const meta = wireObject(value, "meta");
2595
+ const membership = value.membership_version ?? value.membershipVersion;
2596
+ const receipt = {
2597
+ revision: wireNumber(value.revision, "index revision", 1),
2598
+ generationId: wireText(value.generation_id ?? value.generationId, "index generation id"),
2599
+ blob: blob.receipt,
2600
+ meta: meta.receipt,
2601
+ coverageJson: wireCoverage(value.coverage_json ?? value.coverageJson),
2602
+ ...(membership === undefined || membership === null
2603
+ ? {}
2604
+ : { membershipVersion: wireNumber(membership, "index membership version", 1) }),
2605
+ committedAt: wireText(value.committed_at ?? value.committedAt, "index commit time"),
2606
+ };
2607
+ return { receipt, downloads: { blob: blob.download, meta: meta.download } };
2608
+ }
2609
+ async function indexV2HeadWithToken(dataDir, scope, token, signal) {
2610
+ try {
2611
+ const answer = await call(`${indexV2Base(scope)}/head`, {
2612
+ token,
2613
+ signal,
2614
+ backoffDataDir: dataDir,
2615
+ });
2616
+ return { status: "ok", head: wireHead(answer) };
2617
+ }
2618
+ catch (error) {
2619
+ if (v2HeadAbsent(error))
2620
+ return { status: "ok", head: null };
2621
+ if (v2EndpointUnsupported(error))
2622
+ return V2_UNSUPPORTED;
2623
+ throw error;
2624
+ }
2625
+ }
2626
+ async function indexV2Head(dataDir, scope, preferred, options = {}) {
2627
+ const token = await cloudToken(dataDir, preferred, {
2628
+ renew: options.renewToken !== false,
2629
+ signal: options.signal,
2630
+ });
2631
+ return indexV2HeadWithToken(dataDir, scope, token, options.signal);
2632
+ }
2633
+ export function headIndexGenerationV2(dataDir, preferred, options = {}) {
2634
+ return indexV2Head(dataDir, { kind: "account" }, preferred, options);
2635
+ }
2636
+ export function headTeamIndexGenerationV2(dataDir, spaceKey, preferred, options = {}) {
2637
+ return indexV2Head(dataDir, { kind: "team", spaceKey }, preferred, options);
2638
+ }
2639
+ function bodyChunks(body, signal) {
2640
+ return Buffer.isBuffer(body) ? bufferChunks(body) : fileChunks(body.path, { signal });
2641
+ }
2642
+ async function bodyDigest(body, signal, onProgress) {
2643
+ if (Buffer.isBuffer(body)) {
2644
+ return {
2645
+ bytes: body.length,
2646
+ sha256: createHash("sha256").update(body).digest("hex"),
2647
+ md5: createHash("md5").update(body).digest("hex"),
2648
+ };
2649
+ }
2650
+ const sha256 = createHash("sha256");
2651
+ const md5 = createHash("md5");
2652
+ let bytes = 0;
2653
+ for await (const chunk of fileChunks(body.path, { signal })) {
2654
+ sha256.update(chunk);
2655
+ md5.update(chunk);
2656
+ bytes += chunk.length;
2657
+ onProgress?.(chunk.length);
2658
+ }
2659
+ return { bytes, sha256: sha256.digest("hex"), md5: md5.digest("hex") };
2660
+ }
2661
+ async function putIndexGenerationObject(dataDir, target, body, expected, options) {
2662
+ const sentHash = createHash("sha256");
2663
+ let sentBytes = 0;
2664
+ let chunks = tapDigest(bodyChunks(body, options.signal), sentHash, (bytes) => {
2665
+ sentBytes += bytes;
2666
+ options.onProgress?.(bytes);
2667
+ });
2668
+ if (options.maxBytesPerSecond) {
2669
+ chunks = paceBackgroundByteStream(dataDir, chunks, options.maxBytesPerSecond, {
2670
+ signal: options.signal,
2671
+ });
2672
+ }
2673
+ const stream = requestBody(chunks);
2674
+ const headers = { ...(target.headers ?? {}) };
2675
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-length")) {
2676
+ headers["content-length"] = String(expected.bytes);
2677
+ }
2678
+ let response;
2679
+ try {
2680
+ response = await fetch(target.url, {
2681
+ method: target.method ?? "PUT",
2682
+ headers,
2683
+ body: stream,
2684
+ duplex: "half",
2685
+ signal: options.signal,
2686
+ });
2687
+ }
2688
+ finally {
2689
+ stream.destroy();
2690
+ }
2691
+ if (!response.ok)
2692
+ fail("internal", `The index generation upload was refused: ${response.status} ${response.statusText}`);
2693
+ if (sentBytes !== expected.bytes || sentHash.digest("hex") !== expected.sha256) {
2694
+ fail("ciphertext_integrity_failed", "The sealed index changed while its immutable generation was being uploaded");
2695
+ }
2696
+ }
2697
+ async function prepareIndexGenerationObject(dataDir, scope, context, generationId, kind, bytes, expectedMembershipVersion, signal) {
2698
+ try {
2699
+ const answer = await call(`${indexV2Base(scope)}/generations`, {
2700
+ method: "POST",
2701
+ token: context.token,
2702
+ backoffDataDir: dataDir,
2703
+ signal,
2704
+ body: JSON.stringify({
2705
+ kind,
2706
+ generation_id: generationId,
2707
+ bytes,
2708
+ device_id: context.machine.device_id,
2709
+ machine_key: context.machine.machine_key,
2710
+ ...(scope.kind === "team" ? { expected_membership_version: expectedMembershipVersion } : {}),
2711
+ }),
2712
+ });
2713
+ return answer.upload_url ?? answer.upload
2714
+ ?? fail("internal", "The control plane vended no upload URL for the index generation");
2715
+ }
2716
+ catch (error) {
2717
+ if (v2EndpointUnsupported(error))
2718
+ return V2_UNSUPPORTED;
2719
+ throw error;
2720
+ }
2721
+ }
2722
+ async function readHeadObject(dataDir, kind, descriptor, lease, options, collect) {
2723
+ const collectLimit = kind === "meta"
2724
+ ? Math.min(MAX_FRAME_METADATA_BYTES, options.maxBytes ?? Number.MAX_SAFE_INTEGER)
2725
+ : options.maxBytes ?? Number.MAX_SAFE_INTEGER;
2726
+ if (collect && descriptor.bytes > collectLimit) {
2727
+ fail("provider_unsupported", `The committed index ${kind} is larger than this buffered read permits`);
2728
+ }
2729
+ const response = await fetch(lease.url, {
2730
+ method: lease.method ?? "GET",
2731
+ headers: lease.headers,
2732
+ signal: options.signal,
2733
+ });
2734
+ if (!response.ok)
2735
+ fail("internal", `The committed index ${kind} read-back was refused: ${response.status} ${response.statusText}`);
2736
+ const hash = createHash("sha256");
2737
+ let bytes = 0;
2738
+ let chunks = responseBodyChunks(response);
2739
+ if (options.maxBytesPerSecond) {
2740
+ chunks = paceBackgroundByteStream(dataDir, chunks, options.maxBytesPerSecond, {
2741
+ signal: options.signal,
2742
+ onProgress: options.onProgress,
2743
+ });
2744
+ }
2745
+ chunks = tapDigest(chunks, hash, (count) => {
2746
+ bytes += count;
2747
+ if (!options.maxBytesPerSecond)
2748
+ options.onProgress?.(count);
2749
+ });
2750
+ let body = null;
2751
+ if (collect)
2752
+ body = await collectChunks(chunks, collectLimit);
2753
+ else
2754
+ for await (const _chunk of chunks) { /* hash-checked without retaining the blob */ }
2755
+ const actual = hash.digest("hex");
2756
+ if (bytes !== descriptor.bytes || actual !== descriptor.sha256) {
2757
+ fail("ciphertext_integrity_failed", `The committed index ${kind} did not match its generation receipt`, {
2758
+ expectedBytes: descriptor.bytes,
2759
+ actualBytes: bytes,
2760
+ });
2761
+ }
2762
+ return body;
424
2763
  }
425
- export async function gdriveClaim(dataDir, state, preferred) {
2764
+ async function verifiedHead(dataDir, head, options) {
2765
+ const meta = await readHeadObject(dataDir, "meta", head.receipt.meta, head.downloads.meta, options, true);
2766
+ await readHeadObject(dataDir, "blob", head.receipt.blob, head.downloads.blob, options, false);
426
2767
  try {
427
- return await call(`/v1/cloud/gdrive/claim`, {
428
- method: "POST", token: await cloudToken(dataDir, preferred),
429
- body: JSON.stringify({ state })
2768
+ return JSON.parse(meta.toString("utf8"));
2769
+ }
2770
+ catch {
2771
+ return fail("ciphertext_integrity_failed", "The committed index metadata is not a valid sealed envelope");
2772
+ }
2773
+ }
2774
+ async function publishIndexV2(dataDir, scope, input, preferred, options = {}) {
2775
+ if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) {
2776
+ fail("invalid_argument", "The expected index revision must be zero or a positive integer");
2777
+ }
2778
+ if (scope.kind === "team" && (!Number.isSafeInteger(input.expectedMembershipVersion) || Number(input.expectedMembershipVersion) < 1)) {
2779
+ fail("invalid_argument", "A team index publication requires the exact current membership version");
2780
+ }
2781
+ const coverage = canonicalCoverage(input.coverageJson);
2782
+ const meta = Buffer.from(JSON.stringify(input.envelope), "utf8");
2783
+ if (meta.length > MAX_FRAME_METADATA_BYTES)
2784
+ fail("invalid_argument", "The sealed index envelope is too large to publish");
2785
+ const declaredBlobBytes = Buffer.isBuffer(input.ciphertext)
2786
+ ? input.ciphertext.length
2787
+ : (await stat(input.ciphertext.path)).size;
2788
+ if (declaredBlobBytes < 1 || declaredBlobBytes > MAX_MANAGED_INDEX_V2_BLOB_BYTES) {
2789
+ fail("provider_unsupported", `The managed search index is ${declaredBlobBytes} bytes; immutable v2 generations currently support at most ${MAX_MANAGED_INDEX_V2_BLOB_BYTES} bytes`);
2790
+ }
2791
+ const [blobDigest, metaDigest] = await Promise.all([
2792
+ bodyDigest(input.ciphertext, options.signal, options.onProgress),
2793
+ bodyDigest(meta, options.signal),
2794
+ ]);
2795
+ if (blobDigest.bytes !== declaredBlobBytes) {
2796
+ fail("ciphertext_integrity_failed", "The sealed index size changed while its immutable generation was being prepared");
2797
+ }
2798
+ const generationId = randomUUID();
2799
+ const context = await managedDeleteContext(dataDir, preferred, { signal: options.signal });
2800
+ if (!context.machine)
2801
+ fail("forbidden", "This machine needs an active Sealkeep Cloud device identity before it can publish the search index");
2802
+ const bound = context;
2803
+ const blobTarget = await prepareIndexGenerationObject(dataDir, scope, bound, generationId, "blob", blobDigest.bytes, input.expectedMembershipVersion, options.signal);
2804
+ if ("status" in blobTarget)
2805
+ return blobTarget;
2806
+ await putIndexGenerationObject(dataDir, blobTarget, input.ciphertext, blobDigest, options);
2807
+ const metaTarget = await prepareIndexGenerationObject(dataDir, scope, bound, generationId, "meta", metaDigest.bytes, input.expectedMembershipVersion, options.signal);
2808
+ if ("status" in metaTarget)
2809
+ return metaTarget;
2810
+ await putIndexGenerationObject(dataDir, metaTarget, meta, metaDigest, options);
2811
+ if (await stableCloudAuthGeneration(dataDir) !== context.authGeneration) {
2812
+ fail("unauthorized", "The Sealkeep Cloud account changed while the index generation was being prepared. The uncommitted objects were left unreachable.");
2813
+ }
2814
+ try {
2815
+ const commit = await call(`${indexV2Base(scope)}/generations/${encodeURIComponent(generationId)}/commit`, {
2816
+ method: "POST",
2817
+ token: context.token,
2818
+ backoffDataDir: dataDir,
2819
+ signal: options.signal,
2820
+ body: JSON.stringify({
2821
+ generation_id: generationId,
2822
+ expected_revision: input.expectedRevision,
2823
+ coverage_json: coverage,
2824
+ blob_bytes: blobDigest.bytes,
2825
+ blob_sha256: blobDigest.sha256,
2826
+ blob_md5: blobDigest.md5,
2827
+ meta_bytes: metaDigest.bytes,
2828
+ meta_sha256: metaDigest.sha256,
2829
+ meta_md5: metaDigest.md5,
2830
+ device_id: bound.machine.device_id,
2831
+ machine_key: bound.machine.machine_key,
2832
+ ...(scope.kind === "team" ? { expected_membership_version: input.expectedMembershipVersion } : {}),
2833
+ }),
430
2834
  });
2835
+ if (commit?.status === "conflict") {
2836
+ const current = await indexV2HeadWithToken(dataDir, scope, context.token, options.signal);
2837
+ if (current.status === "unsupported")
2838
+ return current;
2839
+ return { status: "conflict", head: current.head };
2840
+ }
431
2841
  }
432
2842
  catch (error) {
433
- if (isVaultlineError(error) && /returned 404|No pending/i.test(error.message))
434
- return null;
2843
+ if (v2EndpointUnsupported(error))
2844
+ return V2_UNSUPPORTED;
2845
+ if (isSealkeepError(error)
2846
+ && error.code === "lease_expired"
2847
+ && String(error.details.remoteCode ?? "").toUpperCase() === INDEX_REVISION_CONFLICT_REMOTE_CODE) {
2848
+ const current = await indexV2HeadWithToken(dataDir, scope, context.token, options.signal);
2849
+ if (current.status === "unsupported")
2850
+ return current;
2851
+ return { status: "conflict", head: current.head };
2852
+ }
435
2853
  throw error;
436
2854
  }
2855
+ const observed = await indexV2HeadWithToken(dataDir, scope, context.token, options.signal);
2856
+ if (observed.status === "unsupported")
2857
+ return observed;
2858
+ const head = observed.head;
2859
+ if (!head || head.receipt.generationId !== generationId) {
2860
+ // Our CAS committed, then another writer advanced the head before the
2861
+ // read-back. That is convergence work, not corruption: expose the new
2862
+ // head so the caller can pull, merge and retry.
2863
+ return { status: "conflict", head };
2864
+ }
2865
+ if (head.receipt.revision !== input.expectedRevision + 1
2866
+ || head.receipt.blob.bytes !== blobDigest.bytes
2867
+ || head.receipt.blob.sha256 !== blobDigest.sha256
2868
+ || head.receipt.meta.bytes !== metaDigest.bytes
2869
+ || head.receipt.meta.sha256 !== metaDigest.sha256
2870
+ || JSON.stringify(head.receipt.coverageJson) !== JSON.stringify(coverage)
2871
+ || (scope.kind === "team" && head.receipt.membershipVersion !== input.expectedMembershipVersion)) {
2872
+ fail("ciphertext_integrity_failed", "Sealkeep Cloud did not expose the exact committed index generation");
2873
+ }
2874
+ const envelope = await verifiedHead(dataDir, head, options);
2875
+ if (await stableCloudAuthGeneration(dataDir) !== context.authGeneration) {
2876
+ fail("unauthorized", "The Sealkeep Cloud account changed during index read-back. The committed generation was not recorded locally.");
2877
+ }
2878
+ return { status: "committed", receipt: head.receipt, head, verifiedReadBack: true, envelope };
437
2879
  }
438
- /**
439
- * The consent flow for the shared client. Two shapes, decided by the server's
440
- * registered redirect: a REMOTE https callback (the plane exchanges and parks
441
- * the tokens; this machine polls its one-time state — works even when the
442
- * browser lives on another device), or a loopback listener that catches the
443
- * code here and exchanges through the authed endpoint.
444
- */
445
- export async function connectGdriveViaCloud(dataDir, options = {}) {
446
- const { createServer } = await import("node:http");
447
- const { randomBytes } = await import("node:crypto");
448
- const config = await gdriveOauthConfig(dataDir, options.preferred);
449
- const redirect = new URL(config.redirect_uri);
450
- const state = randomBytes(16).toString("base64url");
451
- if (redirect.protocol === "https:") {
452
- const consent = new URL("https://accounts.google.com/o/oauth2/v2/auth");
453
- consent.searchParams.set("client_id", config.client_id);
454
- consent.searchParams.set("redirect_uri", config.redirect_uri);
455
- consent.searchParams.set("response_type", "code");
456
- consent.searchParams.set("scope", config.scope);
457
- consent.searchParams.set("access_type", "offline");
458
- consent.searchParams.set("prompt", "consent");
459
- consent.searchParams.set("state", state);
460
- options.onConsentUrl?.(consent.toString());
461
- const { launchBrowser } = await import("./providers/gdrive.js");
462
- await Promise.resolve((options.opener ?? launchBrowser)(consent.toString())).catch(() => undefined);
463
- const deadline = Date.now() + (options.timeoutMs ?? 300_000);
464
- while (Date.now() < deadline) {
465
- const parcel = await gdriveClaim(dataDir, state, options.preferred);
466
- if (parcel)
467
- return { kind: "gdrive", refreshToken: parcel.refresh_token, clientId: config.client_id, exchange: "cloud" };
468
- await new Promise((rest) => setTimeout(rest, options.pollMs ?? 2000));
469
- }
470
- throw new VaultlineError("unauthorized", "No consent arrived in time. Run the connect again.");
2880
+ export function publishIndexGenerationV2(dataDir, input, preferred, options = {}) {
2881
+ return publishIndexV2(dataDir, { kind: "account" }, input, preferred, options);
2882
+ }
2883
+ export function publishTeamIndexGenerationV2(dataDir, spaceKey, input, preferred, options = {}) {
2884
+ return publishIndexV2(dataDir, { kind: "team", spaceKey }, input, preferred, options);
2885
+ }
2886
+ async function pullIndexV2Snapshot(dataDir, scope, preferred, options = {}) {
2887
+ const origin = cloudUrl();
2888
+ const token = await cloudToken(dataDir, preferred, {
2889
+ renew: options.renewToken !== false,
2890
+ signal: options.signal,
2891
+ });
2892
+ const authGeneration = await stableCloudAuthGeneration(dataDir);
2893
+ const account = await cachedCloudTokenRead(dataDir, preferred);
2894
+ if (account.token !== token || account.generation !== authGeneration) {
2895
+ fail("unauthorized", "The Sealkeep Cloud account changed before the index snapshot could be read");
471
2896
  }
472
- const server = createServer();
473
- await new Promise((ready, refuse) => { server.once("error", refuse); server.listen(Number(redirect.port), "127.0.0.1", ready); });
474
- let timer;
475
- const code = new Promise((resolve, reject) => {
476
- timer = setTimeout(() => reject(new VaultlineError("unauthorized", "No response from the browser in time. Run the connect again.")), options.timeoutMs ?? 300_000);
477
- server.on("request", (request, response) => {
478
- const url = new URL(request.url ?? "/", config.redirect_uri);
479
- if (url.pathname !== redirect.pathname) {
480
- response.writeHead(404);
481
- response.end();
482
- return;
2897
+ const current = await indexV2HeadWithToken(dataDir, scope, token, options.signal);
2898
+ if (current.status === "unsupported")
2899
+ return current;
2900
+ if (!current.head)
2901
+ return { status: "ok", snapshot: null };
2902
+ const receipt = current.head.receipt;
2903
+ const metaLimit = Math.min(MAX_FRAME_METADATA_BYTES, options.maxBytes ?? Number.MAX_SAFE_INTEGER);
2904
+ const snapshotBytes = receipt.meta.bytes + receipt.blob.bytes;
2905
+ if (!Number.isSafeInteger(snapshotBytes) || receipt.meta.bytes > metaLimit
2906
+ || snapshotBytes > (options.maxBytes ?? Number.MAX_SAFE_INTEGER)) {
2907
+ fail("provider_unsupported", "The committed index exceeds this buffered read's byte limit");
2908
+ }
2909
+ let cacheIdentity;
2910
+ if (scope.kind === "team") {
2911
+ // The head is always fetched with current credentials. This separate live
2912
+ // membership row supplies the authoritative account id for disk isolation;
2913
+ // neither that row nor its permission is stored in the ciphertext cache.
2914
+ try {
2915
+ const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(scope.spaceKey)}/membership`, token, { signal: options.signal });
2916
+ const member = rowsOf(answer, ["membership", "member", "item", "data"])[0];
2917
+ if (!member || !Number.isSafeInteger(member.account_id) || member.account_id <= 0) {
2918
+ fail("team_membership_revoked", "The current Team member could not be verified");
483
2919
  }
484
- const deny = (message) => {
485
- response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
486
- response.end("<p>Sealkeep could not finish connecting. Return to the terminal.</p>");
487
- reject(new VaultlineError("unauthorized", message));
488
- };
489
- if (url.searchParams.get("error"))
490
- return deny(`Google reported: ${url.searchParams.get("error")}`);
491
- if (url.searchParams.get("state") !== state)
492
- return deny("The browser reply did not match this connection attempt (state mismatch)");
493
- const received = url.searchParams.get("code");
494
- if (!received)
495
- return deny("The browser reply carried no authorization code");
496
- response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
497
- response.end("<p>Sealkeep is connected to Google Drive. You can close this tab.</p>");
498
- resolve(received);
499
- });
500
- });
501
- code.catch(() => { });
502
- const consent = new URL("https://accounts.google.com/o/oauth2/v2/auth");
503
- consent.searchParams.set("client_id", config.client_id);
504
- consent.searchParams.set("redirect_uri", config.redirect_uri);
505
- consent.searchParams.set("response_type", "code");
506
- consent.searchParams.set("scope", config.scope);
507
- consent.searchParams.set("access_type", "offline");
508
- consent.searchParams.set("prompt", "consent");
509
- consent.searchParams.set("state", state);
2920
+ if (account.refs.vaultId && Number.isSafeInteger(receipt.membershipVersion) && Number(receipt.membershipVersion) > 0) {
2921
+ cacheIdentity = { origin, accountId: member.account_id, vaultId: account.refs.vaultId, spaceKey: scope.spaceKey, receipt };
2922
+ }
2923
+ }
2924
+ catch (error) {
2925
+ // Older compatible planes may authorize the immutable head without this
2926
+ // account-id endpoint. They retain the existing uncached transport; a
2927
+ // denied membership or any other failure must never read the cache.
2928
+ if (!v2EndpointUnsupported(error))
2929
+ throw error;
2930
+ }
2931
+ }
2932
+ const cached = cacheIdentity ? await readTeamIndexCache(dataDir, cacheIdentity) : null;
2933
+ const meta = cached?.meta ?? await readHeadObject(dataDir, "meta", receipt.meta, current.head.downloads.meta, options, true);
2934
+ const ciphertext = cached?.ciphertext ?? await readHeadObject(dataDir, "blob", receipt.blob, current.head.downloads.blob, options, true);
2935
+ let envelope;
510
2936
  try {
511
- options.onConsentUrl?.(consent.toString());
512
- const { launchBrowser } = await import("./providers/gdrive.js");
513
- await Promise.resolve((options.opener ?? launchBrowser)(consent.toString())).catch(() => undefined);
514
- const authorizationCode = await code;
515
- const tokens = await gdriveExchange(dataDir, { code: authorizationCode, redirectUri: config.redirect_uri }, options.preferred);
516
- return { kind: "gdrive", refreshToken: tokens.refresh_token, clientId: config.client_id, exchange: "cloud" };
2937
+ envelope = JSON.parse(meta.toString("utf8"));
517
2938
  }
518
- finally {
519
- if (timer)
520
- clearTimeout(timer);
521
- server.close();
2939
+ catch {
2940
+ return fail("ciphertext_integrity_failed", "The committed index metadata is not a valid sealed envelope");
522
2941
  }
2942
+ const assertAccount = async () => {
2943
+ options.signal?.throwIfAborted();
2944
+ const after = await cachedCloudTokenRead(dataDir, preferred);
2945
+ if (origin !== cloudUrl() || after.generation !== authGeneration || after.token !== token
2946
+ || !sameCloudCredentialRefs(after.refs, account.refs)) {
2947
+ fail("unauthorized", "The Sealkeep Cloud account changed during index download. The snapshot was not installed.");
2948
+ }
2949
+ };
2950
+ await assertAccount();
2951
+ if (cacheIdentity && !cached)
2952
+ await writeTeamIndexCache(dataDir, cacheIdentity, meta, ciphertext);
2953
+ await assertAccount();
2954
+ const snapshot = { receipt: current.head.receipt, head: current.head, envelope, ciphertext: ciphertext };
2955
+ if (cacheIdentity)
2956
+ teamSnapshotCacheIdentities.set(snapshot, structuredClone(cacheIdentity));
2957
+ return { status: "ok", snapshot };
523
2958
  }
524
- export async function pushIndexObject(dataDir, kind, body, preferred) {
525
- const token = await cloudToken(dataDir, preferred);
526
- const lease = await call(`/v1/cloud/index/upload`, { method: "POST", body: JSON.stringify({ kind, bytes: body.length }), token });
527
- const target = lease.upload_url ?? fail("internal", "The control plane vended no upload URL for the index slot");
528
- const response = await fetch(target.url, { method: target.method || "PUT", headers: target.headers ?? {}, body: new Uint8Array(body) });
529
- if (!response.ok)
530
- fail("internal", `The index ${kind} upload was refused: ${response.status} ${response.statusText}`);
2959
+ export function pullIndexGenerationV2(dataDir, preferred, options = {}) {
2960
+ return pullIndexV2Snapshot(dataDir, { kind: "account" }, preferred, options);
531
2961
  }
532
- export async function pullIndexObject(dataDir, kind, preferred) {
533
- const token = await cloudToken(dataDir, preferred);
534
- const lease = await call(`/v1/cloud/index/download?kind=${kind}`, { token }).catch((error) => {
535
- if (isVaultlineError(error) && /not.*found|no index/i.test(error.message))
536
- return null;
537
- throw error;
538
- });
539
- if (!lease?.download?.url)
540
- return null;
541
- const response = await fetch(lease.download.url);
542
- if (response.status === 404)
543
- return null;
544
- if (!response.ok)
545
- fail("internal", `The index ${kind} download was refused: ${response.status} ${response.statusText}`);
546
- return Buffer.from(await response.arrayBuffer());
2962
+ export function pullTeamIndexGenerationV2(dataDir, spaceKey, preferred, options = {}) {
2963
+ return pullIndexV2Snapshot(dataDir, { kind: "team", spaceKey }, preferred, options);
547
2964
  }
548
- export async function listCloudArchives(dataDir, preferred) {
549
- const token = await cloudToken(dataDir, preferred);
550
- const rows = await call("/v1/cloud/archives", { token });
551
- return Array.isArray(rows) ? rows : [];
2965
+ export async function listCloudArchives(dataDir, preferred, options = {}) {
2966
+ const token = options.token ?? await cloudToken(dataDir, preferred, { renew: options.renewToken !== false });
2967
+ // The flat listing returns only the newest 200 rows — on a real vault that
2968
+ // silently hid five sixths of the account from every "is there a copy?"
2969
+ // check. The paged companion walks id-cursor pages until a short one; a
2970
+ // plane without it falls back to the flat call, so an older control plane
2971
+ // keeps the behaviour it always had. A mid-walk failure is different: once
2972
+ // one page succeeded, a capped flat fallback would be a false complete list.
2973
+ const PAGE = 200;
2974
+ const rows = [];
2975
+ try {
2976
+ let before = null;
2977
+ for (let pages = 0; pages < 500; pages++) {
2978
+ const batch = await call(`/v1/cloud/archives/page${before !== null ? `?before=${before}` : ""}`, { token, signal: options.signal });
2979
+ if (!Array.isArray(batch))
2980
+ throw new Error("paged listing answered with a non-array");
2981
+ rows.push(...batch);
2982
+ if (batch.length < PAGE)
2983
+ return rows;
2984
+ const cursor = batch[batch.length - 1]?.id;
2985
+ if (typeof cursor !== "number")
2986
+ throw new Error("paged listing rows carry no id cursor");
2987
+ before = cursor;
2988
+ }
2989
+ return rows;
2990
+ }
2991
+ catch (error) {
2992
+ // A failure before the first page can mean an older plane has no cursor
2993
+ // route, so its capped flat listing is the only compatible answer. Once a
2994
+ // page has succeeded, however, the route demonstrably exists: falling
2995
+ // back after page two fails would return only the newest 200 rows and
2996
+ // falsely present that truncation as the complete account catalog.
2997
+ if (options.signal?.aborted || rows.length > 0)
2998
+ throw error;
2999
+ }
3000
+ const flat = await call("/v1/cloud/archives", { token, signal: options.signal });
3001
+ if (!Array.isArray(flat))
3002
+ return [];
3003
+ // A short capped response is necessarily the whole catalog; exactly PAGE
3004
+ // rows is ambiguous on a legacy plane and must not be cached as complete.
3005
+ Object.defineProperty(flat, "complete", { value: flat.length < PAGE, enumerable: false });
3006
+ return flat;
552
3007
  }
553
3008
  /**
554
- * Really deletes one stored object from managed storage.
555
- *
556
- * Goes through the control plane rather than storage directly, because it is
557
- * the same call the account panel's Archives section makes — one delete path,
558
- * so the product and the panel cannot come to mean different things by
559
- * "deleted". The control plane removes the object and its row, and billing
560
- * follows the row.
3009
+ * Cheap first-run question: does this signed-in account already preserve any
3010
+ * archive? It deliberately fetches only the first cursor page instead of using
3011
+ * `listCloudArchives`, because setup needs a branch, not a thousand-row index.
3012
+ * Older control planes fall back to their capped flat listing.
561
3013
  */
562
- export async function deleteCloudArchive(dataDir, vaultRef, preferred) {
563
- await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}`, { method: "DELETE", token: await cloudToken(dataDir, preferred) });
3014
+ export async function hasCloudArchives(dataDir, preferred) {
3015
+ const token = await cloudToken(dataDir, preferred);
3016
+ try {
3017
+ const rows = await call("/v1/cloud/archives/page", { token });
3018
+ if (Array.isArray(rows))
3019
+ return rows.length > 0;
3020
+ }
3021
+ catch { /* older plane: use its original listing */ }
3022
+ const rows = await call("/v1/cloud/archives", { token });
3023
+ return Array.isArray(rows) && rows.length > 0;
3024
+ }
3025
+ export async function managedDeleteContext(dataDir, preferred, options = {}) {
3026
+ options.signal?.throwIfAborted();
3027
+ // cloudToken can legitimately renew and advance the generation. Sample the
3028
+ // generation after obtaining it, then bind the device identity to that exact
3029
+ // token instead of asking the keychain for a token a second time.
3030
+ const token = await cloudToken(dataDir, preferred, { signal: options.signal });
3031
+ const generationBeforeMachine = await stableCloudAuthGeneration(dataDir);
3032
+ if (Object.hasOwn(options, "expectedAuthGeneration") && options.expectedAuthGeneration !== generationBeforeMachine) {
3033
+ fail("unauthorized", "The Sealkeep Cloud account changed before the delete began. Nothing was deleted; retry for the current account.");
3034
+ }
3035
+ let machine = await machineCredential(dataDir, preferred);
3036
+ if (!machine) {
3037
+ machine = await registerMachine(dataDir, token, preferred, { signal: options.signal });
3038
+ }
3039
+ // Lazy registration, including one won by a sibling lane, legitimately
3040
+ // publishes a new generation. Rebind only after proving both credentials
3041
+ // came from one stable scope and the exact account token is unchanged. A
3042
+ // sibling can finish registration between these two reads, so discard that
3043
+ // pair and retry once just like the individual cache snapshots do.
3044
+ for (let attempt = 0; attempt < CLOUD_AUTH_SNAPSHOT_ATTEMPTS; attempt += 1) {
3045
+ options.signal?.throwIfAborted();
3046
+ const accountAfterMachine = await cachedCloudTokenRead(dataDir, preferred);
3047
+ if (accountAfterMachine.token !== token) {
3048
+ fail("unauthorized", "The Sealkeep Cloud account changed while the delete was being prepared. Nothing was deleted; retry for the current account.");
3049
+ }
3050
+ const machineAfter = await cachedMachineCredentialRead(dataDir, preferred);
3051
+ if (machineAfter.generation !== accountAfterMachine.generation)
3052
+ continue;
3053
+ if (await stableCloudAuthGeneration(dataDir) !== accountAfterMachine.generation)
3054
+ continue;
3055
+ return {
3056
+ authGeneration: accountAfterMachine.generation,
3057
+ token,
3058
+ machine: machineAfter.read.credential,
3059
+ };
3060
+ }
3061
+ return fail("unauthorized", "The Sealkeep Cloud account changed while the delete was being prepared. Nothing was deleted; retry for the current account.");
3062
+ }
3063
+ export async function deleteCloudArchive(dataDir, vaultRef, preferred, options = {}) {
3064
+ options.signal?.throwIfAborted();
3065
+ // Proving which machine is asking lets the plane refuse a machine that did
3066
+ // not seal this archive. Every member of a chunk-family delete receives the
3067
+ // same context, so a mid-operation login change cannot redirect later
3068
+ // DELETEs into a different account with a coincidentally identical ref.
3069
+ const context = options.context ?? await managedDeleteContext(dataDir, preferred);
3070
+ if (await stableCloudAuthGeneration(dataDir) !== context.authGeneration) {
3071
+ fail("unauthorized", "The Sealkeep Cloud account changed during the delete. Remaining objects were left recorded for a safe retry.");
3072
+ }
3073
+ await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}`, {
3074
+ method: "DELETE",
3075
+ token: context.token,
3076
+ body: JSON.stringify(context.machine ?? {}),
3077
+ signal: options.signal,
3078
+ });
564
3079
  }
565
3080
  /**
566
3081
  * Fetches the ciphertext, whichever way the control plane delegated.
@@ -569,7 +3084,7 @@ export async function deleteCloudArchive(dataDir, vaultRef, preferred) {
569
3084
  * vended credential is signed here. Providers that Spala cannot presign for —
570
3085
  * anything S3-compatible — arrive as the vended shape.
571
3086
  */
572
- async function fetchLeasedObject(lease) {
3087
+ async function fetchLeasedObjectStream(lease, options = {}) {
573
3088
  if (lease.vended) {
574
3089
  assertVendedSignable(lease.vended);
575
3090
  assertVendedFresh(lease.vended, {});
@@ -578,41 +3093,98 @@ async function fetchLeasedObject(lease) {
578
3093
  if (!lease.object_key)
579
3094
  fail("internal", "Sealkeep Cloud vended a credential but no object key");
580
3095
  const client = new S3UploadClient(v.provider, { provider: v.provider, region: v.region, bucket: v.bucket, host: v.host, port: v.port, protocol: v.protocol }, { accessKeyId: v.akid, secretAccessKey: v.secret_access_key, sessionToken: v.session_token });
581
- return client.download({
3096
+ return client.downloadStream({
582
3097
  archiveId: lease.vault_ref, provider: v.provider, objectKey: lease.object_key, expiresAt: new Date(Date.now() + 9e5).toISOString(),
583
3098
  method: "MULTIPART", uploadUrl: "", requiredHeaders: {}, status: "active", uploadable: true
584
- });
3099
+ }, { signal: options.signal });
585
3100
  }
586
3101
  if (!lease.download?.url)
587
3102
  fail("internal", "Sealkeep Cloud returned a lease with nowhere to download from");
588
- const response = await fetch(lease.download.url, { method: lease.download.method ?? "GET", headers: lease.download.headers });
3103
+ const response = await fetch(lease.download.url, { method: lease.download.method ?? "GET", headers: lease.download.headers, signal: options.signal });
589
3104
  if (!response.ok)
590
3105
  fail("internal", `Storage refused the download: ${response.status} ${response.statusText}`);
591
- return Buffer.from(await response.arrayBuffer());
3106
+ return responseBodyChunks(response);
3107
+ }
3108
+ /** Compatibility for APIs that explicitly return a Buffer (chunk reads/open). */
3109
+ async function fetchLeasedObject(lease, options = {}) {
3110
+ return collectChunks(await fetchLeasedObjectStream(lease, options), Number.MAX_SAFE_INTEGER);
592
3111
  }
593
3112
  /**
594
3113
  * A byte range of a stored object, for chunk-granular reads. The whole-object
595
3114
  * hash cannot be checked on a slice — that is the point of a slice — so the
596
3115
  * caller's integrity comes from the per-chunk hashes and AEAD tags inside
597
- * (see vaultline-crypto/chunk-access). Only the presigned-URL lease shape
598
- * supports ranges today; a vended-credential lease falls back to whole-object.
3116
+ * (see sealkeep-crypto/chunk-access). Only the presigned-URL lease shape
3117
+ * supports ranges today. A range request never falls back to a whole object:
3118
+ * neither an unsupported lease nor a storage server's 200 can spend an
3119
+ * unbounded amount of memory/bandwidth on a small search or metadata read.
599
3120
  */
600
- export async function pullCiphertextRange(dataDir, vaultRef, storedOffset, storedLength, preferred) {
601
- const token = await cloudToken(dataDir, preferred);
602
- const lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token });
603
- if (!lease.download?.url) {
604
- const whole = await fetchLeasedObject(lease);
605
- return whole.subarray(storedOffset, storedOffset + storedLength);
3121
+ export async function pullCiphertextRange(dataDir, vaultRef, storedOffset, storedLength, preferred, options = {}) {
3122
+ const maxBytes = options.maxBytes ?? 8 * 1024 * 1024;
3123
+ if (!Number.isSafeInteger(storedOffset) || storedOffset < 0
3124
+ || !Number.isSafeInteger(storedLength) || storedLength <= 0
3125
+ || !Number.isSafeInteger(storedOffset + storedLength)
3126
+ || !Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > LEGACY_BUFFER_LIMIT_BYTES
3127
+ || storedLength > maxBytes) {
3128
+ fail("invalid_argument", "The archive range must fit its bounded byte limit");
3129
+ }
3130
+ const token = await cloudToken(dataDir, preferred, { signal: options.signal });
3131
+ const lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token, signal: options.signal });
3132
+ const result = await checkedLeasedRange(dataDir, lease, vaultRef, storedOffset, storedLength, options);
3133
+ return result.body;
3134
+ }
3135
+ /** The same genuine-storage proof is used by range consumers and presence
3136
+ * probes. Descriptor metadata alone is never evidence that an object exists. */
3137
+ async function checkedLeasedRange(dataDir, lease, vaultRef, storedOffset, storedLength, options) {
3138
+ if (!lease || typeof lease !== "object")
3139
+ fail("ciphertext_integrity_failed", "The archive range has no valid object descriptor");
3140
+ const total = lease.bytes;
3141
+ if (lease.vault_ref !== vaultRef || !Number.isSafeInteger(total) || total <= 0
3142
+ || storedOffset + storedLength > total || typeof lease.ciphertext_sha256 !== "string"
3143
+ || !/^[a-f0-9]{64}$/i.test(lease.ciphertext_sha256)) {
3144
+ fail("ciphertext_integrity_failed", "The archive range has no valid object descriptor");
3145
+ }
3146
+ if (typeof lease.download?.url !== "string" || !lease.download.url || lease.download.url.trim() !== lease.download.url
3147
+ || (lease.download.method !== undefined && (typeof lease.download.method !== "string" || lease.download.method.toUpperCase() !== "GET"))) {
3148
+ fail("provider_unsupported", "This archive has no bounded range download transport");
3149
+ }
3150
+ try {
3151
+ const destination = new URL(lease.download.url);
3152
+ if (!["http:", "https:"].includes(destination.protocol) || destination.username || destination.password || destination.hash) {
3153
+ fail("provider_unsupported", "This archive has no bounded range download transport");
3154
+ }
3155
+ }
3156
+ catch {
3157
+ fail("provider_unsupported", "This archive has no bounded range download transport");
3158
+ }
3159
+ const end = storedOffset + storedLength - 1;
3160
+ let headers;
3161
+ try {
3162
+ headers = new Headers(lease.download.headers);
3163
+ }
3164
+ catch {
3165
+ return fail("provider_unsupported", "The archive range has invalid signed transport headers");
3166
+ }
3167
+ headers.set("range", `bytes=${storedOffset}-${end}`);
3168
+ await options.onDescriptor?.(Object.freeze({ vaultRef, bytes: total, ciphertextSha256: lease.ciphertext_sha256.toLowerCase() }));
3169
+ options.signal?.throwIfAborted();
3170
+ const response = await fetch(lease.download.url, {
3171
+ method: "GET", headers, redirect: "error", signal: options.signal,
3172
+ });
3173
+ if (response.status === 404 && options.missingIsAbsent) {
3174
+ await response.body?.cancel().catch(() => undefined);
3175
+ return { body: null, total: 0 };
606
3176
  }
607
- const response = await fetch(lease.download.url, { headers: { range: `bytes=${storedOffset}-${storedOffset + storedLength - 1}` } });
608
- if (response.status === 206)
609
- return Buffer.from(await response.arrayBuffer());
610
- if (response.ok) {
611
- // A store that ignores Range answers 200 with everything; take the slice
612
- // rather than failing a read that can still be served.
613
- return Buffer.from(await response.arrayBuffer()).subarray(storedOffset, storedOffset + storedLength);
3177
+ const contentLength = response.headers.get("content-length");
3178
+ if (response.status !== 206 || response.headers.get("content-range") !== `bytes ${storedOffset}-${end}/${total}`
3179
+ || (contentLength !== null && contentLength !== String(storedLength))
3180
+ || (response.headers.get("content-encoding") && response.headers.get("content-encoding") !== "identity")) {
3181
+ await response.body?.cancel().catch(() => undefined);
3182
+ fail("ciphertext_integrity_failed", "Storage did not return the exact requested archive byte range");
614
3183
  }
615
- return fail("internal", `Storage refused the ranged read: ${response.status} ${response.statusText}`);
3184
+ const body = await collectChunks(backgroundObjectReadStream(dataDir, responseBodyChunks(response), options), storedLength);
3185
+ if (body.length !== storedLength)
3186
+ fail("ciphertext_integrity_failed", "The archive byte range was truncated");
3187
+ return { body, total };
616
3188
  }
617
3189
  /**
618
3190
  * Existence and stored size of a managed object, proven against STORAGE, not
@@ -622,39 +3194,86 @@ export async function pullCiphertextRange(dataDir, vaultRef, storedOffset, store
622
3194
  * count. This is what lets chunk-folder verification and resume treat managed
623
3195
  * storage with exactly the discipline `verifyStored` demands of it.
624
3196
  */
625
- export async function statCiphertext(dataDir, vaultRef, preferred) {
626
- const token = await cloudToken(dataDir, preferred);
3197
+ export async function statCiphertext(dataDir, vaultRef, preferred, options = {}) {
3198
+ const origin = cloudUrl();
3199
+ const token = await cloudToken(dataDir, preferred, { signal: options.signal });
3200
+ const account = await cachedCloudTokenRead(dataDir, preferred);
3201
+ if (account.token !== token)
3202
+ fail("unauthorized", "The account changed before the archive storage check");
3203
+ const assertCurrent = async () => {
3204
+ options.signal?.throwIfAborted();
3205
+ const current = await cachedCloudTokenRead(dataDir, preferred);
3206
+ if (origin !== cloudUrl() || current.token !== token || current.generation !== account.generation
3207
+ || !sameCloudCredentialRefs(current.refs, account.refs)) {
3208
+ fail("unauthorized", "The account changed during the archive storage check or incomplete upload retry");
3209
+ }
3210
+ };
627
3211
  let lease;
628
3212
  try {
629
- lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token });
3213
+ lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token, signal: options.signal });
630
3214
  }
631
3215
  catch (error) {
632
- if (isVaultlineError(error) && (error.code === "archive_not_found" || /returned 404|not[ _-]?found/i.test(error.message))) {
3216
+ await assertCurrent();
3217
+ if (options.allowIncomplete === true && isSealkeepError(error) && error.code === "upload_incomplete") {
3218
+ const incompleteUpload = Object.freeze({ status: "upload_incomplete" });
3219
+ incompleteUploads.set(incompleteUpload, { dataDir: resolve(dataDir), vaultRef, token, assertCurrent });
3220
+ return { exists: false, bytes: 0, incompleteUpload };
3221
+ }
3222
+ if (isSealkeepError(error) && (error.code === "archive_not_found" || error.details?.httpStatus === 404)) {
633
3223
  return { exists: false, bytes: 0 };
634
3224
  }
635
3225
  throw error;
636
3226
  }
637
- if (!lease.download?.url)
638
- return { exists: true, bytes: lease.bytes };
639
- const response = await fetch(lease.download.url, { headers: { range: "bytes=0-0" } });
640
- if (response.status === 404)
3227
+ const checked = await checkedLeasedRange(dataDir, lease, vaultRef, 0, 1, { signal: options.signal, missingIsAbsent: true });
3228
+ await assertCurrent();
3229
+ if (!checked.body)
641
3230
  return { exists: false, bytes: 0 };
642
- if (response.status === 206) {
643
- const total = Number((response.headers.get("content-range") ?? "").split("/").pop());
644
- await response.arrayBuffer().catch(() => undefined);
645
- return { exists: true, bytes: Number.isFinite(total) && total > 0 ? total : lease.bytes };
3231
+ checked.body.fill(0);
3232
+ return { exists: true, bytes: checked.total };
3233
+ }
3234
+ export async function pullCiphertextStream(dataDir, vaultRef, preferred, options = {}) {
3235
+ options.signal?.throwIfAborted();
3236
+ if (options.maxBytes !== undefined && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)) {
3237
+ fail("invalid_argument", "The archive read limit must be a non-negative safe integer");
646
3238
  }
647
- if (response.ok)
648
- return { exists: true, bytes: Buffer.from(await response.arrayBuffer()).length };
649
- return fail("internal", `Storage refused the presence check: ${response.status} ${response.statusText}`);
3239
+ const token = await cloudToken(dataDir, preferred, { signal: options.signal });
3240
+ const lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token, signal: options.signal });
3241
+ if (lease.vault_ref !== vaultRef || !Number.isSafeInteger(lease.bytes) || lease.bytes <= 0
3242
+ || !/^[a-f0-9]{64}$/i.test(lease.ciphertext_sha256 ?? "")) {
3243
+ fail("ciphertext_integrity_failed", "Archive download has no valid object integrity descriptor");
3244
+ }
3245
+ if (options.maxBytes !== undefined && lease.bytes > options.maxBytes) {
3246
+ fail("provider_unsupported", "The archive exceeds this bounded read's byte limit");
3247
+ }
3248
+ const source = await fetchLeasedObjectStream(lease, { signal: options.signal });
3249
+ const checked = (async function* () {
3250
+ const hash = createHash("sha256");
3251
+ let bytes = 0;
3252
+ for await (const value of source) {
3253
+ options.signal?.throwIfAborted();
3254
+ const chunk = value;
3255
+ bytes += chunk.length;
3256
+ // Throwing closes the underlying iterator (and cancels fetch's body),
3257
+ // without yielding the chunk which crossed the declared/budgeted size.
3258
+ if (bytes > lease.bytes || (options.maxBytes !== undefined && bytes > options.maxBytes)) {
3259
+ fail("ciphertext_integrity_failed", "Downloaded ciphertext exceeds its recorded size or bounded read limit");
3260
+ }
3261
+ hash.update(chunk);
3262
+ yield chunk;
3263
+ }
3264
+ const digest = hash.digest("hex");
3265
+ if (digest !== lease.ciphertext_sha256) {
3266
+ fail("internal", "Downloaded ciphertext does not match the hash recorded at upload; refusing it");
3267
+ }
3268
+ if (bytes !== lease.bytes) {
3269
+ fail("internal", `Downloaded ciphertext is ${bytes} bytes; the upload record says ${lease.bytes}`);
3270
+ }
3271
+ })();
3272
+ const background = backgroundObjectReadStream(dataDir, checked, options);
3273
+ return options.unframe ? unframeChunks(background, OBJECT_MAGIC) : background;
650
3274
  }
651
- export async function pullCiphertext(dataDir, vaultRef, preferred) {
652
- const token = await cloudToken(dataDir, preferred);
653
- const lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token });
654
- const ciphertext = await fetchLeasedObject(lease);
655
- const digest = createHash("sha256").update(ciphertext).digest("hex");
656
- if (digest !== lease.ciphertext_sha256)
657
- fail("internal", "Downloaded ciphertext does not match the hash recorded at upload; refusing it");
3275
+ export async function pullCiphertext(dataDir, vaultRef, preferred, options = {}) {
3276
+ const ciphertext = await collectChunks(await pullCiphertextStream(dataDir, vaultRef, preferred, options), Number.MAX_SAFE_INTEGER);
658
3277
  return { ciphertext, bytes: ciphertext.length };
659
3278
  }
660
3279
  /**
@@ -673,11 +3292,17 @@ export async function pullCiphertext(dataDir, vaultRef, preferred) {
673
3292
  * keeps them restorable from the machine that made them.
674
3293
  */
675
3294
  const OBJECT_MAGIC = Buffer.from("VLA1", "ascii");
676
- export function frameObject(envelope, ciphertext) {
3295
+ export function frameObjectHeader(envelope) {
677
3296
  const meta = Buffer.from(JSON.stringify(envelope), "utf8");
3297
+ if (meta.length > MAX_FRAME_METADATA_BYTES) {
3298
+ fail("invalid_argument", `Archive envelope metadata is ${meta.length} bytes; the safe limit is ${MAX_FRAME_METADATA_BYTES}`);
3299
+ }
678
3300
  const length = Buffer.allocUnsafe(4);
679
3301
  length.writeUInt32BE(meta.length, 0);
680
- return Buffer.concat([OBJECT_MAGIC, length, meta, ciphertext]);
3302
+ return Buffer.concat([OBJECT_MAGIC, length, meta]);
3303
+ }
3304
+ export function frameObject(envelope, ciphertext) {
3305
+ return Buffer.concat([frameObjectHeader(envelope), ciphertext]);
681
3306
  }
682
3307
  export function unframeObject(blob) {
683
3308
  if (blob.length < OBJECT_MAGIC.length + 4 || !blob.subarray(0, OBJECT_MAGIC.length).equals(OBJECT_MAGIC))
@@ -702,9 +3327,86 @@ export function unframeObject(blob) {
702
3327
  * hash recorded at seal time is checked inside decryptArchive, after the AEAD
703
3328
  * has authenticated every chunk.
704
3329
  */
705
- export async function openFromCloud(dataDir, vaultRef, destination, rawPhrase, preferred) {
706
- const { ciphertext: blob } = await pullCiphertext(dataDir, vaultRef, preferred);
707
- const { envelope, ciphertext } = unframeObject(blob);
3330
+ /**
3331
+ * One archive's bytes out of managed storage, in whichever layout it was
3332
+ * stored — because the caller of `open` is precisely the machine that has no
3333
+ * local record to consult.
3334
+ *
3335
+ * A buffered archive is one object at its own reference. A STREAMED archive is
3336
+ * not: it is a folder of chunks plus a sealed sidecar, addressed
3337
+ * `<id>.chunk-NNNNNN` and `<id>.envelope.skmeta`, with nothing at `<id>` at
3338
+ * all. `open` used to fetch the reference and only the reference, so every
3339
+ * streamed archive answered 404 — and streaming is what large sessions and
3340
+ * nearly-full disks fall back to, so the archives most worth reading back were
3341
+ * the ones that could not be. On a company account that is the whole
3342
+ * shared-memory story failing: a colleague's biggest session is the one you
3343
+ * cannot open.
3344
+ *
3345
+ * The single object is tried first, so an ordinary archive still costs one
3346
+ * round trip and the old path is untouched.
3347
+ */
3348
+ async function pullArchiveBody(dataDir, vaultRef, preferred) {
3349
+ const single = await statCiphertext(dataDir, vaultRef, preferred);
3350
+ if (single.exists) {
3351
+ const { ciphertext: blob } = await pullCiphertext(dataDir, vaultRef, preferred);
3352
+ const { envelope, ciphertext } = unframeObject(blob);
3353
+ return { envelope, ciphertext, blob, chunked: false };
3354
+ }
3355
+ const { managedRefForKey } = await import("./managed-chunks.js");
3356
+ const { ENVELOPE_OBJECT, chunkObjectName } = await import("./chunk-store.js");
3357
+ const sidecarRef = managedRefForKey(`${vaultRef}/${ENVELOPE_OBJECT}`);
3358
+ if (!(await statCiphertext(dataDir, sidecarRef, preferred)).exists) {
3359
+ fail("archive_not_found", `Managed storage holds nothing under ${vaultRef}. Check the reference with \`sealkeep cloud archives\`.`);
3360
+ }
3361
+ const { ciphertext: sidecarBlob } = await pullCiphertext(dataDir, sidecarRef, preferred);
3362
+ const { envelope } = unframeObject(sidecarBlob);
3363
+ if (!envelope)
3364
+ fail("archive_not_found", `The sidecar for ${vaultRef} carries no envelope, so its chunks cannot be reassembled.`);
3365
+ // The envelope names every chunk and its hash, so the folder is walked from
3366
+ // the record rather than from a listing: no ordering to trust, no extra
3367
+ // object to be fooled by, and a truncated folder is caught here rather than
3368
+ // as a decryption failure nobody can act on.
3369
+ const headers = Array.isArray(envelope.chunks) ? envelope.chunks : [];
3370
+ if (headers.length === 0)
3371
+ fail("archive_not_found", `The envelope for ${vaultRef} lists no chunks.`);
3372
+ const parts = [];
3373
+ for (let index = 0; index < headers.length; index += 1) {
3374
+ const ref = managedRefForKey(`${vaultRef}/${chunkObjectName(index)}`);
3375
+ const { ciphertext: body } = await pullCiphertext(dataDir, ref, preferred);
3376
+ const expected = headers[index]?.sha256;
3377
+ if (expected && sha256(body) !== expected) {
3378
+ fail("ciphertext_integrity_failed", `Chunk ${index} of ${vaultRef} does not match the hash its envelope recorded`, { vaultRef });
3379
+ }
3380
+ parts.push(body);
3381
+ }
3382
+ return { envelope, ciphertext: Buffer.concat(parts), blob: sidecarBlob, chunked: true };
3383
+ }
3384
+ /** Every managed reference `vaultRef` occupies, so a caller can pull the raw parts. */
3385
+ export async function managedArchiveParts(dataDir, vaultRef, preferred) {
3386
+ if ((await statCiphertext(dataDir, vaultRef, preferred)).exists)
3387
+ return { chunked: false, refs: [vaultRef] };
3388
+ const { managedRefForKey } = await import("./managed-chunks.js");
3389
+ const { ENVELOPE_OBJECT, chunkObjectName } = await import("./chunk-store.js");
3390
+ const sidecarRef = managedRefForKey(`${vaultRef}/${ENVELOPE_OBJECT}`);
3391
+ if (!(await statCiphertext(dataDir, sidecarRef, preferred)).exists) {
3392
+ fail("archive_not_found", `Managed storage holds nothing under ${vaultRef}. Check the reference with \`sealkeep cloud archives\`.`);
3393
+ }
3394
+ const { ciphertext: sidecarBlob } = await pullCiphertext(dataDir, sidecarRef, preferred);
3395
+ const { envelope } = unframeObject(sidecarBlob);
3396
+ const headers = Array.isArray(envelope?.chunks) ? envelope.chunks : [];
3397
+ return {
3398
+ chunked: true,
3399
+ refs: [...headers.map((_, index) => managedRefForKey(`${vaultRef}/${chunkObjectName(index)}`)), sidecarRef]
3400
+ };
3401
+ }
3402
+ /**
3403
+ * `unlock` is a phrase, or a recipient's private key — the second is how an
3404
+ * organisation reads what a colleague sealed without anybody sharing a vault
3405
+ * phrase, which is the only workable shape for a team.
3406
+ */
3407
+ export async function openFromCloud(dataDir, vaultRef, destination, unlock, preferred) {
3408
+ const withKey = typeof unlock === "object";
3409
+ const { envelope, ciphertext, blob } = await pullArchiveBody(dataDir, vaultRef, preferred);
708
3410
  if (!envelope) {
709
3411
  // Two very different objects arrive with no envelope: a plain-mode
710
3412
  // archive (readable by its owner's explicit choice at setup — bare gzip,
@@ -720,16 +3422,108 @@ export async function openFromCloud(dataDir, vaultRef, destination, rawPhrase, p
720
3422
  }
721
3423
  fail("archive_not_found", `${vaultRef} was stored before archives carried their own envelope. Restore it with \`sealkeep recover ${vaultRef} <destination>\` on the machine that archived it, then push it again.`);
722
3424
  }
3425
+ // The keygen prints raw 32 bytes as base64; the crypto wants a key object,
3426
+ // whose string form is PEM. Accept either, so a member can paste what they
3427
+ // were handed rather than convert it first.
3428
+ const opener = withKey
3429
+ ? { privateKey: /BEGIN [A-Z ]*PRIVATE KEY/.test(unlock.privateKey)
3430
+ ? unlock.privateKey
3431
+ : x25519PrivateKeyFromRaw(Buffer.from(unlock.privateKey, "base64")) }
3432
+ : { phrase: canonicalPhrase(unlock) };
723
3433
  let plaintext;
724
3434
  try {
725
- plaintext = openEnvelope(envelope, ciphertext, { phrase: canonicalPhrase(rawPhrase) });
3435
+ plaintext = openEnvelope(envelope, ciphertext, opener);
726
3436
  }
727
3437
  catch (error) {
728
- fail("recovery_phrase_mismatch", `Unable to open ${vaultRef}: ${error instanceof Error ? error.message : "decryption failed"}`);
3438
+ const detail = error instanceof Error ? error.message : "decryption failed";
3439
+ // Naming the recovery phrase when no phrase was offered sent people to
3440
+ // their recovery kit for a problem the kit cannot fix. The two ways in
3441
+ // fail for different reasons and deserve different sentences.
3442
+ if (withKey) {
3443
+ fail("recipient_key_mismatch", `Unable to open ${vaultRef} with that key: ${detail}. This archive is not wrapped for it — check the archive was rewrapped for your key (\`sealkeep rewrap\`), or open it with the recovery phrase.`);
3444
+ }
3445
+ fail("recovery_phrase_mismatch", `Unable to open ${vaultRef}: ${detail}`);
729
3446
  }
730
3447
  await writeFile(destination, plaintext, { mode: 0o600 });
731
3448
  return { output: destination, bytes: plaintext.length };
732
3449
  }
3450
+ /**
3451
+ * Tells the account's security feed what the local scanner found.
3452
+ *
3453
+ * The scan itself happens on this machine, on the plaintext, because this is
3454
+ * the only place the plaintext exists — the plane holds ciphertext and could
3455
+ * not scan it if it wanted to. What travels is the rule that matched, how
3456
+ * often, this machine, and the archive: never the matched text, never the
3457
+ * surrounding line, never the project name. A leak report that repeated the
3458
+ * leak would be a second leak, and one sent to a server that was designed
3459
+ * never to see the first would be worse than that.
3460
+ *
3461
+ * Best-effort by construction. An account that is not on a team plan answers
3462
+ * 402 and we say nothing; a network failure loses a report, not a seal. The
3463
+ * one thing this must never do is prevent an archive from being sealed.
3464
+ */
3465
+ export async function reportSecurityFindings(dataDir, input, preferred) {
3466
+ if (input.findings.length === 0)
3467
+ return { reported: 0 };
3468
+ // One row per rule, not per match: a session that pasted the same key forty
3469
+ // times is one problem, and forty rows would bury the other thirty-nine
3470
+ // findings under it.
3471
+ const byKind = new Map();
3472
+ for (const finding of input.findings) {
3473
+ const seen = byKind.get(finding.kind);
3474
+ if (seen) {
3475
+ seen.occurrences += 1;
3476
+ if (finding.severity === "high")
3477
+ seen.severity = "high";
3478
+ }
3479
+ else
3480
+ byKind.set(finding.kind, { kind: finding.kind, severity: finding.severity, occurrences: 1 });
3481
+ }
3482
+ const token = await cloudToken(dataDir, preferred).catch(() => null);
3483
+ if (!token)
3484
+ return { reported: 0 };
3485
+ const machine = await machineCredential(dataDir, preferred).catch(() => null);
3486
+ let reported = 0;
3487
+ for (const row of byKind.values()) {
3488
+ try {
3489
+ await call("/v1/cloud/security/findings", {
3490
+ method: "POST",
3491
+ token,
3492
+ body: JSON.stringify({
3493
+ vault_ref: input.vaultRef,
3494
+ device_id: machine?.device_id,
3495
+ kind: row.kind,
3496
+ severity: row.severity,
3497
+ occurrences: row.occurrences
3498
+ })
3499
+ });
3500
+ reported += 1;
3501
+ }
3502
+ catch (error) {
3503
+ // 402 means this account does not buy org monitoring, which is a
3504
+ // perfectly ordinary answer and not worth a word. Anything else is a
3505
+ // lost report, and a lost report must still not cost anyone a seal.
3506
+ if (isSealkeepError(error) && error.code === "payment_required")
3507
+ return { reported };
3508
+ return { reported };
3509
+ }
3510
+ }
3511
+ return { reported };
3512
+ }
3513
+ /** What the company's own rules say, for machines to apply before sealing. */
3514
+ export async function securityPolicy(dataDir, preferred) {
3515
+ const token = await cloudToken(dataDir, preferred).catch(() => null);
3516
+ if (!token)
3517
+ return null;
3518
+ try {
3519
+ const body = await call("/v1/cloud/security/policy", { token });
3520
+ const rules = body.rules_json ? JSON.parse(body.rules_json) : [];
3521
+ return { blockOnHigh: body.block_on_high === true, rules: Array.isArray(rules) ? rules : [] };
3522
+ }
3523
+ catch {
3524
+ return null;
3525
+ }
3526
+ }
733
3527
  /**
734
3528
  * The judgement, kept separate from the fetching so it can be tested honestly.
735
3529
  *
@@ -800,53 +3594,178 @@ export async function verifyArchives(dataDir, options = {}, preferred) {
800
3594
  /** Writes the verified marker for an archive this machine holds locally. */
801
3595
  async function recordVerified(dataDir, vaultRef, bytes) {
802
3596
  const record = (await listArchives(dataDir)).find((item) => item.id === vaultRef);
803
- if (!record || record.version !== 2 || record.remote?.verifiedAt)
3597
+ if (!record || record.version !== 2)
804
3598
  return;
805
3599
  const stored = {
806
3600
  provider: "vaultline", bucket: "vaultline-managed", objectKey: vaultRef,
807
3601
  bytes, checksum: record.cipher.ciphertextSha256, verifiedAt: new Date().toISOString()
808
3602
  };
809
- await writeRecord((await readConfig(dataDir)).storage.root, { ...record, remote: stored });
3603
+ await mutateArchiveRecord((await readConfig(dataDir)).storage.root, vaultRef, (current) => current.version === 2 ? appendArchiveCopy(current, stored) : current);
810
3604
  }
811
- export async function pushArchive(dataDir, archiveId, preferred, options = {}) {
3605
+ /**
3606
+ * Store one already-sealed archive under an explicit managed-storage ref.
3607
+ *
3608
+ * Unlike `pushArchive`, this deliberately does not update a vault record. It
3609
+ * is the narrow primitive used by the team-history backfill: the original
3610
+ * private record and every one of its copies remain untouched while a second,
3611
+ * membership-wrapped object is uploaded, read back, and hash verified. The
3612
+ * caller may publish a team grant only after `durable` is true.
3613
+ */
3614
+ export async function pushFramedArchive(dataDir, input, preferred, options = {}) {
812
3615
  await readConfig(dataDir);
813
- const record = (await listArchives(dataDir)).find((item) => item.id === archiveId);
814
- if (!record)
815
- fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
816
- const ciphertext = await readFile(record.objectPath);
817
- if (sha256(ciphertext) !== record.cipher.ciphertextSha256)
818
- fail("ciphertext_integrity_failed", "Ciphertext changed on disk since it was archived", { archiveId });
819
- // What we store is the envelope plus the ciphertext, so the archive can be
820
- // opened by anyone holding the recovery phrase and nothing else.
821
- const object = record.envelope ? frameObject(record.envelope, ciphertext) : ciphertext;
822
- const digest = sha256(object);
823
- const lease = await requestUpload(dataDir, { vaultRef: record.id, ciphertextSha256: digest, bytes: object.length }, preferred);
824
- await putCiphertext(lease, object, options.signal);
825
- try {
826
- await completeUpload(dataDir, lease.lease_id, object.length, preferred);
827
- // Retention reads remote.verifiedAt to decide whether a source may be
828
- // reclaimed, so this is the field that authorises deleting a customer's
829
- // only local copy. It is written only once storage has been made to prove
830
- // it holds these exact bytes — the same discipline the bring-your-own-bucket
831
- // path follows.
832
- const problem = await verifyStored(dataDir, lease, object, digest, preferred);
833
- if (problem) {
3616
+ const resumed = options.incompleteUpload && incompleteUploadBinding(options.incompleteUpload, dataDir, input.vaultRef);
3617
+ if (resumed)
3618
+ await resumed.assertCurrent();
3619
+ const handle = await open(input.ciphertextPath, "r").catch(() => fail("archive_not_found", `Archive ciphertext is not readable: ${input.ciphertextPath}`, {
3620
+ archiveId: input.vaultRef,
3621
+ path: input.ciphertextPath,
3622
+ }));
3623
+ try {
3624
+ // Hash the immutable descriptor and the ciphertext through the same open
3625
+ // file descriptor. A pathname replacement between measure and upload can
3626
+ // therefore never substitute another archive.
3627
+ const header = frameObjectHeader(input.envelope);
3628
+ const ciphertextHash = createHash("sha256");
3629
+ const objectHash = createHash("sha256").update(header);
3630
+ let ciphertextBytes = 0;
3631
+ for await (const chunk of fileHandleChunks(handle, { signal: options.signal })) {
3632
+ ciphertextHash.update(chunk);
3633
+ objectHash.update(chunk);
3634
+ ciphertextBytes += chunk.length;
3635
+ options.onProgress?.(ciphertextBytes);
3636
+ await options.onYield?.();
3637
+ }
3638
+ if (ciphertextHash.digest("hex") !== input.ciphertextSha256 || ciphertextBytes !== input.ciphertextBytes) {
3639
+ fail("ciphertext_integrity_failed", "Ciphertext changed on disk since it was archived", { archiveId: input.vaultRef });
3640
+ }
3641
+ const objectBytes = header.length + ciphertextBytes;
3642
+ const digest = objectHash.digest("hex");
3643
+ const lease = await requestUpload(dataDir, {
3644
+ vaultRef: input.vaultRef,
3645
+ ciphertextSha256: digest,
3646
+ bytes: objectBytes,
3647
+ }, preferred, { signal: options.signal, incompleteUpload: options.incompleteUpload });
3648
+ if (resumed) {
3649
+ // Issuing the lease must not have changed the pending row, and another
3650
+ // writer may have completed it while we were obtaining the signed URL.
3651
+ const current = await pendingUploadDescriptor(resumed, {
3652
+ vaultRef: input.vaultRef, ciphertextSha256: digest, bytes: objectBytes,
3653
+ }, options.signal);
3654
+ if (current.objectKey !== lease.object_key)
3655
+ fail("ciphertext_integrity_failed", "The pending archive object key changed before retry");
3656
+ await resumed.assertCurrent();
3657
+ }
3658
+ let uploaded;
3659
+ let uploadCompleted = false;
3660
+ try {
3661
+ const sentHash = createHash("sha256");
3662
+ let sentBytes = 0;
3663
+ const measuredBody = backgroundObjectReadStream(dataDir, tapDigest(concatStreams([oneChunk(header), fileHandleChunks(handle, { signal: options.signal })]), sentHash, (bytes) => {
3664
+ sentBytes += bytes;
3665
+ options.onProgress?.(ciphertextBytes + sentBytes);
3666
+ }), { signal: options.signal, onYield: options.onYield });
3667
+ const body = options.maxBytesPerSecond
3668
+ ? paceBackgroundByteStream(dataDir, measuredBody, options.maxBytesPerSecond, {
3669
+ signal: options.signal,
3670
+ now: options.paceNow,
3671
+ sleep: options.paceSleep,
3672
+ })
3673
+ : measuredBody;
3674
+ uploaded = await putCiphertextStream(lease, body, objectBytes, options.signal);
3675
+ uploadCompleted = true;
3676
+ if (sentBytes !== objectBytes || sentHash.digest("hex") !== digest) {
3677
+ fail("ciphertext_integrity_failed", "Ciphertext changed while it was being uploaded; the remote object was not marked durable", {
3678
+ archiveId: input.vaultRef,
3679
+ });
3680
+ }
3681
+ }
3682
+ catch (error) {
3683
+ // A create-only object may already have landed before a crash. Complete
3684
+ // the new lease and prove the existing bytes rather than writing again.
3685
+ const message = error instanceof Error ? error.message : String(error);
3686
+ if (!/\b412\b|PreconditionFailed/.test(message))
3687
+ throw error;
3688
+ }
3689
+ try {
3690
+ if (resumed) {
3691
+ const machine = await machineCredential(dataDir, preferred);
3692
+ await resumed.assertCurrent();
3693
+ const completion = await call(`/v1/cloud/uploads/${lease.lease_id}/complete`, {
3694
+ method: "POST", token: resumed.token, signal: options.signal,
3695
+ body: JSON.stringify({ bytes: objectBytes, ...(machine ?? {}) }), maxResponseBytes: 64 * 1024,
3696
+ });
3697
+ await resumed.assertCurrent();
3698
+ if (completion?.vault_ref !== input.vaultRef || completion.object_key !== lease.object_key
3699
+ || typeof completion.durable_at !== "string" || !Number.isFinite(Date.parse(completion.durable_at))) {
3700
+ fail("ciphertext_integrity_failed", "The completion receipt does not bind this exact resumed archive");
3701
+ }
3702
+ }
3703
+ else {
3704
+ await completeUpload(dataDir, lease.lease_id, objectBytes, preferred, { signal: options.signal });
3705
+ }
3706
+ const problem = await verifyStored(dataDir, lease, {
3707
+ bytes: objectBytes,
3708
+ digest,
3709
+ ...(uploadCompleted ? { remoteChecksum: uploaded?.remoteChecksum } : {}),
3710
+ }, preferred, {
3711
+ signal: options.signal, incompleteUpload: options.incompleteUpload,
3712
+ maxBytesPerSecond: options.maxBytesPerSecond, paceNow: options.paceNow, paceSleep: options.paceSleep,
3713
+ onYield: options.onYield,
3714
+ onProgress: options.onProgress ? (bytes) => options.onProgress(ciphertextBytes + objectBytes + bytes) : undefined,
3715
+ });
3716
+ if (problem) {
3717
+ return {
3718
+ archiveId: input.vaultRef,
3719
+ objectKey: lease.object_key,
3720
+ bytes: objectBytes,
3721
+ durable: false,
3722
+ checksum: digest,
3723
+ note: `Uploaded, but managed storage could not be proven to hold it: ${problem}. The private copy is untouched.`,
3724
+ };
3725
+ }
3726
+ return { archiveId: input.vaultRef, objectKey: lease.object_key, bytes: objectBytes, checksum: digest, durable: true };
3727
+ }
3728
+ catch (error) {
3729
+ if (options.signal?.aborted)
3730
+ throw options.signal.reason ?? error;
834
3731
  return {
835
- archiveId: record.id, objectKey: lease.object_key, bytes: object.length, durable: false,
836
- note: `Uploaded, but managed storage could not be proven to hold it: ${problem}. The local copy is untouched.`
3732
+ archiveId: input.vaultRef,
3733
+ objectKey: lease.object_key,
3734
+ bytes: objectBytes,
3735
+ durable: false,
3736
+ checksum: digest,
3737
+ note: `Uploaded, but the control plane did not record it: ${error instanceof Error ? error.message : "unknown error"}`,
837
3738
  };
838
3739
  }
839
- const stored = {
840
- provider: "vaultline", bucket: "vaultline-managed", objectKey: lease.object_key,
841
- bytes: object.length, checksum: digest, verifiedAt: new Date().toISOString()
842
- };
843
- await writeRecord((await readConfig(dataDir)).storage.root, { ...record, remote: stored });
844
- return { archiveId: record.id, objectKey: lease.object_key, bytes: object.length, durable: true };
845
3740
  }
846
- catch (error) {
847
- return {
848
- archiveId: record.id, objectKey: lease.object_key, bytes: object.length, durable: false,
849
- note: `Uploaded, but the control plane did not record it: ${error instanceof Error ? error.message : "unknown error"}`
850
- };
3741
+ finally {
3742
+ await handle.close();
851
3743
  }
852
3744
  }
3745
+ export async function pushArchive(dataDir, archiveId, preferred, options = {}) {
3746
+ await readConfig(dataDir);
3747
+ const record = (await listArchives(dataDir)).find((item) => item.id === archiveId);
3748
+ if (!record)
3749
+ fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
3750
+ if (record.version !== 2)
3751
+ fail("invalid_argument", `Archive ${archiveId} uses format v1. Run: sealkeep migrate`, { archiveId });
3752
+ const ciphertextPath = options.ciphertextPath ?? record.objectPath;
3753
+ const pushed = await pushFramedArchive(dataDir, {
3754
+ vaultRef: record.id,
3755
+ envelope: record.envelope,
3756
+ ciphertextPath,
3757
+ ciphertextSha256: record.cipher.ciphertextSha256,
3758
+ ciphertextBytes: record.cipher.storedBytes,
3759
+ }, preferred, options);
3760
+ if (!pushed.durable)
3761
+ return pushed;
3762
+ // Retention reads verified copies to decide whether a source may be
3763
+ // reclaimed. The generic primitive above intentionally knows nothing about
3764
+ // records; the ordinary private upload path records durability here.
3765
+ const stored = {
3766
+ provider: "vaultline", bucket: "vaultline-managed", objectKey: pushed.objectKey,
3767
+ bytes: pushed.bytes, checksum: pushed.checksum ?? "", verifiedAt: new Date().toISOString(),
3768
+ };
3769
+ await mutateArchiveRecord((await readConfig(dataDir)).storage.root, record.id, (current) => current.version === 2 ? appendArchiveCopy(current, stored) : current);
3770
+ return pushed;
3771
+ }