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
@@ -35,8 +35,8 @@ const enrollSchema = z.object({
35
35
  label: z.string().min(1).max(120),
36
36
  publicKey: z.string().min(40).max(100)
37
37
  }).strict();
38
- function json(response, status, value) {
39
- response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
38
+ function json(response, status, value, headers = {}) {
39
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", ...headers });
40
40
  response.end(JSON.stringify(value));
41
41
  }
42
42
  async function readBody(request) {
@@ -84,11 +84,24 @@ export function createControlPlaneServer(options = {}) {
84
84
  let status = 500;
85
85
  let deviceId;
86
86
  try {
87
- const limitKey = request.headers["x-vaultline-device"] ?? request.socket.remoteAddress ?? "anonymous";
88
- if (!limiter.take(limitKey, now())) {
87
+ // The remote address is not caller-controlled. Using the presented
88
+ // device header here let an unauthenticated caller rotate fake ids and
89
+ // mint an unlimited number of fresh buckets before signature checking.
90
+ const limitKey = request.socket.remoteAddress ?? "anonymous";
91
+ const limited = limiter.consume(limitKey, now());
92
+ if (!limited.allowed) {
89
93
  status = 429;
90
94
  store.audit({ action: `${method} ${url.pathname}`, outcome: "denied", detail: { reason: "rate_limited" } });
91
- return json(response, 429, { error: { code: "rate_limited", message: "Too many requests" } });
95
+ const retryAfterSeconds = Math.max(1, Math.ceil(limited.retryAfterMs / 1_000));
96
+ return json(response, 429, { error: {
97
+ code: "rate_limited", message: "Too many requests",
98
+ retryAfterMs: limited.retryAfterMs, retryAt: new Date(now() + limited.retryAfterMs).toISOString(),
99
+ } }, {
100
+ "retry-after": String(retryAfterSeconds),
101
+ "ratelimit-limit": String(limiter.capacity),
102
+ "ratelimit-remaining": String(limited.remaining),
103
+ "ratelimit-reset": String(Math.ceil(limited.resetAt / 1_000)),
104
+ });
92
105
  }
93
106
  if (method === "GET" && url.pathname === "/health") {
94
107
  status = 200;
@@ -179,7 +192,7 @@ export function createControlPlaneServer(options = {}) {
179
192
  return json(response, 402, { error: { code: "quota_exceeded", message: "This account has no remaining quota" } });
180
193
  }
181
194
  // The object key is allocated here. A client cannot choose where its bytes land.
182
- const objectKey = `${config.prefix.replace(/^\/+|\/+$/g, "")}/${input.archiveId}.vlarchive`;
195
+ const objectKey = `${config.prefix.replace(/^\/+|\/+$/g, "")}/${input.archiveId}.skarchive`;
183
196
  const issuedAt = now();
184
197
  const lease = store.addLease({
185
198
  id: randomUUID(), accountId: device.accountId, deviceId: device.id, archiveId: input.archiveId, storageConfigId: config.id,
@@ -53,7 +53,23 @@ export declare function leaseExpired(lease: UploadLease, now?: number): boolean;
53
53
  * replayed lease is refused as expired rather than as an unsigned lease.
54
54
  */
55
55
  export declare function assertLeaseUsable(lease: UploadLease, now?: number): void;
56
- /** Feature flag for provider work in progress. Off by default and read per call so tests and operators can flip it. */
56
+ export type DaemonSignerGrant = {
57
+ release(): void;
58
+ };
59
+ /**
60
+ * Grants provider-signing capability to one running daemon instance.
61
+ *
62
+ * The grant is process-local and lifetime-scoped: it does not modify the
63
+ * environment or make a separate CLI process upload-capable. Reference
64
+ * counting matters for embedded/test processes which legitimately host more
65
+ * than one daemon for different vaults.
66
+ */
67
+ export declare function acquireDaemonSignerGrant(): DaemonSignerGrant;
68
+ /**
69
+ * Explicit operator enablement, or a capability held by a live daemon.
70
+ * Supplying an environment is a hermetic check: callers which deliberately
71
+ * pass `{}` must not inherit capability from another daemon in this process.
72
+ */
57
73
  export declare function signerEnabled(env?: NodeJS.ProcessEnv): boolean;
58
74
  /**
59
75
  * The contract a real S3/R2/GCS client will implement. Credentials belong to the
@@ -18,7 +18,7 @@ class PlannedProvider {
18
18
  // Deliberately non-functional until real credential-backed signers are configured.
19
19
  return {
20
20
  archiveId, provider: this.provider,
21
- objectKey: `${config.prefix.replace(/\/$/, "")}/${archiveId}.vlarchive`,
21
+ objectKey: `${config.prefix.replace(/\/$/, "")}/${archiveId}.skarchive`,
22
22
  expiresAt: new Date(issuedAt + (input.ttlMs ?? DEFAULT_LEASE_TTL_MS)).toISOString(),
23
23
  method: this.method,
24
24
  uploadUrl: `vaultline+pending://${this.provider}/${config.bucket}/${archiveId}`,
@@ -43,18 +43,44 @@ export function assertLeaseUsable(lease, now = Date.now()) {
43
43
  if (!lease.uploadable || lease.status !== "active")
44
44
  fail("signer_not_configured", "This lease cannot upload: no credential-backed signer is configured", { provider: lease.provider, archiveId: lease.archiveId });
45
45
  }
46
- /** Feature flag for provider work in progress. Off by default and read per call so tests and operators can flip it. */
47
- export function signerEnabled(env = process.env) {
48
- return envVar("ENABLE_SIGNER", env) === "1";
46
+ let daemonSignerGrants = 0;
47
+ /**
48
+ * Grants provider-signing capability to one running daemon instance.
49
+ *
50
+ * The grant is process-local and lifetime-scoped: it does not modify the
51
+ * environment or make a separate CLI process upload-capable. Reference
52
+ * counting matters for embedded/test processes which legitimately host more
53
+ * than one daemon for different vaults.
54
+ */
55
+ export function acquireDaemonSignerGrant() {
56
+ daemonSignerGrants += 1;
57
+ let held = true;
58
+ return {
59
+ release() {
60
+ if (!held)
61
+ return;
62
+ held = false;
63
+ daemonSignerGrants = Math.max(0, daemonSignerGrants - 1);
64
+ },
65
+ };
66
+ }
67
+ /**
68
+ * Explicit operator enablement, or a capability held by a live daemon.
69
+ * Supplying an environment is a hermetic check: callers which deliberately
70
+ * pass `{}` must not inherit capability from another daemon in this process.
71
+ */
72
+ export function signerEnabled(env) {
73
+ return envVar("ENABLE_SIGNER", env ?? process.env) === "1"
74
+ || (env === undefined && daemonSignerGrants > 0);
49
75
  }
50
76
  const uploadClients = new Map();
51
77
  /** Registration is refused unless the operator explicitly enabled signer work. */
52
- export function registerUploadClient(client, env = process.env) {
78
+ export function registerUploadClient(client, env) {
53
79
  if (!signerEnabled(env))
54
80
  fail("signer_not_configured", "Set SEALKEEP_ENABLE_SIGNER=1 to register a provider upload client", { provider: client.kind });
55
81
  uploadClients.set(client.kind, client);
56
82
  }
57
- export function uploadClientFor(kind, env = process.env) {
83
+ export function uploadClientFor(kind, env) {
58
84
  const client = signerEnabled(env) ? uploadClients.get(kind) : undefined;
59
85
  return client ?? fail("signer_not_configured", `No upload client is available for ${kind}. Sealkeep cannot upload until a reviewed signer with isolated credentials exists.`, { provider: kind, signerEnabled: signerEnabled(env) });
60
86
  }
@@ -43,7 +43,7 @@ export declare function encryptLegacyArchive(plaintext: Buffer, phrase: string):
43
43
  ciphertext: string;
44
44
  };
45
45
  };
46
- /** @deprecated Format v1 reader. New archives use `vaultline-crypto`. */
46
+ /** @deprecated Format v1 reader. New archives use `sealkeep-crypto`. */
47
47
  export declare function decryptLegacyArchive(ciphertext: Buffer, envelope: {
48
48
  nonce: string;
49
49
  authTag: string;
@@ -96,14 +96,14 @@ export function encryptLegacyArchive(plaintext, phrase) {
96
96
  const archiveKey = randomBytes(KEY_BYTES);
97
97
  const nonce = randomBytes(NONCE_BYTES);
98
98
  const cipher = createCipheriv("aes-256-gcm", archiveKey, nonce, { authTagLength: TAG_BYTES });
99
- cipher.setAAD(Buffer.from("vaultline-archive:v1"));
99
+ cipher.setAAD(Buffer.from("sealkeep-archive:v1"));
100
100
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
101
101
  const authTag = cipher.getAuthTag();
102
102
  const salt = randomBytes(16);
103
103
  const wrapNonce = randomBytes(NONCE_BYTES);
104
104
  const wrappingKey = recoveryKey(phrase, salt);
105
105
  const wrapper = createCipheriv("aes-256-gcm", wrappingKey, wrapNonce, { authTagLength: TAG_BYTES });
106
- wrapper.setAAD(Buffer.from("vaultline-wrapped-key:v1"));
106
+ wrapper.setAAD(Buffer.from("sealkeep-wrapped-key:v1"));
107
107
  const wrapped = Buffer.concat([wrapper.update(archiveKey), wrapper.final()]);
108
108
  return {
109
109
  ciphertext,
@@ -118,15 +118,15 @@ export function encryptLegacyArchive(plaintext, phrase) {
118
118
  }
119
119
  };
120
120
  }
121
- /** @deprecated Format v1 reader. New archives use `vaultline-crypto`. */
121
+ /** @deprecated Format v1 reader. New archives use `sealkeep-crypto`. */
122
122
  export function decryptLegacyArchive(ciphertext, envelope, phrase) {
123
123
  const wrappingKey = recoveryKey(phrase, Buffer.from(envelope.wrappedKey.salt, "base64url"));
124
124
  const unwrap = createDecipheriv("aes-256-gcm", wrappingKey, Buffer.from(envelope.wrappedKey.nonce, "base64url"), { authTagLength: TAG_BYTES });
125
- unwrap.setAAD(Buffer.from("vaultline-wrapped-key:v1"));
125
+ unwrap.setAAD(Buffer.from("sealkeep-wrapped-key:v1"));
126
126
  unwrap.setAuthTag(Buffer.from(envelope.wrappedKey.authTag, "base64url"));
127
127
  const archiveKey = Buffer.concat([unwrap.update(Buffer.from(envelope.wrappedKey.ciphertext, "base64url")), unwrap.final()]);
128
128
  const decipher = createDecipheriv("aes-256-gcm", archiveKey, Buffer.from(envelope.nonce, "base64url"), { authTagLength: TAG_BYTES });
129
- decipher.setAAD(Buffer.from("vaultline-archive:v1"));
129
+ decipher.setAAD(Buffer.from("sealkeep-archive:v1"));
130
130
  decipher.setAuthTag(Buffer.from(envelope.authTag, "base64url"));
131
131
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
132
132
  }
@@ -0,0 +1,70 @@
1
+ export type DaemonProcessIdentityLookupResult = {
2
+ state: "identified";
3
+ identity: string;
4
+ } | {
5
+ state: "dead";
6
+ } | {
7
+ state: "unknown";
8
+ };
9
+ export type DaemonProcessIdentityLookup = (pid: number) => Promise<DaemonProcessIdentityLookupResult>;
10
+ export type DaemonWorkerLease = {
11
+ instanceId: string;
12
+ pid: number;
13
+ processIdentity: string;
14
+ path: string;
15
+ /** Proves this exact process generation still owns the published lease. */
16
+ assertOwned(): Promise<void>;
17
+ release(): Promise<void>;
18
+ };
19
+ /**
20
+ * A process-generation election, separate from the UI listener election.
21
+ *
22
+ * The listener may be replaced or borrowed during a package-manager/service
23
+ * reload. Queue, index, upload, retention and team reconciliation are a single
24
+ * stateful unit and must stay owned by the old daemon until all of its lanes
25
+ * have quiesced. Keeping this election in the vault makes manager crashes and
26
+ * PID reuse safe too; no particular service manager is trusted as the lock.
27
+ */
28
+ export declare const daemonWorkerLeaseDir: (dataDir: string) => string;
29
+ /**
30
+ * The installed service may itself still carry an older background scheduling
31
+ * class while it reconciles its launchd definition. On macOS, execute the
32
+ * tiny `ps` probe at the default task policy so that migration can make
33
+ * progress under load. This is an argv-only wrapper: no shell or interpolation.
34
+ */
35
+ export declare function daemonPsIdentityCommand(pid: number, platform?: NodeJS.Platform): {
36
+ file: string;
37
+ args: string[];
38
+ };
39
+ export declare const daemonProcessIdentity: DaemonProcessIdentityLookup;
40
+ /** Legacy ps generations did not record locale/timezone. A changed rendering
41
+ * cannot prove PID reuse across an upgrade; a still-live legacy owner remains
42
+ * uncertain until it exits instead of being mistaken for a stale process. */
43
+ export declare function classifyDaemonProcessGeneration(owner: DaemonProcessIdentityLookupResult, expectedIdentity: string): "current" | "stale" | "unknown";
44
+ /** Publish before a worker receives work or SIGSTOP. The immutable candidate
45
+ * outlives its daemon, so a replacement cannot overlap an orphan's last writes.
46
+ * It is not released on parent shutdown until the exact child is proved gone. */
47
+ export declare function registerDaemonIndexWorker(dataDir: string, pid: number, processIdentity: string, options?: {
48
+ identityLookup?: DaemonProcessIdentityLookup;
49
+ }): Promise<{
50
+ scratchGeneration: string;
51
+ retireAfterExit(): Promise<void>;
52
+ }>;
53
+ /** Context also writes durable per-session state; a replacement daemon must
54
+ * wait for its exact old generation, even while the orphan watchdog cancels it. */
55
+ export declare function registerDaemonContextWorker(dataDir: string, pid: number, processIdentity: string): Promise<{
56
+ retireAfterExit(): Promise<void>;
57
+ }>;
58
+ /**
59
+ * Elects the sole complete daemon worker for this vault.
60
+ *
61
+ * `null` means another identified generation owns the vault, or an existing
62
+ * record could not safely be classified. Uncertainty fails closed: preserving
63
+ * availability never justifies two retention/index writers.
64
+ */
65
+ export declare function acquireDaemonWorkerLease(dataDir: string, options?: {
66
+ identityLookup?: DaemonProcessIdentityLookup;
67
+ electionWindowMs?: number;
68
+ /** Test seam; production uses a one-second fail-closed ceiling. */
69
+ identityLookupTimeoutMs?: number;
70
+ }): Promise<DaemonWorkerLease | null>;
@@ -0,0 +1,420 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
+ import { mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { cleanupInterruptedIndexScratch } from "./index-scratch-cleanup.js";
7
+ const execFileAsync = promisify(execFile);
8
+ const ELECTION_WINDOW_MS = 80;
9
+ // Process generation is part of the safety boundary, but it must not become an
10
+ // availability boundary. On a heavily loaded Mac, `ps` can inherit the
11
+ // daemon's scheduling class and otherwise wait indefinitely before emitting
12
+ // its one line. A timeout is uncertainty, never proof that a lease is stale.
13
+ // A bound on the identity probe, not a budget for it. POSIX answers from
14
+ // procfs or a `ps` call in milliseconds; Windows has no such file and must
15
+ // start PowerShell, which on a cold, loaded machine takes seconds — at 750 ms
16
+ // every probe there timed out, and a timed-out probe reads as "this process
17
+ // cannot be verified", which refuses queue locks, scratch reservations and
18
+ // workers outright (about 150 failures on a Windows CI runner).
19
+ const PROCESS_IDENTITY_COMMAND_TIMEOUT_MS = process.platform === "win32" ? 10_000 : 750;
20
+ const PROCESS_IDENTITY_LOOKUP_TIMEOUT_MS = 1_000;
21
+ const processIdentityCommandOptions = {
22
+ windowsHide: true,
23
+ timeout: PROCESS_IDENTITY_COMMAND_TIMEOUT_MS,
24
+ killSignal: "SIGKILL",
25
+ maxBuffer: 4 * 1024,
26
+ // ps lstart is otherwise localized and timezone-dependent: two observers
27
+ // could call the same live generation stale, or fail to arm its watchdog.
28
+ env: identityCommandEnvironment(),
29
+ };
30
+ /**
31
+ * The environment the identity command runs with.
32
+ *
33
+ * The allowlist exists for POSIX, where `ps lstart` prints a localized,
34
+ * timezone-dependent date: two observers reading different locales could call
35
+ * the same live generation stale. Windows reads an integer tick count, which
36
+ * no locale can change, and powershell.exe will not start under a pruned
37
+ * environment — measured on a CI runner, it hung until the probe's timeout
38
+ * (10 s) with a short list and answered in 0.6 s with the inherited one, and
39
+ * a probe that cannot answer refuses queue locks, scratch reservations and
40
+ * workers. So Windows inherits the environment and pins only the locale.
41
+ */
42
+ function identityCommandEnvironment() {
43
+ const deterministic = { LC_ALL: "C", LANG: "C", TZ: "UTC" };
44
+ if (process.platform === "win32")
45
+ return { ...process.env, ...deterministic };
46
+ return {
47
+ PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR,
48
+ TEMP: process.env.TEMP, TMP: process.env.TMP, TMPDIR: process.env.TMPDIR,
49
+ ...deterministic,
50
+ };
51
+ }
52
+ let currentDaemonProcessIdentity;
53
+ /**
54
+ * A process-generation election, separate from the UI listener election.
55
+ *
56
+ * The listener may be replaced or borrowed during a package-manager/service
57
+ * reload. Queue, index, upload, retention and team reconciliation are a single
58
+ * stateful unit and must stay owned by the old daemon until all of its lanes
59
+ * have quiesced. Keeping this election in the vault makes manager crashes and
60
+ * PID reuse safe too; no particular service manager is trusted as the lock.
61
+ */
62
+ export const daemonWorkerLeaseDir = (dataDir) => join(dataDir, "daemon-worker-leases");
63
+ const pause = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
64
+ function processLiveness(pid) {
65
+ if (!Number.isInteger(pid) || pid <= 0)
66
+ return "dead";
67
+ try {
68
+ process.kill(pid, 0);
69
+ return "alive";
70
+ }
71
+ catch (error) {
72
+ return error.code === "ESRCH" ? "dead" : "unknown";
73
+ }
74
+ }
75
+ /**
76
+ * The installed service may itself still carry an older background scheduling
77
+ * class while it reconciles its launchd definition. On macOS, execute the
78
+ * tiny `ps` probe at the default task policy so that migration can make
79
+ * progress under load. This is an argv-only wrapper: no shell or interpolation.
80
+ */
81
+ export function daemonPsIdentityCommand(pid, platform = process.platform) {
82
+ const args = ["-p", String(pid), "-o", "lstart="];
83
+ return platform === "darwin"
84
+ ? { file: "/usr/sbin/taskpolicy", args: ["-a", "/bin/ps", ...args] }
85
+ : { file: "ps", args };
86
+ }
87
+ /** A PID plus this kernel-backed generation is not reusable by a later process. */
88
+ async function uncachedDaemonProcessIdentity(pid) {
89
+ const before = processLiveness(pid);
90
+ if (before === "dead")
91
+ return { state: "dead" };
92
+ if (before === "unknown")
93
+ return { state: "unknown" };
94
+ try {
95
+ if (process.platform === "linux") {
96
+ const [raw, bootId] = await Promise.all([
97
+ readFile(`/proc/${pid}/stat`, "utf8"),
98
+ readFile("/proc/sys/kernel/random/boot_id", "utf8"),
99
+ ]);
100
+ const fieldsAfterName = raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/);
101
+ const startTicks = fieldsAfterName[19]; // proc(5) field 22; this slice begins at field 3.
102
+ if (startTicks && /^\d+$/.test(startTicks) && bootId.trim()) {
103
+ return { state: "identified", identity: `linux:${bootId.trim()}:${startTicks}` };
104
+ }
105
+ }
106
+ else if (process.platform === "win32") {
107
+ const command = `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;
108
+ const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], processIdentityCommandOptions);
109
+ const started = stdout.trim();
110
+ if (/^\d+$/.test(started))
111
+ return { state: "identified", identity: `windows:${started}` };
112
+ }
113
+ else {
114
+ const command = daemonPsIdentityCommand(pid);
115
+ const { stdout } = await execFileAsync(command.file, command.args, processIdentityCommandOptions);
116
+ const started = stdout.trim();
117
+ if (started)
118
+ return { state: "identified", identity: `ps-utc:${started}` };
119
+ }
120
+ }
121
+ catch { }
122
+ return processLiveness(pid) === "dead" ? { state: "dead" } : { state: "unknown" };
123
+ }
124
+ export const daemonProcessIdentity = async (pid) => {
125
+ if (pid !== process.pid)
126
+ return uncachedDaemonProcessIdentity(pid);
127
+ if (!currentDaemonProcessIdentity) {
128
+ const lookup = uncachedDaemonProcessIdentity(pid);
129
+ currentDaemonProcessIdentity = lookup;
130
+ void lookup.then((result) => {
131
+ // This process's exact kernel generation cannot change while it remains
132
+ // alive, so an identified result is safe to retain. A transient failure
133
+ // must not poison every future election in this long-lived process.
134
+ if (result.state !== "identified" && currentDaemonProcessIdentity === lookup) {
135
+ currentDaemonProcessIdentity = undefined;
136
+ }
137
+ }, () => {
138
+ if (currentDaemonProcessIdentity === lookup)
139
+ currentDaemonProcessIdentity = undefined;
140
+ });
141
+ }
142
+ return currentDaemonProcessIdentity;
143
+ };
144
+ /**
145
+ * Bounds both production and injected generation lookups. Only an explicit
146
+ * `dead` result permits reclaiming another candidate; timeout, exceptions and
147
+ * malformed adapter results all remain unknown and therefore fail closed.
148
+ */
149
+ async function boundedProcessIdentity(lookup, pid, timeoutMs) {
150
+ let timeout;
151
+ const deadline = new Promise((resolve) => {
152
+ timeout = setTimeout(() => resolve({ state: "unknown" }), timeoutMs);
153
+ });
154
+ try {
155
+ const result = await Promise.race([
156
+ Promise.resolve().then(() => lookup(pid)),
157
+ deadline,
158
+ ]);
159
+ if (result.state === "dead" || result.state === "unknown")
160
+ return result;
161
+ if (result.state === "identified" && typeof result.identity === "string"
162
+ && result.identity.length > 0 && result.identity.length <= 512)
163
+ return result;
164
+ return { state: "unknown" };
165
+ }
166
+ catch {
167
+ return processLiveness(pid) === "dead" ? { state: "dead" } : { state: "unknown" };
168
+ }
169
+ finally {
170
+ if (timeout)
171
+ clearTimeout(timeout);
172
+ }
173
+ }
174
+ function parseCandidate(raw) {
175
+ try {
176
+ const value = JSON.parse(raw);
177
+ if (value.version !== 1 || typeof value.instanceId !== "string"
178
+ || !/^[A-Za-z0-9_-]{16,128}$/.test(value.instanceId)
179
+ || !Number.isInteger(value.pid) || (value.pid ?? 0) <= 0
180
+ || typeof value.processIdentity !== "string" || value.processIdentity.length < 1 || value.processIdentity.length > 512
181
+ || (value.role !== undefined && value.role !== "index-child" && value.role !== "context-child")
182
+ || (value.role !== undefined && (typeof value.scratchGeneration !== "string"
183
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value.scratchGeneration)))
184
+ || !Number.isFinite(value.createdAtMs))
185
+ return null;
186
+ return value;
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
192
+ async function readCandidate(path) {
193
+ try {
194
+ const raw = await readFile(path, "utf8");
195
+ const value = parseCandidate(raw);
196
+ return value ? { state: "present", raw, value } : { state: "invalid" };
197
+ }
198
+ catch (error) {
199
+ return error.code === "ENOENT" ? { state: "missing" } : { state: "invalid" };
200
+ }
201
+ }
202
+ /** Legacy ps generations did not record locale/timezone. A changed rendering
203
+ * cannot prove PID reuse across an upgrade; a still-live legacy owner remains
204
+ * uncertain until it exits instead of being mistaken for a stale process. */
205
+ export function classifyDaemonProcessGeneration(owner, expectedIdentity) {
206
+ if (owner.state === "dead")
207
+ return "stale";
208
+ if (owner.state === "unknown")
209
+ return "unknown";
210
+ if (expectedIdentity.startsWith("ps:") && owner.identity.startsWith("ps-utc:"))
211
+ return "unknown";
212
+ return owner.identity === expectedIdentity ? "current" : "stale";
213
+ }
214
+ async function candidateState(candidate, lookup) {
215
+ const owner = await lookup(candidate.pid);
216
+ return classifyDaemonProcessGeneration(owner, candidate.processIdentity);
217
+ }
218
+ /**
219
+ * Moves an immutable candidate out of the election instead of unlinking the
220
+ * pathname observed earlier. The inode/content recheck prevents a stale
221
+ * observer from deleting a successor if storage is concurrently repaired.
222
+ */
223
+ async function quarantineExact(path, observed, reason) {
224
+ const [current, details] = await Promise.all([
225
+ readFile(path, "utf8").catch(() => null),
226
+ stat(path).catch(() => null),
227
+ ]);
228
+ if (current !== observed.raw || !details?.isFile())
229
+ return;
230
+ const fingerprint = createHash("sha256")
231
+ .update(`${observed.value.instanceId}\n${details.dev}:${details.ino}:${details.birthtimeMs}\n${observed.raw}`)
232
+ .digest("hex")
233
+ .slice(0, 20);
234
+ const quarantine = `${path}.${reason}-${fingerprint}-${randomUUID()}`;
235
+ try {
236
+ await rename(path, quarantine);
237
+ await rm(quarantine, { force: true });
238
+ }
239
+ catch (error) {
240
+ if (error.code !== "ENOENT")
241
+ throw error;
242
+ }
243
+ }
244
+ async function publishCandidate(directory, candidate) {
245
+ const path = join(directory, `${candidate.instanceId}.json`);
246
+ const staging = join(directory, `.${candidate.instanceId}.${randomUUID()}.pending`);
247
+ const handle = await open(staging, "wx", 0o600);
248
+ try {
249
+ await handle.writeFile(JSON.stringify(candidate) + "\n", "utf8");
250
+ await handle.sync();
251
+ }
252
+ finally {
253
+ await handle.close();
254
+ }
255
+ try {
256
+ await rename(staging, path);
257
+ let directoryHandle;
258
+ try {
259
+ directoryHandle = await open(directory, "r");
260
+ await directoryHandle.sync();
261
+ }
262
+ catch (error) {
263
+ if (process.platform !== "win32")
264
+ throw error;
265
+ }
266
+ finally {
267
+ await directoryHandle?.close();
268
+ }
269
+ return path;
270
+ }
271
+ catch (error) {
272
+ await rm(staging, { force: true }).catch(() => undefined);
273
+ throw error;
274
+ }
275
+ }
276
+ /** Publish before a worker receives work or SIGSTOP. The immutable candidate
277
+ * outlives its daemon, so a replacement cannot overlap an orphan's last writes.
278
+ * It is not released on parent shutdown until the exact child is proved gone. */
279
+ export async function registerDaemonIndexWorker(dataDir, pid, processIdentity, options = {}) {
280
+ return registerDaemonBackgroundWorker(dataDir, pid, processIdentity, "index-child", options);
281
+ }
282
+ /** Context also writes durable per-session state; a replacement daemon must
283
+ * wait for its exact old generation, even while the orphan watchdog cancels it. */
284
+ export async function registerDaemonContextWorker(dataDir, pid, processIdentity) {
285
+ return registerDaemonBackgroundWorker(dataDir, pid, processIdentity, "context-child");
286
+ }
287
+ async function registerDaemonBackgroundWorker(dataDir, pid, processIdentity, role, options = {}) {
288
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid
289
+ || !processIdentity || processIdentity.length > 512)
290
+ throw new Error("Invalid index worker process generation");
291
+ const lookup = (target) => boundedProcessIdentity(options.identityLookup ?? daemonProcessIdentity, target, PROCESS_IDENTITY_LOOKUP_TIMEOUT_MS);
292
+ const current = await lookup(pid);
293
+ if (current.state !== "identified" || current.identity !== processIdentity) {
294
+ throw new Error("Index worker process generation could not be verified before publication");
295
+ }
296
+ const directory = daemonWorkerLeaseDir(dataDir);
297
+ await mkdir(directory, { recursive: true, mode: 0o700 });
298
+ const candidate = {
299
+ version: 1, role, scratchGeneration: randomUUID(), instanceId: randomBytes(24).toString("base64url"),
300
+ pid, processIdentity, createdAtMs: Date.now(),
301
+ };
302
+ const path = await publishCandidate(directory, candidate);
303
+ const observed = { raw: JSON.stringify(candidate) + "\n", value: candidate };
304
+ return {
305
+ scratchGeneration: candidate.scratchGeneration,
306
+ async retireAfterExit() {
307
+ if (await candidateState(candidate, lookup) !== "stale") {
308
+ throw new Error("A live or unverified index worker still owns its startup barrier");
309
+ }
310
+ if (role === "index-child")
311
+ await cleanupInterruptedIndexScratch(dataDir, candidate.scratchGeneration);
312
+ await quarantineExact(path, observed, "released");
313
+ },
314
+ };
315
+ }
316
+ /**
317
+ * Elects the sole complete daemon worker for this vault.
318
+ *
319
+ * `null` means another identified generation owns the vault, or an existing
320
+ * record could not safely be classified. Uncertainty fails closed: preserving
321
+ * availability never justifies two retention/index writers.
322
+ */
323
+ export async function acquireDaemonWorkerLease(dataDir, options = {}) {
324
+ const lookup = options.identityLookup ?? daemonProcessIdentity;
325
+ const lookupTimeoutMs = Math.max(1, options.identityLookupTimeoutMs ?? PROCESS_IDENTITY_LOOKUP_TIMEOUT_MS);
326
+ // One election can observe several immutable candidates for the same process
327
+ // (including its freshly published candidate). Resolve that generation once
328
+ // so one delayed `ps` cannot be multiplied by the number of lease files.
329
+ const generations = new Map();
330
+ const lookupOnce = (pid) => {
331
+ let pending = generations.get(pid);
332
+ if (!pending) {
333
+ pending = boundedProcessIdentity(lookup, pid, lookupTimeoutMs);
334
+ generations.set(pid, pending);
335
+ }
336
+ return pending;
337
+ };
338
+ const self = await lookupOnce(process.pid);
339
+ if (self.state !== "identified") {
340
+ throw new Error("Sealkeep could not verify this process generation, so it refused to start a vault worker");
341
+ }
342
+ const directory = daemonWorkerLeaseDir(dataDir);
343
+ await mkdir(directory, { recursive: true, mode: 0o700 });
344
+ const candidate = {
345
+ version: 1,
346
+ instanceId: randomBytes(24).toString("base64url"),
347
+ pid: process.pid,
348
+ processIdentity: self.identity,
349
+ createdAtMs: Date.now(),
350
+ };
351
+ const path = await publishCandidate(directory, candidate);
352
+ const observedSelf = { raw: JSON.stringify(candidate) + "\n", value: candidate };
353
+ const release = () => quarantineExact(path, observedSelf, "released");
354
+ const assertOwned = async () => {
355
+ const observed = await readCandidate(path);
356
+ if (observed.state !== "present" || observed.raw !== observedSelf.raw
357
+ || observed.value.instanceId !== candidate.instanceId
358
+ || observed.value.pid !== candidate.pid
359
+ || observed.value.processIdentity !== candidate.processIdentity) {
360
+ throw new Error("Sealkeep no longer owns the daemon worker lease for this vault");
361
+ }
362
+ };
363
+ await pause(Math.max(0, options.electionWindowMs ?? ELECTION_WINDOW_MS));
364
+ const entries = await readdir(directory).catch(() => []);
365
+ const active = [];
366
+ let uncertain = false;
367
+ let liveIndexChild = false;
368
+ for (const name of entries) {
369
+ if (!name.endsWith(".json"))
370
+ continue;
371
+ const candidatePath = join(directory, name);
372
+ const observed = await readCandidate(candidatePath);
373
+ // A losing contender can remove its uniquely named immutable candidate
374
+ // after our readdir snapshot. Its absence is normal contention, not
375
+ // evidence of a corrupt possibly-live owner.
376
+ if (observed.state === "missing")
377
+ continue;
378
+ // Published candidates are written+fsynced before atomic rename. An
379
+ // invalid JSON record therefore means corruption, not an in-flight write.
380
+ if (observed.state === "invalid") {
381
+ uncertain = true;
382
+ continue;
383
+ }
384
+ const state = await candidateState(observed.value, lookupOnce);
385
+ if (state === "stale") {
386
+ if (observed.value.role === "index-child") {
387
+ try {
388
+ await cleanupInterruptedIndexScratch(dataDir, observed.value.scratchGeneration);
389
+ }
390
+ catch {
391
+ uncertain = true;
392
+ continue;
393
+ }
394
+ }
395
+ await quarantineExact(candidatePath, observed, "stale").catch(() => { uncertain = true; });
396
+ continue;
397
+ }
398
+ if (state === "unknown") {
399
+ uncertain = true;
400
+ continue;
401
+ }
402
+ if (observed.value.role === "index-child" || observed.value.role === "context-child")
403
+ liveIndexChild = true;
404
+ else
405
+ active.push(observed.value);
406
+ }
407
+ active.sort((left, right) => left.createdAtMs - right.createdAtMs || left.instanceId.localeCompare(right.instanceId));
408
+ if (uncertain || liveIndexChild || active[0]?.instanceId !== candidate.instanceId) {
409
+ await release().catch(() => undefined);
410
+ return null;
411
+ }
412
+ return {
413
+ instanceId: candidate.instanceId,
414
+ pid: candidate.pid,
415
+ processIdentity: candidate.processIdentity,
416
+ path,
417
+ assertOwned,
418
+ release,
419
+ };
420
+ }