sealkeep 0.8.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +244 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1746 -166
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.d.ts +1 -0
  150. package/dist/src/migrate.js +33 -5
  151. package/dist/src/notify.d.ts +1 -1
  152. package/dist/src/notify.js +5 -5
  153. package/dist/src/offload.d.ts +201 -14
  154. package/dist/src/offload.js +1848 -140
  155. package/dist/src/onboarding.d.ts +8 -1
  156. package/dist/src/onboarding.js +4 -4
  157. package/dist/src/packages.d.ts +2 -2
  158. package/dist/src/packages.js +10 -2
  159. package/dist/src/passkey.d.ts +0 -1
  160. package/dist/src/passkey.js +2 -7
  161. package/dist/src/password-lock.d.ts +2 -2
  162. package/dist/src/password-lock.js +6 -6
  163. package/dist/src/paths.d.ts +2 -0
  164. package/dist/src/paths.js +2 -0
  165. package/dist/src/presence.d.ts +86 -0
  166. package/dist/src/presence.js +240 -0
  167. package/dist/src/progress-deadline.d.ts +21 -0
  168. package/dist/src/progress-deadline.js +91 -0
  169. package/dist/src/project-repair.d.ts +55 -0
  170. package/dist/src/project-repair.js +131 -0
  171. package/dist/src/providers/gcs.d.ts +28 -7
  172. package/dist/src/providers/gcs.js +35 -24
  173. package/dist/src/providers/gdrive.d.ts +71 -8
  174. package/dist/src/providers/gdrive.js +223 -50
  175. package/dist/src/providers/index.d.ts +11 -3
  176. package/dist/src/providers/index.js +30 -10
  177. package/dist/src/providers/s3.d.ts +30 -8
  178. package/dist/src/providers/s3.js +41 -30
  179. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  180. package/dist/src/providers/safe-storage-fetch.js +72 -0
  181. package/dist/src/queue.d.ts +136 -19
  182. package/dist/src/queue.js +862 -96
  183. package/dist/src/reclaim-transaction.d.ts +156 -0
  184. package/dist/src/reclaim-transaction.js +1027 -0
  185. package/dist/src/recovery-codes.d.ts +32 -0
  186. package/dist/src/recovery-codes.js +338 -0
  187. package/dist/src/recovery.js +12 -9
  188. package/dist/src/rehydrate.d.ts +25 -22
  189. package/dist/src/rehydrate.js +319 -23
  190. package/dist/src/restore.d.ts +57 -4
  191. package/dist/src/restore.js +272 -36
  192. package/dist/src/resume-stub.d.ts +92 -0
  193. package/dist/src/resume-stub.js +417 -0
  194. package/dist/src/retention.d.ts +98 -7
  195. package/dist/src/retention.js +1066 -62
  196. package/dist/src/rotate.js +3 -3
  197. package/dist/src/search.d.ts +566 -8
  198. package/dist/src/search.js +5897 -290
  199. package/dist/src/secrets.d.ts +51 -7
  200. package/dist/src/secrets.js +316 -24
  201. package/dist/src/service.d.ts +49 -11
  202. package/dist/src/service.js +776 -35
  203. package/dist/src/share.js +3 -3
  204. package/dist/src/shared-spaces.d.ts +98 -0
  205. package/dist/src/shared-spaces.js +214 -0
  206. package/dist/src/source-reader.d.ts +73 -0
  207. package/dist/src/source-reader.js +715 -0
  208. package/dist/src/spool.d.ts +1 -1
  209. package/dist/src/spool.js +1 -1
  210. package/dist/src/start-tui.js +2 -1
  211. package/dist/src/start.js +2 -2
  212. package/dist/src/storage-endpoint.d.ts +21 -0
  213. package/dist/src/storage-endpoint.js +122 -0
  214. package/dist/src/storage-setup.js +12 -12
  215. package/dist/src/storage-targets.d.ts +109 -6
  216. package/dist/src/storage-targets.js +975 -67
  217. package/dist/src/stream-to-cloud.d.ts +5 -1
  218. package/dist/src/stream-to-cloud.js +34 -14
  219. package/dist/src/sync-rules.d.ts +31 -6
  220. package/dist/src/sync-rules.js +153 -14
  221. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  222. package/dist/src/team-backfill-scheduling.js +33 -0
  223. package/dist/src/team-backfill.d.ts +116 -0
  224. package/dist/src/team-backfill.js +1429 -0
  225. package/dist/src/team-index-cache.d.ts +16 -0
  226. package/dist/src/team-index-cache.js +152 -0
  227. package/dist/src/team-offboarding.d.ts +38 -0
  228. package/dist/src/team-offboarding.js +1043 -0
  229. package/dist/src/team-presence.d.ts +127 -0
  230. package/dist/src/team-presence.js +904 -0
  231. package/dist/src/team-publication-policy.d.ts +20 -0
  232. package/dist/src/team-publication-policy.js +140 -0
  233. package/dist/src/team-realtime.d.ts +68 -0
  234. package/dist/src/team-realtime.js +816 -0
  235. package/dist/src/team-source-facts-cache.d.ts +23 -0
  236. package/dist/src/team-source-facts-cache.js +255 -0
  237. package/dist/src/trash.d.ts +1 -1
  238. package/dist/src/trash.js +2 -2
  239. package/dist/src/tui.js +11 -12
  240. package/dist/src/types.d.ts +173 -7
  241. package/dist/src/types.js +20 -0
  242. package/dist/src/ui-server.d.ts +163 -35
  243. package/dist/src/ui-server.js +712 -72
  244. package/dist/src/ui.d.ts +1 -2
  245. package/dist/src/ui.js +1 -2
  246. package/dist/src/upload.d.ts +27 -0
  247. package/dist/src/upload.js +383 -43
  248. package/dist/src/vault.d.ts +235 -29
  249. package/dist/src/vault.js +1783 -190
  250. package/dist/src/watcher.d.ts +7 -1
  251. package/dist/src/watcher.js +198 -55
  252. package/dist/src/worker.d.ts +27 -3
  253. package/dist/src/worker.js +274 -55
  254. package/package.json +33 -12
  255. package/scripts/native-reboot-rehearsal.mjs +90 -0
  256. package/web/app.js +6032 -343
  257. package/web/bootstrap.js +17 -0
  258. package/web/index.html +255 -57
  259. package/web/rail.js +317 -40
  260. package/web/retention.html +2 -2
  261. package/web/rules-view.js +188 -16
  262. package/web/sessions-view.js +485 -62
  263. package/web/sessions.html +2 -2
  264. package/web/setup-api.js +152 -29
  265. package/web/setup-logic.js +68 -9
  266. package/web/setup.html +113 -44
  267. package/web/setup.js +604 -71
  268. package/web/style.css +513 -98
  269. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  275. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -0,0 +1,904 @@
