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/ui.d.ts CHANGED
@@ -11,7 +11,6 @@ export declare const blue: (value: string) => string;
11
11
  export declare const green: (value: string) => string;
12
12
  export declare const amber: (value: string) => string;
13
13
  export declare const red: (value: string) => string;
14
- export declare const underline: (value: string) => string;
15
14
  /** State markers. Text-first so they survive `NO_COLOR` and screen readers. */
16
15
  export declare const mark: {
17
16
  ok: () => string;
@@ -21,7 +20,7 @@ export declare const mark: {
21
20
  };
22
21
  export declare function heading(text: string): string;
23
22
  /** The recurring device: the line past which plaintext does not travel. */
24
- export declare function vaultline(label?: string): string;
23
+ export declare function divider(label?: string): string;
25
24
  export declare function keyValue(pairs: [string, string][], indent?: string): string;
26
25
  export declare function bytes(value: number): string;
27
26
  export declare function relativeTime(iso: string): string;
package/dist/src/ui.js CHANGED
@@ -14,7 +14,6 @@ export const blue = wrap("38;5;33", "39");
14
14
  export const green = wrap("38;5;29", "39");
15
15
  export const amber = wrap("38;5;172", "39");
16
16
  export const red = wrap("38;5;160", "39");
17
- export const underline = wrap("4", "24");
18
17
  /** State markers. Text-first so they survive `NO_COLOR` and screen readers. */
19
18
  export const mark = {
20
19
  ok: () => green("ok"),
@@ -26,7 +25,7 @@ export function heading(text) {
26
25
  return `\n${bold(text)}\n${dim("─".repeat(Math.min(text.length + 12, 60)))}`;
27
26
  }
28
27
  /** The recurring device: the line past which plaintext does not travel. */
29
- export function vaultline(label = "plaintext ends here") {
28
+ export function divider(label = "plaintext ends here") {
30
29
  return `${dim("─".repeat(4))}${dim("┤")} ${blue(label)} ${dim("├")}${dim("─".repeat(Math.max(4, 52 - label.length)))}`;
31
30
  }
32
31
  export function keyValue(pairs, indent = " ") {
@@ -1,5 +1,6 @@
1
1
  import { type ProviderUploadClient, type UploadLease } from "./control-plane.js";
2
2
  import { type EndpointOverrides } from "./providers/index.js";
3
+ import { type ArchiveRecordV2 } from "./types.js";
3
4
  export type UploadOptions = {
4
5
  /** Routed multi-target override: upload to THIS storage instead of config.remoteStorage. */
5
6
  remoteStorage?: {
@@ -16,15 +17,41 @@ export type UploadOptions = {
16
17
  overrides?: EndpointOverrides;
17
18
  now?: number;
18
19
  ttlMs?: number;
20
+ signal?: AbortSignal;
21
+ /** Cumulative bytes read or sent; used only to reset an owning idle deadline. */
22
+ onProgress?: (bytes: number) => void;
23
+ /** Sustained upload ceiling. The daemon supplies the per-machine setting;
24
+ * an explicit/manual upload may omit it. */
25
+ maxBytesPerSecond?: number;
26
+ /** Deterministic pacing seams. */
27
+ paceNow?: () => number;
28
+ paceSleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
29
+ /** Internal bridge path for a provider-to-provider transfer. */
30
+ ciphertextPath?: string;
31
+ /**
32
+ * A transfer run coalesces this advisory account update after all of its
33
+ * destination verifies and source deletes. Ordinary one-off/queue uploads
34
+ * keep the non-blocking default.
35
+ */
36
+ reportTargetUsage?: boolean;
19
37
  };
20
38
  export type UploadOutcome = {
21
39
  archiveId: string;
22
40
  objectKey: string;
41
+ copyId: string;
23
42
  bytes: number;
24
43
  checksum: string;
25
44
  verified: boolean;
26
45
  provider: string;
27
46
  };
47
+ export declare function archiveWrapsEveryMember(record: ArchiveRecordV2, publicKeys: string[]): boolean;
48
+ /** Retry grants for durable project archives, including ones uploaded offline. */
49
+ export declare function reconcileManagedTeamArchives(dataDir: string, options?: {
50
+ signal?: AbortSignal;
51
+ }): Promise<{
52
+ registered: number;
53
+ failed: number;
54
+ }>;
28
55
  /**
29
56
  * Uploads one archive's ciphertext and verifies it landed intact.
30
57
  *
@@ -1,12 +1,142 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
2
+ import { open } from "node:fs/promises";
3
3
  import { assertLeaseUsable } from "./control-plane.js";
4
4
  import { fail } from "./errors.js";
5
5
  import { recordAudit } from "./audit.js";
6
6
  import { createActiveLease } from "./providers/index.js";
7
7
  import { uploadClientFromStore } from "./providers/index.js";
8
8
  import { isV2 } from "./types.js";
9
- import { listArchives, readConfig, writeRecord } from "./vault.js";
9
+ import { listArchives, mutateArchiveRecord, readConfig } from "./vault.js";
10
+ import { STORAGE_PREFIX_DEFAULT } from "./branding.js";
11
+ import { appendArchiveCopy, archiveCopies, archiveCopyId, hasVerifiedCopy } from "./archive-copies.js";
12
+ import { collectChunks, digestChunks, fileHandleChunks, LEGACY_BUFFER_LIMIT_BYTES, tapDigest } from "./byte-stream.js";
13
+ import { paceBackgroundByteStream } from "./background-bandwidth.js";
14
+ export function archiveWrapsEveryMember(record, publicKeys) {
15
+ if (!record.envelope || publicKeys.length === 0)
16
+ return false;
17
+ const wanted = [...new Set(publicKeys)].sort();
18
+ const actual = record.envelope.wrappedKeys
19
+ .filter((entry) => entry.type === "x25519")
20
+ .map((entry) => entry.recipientPublicKey)
21
+ .sort();
22
+ // Phrase recovery remains private to the owner and is not a team identity.
23
+ // Every x25519 recipient, however, is an account/device that can open the
24
+ // object once the hosted membership gate serves it. A subset check retained
25
+ // removed or unrelated keys and could make an old grant look current.
26
+ return actual.length === wanted.length
27
+ && actual.every((publicKey, index) => publicKey === wanted[index]);
28
+ }
29
+ function configuredSpaceKeyForRecord(config, record, project) {
30
+ const bindings = config.teamSpaces ?? {};
31
+ const stableScope = record.source.projectScope;
32
+ if (stableScope) {
33
+ return Object.values(bindings).some((binding) => binding.spaceKey === stableScope)
34
+ ? stableScope
35
+ : null;
36
+ }
37
+ return project ? bindings[project]?.spaceKey ?? null : null;
38
+ }
39
+ /**
40
+ * Publish the membership-gated project grant after the account copy is proven
41
+ * durable. This is deliberately idempotent and best-effort at the upload call
42
+ * site: the daemon-wide reconciliation below retries every missed grant, so a
43
+ * short control-plane outage never turns a successful backup into a failed
44
+ * backup or requires a person to run a sharing command.
45
+ */
46
+ async function registerManagedTeamArchive(dataDir, record, options = {}) {
47
+ options.signal?.throwIfAborted();
48
+ if (!archiveCopies(record).some((copy) => copy.provider === "vaultline" && copy.verifiedAt))
49
+ return false;
50
+ const config = await readConfig(dataDir);
51
+ const { projectOfRecord } = await import("./adapters.js");
52
+ const project = record.source.projectScope ? null : await projectOfRecord(record).catch(() => null);
53
+ const spaceKey = configuredSpaceKeyForRecord(config, record, project);
54
+ if (!spaceKey)
55
+ return false;
56
+ const { getTeamRoster, listTeamArchives, registerTeamArchive } = await import("./cloud.js");
57
+ const roster = await getTeamRoster(dataDir, spaceKey, undefined, { signal: options.signal });
58
+ if (!archiveWrapsEveryMember(record, roster.members.map((member) => member.public_key)))
59
+ return false;
60
+ const existing = await listTeamArchives(dataDir, spaceKey, undefined, { signal: options.signal });
61
+ if (existing.some((row) => row.vault_ref === record.id && row.membership_version === roster.membership_version))
62
+ return true;
63
+ await registerTeamArchive(dataDir, spaceKey, record.id, roster.membership_version, undefined, { signal: options.signal });
64
+ return true;
65
+ }
66
+ /** Retry grants for durable project archives, including ones uploaded offline. */
67
+ export async function reconcileManagedTeamArchives(dataDir, options = {}) {
68
+ options.signal?.throwIfAborted();
69
+ const config = await readConfig(dataDir);
70
+ if (!config.teamSpaces || Object.keys(config.teamSpaces).length === 0)
71
+ return { registered: 0, failed: 0 };
72
+ const { projectOfRecord } = await import("./adapters.js");
73
+ const bySpace = new Map();
74
+ for (const record of await listArchives(dataDir)) {
75
+ if (!isV2(record) || !archiveCopies(record).some((copy) => copy.provider === "vaultline" && copy.verifiedAt))
76
+ continue;
77
+ const project = record.source.projectScope ? null : await projectOfRecord(record).catch(() => null);
78
+ const spaceKey = configuredSpaceKeyForRecord(config, record, project);
79
+ if (!spaceKey)
80
+ continue;
81
+ const records = bySpace.get(spaceKey) ?? [];
82
+ records.push(record);
83
+ bySpace.set(spaceKey, records);
84
+ }
85
+ const { getTeamRoster, listTeamArchives, registerTeamArchive } = await import("./cloud.js");
86
+ let registered = 0;
87
+ let failed = 0;
88
+ const membershipVersionBySpace = new Map();
89
+ for (const [spaceKey, records] of bySpace) {
90
+ options.signal?.throwIfAborted();
91
+ let shareable;
92
+ try {
93
+ const roster = await getTeamRoster(dataDir, spaceKey, undefined, { signal: options.signal });
94
+ const memberKeys = roster.members.map((member) => member.public_key);
95
+ shareable = records.filter((record) => archiveWrapsEveryMember(record, memberKeys));
96
+ membershipVersionBySpace.set(spaceKey, roster.membership_version);
97
+ }
98
+ catch (error) {
99
+ if (options.signal?.aborted)
100
+ throw options.signal.reason ?? error;
101
+ failed += records.length;
102
+ continue;
103
+ }
104
+ if (shareable.length === 0)
105
+ continue;
106
+ let existing;
107
+ try {
108
+ const membershipVersion = membershipVersionBySpace.get(spaceKey);
109
+ existing = new Set((await listTeamArchives(dataDir, spaceKey, undefined, { signal: options.signal }))
110
+ .filter((row) => row.membership_version === membershipVersion)
111
+ .map((row) => row.vault_ref));
112
+ }
113
+ catch (error) {
114
+ if (options.signal?.aborted)
115
+ throw options.signal.reason ?? error;
116
+ failed += shareable.length;
117
+ continue;
118
+ }
119
+ for (const record of shareable) {
120
+ options.signal?.throwIfAborted();
121
+ if (existing.has(record.id))
122
+ continue;
123
+ try {
124
+ const membershipVersion = membershipVersionBySpace.get(spaceKey);
125
+ if (!membershipVersion)
126
+ throw new Error("team_roster_version_missing");
127
+ await registerTeamArchive(dataDir, spaceKey, record.id, membershipVersion, undefined, { signal: options.signal });
128
+ existing.add(record.id);
129
+ registered += 1;
130
+ }
131
+ catch (error) {
132
+ if (options.signal?.aborted)
133
+ throw options.signal.reason ?? error;
134
+ failed += 1;
135
+ }
136
+ }
137
+ }
138
+ return { registered, failed };
139
+ }
10
140
  /**
11
141
  * Uploads one archive's ciphertext and verifies it landed intact.
12
142
  *
@@ -17,64 +147,274 @@ import { listArchives, readConfig, writeRecord } from "./vault.js";
17
147
  */
18
148
  export async function uploadArchive(dataDir, archiveId, options = {}) {
19
149
  const config = await readConfig(dataDir);
20
- const remoteStorage = options.remoteStorage ?? config.remoteStorage ?? fail("storage_not_configured", "No remote target configured. Run: sealkeep storage configure ...");
21
150
  const record = (await listArchives(dataDir)).find((item) => item.id === archiveId);
22
151
  if (!record)
23
152
  fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
24
153
  if (!isV2(record))
25
154
  fail("invalid_argument", `Archive ${archiveId} uses format v1. Run: sealkeep migrate`, { archiveId });
26
- const ciphertext = await readFile(record.objectPath);
27
- const localChecksum = createHash("sha256").update(ciphertext).digest("base64");
28
- const providerConfig = remoteStorage;
29
- const lease = createActiveLease(providerConfig, { archiveId, ciphertextSha256: record.cipher.ciphertextSha256, bytes: ciphertext.length, now: options.now, ttlMs: options.ttlMs });
30
- assertLeaseUsable(lease, options.now);
31
- const client = options.client ?? await uploadClientFromStore(dataDir, options.storageConfigId ?? config.vaultId, providerConfig, options.overrides);
32
- const uploaded = await client.upload(lease, ciphertext);
33
- const head = await client.head(lease);
34
- // What the provider should be holding. For a single-part object that is the
35
- // whole-object SHA-256; for a multipart one it is S3's composite of the part
36
- // digests, which `upload` returns. Comparing the stored checksum against
37
- // `localChecksum` unconditionally would fail every multipart archive.
38
- const expected = uploaded.remoteChecksum;
39
- const problems = [];
40
- if (!head.exists)
41
- problems.push("the provider does not report the object as present");
42
- if (head.bytes !== ciphertext.length)
43
- problems.push(`the provider stored ${head.bytes} bytes; ${ciphertext.length} were sent`);
44
- if (head.checksum === undefined) {
45
- // Previously this case silently skipped the comparison, which left byte
46
- // count as the only real check — an endpoint returning the right length of
47
- // wrong bytes verified clean. Reclaim depends on this verdict, so an
48
- // unprovable upload is a failed upload.
49
- problems.push("the provider did not report a stored checksum, so the bytes it holds cannot be proven");
155
+ const project = record.source.project ?? null;
156
+ // A stable scope means the archive was sealed for a team even if this
157
+ // machine has since removed or renamed its local binding. Older records may
158
+ // carry only the project, so the current binding is the second proof.
159
+ const teamBound = Boolean(record.source.projectScope || (project && config.teamSpaces?.[project]));
160
+ const explicitTransport = options.remoteStorage !== undefined || options.client !== undefined;
161
+ let targetId = options.targetId;
162
+ let remoteStorage = options.remoteStorage ?? config.remoteStorage ?? null;
163
+ let routedTarget = null;
164
+ if (explicitTransport) {
165
+ // Explicit transports are test/advanced seams and keep their old behavior,
166
+ // but they may not move a team-readable archive into personal storage.
167
+ const provider = options.remoteStorage?.provider ?? options.client?.kind ?? remoteStorage?.provider;
168
+ if (teamBound && provider !== "vaultline") {
169
+ fail("storage_not_configured", "Shared project memory must be stored in Sealkeep Cloud so every approved member can reach the same encrypted archive.");
170
+ }
171
+ // A caller may choose an account target while supplying its transport
172
+ // shape explicitly (the CLI's Drive single-object path does this). The id
173
+ // still owns the credential and endpoint; explicit transport must not
174
+ // silently fall back to the vault-wide legacy secret.
175
+ if (targetId && options.remoteStorage) {
176
+ const { resolveTargets } = await import("./storage-targets.js");
177
+ const named = (await resolveTargets(dataDir)).find((target) => target.id === targetId);
178
+ if (!named)
179
+ fail("storage_not_configured", `Storage target "${targetId}" is not configured on this machine.`);
180
+ if (named.provider !== options.remoteStorage.provider
181
+ || named.bucket !== options.remoteStorage.bucket) {
182
+ fail("invalid_argument", `Storage target "${targetId}" no longer describes the supplied ${options.remoteStorage.provider} destination.`);
183
+ }
184
+ routedTarget = named;
185
+ }
50
186
  }
51
- else if (head.checksum !== expected) {
52
- problems.push(`the provider's stored checksum ${head.checksum} does not match the ${expected} we uploaded`);
187
+ else {
188
+ // This is the single routing boundary for every ordinary caller: dashboard,
189
+ // daemon, CLI upload-pending, and integrations all inherit account order,
190
+ // caps and project pins without each surface reimplementing them.
191
+ const { chooseTarget } = await import("./storage-targets.js");
192
+ const target = await chooseTarget(dataDir, {
193
+ project,
194
+ projectKey: record.source.projectKey ?? null,
195
+ bytes: record.cipher.storedBytes,
196
+ requireManaged: teamBound,
197
+ ...(targetId ? { targetId } : {}),
198
+ });
199
+ routedTarget = target;
200
+ targetId = target.id;
201
+ remoteStorage = target.provider === "vaultline"
202
+ ? null
203
+ : {
204
+ provider: target.provider,
205
+ bucket: target.bucket ?? (target.provider === "gdrive" ? "Sealkeep" : ""),
206
+ prefix: target.prefix ?? STORAGE_PREFIX_DEFAULT,
207
+ ...(target.region ? { region: target.region } : {}),
208
+ };
53
209
  }
54
- if (problems.length > 0) {
55
- await recordAudit(dataDir, "upload.verify", "denied", { archiveId, objectKey: lease.objectKey, problems: problems.length });
56
- fail("ciphertext_integrity_failed", `Refusing to mark ${archiveId} durable: ${problems.join("; ")}`, { archiveId, objectKey: lease.objectKey });
210
+ /**
211
+ * A managed account IS a remote target.
212
+ *
213
+ * This gated on a configured bucket alone, so a paying account signed in to
214
+ * managed storage was told "No remote target configured" for every one of
215
+ * 1,035 archives — the plan's 500 GB sat at 0 B used while the only copy of
216
+ * everything stayed on one disk. Managed accounts take the plane's leased
217
+ * path, which verifies durability before reporting success; a configured
218
+ * bucket still wins when both exist.
219
+ */
220
+ // "vaultline" as an explicit remoteStorage entry is the older of the two
221
+ // equally valid enrolled-vault shapes (the newer carries only the cloud
222
+ // token). Both mean the same thing — managed storage — so both take the
223
+ // plane's leased path; the provider ladder below is for buckets the USER
224
+ // brings, and handing it "vaultline" ends at "enable a signer", an
225
+ // instruction no managed account can follow.
226
+ if (!remoteStorage || remoteStorage.provider === "vaultline") {
227
+ const { cloudToken, pushArchive } = await import("./cloud.js");
228
+ await cloudToken(dataDir, undefined, { signal: options.signal }).catch((error) => {
229
+ if (options.signal?.aborted)
230
+ throw options.signal.reason ?? error;
231
+ return fail("storage_not_configured", "No remote target configured. Run: sealkeep storage configure ... (or sign in with `sealkeep cloud login`)");
232
+ });
233
+ const pushed = await pushArchive(dataDir, archiveId, undefined, {
234
+ signal: options.signal,
235
+ onProgress: options.onProgress,
236
+ maxBytesPerSecond: options.maxBytesPerSecond,
237
+ paceNow: options.paceNow,
238
+ paceSleep: options.paceSleep,
239
+ ...(options.ciphertextPath ? { ciphertextPath: options.ciphertextPath } : {}),
240
+ });
241
+ if (!pushed.durable) {
242
+ fail("ciphertext_integrity_failed", pushed.note ?? `Managed storage did not verify archive ${archiveId}; the local copy is untouched.`, {
243
+ archiveId,
244
+ objectKey: pushed.objectKey,
245
+ });
246
+ }
247
+ let current = (await listArchives(dataDir)).find((item) => isV2(item) && item.id === archiveId);
248
+ if (current && targetId) {
249
+ current = await mutateArchiveRecord(config.storage.root, archiveId, (latest) => {
250
+ if (!isV2(latest))
251
+ return latest;
252
+ const managed = archiveCopies(latest).find((copy) => copy.provider === "vaultline" && copy.objectKey === pushed.objectKey);
253
+ return managed ? appendArchiveCopy(latest, { ...managed, targetId }) : latest;
254
+ });
255
+ }
256
+ if (current) {
257
+ try {
258
+ await registerManagedTeamArchive(dataDir, current, { signal: options.signal });
259
+ }
260
+ catch (error) {
261
+ if (options.signal?.aborted)
262
+ throw options.signal.reason ?? error;
263
+ // Registration is retried by the daemon-wide reconciliation lane.
264
+ }
265
+ }
266
+ if (options.reportTargetUsage !== false) {
267
+ const { pushTargetUsage } = await import("./storage-targets.js");
268
+ void pushTargetUsage(dataDir);
269
+ }
270
+ return {
271
+ archiveId: pushed.archiveId,
272
+ objectKey: pushed.objectKey,
273
+ copyId: archiveCopyId({ provider: "vaultline", bucket: "vaultline-managed", objectKey: pushed.objectKey }),
274
+ bytes: pushed.bytes,
275
+ checksum: "",
276
+ verified: true,
277
+ provider: "vaultline"
278
+ };
279
+ }
280
+ const ciphertextPath = options.ciphertextPath ?? record.objectPath;
281
+ const handle = await open(ciphertextPath, "r").catch(() => fail("archive_not_found", `Archive ciphertext is not readable: ${ciphertextPath}`, { archiveId, path: ciphertextPath }));
282
+ try {
283
+ const measured = await digestChunks(fileHandleChunks(handle, { signal: options.signal }), options.onProgress);
284
+ if (measured.sha256Hex !== record.cipher.ciphertextSha256 || measured.bytes !== record.cipher.storedBytes) {
285
+ fail("ciphertext_integrity_failed", `The sealed bytes for ${archiveId} changed on this disk; nothing was uploaded.`, { archiveId });
286
+ }
287
+ const localChecksum = measured.sha256Base64;
288
+ const providerConfig = remoteStorage;
289
+ const lease = createActiveLease(providerConfig, { archiveId, ciphertextSha256: record.cipher.ciphertextSha256, bytes: measured.bytes, now: options.now, ttlMs: options.ttlMs });
290
+ assertLeaseUsable(lease, options.now);
291
+ // Target-id credentials let one machine connect more than one personal
292
+ // destination. Only the synthesized `primary` target may fall back to the
293
+ // legacy vault-id secret, so upgrades keep working without letting named
294
+ // targets borrow one another's credentials.
295
+ const routedConnection = routedTarget
296
+ ? await (async () => {
297
+ const { credentialConfigIdForTarget, endpointOverridesForResolvedTarget } = await import("./storage-targets.js");
298
+ return {
299
+ credentialId: await credentialConfigIdForTarget(dataDir, routedTarget),
300
+ overrides: await endpointOverridesForResolvedTarget(dataDir, routedTarget),
301
+ };
302
+ })()
303
+ : null;
304
+ if (routedTarget && !routedConnection?.credentialId) {
305
+ fail("signer_not_configured", `Storage target "${routedTarget.id}" is not connected on this machine. Connect it in Settings before uploading.`);
306
+ }
307
+ const client = options.client ?? await uploadClientFromStore(dataDir, options.storageConfigId ?? routedConnection?.credentialId ?? config.vaultId, providerConfig,
308
+ // An explicit/first-run endpoint belongs only to the legacy primary.
309
+ // Named targets always sign against their own account routing metadata.
310
+ routedTarget
311
+ ? routedTarget.id === "primary" ? (options.overrides ?? routedConnection.overrides) : routedConnection.overrides
312
+ : options.overrides);
313
+ // Every production storage client implements uploadStream. The bounded
314
+ // compatibility branch keeps small injected/test clients working, but a
315
+ // legacy client can never make a multi-GB file become one giant Buffer.
316
+ const streaming = client;
317
+ let uploaded;
318
+ if (typeof streaming.uploadStream === "function") {
319
+ const sentHash = createHash("sha256");
320
+ let sentBytes = 0;
321
+ const body = tapDigest(fileHandleChunks(handle, { signal: options.signal }), sentHash, (bytes) => {
322
+ sentBytes += bytes;
323
+ options.onProgress?.(sentBytes);
324
+ });
325
+ uploaded = await streaming.uploadStream(lease.objectKey, options.maxBytesPerSecond
326
+ ? paceBackgroundByteStream(dataDir, body, options.maxBytesPerSecond, {
327
+ signal: options.signal,
328
+ now: options.paceNow,
329
+ sleep: options.paceSleep,
330
+ })
331
+ : body, { bytes: measured.bytes, ...(options.signal ? { signal: options.signal } : {}) });
332
+ if (sentBytes !== measured.bytes || sentHash.digest("hex") !== record.cipher.ciphertextSha256) {
333
+ fail("ciphertext_integrity_failed", `The sealed bytes for ${archiveId} changed while they were uploading; the remote object was not marked durable.`, { archiveId });
334
+ }
335
+ }
336
+ else {
337
+ if (measured.bytes > LEGACY_BUFFER_LIMIT_BYTES) {
338
+ fail("provider_unsupported", `The ${client.kind} client cannot stream this ${measured.bytes}-byte archive. Update the client before retrying; nothing was marked durable.`, { archiveId, bytes: measured.bytes });
339
+ }
340
+ const ciphertext = await collectChunks(fileHandleChunks(handle, { signal: options.signal }), LEGACY_BUFFER_LIMIT_BYTES, options.onProgress);
341
+ uploaded = await client.upload(lease, ciphertext);
342
+ }
343
+ const head = await client.head(lease);
344
+ // What the provider should be holding. For a single-part object that is the
345
+ // whole-object SHA-256; for a multipart one it is S3's composite of the part
346
+ // digests, which `upload` returns. Comparing the stored checksum against
347
+ // `localChecksum` unconditionally would fail every multipart archive.
348
+ const expected = uploaded.remoteChecksum;
349
+ const problems = [];
350
+ if (uploaded.bytes !== measured.bytes)
351
+ problems.push(`the upload client consumed ${uploaded.bytes} bytes; ${measured.bytes} were expected`);
352
+ if (!head.exists)
353
+ problems.push("the provider does not report the object as present");
354
+ if (head.bytes !== measured.bytes)
355
+ problems.push(`the provider stored ${head.bytes} bytes; ${measured.bytes} were sent`);
356
+ if (head.checksum === undefined) {
357
+ // Previously this case silently skipped the comparison, which left byte
358
+ // count as the only real check — an endpoint returning the right length of
359
+ // wrong bytes verified clean. Reclaim depends on this verdict, so an
360
+ // unprovable upload is a failed upload.
361
+ problems.push("the provider did not report a stored checksum, so the bytes it holds cannot be proven");
362
+ }
363
+ else if (!expected || head.checksum !== expected) {
364
+ problems.push(`the provider's stored checksum ${head.checksum} does not match the ${expected ?? "missing checksum"} we uploaded`);
365
+ }
366
+ if (problems.length > 0) {
367
+ await recordAudit(dataDir, "upload.verify", "denied", { archiveId, objectKey: lease.objectKey, problems: problems.length });
368
+ fail("ciphertext_integrity_failed", `Refusing to mark ${archiveId} durable: ${problems.join("; ")}`, { archiveId, objectKey: lease.objectKey });
369
+ }
370
+ const storageTargets = await import("./storage-targets.js");
371
+ const explicitEndpoint = options.overrides?.host
372
+ ? `${options.overrides.protocol ?? "https"}://${options.overrides.host}${options.overrides.port ? `:${options.overrides.port}` : ""}`
373
+ : undefined;
374
+ const destination = routedTarget
375
+ ? await storageTargets.destinationIdentityForTarget(dataDir, routedTarget)
376
+ : storageTargets.storageDestinationIdentity({
377
+ id: targetId ?? "explicit",
378
+ provider: remoteStorage.provider,
379
+ bucket: remoteStorage.bucket,
380
+ prefix: remoteStorage.prefix,
381
+ ...(remoteStorage.region ? { region: remoteStorage.region } : {}),
382
+ ...(explicitEndpoint ? { endpoint: explicitEndpoint } : {}),
383
+ }, explicitEndpoint);
384
+ const remote = {
385
+ provider: lease.provider,
386
+ bucket: remoteStorage.bucket,
387
+ objectKey: lease.objectKey,
388
+ bytes: measured.bytes,
389
+ checksum: localChecksum,
390
+ verifiedAt: new Date(options.now ?? Date.now()).toISOString(),
391
+ destination,
392
+ destinationFingerprint: storageTargets.storageDestinationFingerprint(destination),
393
+ ...(targetId ? { targetId } : {}),
394
+ };
395
+ await mutateArchiveRecord(config.storage.root, archiveId, (current) => isV2(current) ? appendArchiveCopy(current, remote) : current);
396
+ await recordAudit(dataDir, "upload.verify", "allowed", { archiveId, objectKey: lease.objectKey, bytes: measured.bytes, provider: lease.provider });
397
+ if (options.reportTargetUsage !== false)
398
+ void storageTargets.pushTargetUsage(dataDir);
399
+ return { archiveId, objectKey: lease.objectKey, copyId: archiveCopyId(remote), bytes: measured.bytes, checksum: localChecksum, verified: true, provider: lease.provider };
57
400
  }
58
- const remote = { provider: lease.provider, bucket: remoteStorage.bucket, objectKey: lease.objectKey, bytes: ciphertext.length, checksum: localChecksum, verifiedAt: new Date(options.now ?? Date.now()).toISOString(), ...(options.targetId ? { targetId: options.targetId } : {}) };
59
- const next = { ...record, remote };
60
- await writeRecord(config.storage.root, next);
61
- await recordAudit(dataDir, "upload.verify", "allowed", { archiveId, objectKey: lease.objectKey, bytes: ciphertext.length, provider: lease.provider });
62
- {
63
- const { pushTargetUsage } = await import("./storage-targets.js");
64
- void pushTargetUsage(dataDir);
401
+ finally {
402
+ await handle.close();
65
403
  }
66
- return { archiveId, objectKey: lease.objectKey, bytes: ciphertext.length, checksum: localChecksum, verified: true, provider: lease.provider };
67
404
  }
68
405
  /** Uploads every archive that has no verified remote object yet. */
69
406
  export async function uploadPending(dataDir, options = {}) {
70
- const records = await listArchives(dataDir);
407
+ const records = (await listArchives(dataDir)).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
71
408
  const uploaded = [];
72
409
  const failed = [];
73
410
  for (const record of records) {
74
- if (isV2(record) && record.remote?.verifiedAt)
411
+ if (isV2(record) && hasVerifiedCopy(record))
75
412
  continue;
76
413
  try {
77
- uploaded.push(await uploadArchive(dataDir, record.id, options));
414
+ const outcome = await uploadArchive(dataDir, record.id, options);
415
+ if (!outcome.verified)
416
+ throw new Error("storage did not verify this upload as durable");
417
+ uploaded.push(outcome);
78
418
  }
79
419
  catch (error) {
80
420
  failed.push({ archiveId: record.id, error: error instanceof Error ? error.message : "upload failed" });