1
+ /**
2
+ * Cross-account live presence.
3
+ *
4
+ * The hosted plane sees an opaque space id, sender/sequence replay guards and
5
+ * ciphertext. A single random feed key makes every event O(1): inviting a new
6
+ * member wraps that key once to their X25519 identity instead of copying every
7
+ * event once per teammate. Identity and feed private material are cached in the
8
+ * strongest OS keystore available and can be recovered on another machine from
9
+ * phrase-sealed server data.
10
+ */
11
+ import { createHash, createHmac, createPublicKey, randomBytes } from "node:crypto";
12
+ import { aeadCipher, aeadDecipher } from "../packages/sealkeep-crypto/src/aead.js";
13
+ import { decryptArchive, encryptArchive, rawPublicKey, unwrapArchiveKey, wrapForKey, x25519PrivateKeyFromRaw, x25519PublicKeyFromRaw, zeroize, } from "../packages/sealkeep-crypto/src/index.js";
14
+ import { cloudUrl, cloudToken, completeTeamAccessRequest, completeTeamProjectRequest, createTeamInvitation, createTeamSpace, frameObject, getTeamIdentity, getTeamMembership, listOutgoingTeamAccessRequests, listOutgoingTeamInvitations, listTeamMembers, listTeamProjectRequests, listTeamSpaces, pollTeamPresence, prepareTeamInvitation, publishTeamPresence, putTeamIdentity, recentTeamPresence, removeTeamMemberAndRotate, resolveTeamInvitee, unframeObject, } from "./cloud.js";
15
+ import { fail, isSealkeepError } from "./errors.js";
16
+ import { canonicalPhrase } from "./mnemonic.js";
17
+ import { chooseBackend } from "./secrets.js";
18
+ import { bindTeamSpace, readConfig, removeTeamProjectDraft, revokeTeamSpaceBindings, teamSpaceOf, unbindTeamSpace, updateTeamSpaceBindings, } from "./vault.js";
19
+ import { readRealtimeTeamPresence } from "./team-realtime.js";
20
+ import { withProgressDeadline } from "./progress-deadline.js";
21
+ const SUITE = "chacha20-poly1305";
22
+ const NONCE_BYTES = 12;
23
+ const TAG_BYTES = 16;
24
+ const SPACE_RE = /^[A-Za-z0-9_-]{43}$/;
25
+ const PROJECT_KEY_RE = /^(?:g1|p1):[a-f0-9]{64}$/;
26
+ const identityRef = (publicKey) => ({
27
+ service: "sealkeep-team-identity",
28
+ account: createHash("sha256").update(`${cloudUrl()}|${publicKey}`).digest("hex"),
29
+ });
30
+ const identityPointerRef = (vaultId) => ({
31
+ service: "sealkeep-team-identity-pointer",
32
+ account: createHash("sha256").update(`${cloudUrl()}|${vaultId}`).digest("hex"),
33
+ });
34
+ const feedRef = (spaceKey) => ({
35
+ service: "sealkeep-team-space",
36
+ account: createHash("sha256").update(`${cloudUrl()}|${spaceKey}`).digest("hex"),
37
+ });
38
+ // Public, domain-separated marker ID that the hosted workflow can recompute.
39
+ // It classifies rotation markers; it is not an authentication credential.
40
+ const bindingSender = (spaceKey) => createHmac("sha256", "sealkeep-team-binding").update(spaceKey).digest("hex").slice(0, 8);
41
+ function deterministicIdentity(phrase) {
42
+ const privateRaw = createHash("sha256")
43
+ .update("sealkeep-team-identity:v1\0")
44
+ .update(canonicalPhrase(phrase), "utf8")
45
+ .digest();
46
+ const privateKey = x25519PrivateKeyFromRaw(privateRaw);
47
+ const publicKey = rawPublicKey(createPublicKey(privateKey)).toString("base64");
48
+ return { version: 1, publicKey, privateKey: privateRaw.toString("base64") };
49
+ }
50
+ function sealIdentity(secret, phrase) {
51
+ const { envelope, ciphertext } = encryptArchive(Buffer.from(JSON.stringify(secret)), {
52
+ recipients: [{ kind: "phrase", phrase: canonicalPhrase(phrase) }],
53
+ adapter: { agent: "sealkeep-team-identity", version: "1" },
54
+ });
55
+ return frameObject(envelope, ciphertext).toString("base64");
56
+ }
57
+ function openIdentity(identity, phrase) {
58
+ try {
59
+ const { envelope, ciphertext } = unframeObject(Buffer.from(identity.key_box, "base64"));
60
+ if (!envelope)
61
+ throw new Error("unframed identity");
62
+ const value = JSON.parse(decryptArchive(envelope, ciphertext, { phrase: canonicalPhrase(phrase) }).toString("utf8"));
63
+ const privateRaw = Buffer.from(value.privateKey, "base64");
64
+ const publicKey = rawPublicKey(createPublicKey(x25519PrivateKeyFromRaw(privateRaw))).toString("base64");
65
+ if (value.version !== 1 || publicKey !== identity.public_key || value.publicKey !== identity.public_key)
66
+ throw new Error("identity mismatch");
67
+ return value;
68
+ }
69
+ catch {
70
+ return fail("recovery_phrase_mismatch", "This recovery phrase cannot open the account's team identity. Team data was not changed.");
71
+ }
72
+ }
73
+ async function cacheIdentity(dataDir, secret, preferred) {
74
+ const backend = await chooseBackend(dataDir, preferred);
75
+ await backend.set(identityRef(secret.publicKey), JSON.stringify(secret));
76
+ await backend.set(identityPointerRef((await readConfig(dataDir)).vaultId), secret.publicKey);
77
+ }
78
+ async function cachedIdentity(dataDir, publicKey, preferred) {
79
+ const raw = await (await chooseBackend(dataDir, preferred)).get(identityRef(publicKey));
80
+ if (!raw)
81
+ return null;
82
+ try {
83
+ const value = JSON.parse(raw);
84
+ return value.version === 1 && value.publicKey === publicKey && Buffer.from(value.privateKey, "base64").length === 32 ? value : null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ /** Called during visible team setup, or lazily on a newly signed-in machine. */
91
+ export async function ensureTeamIdentity(dataDir, phrase, preferred) {
92
+ const backend = await chooseBackend(dataDir, preferred);
93
+ const pointer = await backend.get(identityPointerRef((await readConfig(dataDir)).vaultId));
94
+ if (pointer) {
95
+ const cached = await cachedIdentity(dataDir, pointer, preferred);
96
+ if (cached)
97
+ return cached;
98
+ }
99
+ const remote = await getTeamIdentity(dataDir, preferred);
100
+ if (remote?.public_key && remote.key_box) {
101
+ const cached = await cachedIdentity(dataDir, remote.public_key, preferred);
102
+ if (cached)
103
+ return cached;
104
+ const opened = openIdentity(remote, phrase);
105
+ await cacheIdentity(dataDir, opened, preferred);
106
+ return opened;
107
+ }
108
+ // Phrase-derived identity makes simultaneous first use on Mac and VPS settle
109
+ // on the same public key. The random feed keys remain random and wrapped.
110
+ const created = deterministicIdentity(phrase);
111
+ await putTeamIdentity(dataDir, { public_key: created.publicKey, key_box: sealIdentity(created, phrase) }, preferred);
112
+ const settled = await getTeamIdentity(dataDir, preferred);
113
+ const identity = settled?.public_key === created.publicKey ? created : settled ? openIdentity(settled, phrase) : created;
114
+ await cacheIdentity(dataDir, identity, preferred);
115
+ return identity;
116
+ }
117
+ async function cachedFeed(dataDir, spaceKey, preferred) {
118
+ const raw = await (await chooseBackend(dataDir, preferred)).get(feedRef(spaceKey));
119
+ if (!raw)
120
+ return null;
121
+ try {
122
+ const value = JSON.parse(raw);
123
+ return value.version === 1
124
+ && value.spaceKey === spaceKey
125
+ && Number.isSafeInteger(value.keyVersion) && value.keyVersion > 0
126
+ && Buffer.from(value.key, "base64").length === 32
127
+ && (value.realtimeKey === undefined || SPACE_RE.test(value.realtimeKey))
128
+ ? value : null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ /** Legacy epoch one used the opaque space id directly; every rotation binds its version too. */
135
+ function feedWrapContext(spaceKey, keyVersion) {
136
+ return keyVersion <= 1 ? spaceKey : `sealkeep-team-feed:v2\0${spaceKey}\0${keyVersion}`;
137
+ }
138
+ async function saveFeed(dataDir, value, preferred) {
139
+ const existing = await cachedFeed(dataDir, value.spaceKey, preferred);
140
+ if (existing && existing.keyVersion > value.keyVersion) {
141
+ return fail("internal", `Refusing to replace team key epoch ${existing.keyVersion} with older epoch ${value.keyVersion}`);
142
+ }
143
+ if (existing && existing.keyVersion === value.keyVersion && existing.key !== value.key) {
144
+ return fail("internal", `Team key epoch ${value.keyVersion} has conflicting key material`);
145
+ }
146
+ const settled = existing && existing.keyVersion === value.keyVersion
147
+ ? { ...existing, ...(value.realtimeKey ? { realtimeKey: value.realtimeKey } : {}) }
148
+ : value;
149
+ await (await chooseBackend(dataDir, preferred)).set(feedRef(value.spaceKey), JSON.stringify(settled));
150
+ return settled;
151
+ }
152
+ function wrapFeed(spaceKey, keyVersion, feedKey, publicKey) {
153
+ const raw = Buffer.from(publicKey, "base64");
154
+ if (raw.length !== 32)
155
+ fail("invalid_argument", "A team identity public key must be 32 bytes");
156
+ return JSON.stringify(wrapForKey(feedKey, x25519PublicKeyFromRaw(raw), SUITE, feedWrapContext(spaceKey, keyVersion), { nonce: randomBytes(NONCE_BYTES) }));
157
+ }
158
+ function unwrapFeed(spaceKey, member, identity) {
159
+ if (member.public_key !== identity.publicKey)
160
+ fail("forbidden", "This team membership belongs to a different account identity");
161
+ const privateRaw = Buffer.from(identity.privateKey, "base64");
162
+ try {
163
+ const wrapped = JSON.parse(member.wrapped_key);
164
+ const key = unwrapArchiveKey([wrapped], SUITE, feedWrapContext(spaceKey, member.key_version), {
165
+ privateKey: x25519PrivateKeyFromRaw(privateRaw),
166
+ });
167
+ try {
168
+ return {
169
+ version: 1, spaceKey, keyVersion: member.key_version, key: key.toString("base64"),
170
+ realtimeKey: SPACE_RE.test(member.realtime_key ?? "") ? member.realtime_key : spaceKey,
171
+ };
172
+ }
173
+ finally {
174
+ zeroize(key);
175
+ }
176
+ }
177
+ catch {
178
+ return fail("recipient_key_mismatch", "This account cannot open the team-space key. Ask the space owner to remove and invite the account again.");
179
+ }
180
+ finally {
181
+ zeroize(privateRaw);
182
+ }
183
+ }
184
+ /** Remove a revoked epoch key without exposing it to config or another process. */
185
+ export async function forgetTeamSpaceFeed(dataDir, spaceKey, preferred) {
186
+ if (!SPACE_RE.test(spaceKey))
187
+ return;
188
+ const backend = await chooseBackend(dataDir, preferred);
189
+ await backend.delete(feedRef(spaceKey));
190
+ }
191
+ async function revokeLocalMembership(dataDir, spaceKey, preferred) {
192
+ await Promise.allSettled([
193
+ forgetTeamSpaceFeed(dataDir, spaceKey, preferred),
194
+ revokeTeamSpaceBindings(dataDir, spaceKey),
195
+ ]);
196
+ }
197
+ /** Fetch and unwrap the authoritative current epoch, never trusting a stale cache. */
198
+ async function refreshFeed(dataDir, spaceKey, phrase, preferred, minimumVersion = 0) {
199
+ let member;
200
+ try {
201
+ member = await getTeamMembership(dataDir, spaceKey, preferred);
202
+ }
203
+ catch (error) {
204
+ if (isSealkeepError(error) && error.code === "team_membership_revoked") {
205
+ await revokeLocalMembership(dataDir, spaceKey, preferred);
206
+ }
207
+ throw error;
208
+ }
209
+ if (!member) {
210
+ await revokeLocalMembership(dataDir, spaceKey, preferred);
211
+ return fail("team_membership_revoked", "This account is no longer a member of that team space");
212
+ }
213
+ if (!Number.isSafeInteger(Number(member.key_version)) || Number(member.key_version) <= 0) {
214
+ return fail("internal", "Sealkeep Cloud returned an invalid team key epoch");
215
+ }
216
+ member.key_version = Number(member.key_version);
217
+ if (member.key_version < minimumVersion) {
218
+ return fail("team_state_changed", `Team space is at epoch ${member.key_version}, below observed epoch ${minimumVersion}`, { currentKeyVersion: member.key_version });
219
+ }
220
+ const existing = await cachedFeed(dataDir, spaceKey, preferred);
221
+ if (existing && existing.keyVersion > member.key_version) {
222
+ return fail("internal", `Sealkeep Cloud attempted to roll team key epoch ${existing.keyVersion} back to ${member.key_version}`);
223
+ }
224
+ let feed;
225
+ if (existing && existing.keyVersion === member.key_version) {
226
+ feed = {
227
+ ...existing,
228
+ realtimeKey: SPACE_RE.test(member.realtime_key ?? "") ? member.realtime_key : existing.realtimeKey ?? spaceKey,
229
+ };
230
+ }
231
+ else {
232
+ const identity = await ensureTeamIdentity(dataDir, phrase, preferred);
233
+ feed = unwrapFeed(spaceKey, member, identity);
234
+ }
235
+ feed = await saveFeed(dataDir, feed, preferred);
236
+ await updateTeamSpaceBindings(dataDir, spaceKey, {
237
+ keyVersion: feed.keyVersion,
238
+ realtimeKey: feed.realtimeKey ?? spaceKey,
239
+ });
240
+ return feed;
241
+ }
242
+ async function loadFeed(dataDir, spaceKey, phrase, preferred) {
243
+ const cached = await cachedFeed(dataDir, spaceKey, preferred);
244
+ if (cached)
245
+ return cached;
246
+ return refreshFeed(dataDir, spaceKey, phrase, preferred);
247
+ }
248
+ /**
249
+ * Reconcile one project's hosted epoch at a normal agent lifecycle boundary.
250
+ * Push remains the fast path; this authoritative read is the crash-safe path
251
+ * when a committed rotation's best-effort socket notice was lost.
252
+ */
253
+ export async function reconcileProjectTeamSpace(dataDir, project, phrase, preferred, projectKey) {
254
+ const binding = teamSpaceOf(await readConfig(dataDir), project, projectKey);
255
+ if (!binding)
256
+ return null;
257
+ const feed = await refreshFeed(dataDir, binding.spaceKey, phrase, preferred);
258
+ return { keyVersion: feed.keyVersion, realtimeKey: feed.realtimeKey ?? binding.spaceKey };
259
+ }
260
+ export async function createProjectTeamSpace(dataDir, project, phrase, preferred, projectKey) {
261
+ if (!project.trim())
262
+ fail("invalid_argument", "Choose a project before enabling team mode");
263
+ const identity = await ensureTeamIdentity(dataDir, phrase, preferred);
264
+ const spaceKey = randomBytes(32).toString("base64url");
265
+ const realtimeKey = randomBytes(32).toString("base64url");
266
+ const key = randomBytes(32);
267
+ try {
268
+ const space = await createTeamSpace(dataDir, {
269
+ space_key: spaceKey,
270
+ wrapped_key: wrapFeed(spaceKey, 1, key, identity.publicKey),
271
+ realtime_key: realtimeKey,
272
+ project_name: project,
273
+ }, preferred);
274
+ const feed = {
275
+ version: 1, spaceKey, keyVersion: space.key_version || 1, key: key.toString("base64"),
276
+ realtimeKey: SPACE_RE.test(space.realtime_key ?? "") ? space.realtime_key : realtimeKey,
277
+ };
278
+ await saveFeed(dataDir, feed, preferred);
279
+ // Seed encrypted project metadata as part of the visible "enable Team mode"
280
+ // action. A newly installed second machine can now identify even a quiet
281
+ // space without copying config or asking the user for its opaque id.
282
+ const bindingEvent = {
283
+ project, kind: "note", line: "Team mode enabled", from: "Sealkeep",
284
+ sender: bindingSender(spaceKey),
285
+ seq: 0, at: new Date().toISOString(),
286
+ ...(projectKey ? { projectKey } : {}),
287
+ };
288
+ const ciphertext = encryptEvent(bindingEvent, spaceKey, feed);
289
+ // Do not report Team mode as enabled until its encrypted discovery seed is
290
+ // durable. Silently losing this row would strand a quiet project on a
291
+ // fresh machine. Normal setup already has a working authenticated plane;
292
+ // if that changes between the two writes, the visible action fails instead
293
+ // of leaving a false-success binding.
294
+ await publishTeamPresence(dataDir, spaceKey, {
295
+ sender: bindingEvent.sender, sequence: 0, key_version: feed.keyVersion, ciphertext,
296
+ }, preferred);
297
+ await bindTeamSpace(dataDir, project, {
298
+ spaceKey, role: "owner", keyVersion: feed.keyVersion, realtimeKey: feed.realtimeKey,
299
+ }, projectKey);
300
+ return { project, ...(projectKey ? { projectKey } : {}), spaceKey, role: "owner", keyVersion: feed.keyVersion, realtimeKey: feed.realtimeKey };
301
+ }
302
+ finally {
303
+ zeroize(key);
304
+ }
305
+ }
306
+ export async function joinProjectTeamSpace(dataDir, project, spaceKey, phrase, preferred, projectKey) {
307
+ if (!SPACE_RE.test(spaceKey))
308
+ fail("invalid_argument", "That team-space id is malformed");
309
+ let member = null;
310
+ try {
311
+ member = await getTeamMembership(dataDir, spaceKey, preferred);
312
+ }
313
+ catch (error) {
314
+ // A pending invitation is expected to have no membership yet; the
315
+ // structured revoked response is otherwise useful to callers.
316
+ if (!(isSealkeepError(error) && error.code === "team_membership_revoked"))
317
+ throw error;
318
+ }
319
+ // Joining a visible pending invitation is the invitee's explicit acceptance.
320
+ // The owner-side invite never creates membership; this compatibility path
321
+ // keeps the existing "join" action useful while routing through the new
322
+ // invitation acceptance endpoint.
323
+ if (!member) {
324
+ const invitation = (await (await import("./cloud.js")).listTeamInvitations(dataDir, preferred)).find((item) => item.space_key === spaceKey && item.status === "pending");
325
+ if (invitation)
326
+ member = await (await import("./cloud.js")).acceptTeamInvitation(dataDir, invitation.id, preferred);
327
+ }
328
+ if (!member)
329
+ return fail("forbidden", "This account has not been invited to that team space");
330
+ const feed = await refreshFeed(dataDir, spaceKey, phrase, preferred);
331
+ // Rejoining the same exact checkout refreshes membership, not folder consent.
332
+ // Never inherit confirmation from another binding key or hosted space.
333
+ const existing = teamSpaceOf(await readConfig(dataDir), project, projectKey);
334
+ const confirmed = existing?.spaceKey === spaceKey && existing.localConfirmed === true;
335
+ await bindTeamSpace(dataDir, project, {
336
+ spaceKey, role: member.role === "owner" ? "owner" : "member",
337
+ keyVersion: feed.keyVersion, realtimeKey: feed.realtimeKey ?? spaceKey,
338
+ ...(confirmed ? { localConfirmed: true, ...(existing.localProject ? { localProject: existing.localProject } : {}) } : {}),
339
+ }, projectKey);
340
+ return { project, ...(projectKey ? { projectKey } : {}), spaceKey, role: member.role, keyVersion: feed.keyVersion, realtimeKey: feed.realtimeKey ?? spaceKey };
341
+ }
342
+ export async function inviteProjectTeamMember(dataDir, project, email, phrase, preferred, projectKey) {
343
+ const binding = teamSpaceOf(await readConfig(dataDir), project, projectKey);
344
+ if (!binding)
345
+ fail("invalid_argument", `Project ${project} is not in team mode`);
346
+ const { prepareTeamInvitationHistory, reconcileTeamInvitationHandoffs } = await import("./team-offboarding.js");
347
+ await reconcileTeamInvitationHandoffs(dataDir, phrase, preferred);
348
+ // The created intent pins the authoritative account and encryption key.
349
+ // Resolving an email before a potentially long capture leaves a race with
350
+ // identity/account changes during the later invitation creation request.
351
+ const invitation = await createTeamInvitation(dataDir, binding.spaceKey, { email: email.trim() }, preferred);
352
+ const targetAccountId = Number(invitation.invitee_account_id);
353
+ const targetPublicKey = invitation.public_key;
354
+ if (invitation.space_key !== binding.spaceKey || !Number.isSafeInteger(targetAccountId) || targetAccountId <= 0
355
+ || typeof targetPublicKey !== "string" || Buffer.from(targetPublicKey, "base64").length !== 32) {
356
+ fail("provider_unsupported", "This backend did not identify the pending invitation's encryption recipient");
357
+ }
358
+ for (let attempt = 0; attempt < 2; attempt += 1) {
359
+ const feed = await refreshFeed(dataDir, binding.spaceKey, phrase, preferred);
360
+ const key = Buffer.from(feed.key, "base64");
361
+ try {
362
+ const captured = await prepareTeamInvitationHistory(dataDir, {
363
+ project, ...(projectKey ? { projectKey } : {}), spaceKey: binding.spaceKey,
364
+ targetAccountId, targetPublicKey,
365
+ }, phrase, preferred);
366
+ if (captured.keyVersion !== undefined && captured.keyVersion !== feed.keyVersion) {
367
+ fail("team_state_changed", "Team keys changed while invitation history was captured");
368
+ }
369
+ await prepareTeamInvitation(dataDir, invitation.id, {
370
+ wrapped_key: wrapFeed(binding.spaceKey, feed.keyVersion, key, targetPublicKey),
371
+ expected_key_version: feed.keyVersion,
372
+ expected_membership_version: captured.membershipVersion,
373
+ expected_index_revision: captured.indexRevision,
374
+ expected_archive_refs: JSON.stringify(captured.archiveRefs),
375
+ }, preferred);
376
+ return { project, invitationId: invitation.id, accountId: targetAccountId, role: "pending" };
377
+ }
378
+ catch (error) {
379
+ if (!(isSealkeepError(error) && error.code === "team_state_changed" && attempt === 0))
380
+ throw error;
381
+ }
382
+ finally {
383
+ zeroize(key);
384
+ }
385
+ }
386
+ return fail("team_state_changed", "The team changed again while this invitation was being prepared");
387
+ }
388
+ export async function listProjectTeamInvitations(dataDir, preferred) {
389
+ return (await import("./cloud.js")).listTeamInvitations(dataDir, preferred);
390
+ }
391
+ /**
392
+ * Complete folder selections made before their cloud project existed.
393
+ * Matching is deliberately an exact, case-insensitive display-name match and
394
+ * requires one unique space; an ambiguous name is left pending for the user to
395
+ * map from the visible project card instead of guessing an access boundary.
396
+ */
397
+ export async function reconcileTeamProjectDrafts(dataDir, phrase, spaces, preferred) {
398
+ const drafts = (await readConfig(dataDir)).teamProjectDrafts ?? [];
399
+ let bound = 0;
400
+ for (const draft of drafts) {
401
+ const wanted = draft.projectName.trim().toLocaleLowerCase();
402
+ const matches = spaces.filter((space) => space.project_name?.trim().toLocaleLowerCase() === wanted);
403
+ if (matches.length !== 1)
404
+ continue;
405
+ const space = matches[0];
406
+ const selections = draft.projectSelections ?? draft.projects.map((project) => ({ project, projectKey: null }));
407
+ for (const selection of selections) {
408
+ await joinProjectTeamSpace(dataDir, selection.project, space.space_key, phrase, preferred, selection.projectKey);
409
+ const current = teamSpaceOf(await readConfig(dataDir), selection.project, selection.projectKey);
410
+ if (current)
411
+ await bindTeamSpace(dataDir, selection.project, { ...current, localConfirmed: true }, selection.projectKey);
412
+ bound += 1;
413
+ }
414
+ // Creating the hosted space initially uses its display name as a safe
415
+ // compatibility binding. For a combined project that label is not a real
416
+ // local folder, so remove it once the explicit folder set is connected.
417
+ const after = await readConfig(dataDir);
418
+ const implicit = after.teamSpaces?.[draft.projectName];
419
+ if (implicit?.spaceKey === space.space_key && !implicit.localConfirmed && !draft.projects.includes(draft.projectName)) {
420
+ await unbindTeamSpace(dataDir, draft.projectName);
421
+ }
422
+ await removeTeamProjectDraft(dataDir, draft.projectName);
423
+ }
424
+ return bound;
425
+ }
426
+ function accessFailureCode(error) {
427
+ if (isSealkeepError(error))
428
+ return error.code;
429
+ const code = error?.code;
430
+ return typeof code === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(code) ? code : "team_access_retry";
431
+ }
432
+ export async function reconcileCloudTeamAccess(dataDir, phrase, preferred) {
433
+ let created = 0;
434
+ let prepared = 0;
435
+ let bound = 0;
436
+ let removed = 0;
437
+ const failures = [];
438
+ // A personal-only vault that has never connected Cloud has no access work.
439
+ // Distinguish absent local credentials from a real hosted authorization
440
+ // failure; the latter must still reach the daemon's attention state.
441
+ try {
442
+ await cloudToken(dataDir, preferred, { renew: false });
443
+ }
444
+ catch (error) {
445
+ const local = await readConfig(dataDir);
446
+ if (isSealkeepError(error) && error.code === "unauthorized"
447
+ && !Object.keys(local.teamSpaces ?? {}).length && !Object.keys(local.teamProjectDrafts ?? {}).length) {
448
+ return { created, prepared, bound, removed };
449
+ }
450
+ throw error;
451
+ }
452
+ // The website records only human intent and a project label. A connected
453
+ // machine owns the cryptographic work: it generates the random feed key,
454
+ // seals it, binds the local project, then acknowledges the cloud request.
455
+ // If the response from createTeamSpace was lost, the next list observes the
456
+ // already-created owner space and completes the same request idempotently.
457
+ let spaces = await listTeamSpaces(dataDir, preferred);
458
+ const projectRequests = await listTeamProjectRequests(dataDir, preferred);
459
+ for (const request of projectRequests) {
460
+ if (request.state !== "requested")
461
+ continue;
462
+ const project = typeof request.project_name === "string" ? request.project_name.trim() : "";
463
+ if (!project)
464
+ continue;
465
+ let space = spaces.find((item) => item.role === "owner" && item.project_name?.trim() === project);
466
+ if (!space) {
467
+ try {
468
+ const made = await createProjectTeamSpace(dataDir, project, phrase, preferred);
469
+ space = { space_key: made.spaceKey, role: "owner", key_version: made.keyVersion, realtime_key: made.realtimeKey, project_name: project };
470
+ spaces.push(space);
471
+ created += 1;
472
+ }
473
+ catch (error) {
474
+ spaces = await listTeamSpaces(dataDir, preferred);
475
+ space = spaces.find((item) => item.role === "owner" && item.project_name?.trim() === project);
476
+ if (!space)
477
+ failures.push({ operation: "create_project", code: accessFailureCode(error) });
478
+ }
479
+ }
480
+ if (!space)
481
+ continue;
482
+ try {
483
+ await completeTeamProjectRequest(dataDir, request.id, space.space_key, preferred);
484
+ }
485
+ catch (error) {
486
+ failures.push({ operation: "complete_project", code: accessFailureCode(error) });
487
+ }
488
+ }
489
+ spaces = await listTeamSpaces(dataDir, preferred);
490
+ bound += await reconcileTeamProjectDrafts(dataDir, phrase, spaces, preferred);
491
+ const config = await readConfig(dataDir);
492
+ const localBySpace = new Map(Object.entries(config.teamSpaces ?? {}).map(([project, binding]) => [binding.spaceKey, { project, binding }]));
493
+ const locallyDisconnected = new Set(config.teamSpaceOptOuts ?? []);
494
+ const outgoing = await listOutgoingTeamInvitations(dataDir, preferred);
495
+ const { prepareTeamInvitationHistory, reconcileTeamInvitationHandoffs } = await import("./team-offboarding.js");
496
+ await reconcileTeamInvitationHandoffs(dataDir, phrase, preferred);
497
+ for (const invitation of outgoing) {
498
+ const local = localBySpace.get(invitation.space_key);
499
+ if (!local || local.binding.role !== "owner" || !invitation.public_key)
500
+ continue;
501
+ const feed = await refreshFeed(dataDir, invitation.space_key, phrase, preferred);
502
+ const targetAccountId = Number(invitation.invitee_account_id)
503
+ || (invitation.invitee_email ? (await resolveTeamInvitee(dataDir, invitation.space_key, invitation.invitee_email, preferred)).account_id : 0);
504
+ if (!Number.isSafeInteger(targetAccountId) || targetAccountId <= 0)
505
+ continue;
506
+ const captured = await prepareTeamInvitationHistory(dataDir, {
507
+ project: local.binding.localProject ?? local.project,
508
+ ...(PROJECT_KEY_RE.test(local.project) ? { projectKey: local.project } : {}),
509
+ spaceKey: invitation.space_key, targetAccountId, targetPublicKey: invitation.public_key,
510
+ }, phrase, preferred);
511
+ const refs = JSON.stringify(captured.archiveRefs);
512
+ if (invitation.wrapped_key && invitation.key_version === feed.keyVersion
513
+ && invitation.history_membership_version === captured.membershipVersion
514
+ && invitation.history_index_revision === captured.indexRevision
515
+ && invitation.history_archive_refs === refs)
516
+ continue;
517
+ const key = Buffer.from(feed.key, "base64");
518
+ try {
519
+ await prepareTeamInvitation(dataDir, invitation.id, {
520
+ wrapped_key: wrapFeed(invitation.space_key, feed.keyVersion, key, invitation.public_key),
521
+ expected_key_version: feed.keyVersion,
522
+ expected_membership_version: captured.membershipVersion,
523
+ expected_index_revision: captured.indexRevision,
524
+ expected_archive_refs: refs,
525
+ }, preferred);
526
+ prepared += 1;
527
+ }
528
+ finally {
529
+ zeroize(key);
530
+ }
531
+ }
532
+ // An accepted invitation is authoritative cloud membership. On every fresh
533
+ // machine, recover its wrapped feed key and bind the human project label
534
+ // without asking for an opaque space id or a local "join" command.
535
+ spaces = await listTeamSpaces(dataDir, preferred);
536
+ for (const space of spaces) {
537
+ if (!SPACE_RE.test(space.space_key) || localBySpace.has(space.space_key) || locallyDisconnected.has(space.space_key))
538
+ continue;
539
+ const project = typeof space.project_name === "string" ? space.project_name.trim() : "";
540
+ if (!project)
541
+ continue;
542
+ const feed = await refreshFeed(dataDir, space.space_key, phrase, preferred);
543
+ await bindTeamSpace(dataDir, project, {
544
+ spaceKey: space.space_key,
545
+ role: space.role === "owner" ? "owner" : "member",
546
+ keyVersion: feed.keyVersion,
547
+ realtimeKey: feed.realtimeKey ?? space.space_key,
548
+ });
549
+ localBySpace.set(space.space_key, { project, binding: {
550
+ spaceKey: space.space_key, role: space.role === "owner" ? "owner" : "member",
551
+ keyVersion: feed.keyVersion, realtimeKey: feed.realtimeKey ?? space.space_key,
552
+ } });
553
+ bound += 1;
554
+ }
555
+ // Membership decisions are made in SealKeep Cloud, while the operation
556
+ // that cuts a new feed key must run where the owner's private identity is.
557
+ // Requests are idempotent: if rotation landed but confirmation did not, the
558
+ // next tick observes that the target is already absent and closes the row.
559
+ const removals = await listOutgoingTeamAccessRequests(dataDir, preferred);
560
+ for (const request of removals) {
561
+ const local = localBySpace.get(request.space_key);
562
+ if (!local || local.binding.role !== "owner")
563
+ continue;
564
+ try {
565
+ let members = await listTeamMembers(dataDir, request.space_key, preferred);
566
+ if (members.some((member) => Number(member.account_id) === Number(request.target_account_id))) {
567
+ try {
568
+ await removeProjectTeamMember(dataDir, local.project, Number(request.target_account_id), phrase, preferred);
569
+ }
570
+ catch (error) {
571
+ if (!(isSealkeepError(error) && error.code === "invalid_argument"))
572
+ throw error;
573
+ members = await listTeamMembers(dataDir, request.space_key, preferred);
574
+ if (members.some((member) => Number(member.account_id) === Number(request.target_account_id)))
575
+ throw error;
576
+ }
577
+ }
578
+ await completeTeamAccessRequest(dataDir, request.id, preferred);
579
+ removed += 1;
580
+ }
581
+ catch (error) {
582
+ // Other projects still progress, but a failed removal is not success.
583
+ failures.push({ operation: "remove_member", code: accessFailureCode(error) });
584
+ }
585
+ }
586
+ return { created, prepared, bound, removed, ...(failures.length ? { failures } : {}) };
587
+ }
588
+ export async function acceptProjectTeamInvitation(dataDir, invitationId, project, phrase, preferred) {
589
+ const member = await (await import("./cloud.js")).acceptTeamInvitation(dataDir, invitationId, preferred);
590
+ if (!member.space_key)
591
+ return fail("internal", "Sealkeep Cloud accepted the invitation but returned no space key");
592
+ const feed = await refreshFeed(dataDir, member.space_key, phrase, preferred);
593
+ await bindTeamSpace(dataDir, project, { spaceKey: member.space_key, role: member.role === "owner" ? "owner" : "member", keyVersion: feed.keyVersion, realtimeKey: feed.realtimeKey ?? member.space_key });
594
+ return { project, spaceKey: member.space_key, role: member.role, keyVersion: feed.keyVersion, realtimeKey: feed.realtimeKey ?? member.space_key };
595
+ }
596
+ export async function declineProjectTeamInvitation(dataDir, invitationId, preferred) {
597
+ return (await import("./cloud.js")).declineTeamInvitation(dataDir, invitationId, preferred);
598
+ }
599
+ /**
600
+ * Remove one account and cut a fresh feed/realtime epoch for everyone left.
601
+ * The hosted operation is the commit point. If its response is lost, the
602
+ * caller's own wrapped membership is enough to prove and recover the result.
603
+ */
604
+ export async function removeProjectTeamMember(dataDir, project, targetAccountId, phrase, preferred, projectKey) {
605
+ if (!Number.isSafeInteger(targetAccountId) || targetAccountId <= 0) {
606
+ fail("invalid_argument", "Choose a valid team member to remove");
607
+ }
608
+ const binding = teamSpaceOf(await readConfig(dataDir), project, projectKey);
609
+ if (!binding)
610
+ fail("invalid_argument", `Project ${project} is not in team mode`);
611
+ const { prepareTeamOffboarding, completeTeamOffboarding, reconcileTeamInvitationHandoffs } = await import("./team-offboarding.js");
612
+ await reconcileTeamInvitationHandoffs(dataDir, phrase, preferred);
613
+ const historyScope = { project, ...(projectKey ? { projectKey } : {}), spaceKey: binding.spaceKey, targetAccountId };
614
+ let originalFailure;
615
+ for (let attempt = 0; attempt < 2; attempt += 1) {
616
+ const feed = await refreshFeed(dataDir, binding.spaceKey, phrase, preferred);
617
+ const members = await listTeamMembers(dataDir, binding.spaceKey, preferred);
618
+ const target = members.find((member) => Number(member.account_id) === targetAccountId);
619
+ if (!target) {
620
+ // A process may have exited after the cloud committed removal but before
621
+ // its saved company-history handoff was published. Retry that exact work.
622
+ await completeTeamOffboarding(dataDir, historyScope, phrase, preferred);
623
+ return {
624
+ project, removedAccountId: targetAccountId, keyVersion: feed.keyVersion,
625
+ realtimeKey: feed.realtimeKey ?? binding.spaceKey, reconciled: true,
626
+ };
627
+ }
628
+ if (target.role === "owner")
629
+ fail("forbidden", "The project owner cannot be removed");
630
+ const owner = members.find((member) => member.role === "owner");
631
+ if (!owner)
632
+ return fail("internal", "This team space has no owner membership");
633
+ const captured = await prepareTeamOffboarding(dataDir, historyScope, phrase, preferred);
634
+ if (captured.keyVersion !== undefined && captured.keyVersion !== feed.keyVersion) {
635
+ fail("team_state_changed", "Team keys changed while its history was preserved; retry removal");
636
+ }
637
+ const nextVersion = feed.keyVersion + 1;
638
+ const nextKey = randomBytes(32);
639
+ const realtimeKey = randomBytes(32).toString("base64url");
640
+ const nextFeed = {
641
+ version: 1, spaceKey: binding.spaceKey, keyVersion: nextVersion,
642
+ key: nextKey.toString("base64"), realtimeKey,
643
+ };
644
+ const marker = {
645
+ project, kind: "note", line: "Team access updated", from: "Sealkeep",
646
+ sender: bindingSender(binding.spaceKey), seq: nextVersion, at: new Date().toISOString(),
647
+ };
648
+ try {
649
+ const wraps = members
650
+ .filter((member) => Number(member.account_id) !== targetAccountId)
651
+ .map((member) => ({
652
+ account_id: Number(member.account_id), public_key: member.public_key,
653
+ wrapped_key: wrapFeed(binding.spaceKey, nextVersion, nextKey, member.public_key),
654
+ }));
655
+ const receipt = await withProgressDeadline("Updating team membership", 60_000, undefined, (signal) => removeTeamMemberAndRotate(dataDir, binding.spaceKey, targetAccountId, {
656
+ expected_key_version: feed.keyVersion,
657
+ expected_membership_version: captured.membershipVersion,
658
+ expected_index_revision: captured.indexRevision,
659
+ expected_archive_refs: JSON.stringify(captured.archiveRefs),
660
+ realtime_key: realtimeKey,
661
+ wraps,
662
+ binding_sender: marker.sender,
663
+ binding_sequence: nextVersion,
664
+ binding_ciphertext: encryptEvent(marker, binding.spaceKey, nextFeed),
665
+ }, preferred, { signal }));
666
+ if (receipt.history_fenced !== true) {
667
+ fail("provider_unsupported", "This backend did not confirm the company-history handoff fence; upgrade the backend before continuing");
668
+ }
669
+ if (Number(receipt.key_version) !== nextVersion || receipt.realtime_key !== realtimeKey) {
670
+ return fail("internal", "Sealkeep Cloud returned a different team epoch than the one committed by this client");
671
+ }
672
+ await saveFeed(dataDir, nextFeed, preferred);
673
+ await updateTeamSpaceBindings(dataDir, binding.spaceKey, { keyVersion: nextVersion, realtimeKey });
674
+ await completeTeamOffboarding(dataDir, historyScope, phrase, preferred);
675
+ return { project, removedAccountId: targetAccountId, keyVersion: nextVersion, realtimeKey, reconciled: false };
676
+ }
677
+ catch (error) {
678
+ originalFailure = error;
679
+ if (isSealkeepError(error) && error.code === "provider_unsupported")
680
+ throw error;
681
+ // A CAS conflict is an ordinary concurrent owner action. A transport
682
+ // error may instead mean the commit landed and only its response was
683
+ // lost. In both cases, one authoritative read distinguishes them.
684
+ try {
685
+ const settled = await refreshFeed(dataDir, binding.spaceKey, phrase, preferred, nextVersion);
686
+ const settledMembers = await listTeamMembers(dataDir, binding.spaceKey, preferred);
687
+ if (!settledMembers.some((member) => Number(member.account_id) === targetAccountId)) {
688
+ await completeTeamOffboarding(dataDir, historyScope, phrase, preferred);
689
+ return {
690
+ project, removedAccountId: targetAccountId, keyVersion: settled.keyVersion,
691
+ realtimeKey: settled.realtimeKey ?? binding.spaceKey, reconciled: true,
692
+ };
693
+ }
694
+ }
695
+ catch { /* the original named failure remains the useful answer */ }
696
+ if (!(isSealkeepError(error) && error.code === "team_state_changed" && attempt === 0))
697
+ throw error;
698
+ }
699
+ finally {
700
+ zeroize(nextKey);
701
+ }
702
+ }
703
+ throw originalFailure;
704
+ }
705
+ function aad(spaceKey, keyVersion) {
706
+ return Buffer.from(`sealkeep-team-presence:v1\0${spaceKey}\0${keyVersion}`, "utf8");
707
+ }
708
+ function encryptEvent(event, spaceKey, feed) {
709
+ const key = Buffer.from(feed.key, "base64");
710
+ const nonce = randomBytes(NONCE_BYTES);
711
+ try {
712
+ const cipher = aeadCipher(SUITE, key, nonce);
713
+ cipher.setAAD(aad(spaceKey, feed.keyVersion));
714
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(event))), cipher.final()]);
715
+ const value = {
716
+ version: 1,
717
+ nonce: nonce.toString("base64"),
718
+ ciphertext: ciphertext.toString("base64"),
719
+ tag: cipher.getAuthTag().toString("base64"),
720
+ };
721
+ return JSON.stringify(value);
722
+ }
723
+ finally {
724
+ zeroize(key);
725
+ }
726
+ }
727
+ function decryptEvent(row, spaceKey, feed) {
728
+ if (row.key_version !== feed.keyVersion)
729
+ return null;
730
+ const key = Buffer.from(feed.key, "base64");
731
+ try {
732
+ const cipherBlock = JSON.parse(row.ciphertext);
733
+ if (cipherBlock.version !== 1)
734
+ return null;
735
+ const decipher = aeadDecipher(SUITE, key, Buffer.from(cipherBlock.nonce, "base64"));
736
+ decipher.setAAD(aad(spaceKey, row.key_version));
737
+ decipher.setAuthTag(Buffer.from(cipherBlock.tag, "base64"));
738
+ const eventValue = JSON.parse(Buffer.concat([
739
+ decipher.update(Buffer.from(cipherBlock.ciphertext, "base64")), decipher.final(),
740
+ ]).toString("utf8"));
741
+ if (!eventValue || typeof eventValue !== "object" || Array.isArray(eventValue))
742
+ return null;
743
+ const event = eventValue;
744
+ if (event.sender !== row.sender || event.seq !== row.sequence || !["started", "finished", "note"].includes(String(event.kind)))
745
+ return null;
746
+ if (typeof event.project !== "string" || !event.project.trim() || event.project.length > 500)
747
+ return null;
748
+ if (event.projectKey !== undefined
749
+ && (typeof event.projectKey !== "string" || !PROJECT_KEY_RE.test(event.projectKey)))
750
+ return null;
751
+ if (typeof event.line !== "string" || event.line.length > 140)
752
+ return null;
753
+ if (typeof event.from !== "string" || !event.from.trim() || event.from.length > 160)
754
+ return null;
755
+ if (typeof event.at !== "string" || event.at.length > 80 || !Number.isFinite(Date.parse(event.at)))
756
+ return null;
757
+ const authorAccountId = Number(row.account_id);
758
+ if (!Number.isSafeInteger(authorAccountId) || authorAccountId <= 0)
759
+ return null;
760
+ return {
761
+ project: event.project, kind: event.kind, line: event.line,
762
+ from: event.from, sender: row.sender, seq: row.sequence, at: event.at,
763
+ ...(typeof event.projectKey === "string" ? { projectKey: event.projectKey } : {}),
764
+ authorAccountId, serverId: row.id,
765
+ };
766
+ }
767
+ catch {
768
+ return null;
769
+ }
770
+ finally {
771
+ zeroize(key);
772
+ }
773
+ }
774
+ export async function publishTeamPresenceEvent(dataDir, spaceKey, event, phrase, preferred) {
775
+ let feed = await loadFeed(dataDir, spaceKey, phrase, preferred);
776
+ for (let attempt = 0; attempt < 2; attempt += 1) {
777
+ const ciphertext = encryptEvent(event, spaceKey, feed);
778
+ if (ciphertext.length > 4096)
779
+ fail("invalid_argument", "A team presence event is too large");
780
+ try {
781
+ await publishTeamPresence(dataDir, spaceKey, {
782
+ sender: event.sender, sequence: event.seq, key_version: feed.keyVersion, ciphertext,
783
+ }, preferred);
784
+ return;
785
+ }
786
+ catch (error) {
787
+ if (isSealkeepError(error) && error.code === "team_membership_revoked") {
788
+ await revokeLocalMembership(dataDir, spaceKey, preferred);
789
+ }
790
+ if (!(isSealkeepError(error) && error.code === "team_state_changed" && attempt === 0))
791
+ throw error;
792
+ const observed = Number(error.details.currentKeyVersion);
793
+ feed = await refreshFeed(dataDir, spaceKey, phrase, preferred, Number.isSafeInteger(observed) && observed > 0 ? observed : feed.keyVersion + 1);
794
+ }
795
+ }
796
+ }
797
+ export async function readTeamPresence(dataDir, spaceKey, phrase, options = {}) {
798
+ let feed = await loadFeed(dataDir, spaceKey, phrase, options.preferred);
799
+ let cursor = Math.max(0, Math.trunc(options.after ?? 0));
800
+ const rows = [];
801
+ let more = false;
802
+ if (options.after === undefined) {
803
+ rows.push(...await recentTeamPresence(dataDir, spaceKey, options.preferred));
804
+ }
805
+ else {
806
+ const maxPages = Math.max(1, Math.min(5, options.maxPages ?? 5));
807
+ for (let page = 0; page < maxPages; page++) {
808
+ const batch = await pollTeamPresence(dataDir, spaceKey, cursor, options.preferred);
809
+ rows.push(...batch);
810
+ for (const row of batch)
811
+ cursor = Math.max(cursor, Number(row.id) || 0);
812
+ if (batch.length < 200) {
813
+ more = false;
814
+ break;
815
+ }
816
+ more = page === maxPages - 1;
817
+ }
818
+ }
819
+ const observedVersion = rows.reduce((highest, row) => Math.max(highest, Number(row.key_version) || 0), 0);
820
+ if (observedVersion > feed.keyVersion) {
821
+ feed = await refreshFeed(dataDir, spaceKey, phrase, options.preferred, observedVersion);
822
+ }
823
+ for (const row of rows)
824
+ cursor = Math.max(cursor, Number(row.id) || 0);
825
+ const events = rows.flatMap((row) => {
826
+ const event = decryptEvent(row, spaceKey, feed);
827
+ return event && (options.includeBinding || row.sender !== bindingSender(spaceKey)) ? [event] : [];
828
+ }).sort((a, b) => b.at.localeCompare(a.at));
829
+ return { events, cursor, more };
830
+ }
831
+ /**
832
+ * Decrypt the daemon's bounded local push inbox without touching the network.
833
+ * `gap` tells the hook to preserve its old cursor and use REST before trusting
834
+ * the newer rows; it must never silently skip an evicted event.
835
+ */
836
+ export async function readCachedTeamPresence(dataDir, spaceKey, phrase, after, preferred) {
837
+ const page = await readRealtimeTeamPresence(dataDir, spaceKey, after);
838
+ if (page.gap || page.rows.length === 0)
839
+ return { events: [], cursor: after, more: false, gap: page.gap };
840
+ let feed = await loadFeed(dataDir, spaceKey, phrase, preferred);
841
+ const observedVersion = page.rows.reduce((highest, row) => Math.max(highest, Number(row.key_version) || 0), 0);
842
+ if (observedVersion > feed.keyVersion) {
843
+ feed = await refreshFeed(dataDir, spaceKey, phrase, preferred, observedVersion);
844
+ }
845
+ const events = page.rows.flatMap((row) => {
846
+ const event = decryptEvent(row, spaceKey, feed);
847
+ return event && row.sender !== bindingSender(spaceKey) ? [event] : [];
848
+ }).sort((a, b) => b.at.localeCompare(a.at));
849
+ return { events, cursor: page.cursor, more: false, gap: page.gap };
850
+ }
851
+ /**
852
+ * On a fresh machine no local project→space binding exists yet. At session
853
+ * start we inspect only spaces this account belongs to, decrypt their bounded
854
+ * recent tails, and bind the one whose encrypted event names this project.
855
+ * This is what makes Mac→VPS and an accepted team invite continue without a
856
+ * command or a copied config file.
857
+ */
858
+ export async function discoverProjectTeamSpace(dataDir, project, phrase, preferred, projectKey) {
859
+ const config = await readConfig(dataDir);
860
+ const existing = teamSpaceOf(config, project, projectKey);
861
+ if (existing)
862
+ return existing;
863
+ const spaces = await listTeamSpaces(dataDir, preferred);
864
+ if (!spaces.length)
865
+ return null;
866
+ const matches = [];
867
+ for (const space of spaces) {
868
+ if (!SPACE_RE.test(space.space_key))
869
+ continue;
870
+ try {
871
+ const page = await readTeamPresence(dataDir, space.space_key, phrase, { preferred, includeBinding: true });
872
+ const named = page.events.filter((event) => event.project === project);
873
+ if (!named.length)
874
+ continue;
875
+ const exact = Boolean(projectKey && named.some((event) => event.projectKey === projectKey));
876
+ // A stable key from another repository is positive evidence that this
877
+ // same-named space is not the caller's. Keyless events are legacy
878
+ // candidates and are considered only when no exact space exists.
879
+ if (projectKey && !exact && !named.some((event) => !event.projectKey))
880
+ continue;
881
+ const member = await getTeamMembership(dataDir, space.space_key, preferred);
882
+ if (!member)
883
+ continue;
884
+ matches.push({ spaceKey: space.space_key, member, exact });
885
+ }
886
+ catch { /* another encrypted project, stale membership, or temporary outage */ }
887
+ }
888
+ // A display label is not identity. If two spaces contain an "api" seed,
889
+ // silently choosing whichever the server listed first crosses an access
890
+ // boundary. Leave both unbound until an explicit cloud selection resolves
891
+ // the ambiguity.
892
+ const exactMatches = matches.filter((match) => match.exact);
893
+ const eligible = exactMatches.length > 0 ? exactMatches : matches;
894
+ if (eligible.length !== 1)
895
+ return null;
896
+ const [{ spaceKey, member }] = eligible;
897
+ await bindTeamSpace(dataDir, project, {
898
+ spaceKey,
899
+ role: member.role === "owner" ? "owner" : "member",
900
+ keyVersion: member.key_version,
901
+ realtimeKey: SPACE_RE.test(member.realtime_key ?? "") ? member.realtime_key : spaceKey,
902
+ }, projectKey);
903
+ return teamSpaceOf(await readConfig(dataDir), project, projectKey);
904
+ }