sealkeep 0.9.0 → 0.11.1

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 +252 -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.d.ts +1 -0
  16. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
  17. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  18. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  20. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  21. package/dist/site/index.html +1808 -1904
  22. package/dist/site/llms.txt +67 -0
  23. package/dist/site/trust/architecture-data-flow.html +53 -0
  24. package/dist/site/trust/audit-roadmap.html +37 -0
  25. package/dist/site/trust/deployment-responsibility.html +11 -0
  26. package/dist/site/trust/dpa-sample.html +30 -0
  27. package/dist/site/trust/release-provenance.html +21 -0
  28. package/dist/site/trust/subprocessors.html +15 -0
  29. package/dist/site/trust/threat-model.html +34 -0
  30. package/dist/site/trust/toms.html +41 -0
  31. package/dist/site/trust-document.css +32 -0
  32. package/dist/site/trust.html +73 -0
  33. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  34. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  35. package/dist/site/visual/index.html +18 -0
  36. package/dist/site.zip +0 -0
  37. package/dist/src/activity.d.ts +9 -0
  38. package/dist/src/activity.js +90 -1
  39. package/dist/src/adapters.d.ts +175 -5
  40. package/dist/src/adapters.js +961 -73
  41. package/dist/src/agent-context.d.ts +135 -0
  42. package/dist/src/agent-context.js +1059 -0
  43. package/dist/src/archive-copies.d.ts +47 -0
  44. package/dist/src/archive-copies.js +179 -0
  45. package/dist/src/audit.d.ts +1 -1
  46. package/dist/src/audit.js +29 -4
  47. package/dist/src/autopilot.d.ts +52 -7
  48. package/dist/src/autopilot.js +143 -25
  49. package/dist/src/background-bandwidth.d.ts +46 -0
  50. package/dist/src/background-bandwidth.js +301 -0
  51. package/dist/src/background-cpu.d.ts +82 -0
  52. package/dist/src/background-cpu.js +212 -0
  53. package/dist/src/background-worker-error.d.ts +12 -0
  54. package/dist/src/background-worker-error.js +18 -0
  55. package/dist/src/branding.d.ts +24 -1
  56. package/dist/src/branding.js +31 -1
  57. package/dist/src/bridge.d.ts +233 -0
  58. package/dist/src/bridge.js +604 -0
  59. package/dist/src/byte-stream.d.ts +91 -0
  60. package/dist/src/byte-stream.js +385 -0
  61. package/dist/src/chunk-store.d.ts +41 -8
  62. package/dist/src/chunk-store.js +161 -65
  63. package/dist/src/cli.js +1698 -163
  64. package/dist/src/cloud.d.ts +841 -31
  65. package/dist/src/cloud.js +3196 -277
  66. package/dist/src/context-background.d.ts +37 -0
  67. package/dist/src/context-background.js +309 -0
  68. package/dist/src/context-drain-child.d.ts +1 -0
  69. package/dist/src/context-drain-child.js +98 -0
  70. package/dist/src/context-reader.d.ts +118 -0
  71. package/dist/src/context-reader.js +447 -0
  72. package/dist/src/control-plane/auth.d.ts +32 -4
  73. package/dist/src/control-plane/auth.js +85 -24
  74. package/dist/src/control-plane/server.js +19 -6
  75. package/dist/src/control-plane.d.ts +17 -1
  76. package/dist/src/control-plane.js +32 -6
  77. package/dist/src/crypto.d.ts +1 -1
  78. package/dist/src/crypto.js +5 -5
  79. package/dist/src/daemon-lease.d.ts +70 -0
  80. package/dist/src/daemon-lease.js +420 -0
  81. package/dist/src/daemon.d.ts +94 -1
  82. package/dist/src/daemon.js +1082 -105
  83. package/dist/src/darwin-service-policy.d.ts +41 -0
  84. package/dist/src/darwin-service-policy.js +60 -0
  85. package/dist/src/dashboard-cli.js +15 -15
  86. package/dist/src/device-authorization.d.ts +37 -0
  87. package/dist/src/device-authorization.js +199 -0
  88. package/dist/src/device-enrollment.d.ts +91 -0
  89. package/dist/src/device-enrollment.js +349 -0
  90. package/dist/src/disk.d.ts +17 -12
  91. package/dist/src/disk.js +43 -17
  92. package/dist/src/doctor.d.ts +35 -1
  93. package/dist/src/doctor.js +332 -41
  94. package/dist/src/durable-ticket-lock.d.ts +24 -0
  95. package/dist/src/durable-ticket-lock.js +232 -0
  96. package/dist/src/enroll.d.ts +1 -1
  97. package/dist/src/enroll.js +13 -7
  98. package/dist/src/env.d.ts +10 -1
  99. package/dist/src/env.js +11 -3
  100. package/dist/src/errors.d.ts +8 -8
  101. package/dist/src/errors.js +6 -6
  102. package/dist/src/flush.d.ts +12 -0
  103. package/dist/src/flush.js +37 -0
  104. package/dist/src/heartbeat.d.ts +86 -12
  105. package/dist/src/heartbeat.js +415 -29
  106. package/dist/src/index-background-watchdog.d.ts +1 -0
  107. package/dist/src/index-background-watchdog.js +94 -0
  108. package/dist/src/index-background-work.d.ts +21 -0
  109. package/dist/src/index-background-work.js +25 -0
  110. package/dist/src/index-background.d.ts +64 -0
  111. package/dist/src/index-background.js +394 -0
  112. package/dist/src/index-build-child.d.ts +1 -0
  113. package/dist/src/index-build-child.js +109 -0
  114. package/dist/src/index-manifest.d.ts +52 -0
  115. package/dist/src/index-manifest.js +444 -0
  116. package/dist/src/index-publication-proof.d.ts +84 -0
  117. package/dist/src/index-publication-proof.js +380 -0
  118. package/dist/src/index-publication-state.d.ts +149 -0
  119. package/dist/src/index-publication-state.js +696 -0
  120. package/dist/src/index-publication-verifier.d.ts +89 -0
  121. package/dist/src/index-publication-verifier.js +341 -0
  122. package/dist/src/index-publish.d.ts +62 -0
  123. package/dist/src/index-publish.js +540 -0
  124. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  125. package/dist/src/index-scratch-cleanup.js +166 -0
  126. package/dist/src/index-segment-types.d.ts +132 -0
  127. package/dist/src/index-segment-types.js +21 -0
  128. package/dist/src/index-segments.d.ts +9 -0
  129. package/dist/src/index-segments.js +516 -0
  130. package/dist/src/index-store.d.ts +123 -0
  131. package/dist/src/index-store.js +495 -0
  132. package/dist/src/index-sync.d.ts +91 -15
  133. package/dist/src/index-sync.js +286 -53
  134. package/dist/src/index-upgrade-publication.d.ts +30 -0
  135. package/dist/src/index-upgrade-publication.js +179 -0
  136. package/dist/src/integration-manager.d.ts +32 -0
  137. package/dist/src/integration-manager.js +394 -0
  138. package/dist/src/leakscan.js +1 -1
  139. package/dist/src/local-api.d.ts +56 -15
  140. package/dist/src/local-api.js +4979 -541
  141. package/dist/src/machine-settings.d.ts +51 -0
  142. package/dist/src/machine-settings.js +166 -0
  143. package/dist/src/managed-chunks.d.ts +5 -2
  144. package/dist/src/managed-chunks.js +14 -14
  145. package/dist/src/mcp-install.d.ts +11 -9
  146. package/dist/src/mcp-install.js +73 -28
  147. package/dist/src/mcp-workspace.d.ts +18 -0
  148. package/dist/src/mcp-workspace.js +50 -0
  149. package/dist/src/mcp.js +294 -25
  150. package/dist/src/migrate.js +27 -21
  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 +5940 -297
  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 +226 -30
  249. package/dist/src/vault.js +1776 -192
  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/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -0,0 +1,816 @@
1
+ /**
2
+ * Push wake-ups for encrypted cross-account presence.
3
+ *
4
+ * REST remains the durable append log and cursor catch-up path. The daemon
5
+ * keeps one Socket.IO connection per machine, multiplexes every locally bound
6
+ * opaque team-space id over it, and writes received ciphertext rows to a
7
+ * bounded local inbox. Agent hooks read that inbox at tool boundaries, so a
8
+ * busy team does not turn "is anyone already touching this?" into continuous
9
+ * HTTP polling.
10
+ *
11
+ * No user setting is involved: the realtime origin/path are derived from the
12
+ * same hosted API URL the signed-in client already uses, and the bearer stays
13
+ * in the native keystore/in-memory Socket.IO handshake.
14
+ */
15
+ import { link, mkdir, open, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
16
+ import { dirname, join } from "node:path";
17
+ import { createHash, randomUUID } from "node:crypto";
18
+ import { io } from "socket.io-client";
19
+ import { cloudAuthGeneration, cloudBackoffUntil, cloudToken, cloudUrl, listTeamSpaces, rememberCloudBackoff, } from "./cloud.js";
20
+ import { readConfig, revokeTeamSpaceBindings, updateTeamSpaceBindings } from "./vault.js";
21
+ const SPACE_RE = /^[A-Za-z0-9_-]{43}$/;
22
+ const SENDER_RE = /^[a-f0-9]{8}$/;
23
+ const CHANNEL_PREFIX = "sealkeep:team:";
24
+ const INBOX_LIMIT = 1_000;
25
+ const PUSH_QUEUE_LIMIT = 256;
26
+ const COMPACT_EVERY = 128;
27
+ const DEFAULT_REFRESH_MS = 5_000;
28
+ const TOKEN_RECHECK_MS = 5 * 60_000;
29
+ const STATUS_HEARTBEAT_MS = 30_000;
30
+ const HEALTH_MAX_AGE_MS = 90_000;
31
+ const SUBSCRIBE_TIMEOUT_MS = 10_000;
32
+ const inboxDir = (dataDir, spaceKey) => join(dataDir, "runtime", "team-presence-inbox", createHash("sha256").update(spaceKey).digest("hex"));
33
+ const rowName = (id) => `row-${String(id).padStart(16, "0")}.json`;
34
+ const ROW_FILE_RE = /^row-([0-9]{16})\.json$/;
35
+ const DROPPED_FILE_RE = /^dropped-([0-9]{16})\.marker$/;
36
+ const statusPath = (dataDir) => join(dataDir, "runtime", "team-realtime.json");
37
+ const writeQueues = new Map();
38
+ const compactCounters = new Map();
39
+ function queued(path, work) {
40
+ const prior = writeQueues.get(path) ?? Promise.resolve();
41
+ const next = prior.catch(() => undefined).then(work);
42
+ // The queue tail is bookkeeping, not a second observer of the operation.
43
+ // Keep it fulfilled even when `next` rejects; the caller owns that rejection.
44
+ // A rejecting `.finally()` tail caused an unhandled rejection during cleanup.
45
+ const tracked = next.then(() => undefined, () => undefined).finally(() => {
46
+ if (writeQueues.get(path) === tracked)
47
+ writeQueues.delete(path);
48
+ });
49
+ writeQueues.set(path, tracked);
50
+ return next;
51
+ }
52
+ async function atomicJson(path, value) {
53
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
54
+ const temporary = `${path}.${randomUUID()}.tmp`;
55
+ await writeFile(temporary, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
56
+ await rename(temporary, path);
57
+ }
58
+ function normalizedRow(value) {
59
+ if (!value || typeof value !== "object" || Array.isArray(value))
60
+ return null;
61
+ const row = value;
62
+ const id = Number(row.id);
63
+ const sequence = Number(row.sequence);
64
+ const keyVersion = Number(row.key_version);
65
+ const sender = typeof row.sender === "string" ? row.sender : "";
66
+ const ciphertext = typeof row.ciphertext === "string" ? row.ciphertext : "";
67
+ if (!Number.isSafeInteger(id) || id <= 0)
68
+ return null;
69
+ if (!Number.isSafeInteger(sequence) || sequence < 0)
70
+ return null;
71
+ if (!Number.isSafeInteger(keyVersion) || keyVersion <= 0)
72
+ return null;
73
+ if (!SENDER_RE.test(sender) || !ciphertext || ciphertext.length > 4_096)
74
+ return null;
75
+ const createdAt = typeof row.created_at === "string" && row.created_at.length <= 80 ? row.created_at : undefined;
76
+ const spaceId = Number(row.space_id);
77
+ const accountId = Number(row.account_id);
78
+ return {
79
+ id, sender, sequence, key_version: keyVersion, ciphertext,
80
+ ...(createdAt ? { created_at: createdAt } : {}),
81
+ ...(Number.isSafeInteger(spaceId) ? { space_id: spaceId } : {}),
82
+ ...(Number.isSafeInteger(accountId) && accountId > 0 ? { account_id: accountId } : {}),
83
+ };
84
+ }
85
+ async function markRealtimeGap(dataDir, spaceKey, droppedThrough) {
86
+ if (!SPACE_RE.test(spaceKey) || !Number.isSafeInteger(droppedThrough) || droppedThrough <= 0)
87
+ return;
88
+ const dir = inboxDir(dataDir, spaceKey);
89
+ await mkdir(dir, { recursive: true, mode: 0o700 });
90
+ const marker = `dropped-${String(droppedThrough).padStart(16, "0")}.marker`;
91
+ try {
92
+ await (await open(join(dir, marker), "wx", 0o600)).close();
93
+ }
94
+ catch (error) {
95
+ if (error.code !== "EEXIST")
96
+ throw error;
97
+ }
98
+ // Immutable, monotonically named markers avoid a cross-process lost-update
99
+ // race. Once the new high-water marker exists, lower ones are redundant.
100
+ const entries = await readdir(dir).catch(() => []);
101
+ await Promise.all(entries.flatMap((name) => {
102
+ const match = DROPPED_FILE_RE.exec(name);
103
+ return match && Number(match[1]) < droppedThrough ? [unlink(join(dir, name)).catch(() => undefined)] : [];
104
+ }));
105
+ }
106
+ async function compactInbox(dataDir, spaceKey) {
107
+ const dir = inboxDir(dataDir, spaceKey);
108
+ const entries = await readdir(dir).catch(() => []);
109
+ const rows = entries.flatMap((name) => {
110
+ const match = ROW_FILE_RE.exec(name);
111
+ return match ? [{ id: Number(match[1]), name }] : [];
112
+ }).filter((item) => Number.isSafeInteger(item.id)).sort((a, b) => a.id - b.id);
113
+ if (rows.length <= INBOX_LIMIT)
114
+ return;
115
+ const removing = rows.slice(0, rows.length - INBOX_LIMIT);
116
+ await markRealtimeGap(dataDir, spaceKey, removing[removing.length - 1].id);
117
+ await Promise.all(removing.map((item) => unlink(join(dir, item.name)).catch(() => undefined)));
118
+ }
119
+ /**
120
+ * Cache one validated wire row in its own immutable file. This is O(event),
121
+ * not O(all spaces × inbox size), and atomic hard links make overlapping daemon handoffs
122
+ * converge on the first complete row instead of overwriting one another.
123
+ */
124
+ export async function cacheRealtimeTeamPresence(dataDir, spaceKey, value) {
125
+ if (!SPACE_RE.test(spaceKey))
126
+ return false;
127
+ const row = normalizedRow(value);
128
+ if (!row)
129
+ return false;
130
+ const dir = inboxDir(dataDir, spaceKey);
131
+ await mkdir(dir, { recursive: true, mode: 0o700 });
132
+ const path = join(dir, rowName(row.id));
133
+ const temporary = join(dir, `.${rowName(row.id)}.${randomUUID()}.tmp`);
134
+ await writeFile(temporary, JSON.stringify(row) + "\n", { mode: 0o600 });
135
+ try {
136
+ await link(temporary, path);
137
+ }
138
+ catch (error) {
139
+ if (error.code === "EEXIST") {
140
+ await unlink(temporary).catch(() => undefined);
141
+ return false;
142
+ }
143
+ await unlink(temporary).catch(() => undefined);
144
+ throw error;
145
+ }
146
+ await unlink(temporary).catch(() => undefined);
147
+ const key = `${dataDir}\0${spaceKey}`;
148
+ const count = compactCounters.get(key);
149
+ if (count === undefined || count + 1 >= COMPACT_EVERY) {
150
+ compactCounters.set(key, 0);
151
+ await compactInbox(dataDir, spaceKey);
152
+ }
153
+ else
154
+ compactCounters.set(key, count + 1);
155
+ return true;
156
+ }
157
+ /** Read only local ciphertext rows newer than a durable cursor. */
158
+ export async function readRealtimeTeamPresence(dataDir, spaceKey, after) {
159
+ const cursorBefore = Number.isSafeInteger(after) ? Math.max(0, Math.trunc(after)) : 0;
160
+ if (!SPACE_RE.test(spaceKey))
161
+ return { rows: [], cursor: cursorBefore, gap: false };
162
+ const dir = inboxDir(dataDir, spaceKey);
163
+ const entries = await readdir(dir).catch(() => []);
164
+ const allRows = entries.flatMap((name) => {
165
+ const match = ROW_FILE_RE.exec(name);
166
+ return match ? [{ id: Number(match[1]), name }] : [];
167
+ }).filter((item) => Number.isSafeInteger(item.id)).sort((a, b) => a.id - b.id);
168
+ const markerHighWater = entries.reduce((max, name) => {
169
+ const match = DROPPED_FILE_RE.exec(name);
170
+ return match ? Math.max(max, Number(match[1])) : max;
171
+ }, 0);
172
+ const overflowHighWater = allRows.length > INBOX_LIMIT ? allRows[allRows.length - INBOX_LIMIT - 1].id : 0;
173
+ const retained = allRows.slice(-INBOX_LIMIT).filter((item) => item.id > cursorBefore);
174
+ const rows = (await Promise.all(retained.map(async (item) => {
175
+ try {
176
+ const row = normalizedRow(JSON.parse(await readFile(join(dir, item.name), "utf8")));
177
+ return row?.id === item.id ? row : null;
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }))).filter((row) => row !== null).sort((a, b) => a.id - b.id);
183
+ return {
184
+ rows,
185
+ cursor: rows.reduce((max, row) => Math.max(max, row.id), cursorBefore),
186
+ gap: cursorBefore < Math.max(markerHighWater, overflowHighWater),
187
+ };
188
+ }
189
+ export function teamRealtimeChannel(routeKey) {
190
+ return `${CHANNEL_PREFIX}${routeKey}`;
191
+ }
192
+ export function teamRealtimeEndpoint(env = process.env) {
193
+ const api = new URL(cloudUrl(env));
194
+ const pathname = api.pathname.replace(/\/+$/, "");
195
+ const prefix = pathname.endsWith("/api") ? pathname.slice(0, -4) : "";
196
+ return { origin: api.origin, path: `${prefix}/realtime` || "/realtime" };
197
+ }
198
+ async function writeStatus(dataDir, status) {
199
+ await queued(statusPath(dataDir), () => atomicJson(statusPath(dataDir), status));
200
+ }
201
+ export async function readTeamRealtimeStatus(dataDir) {
202
+ try {
203
+ const value = JSON.parse(await readFile(statusPath(dataDir), "utf8"));
204
+ if (value.version !== 1 || typeof value.connected !== "boolean" || typeof value.updatedAt !== "string" || !Array.isArray(value.subscribedSpaces))
205
+ return null;
206
+ return {
207
+ version: 1,
208
+ connected: value.connected,
209
+ updatedAt: value.updatedAt,
210
+ subscribedSpaces: value.subscribedSpaces.filter((item) => typeof item === "string" && SPACE_RE.test(item)),
211
+ ...(typeof value.connectionId === "string" ? { connectionId: value.connectionId.slice(0, 80) } : {}),
212
+ ...(typeof value.lastError === "string" ? { lastError: value.lastError.slice(0, 240) } : {}),
213
+ };
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ }
219
+ export function teamRealtimeStatusHealthy(status, spaceKey, options = {}) {
220
+ if (!status?.connected || !status.subscribedSpaces.includes(spaceKey))
221
+ return false;
222
+ const age = (options.now ?? Date.now()) - Date.parse(status.updatedAt);
223
+ return Number.isFinite(age) && age >= 0 && age <= (options.maxAgeMs ?? HEALTH_MAX_AGE_MS);
224
+ }
225
+ export async function teamRealtimeHealthy(dataDir, spaceKey, options = {}) {
226
+ return teamRealtimeStatusHealthy(await readTeamRealtimeStatus(dataDir), spaceKey, options);
227
+ }
228
+ function errorLine(error) {
229
+ return (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ").slice(0, 240);
230
+ }
231
+ const objectOf = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
232
+ function numberOf(values) {
233
+ for (const value of values) {
234
+ const numeric = Number(value);
235
+ if (Number.isFinite(numeric) && numeric >= 0)
236
+ return numeric;
237
+ }
238
+ return null;
239
+ }
240
+ /** Normalize Socket.IO middleware errors and Engine.IO HTTP 429 responses. */
241
+ function realtimeRateLimit(error) {
242
+ const root = objectOf(error);
243
+ const data = objectOf(root?.data);
244
+ const context = objectOf(root?.context);
245
+ let body = null;
246
+ const responseText = typeof context?.responseText === "string" ? context.responseText : "";
247
+ if (responseText) {
248
+ try {
249
+ body = objectOf(JSON.parse(responseText));
250
+ }
251
+ catch { /* a proxy may return a plain-text 429 */ }
252
+ }
253
+ const bodyError = objectOf(body?.error);
254
+ const dataError = objectOf(data?.error);
255
+ const records = [root, data, dataError, context, body, bodyError].filter((item) => item !== null);
256
+ const status = numberOf(records.flatMap((item) => [item.status, item.statusCode, item.httpStatus]));
257
+ const words = records.flatMap((item) => [item.code, item.message, item.error])
258
+ .filter((item) => typeof item === "string").join(" ");
259
+ if (status !== 429 && !/(?:^|\D)429(?:\D|$)|rate[_ -]?limit|request allowance.*(?:exhausted|reached)|PROJECT_API_REQUEST_LIMIT_EXCEEDED/i.test(words))
260
+ return null;
261
+ const milliseconds = numberOf(records.flatMap((item) => [item.retryAfterMs, item.retry_after_ms]));
262
+ if (milliseconds !== null)
263
+ return { retryAfterMs: Math.max(1_000, milliseconds) };
264
+ const seconds = numberOf(records.flatMap((item) => [item.retryAfterSeconds, item.retry_after_seconds, item.retryAfter]));
265
+ if (seconds !== null)
266
+ return { retryAfterMs: Math.max(1_000, seconds * 1_000) };
267
+ const resetAt = records.flatMap((item) => [item.resetAt, item.reset_at]).find((item) => typeof item === "string");
268
+ if (typeof resetAt === "string" && Number.isFinite(Date.parse(resetAt))) {
269
+ return { retryAfterMs: Math.max(1_000, Date.parse(resetAt) - Date.now()) };
270
+ }
271
+ try {
272
+ const header = typeof context?.getResponseHeader === "function"
273
+ ? String(context.getResponseHeader("retry-after") ?? "") : "";
274
+ const headerSeconds = Number(header);
275
+ if (Number.isFinite(headerSeconds) && headerSeconds >= 0)
276
+ return { retryAfterMs: Math.max(1_000, headerSeconds * 1_000) };
277
+ const headerAt = Date.parse(header);
278
+ if (Number.isFinite(headerAt))
279
+ return { retryAfterMs: Math.max(1_000, headerAt - Date.now()) };
280
+ }
281
+ catch { /* an Engine.IO context need not expose response headers */ }
282
+ return { retryAfterMs: 60_000 };
283
+ }
284
+ /**
285
+ * Start the package-owned machine listener. It is deliberately fail-open:
286
+ * missing sign-in, no team spaces, network failures, and auth expiry leave the
287
+ * durable REST path intact and never stop the preservation daemon.
288
+ */
289
+ export async function startTeamRealtime(dataDir, options = {}) {
290
+ const endpoint = teamRealtimeEndpoint();
291
+ // Public status and local inboxes are keyed by the stable logical space id.
292
+ // The values are rotatable, unguessable Socket.IO room ids.
293
+ const desired = new Map();
294
+ const subscribed = new Map();
295
+ const subscribing = new Map();
296
+ let socket = null;
297
+ let socketGeneration = 0;
298
+ let tokenInUse = null;
299
+ let lastTokenCheckAt = 0;
300
+ let nextTokenAttemptAt = 0;
301
+ let authGeneration = null;
302
+ let connectionId;
303
+ let closed = false;
304
+ let reconnectDelayMs = 500;
305
+ let reconnectTimer;
306
+ let refresh;
307
+ let refreshInFlight;
308
+ let closeInFlight;
309
+ let lastError;
310
+ let readyResolved = false;
311
+ let readyPersisting = false;
312
+ let resolveReady;
313
+ const ready = new Promise((resolve) => { resolveReady = resolve; });
314
+ const inboxWork = new Map();
315
+ const activeDrains = new Set();
316
+ let reconciliation = null;
317
+ const routeOf = (binding) => binding.realtimeKey && SPACE_RE.test(binding.realtimeKey) ? binding.realtimeKey : binding.spaceKey;
318
+ const sameRoutes = (left, right) => left.size === right.size && [...left].every(([spaceKey, routeKey]) => right.get(spaceKey) === routeKey);
319
+ /** Reload local bindings only; this is the cheap five-second discovery path. */
320
+ const reloadDesired = async () => {
321
+ const config = await readConfig(dataDir);
322
+ const next = new Map();
323
+ for (const binding of Object.values(config.teamSpaces ?? {})) {
324
+ if (!SPACE_RE.test(binding.spaceKey))
325
+ continue;
326
+ next.set(binding.spaceKey, routeOf(binding));
327
+ }
328
+ const changed = !sameRoutes(desired, next);
329
+ desired.clear();
330
+ for (const [spaceKey, routeKey] of next)
331
+ desired.set(spaceKey, routeKey);
332
+ return changed;
333
+ };
334
+ /** Best-effort removal of the revoked epoch key from this machine's keystore. */
335
+ const forgetRevokedFeed = async (spaceKey) => {
336
+ try {
337
+ const core = await import("./team-presence.js");
338
+ const forget = core.forgetTeamSpaceFeed;
339
+ if (typeof forget === "function") {
340
+ await forget(dataDir, spaceKey, options.preferred);
341
+ }
342
+ }
343
+ catch (error) {
344
+ // The binding is already revoked. Keep that security boundary even if a
345
+ // platform keystore temporarily refuses deletion; surface it for repair.
346
+ lastError = `team feed revoke: ${errorLine(error)}`;
347
+ }
348
+ };
349
+ /**
350
+ * One authoritative REST reconciliation per connection generation or
351
+ * rotation notice. It is deliberately not called by the steady refresh
352
+ * timer: Socket.IO remains the wake-up path and REST remains bounded.
353
+ */
354
+ const reconcileMemberships = () => {
355
+ if (reconciliation)
356
+ return reconciliation;
357
+ const work = (async () => {
358
+ const before = await readConfig(dataDir);
359
+ const localBySpace = new Map();
360
+ for (const binding of Object.values(before.teamSpaces ?? {})) {
361
+ if (SPACE_RE.test(binding.spaceKey) && !localBySpace.has(binding.spaceKey))
362
+ localBySpace.set(binding.spaceKey, binding);
363
+ }
364
+ if (!localBySpace.size) {
365
+ const routingChanged = await reloadDesired();
366
+ return { bindingChanged: false, routingChanged };
367
+ }
368
+ const hosted = await listTeamSpaces(dataDir, options.preferred);
369
+ const hostedBySpace = new Map(hosted.flatMap((raw) => {
370
+ const row = raw;
371
+ return SPACE_RE.test(row.space_key) ? [[row.space_key, row]] : [];
372
+ }));
373
+ let bindingChanged = false;
374
+ for (const [spaceKey, local] of localBySpace) {
375
+ const remote = hostedBySpace.get(spaceKey);
376
+ if (!remote) {
377
+ await revokeTeamSpaceBindings(dataDir, spaceKey);
378
+ await forgetRevokedFeed(spaceKey);
379
+ bindingChanged = true;
380
+ continue;
381
+ }
382
+ const patch = {};
383
+ const keyVersion = Number(remote.key_version);
384
+ if (Number.isSafeInteger(keyVersion) && keyVersion > 0)
385
+ patch.keyVersion = keyVersion;
386
+ if (remote.role === "owner" || remote.role === "member")
387
+ patch.role = remote.role;
388
+ if (typeof remote.realtime_key === "string" && SPACE_RE.test(remote.realtime_key))
389
+ patch.realtimeKey = remote.realtime_key;
390
+ if ((patch.keyVersion !== undefined && patch.keyVersion !== local.keyVersion)
391
+ || (patch.role !== undefined && patch.role !== local.role)
392
+ || (patch.realtimeKey !== undefined && patch.realtimeKey !== local.realtimeKey))
393
+ bindingChanged = true;
394
+ await updateTeamSpaceBindings(dataDir, spaceKey, patch);
395
+ }
396
+ const routingChanged = await reloadDesired();
397
+ return { bindingChanged, routingChanged };
398
+ })();
399
+ const tracked = work.finally(() => {
400
+ if (reconciliation === tracked)
401
+ reconciliation = null;
402
+ });
403
+ reconciliation = tracked;
404
+ return tracked;
405
+ };
406
+ const enqueuePresence = (spaceKey, value) => {
407
+ const row = normalizedRow(value);
408
+ if (!row)
409
+ return;
410
+ const work = inboxWork.get(spaceKey) ?? { rows: [], droppedThrough: 0, running: false };
411
+ inboxWork.set(spaceKey, work);
412
+ if (work.rows.length >= PUSH_QUEUE_LIMIT)
413
+ work.droppedThrough = Math.max(work.droppedThrough, row.id);
414
+ else
415
+ work.rows.push(row);
416
+ if (work.running)
417
+ return;
418
+ work.running = true;
419
+ const drain = (async () => {
420
+ for (;;) {
421
+ const next = work.rows.shift();
422
+ if (next) {
423
+ try {
424
+ await cacheRealtimeTeamPresence(dataDir, spaceKey, next);
425
+ }
426
+ catch (error) {
427
+ work.droppedThrough = Math.max(work.droppedThrough, next.id);
428
+ lastError = `presence inbox: ${errorLine(error)}`;
429
+ }
430
+ continue;
431
+ }
432
+ if (work.droppedThrough > 0) {
433
+ const droppedThrough = work.droppedThrough;
434
+ work.droppedThrough = 0;
435
+ try {
436
+ await markRealtimeGap(dataDir, spaceKey, droppedThrough);
437
+ }
438
+ catch (error) {
439
+ lastError = `presence gap marker: ${errorLine(error)}`;
440
+ }
441
+ continue;
442
+ }
443
+ break;
444
+ }
445
+ work.running = false;
446
+ })();
447
+ activeDrains.add(drain);
448
+ void drain.then(() => activeDrains.delete(drain), () => activeDrains.delete(drain));
449
+ };
450
+ const snapshot = () => ({
451
+ version: 1,
452
+ connected: socket?.connected === true,
453
+ updatedAt: new Date().toISOString(),
454
+ subscribedSpaces: [...subscribed.keys()].sort(),
455
+ ...(connectionId ? { connectionId } : {}),
456
+ ...(lastError ? { lastError } : {}),
457
+ });
458
+ const persistStatus = () => closed ? Promise.resolve() : writeStatus(dataDir, snapshot()).catch(() => undefined);
459
+ const scheduleReconnect = () => {
460
+ if (closed || reconnectTimer || Date.now() < nextTokenAttemptAt)
461
+ return;
462
+ const delay = reconnectDelayMs;
463
+ reconnectDelayMs = Math.min(15_000, reconnectDelayMs * 2);
464
+ nextTokenAttemptAt = Date.now() + delay;
465
+ reconnectTimer = setTimeout(() => {
466
+ reconnectTimer = undefined;
467
+ void refresh();
468
+ }, delay);
469
+ reconnectTimer.unref?.();
470
+ };
471
+ const settleReady = () => {
472
+ if (closed || readyResolved || readyPersisting || ![...desired.keys()].every((spaceKey) => subscribed.has(spaceKey)))
473
+ return;
474
+ // A separate hook process consumes the durable status file, not this
475
+ // closure. Do not announce readiness until the acknowledged subscription
476
+ // generation is visible there, or the very next tool boundary can read the
477
+ // prior disconnected generation and skip its required reconnect catch-up.
478
+ const generation = socketGeneration;
479
+ const expectedConnectionId = connectionId;
480
+ const expectedSpaces = [...desired.keys()].sort();
481
+ const expectedStatus = snapshot();
482
+ readyPersisting = true;
483
+ void writeStatus(dataDir, expectedStatus).then(() => readTeamRealtimeStatus(dataDir)).then((durable) => {
484
+ readyPersisting = false;
485
+ const sameSpaces = durable?.subscribedSpaces.length === expectedSpaces.length
486
+ && expectedSpaces.every((spaceKey, index) => durable.subscribedSpaces[index] === spaceKey);
487
+ const socketMatches = expectedStatus.connected ? socket?.connected === true : socket?.connected !== true;
488
+ if (closed || readyResolved)
489
+ return;
490
+ if (generation !== socketGeneration || expectedConnectionId !== connectionId || !socketMatches
491
+ || ![...desired.keys()].every((spaceKey) => subscribed.has(spaceKey))
492
+ || durable?.connected !== expectedStatus.connected || durable.connectionId !== expectedConnectionId || !sameSpaces)
493
+ return;
494
+ readyResolved = true;
495
+ resolveReady();
496
+ }, () => { readyPersisting = false; });
497
+ };
498
+ const syncSubscriptions = () => {
499
+ if (!socket?.connected)
500
+ return;
501
+ const generation = socketGeneration;
502
+ const targetSocket = socket;
503
+ for (const [spaceKey, routeKey] of [...subscribed]) {
504
+ if (desired.get(spaceKey) === routeKey)
505
+ continue;
506
+ socket.emit("unsubscribe", { channel: teamRealtimeChannel(routeKey) });
507
+ subscribed.delete(spaceKey);
508
+ subscribing.delete(spaceKey);
509
+ }
510
+ for (const [spaceKey, routeKey] of desired) {
511
+ if (subscribed.get(spaceKey) === routeKey || subscribing.get(spaceKey) === routeKey)
512
+ continue;
513
+ subscribing.set(spaceKey, routeKey);
514
+ let settled = false;
515
+ const timeout = setTimeout(() => {
516
+ if (settled)
517
+ return;
518
+ settled = true;
519
+ if (generation !== socketGeneration || socket !== targetSocket)
520
+ return;
521
+ if (subscribing.get(spaceKey) === routeKey)
522
+ subscribing.delete(spaceKey);
523
+ lastError = `subscription timed out for ${spaceKey.slice(0, 8)}`;
524
+ void persistStatus();
525
+ }, SUBSCRIBE_TIMEOUT_MS);
526
+ timeout.unref?.();
527
+ socket.emit("subscribe", { channel: teamRealtimeChannel(routeKey) }, (ack) => {
528
+ if (settled)
529
+ return;
530
+ settled = true;
531
+ clearTimeout(timeout);
532
+ if (generation !== socketGeneration || socket !== targetSocket)
533
+ return;
534
+ if (subscribing.get(spaceKey) !== routeKey || desired.get(spaceKey) !== routeKey)
535
+ return;
536
+ subscribing.delete(spaceKey);
537
+ if (closed || !ack || typeof ack !== "object" || Array.isArray(ack))
538
+ return;
539
+ const response = ack;
540
+ if (response.success === true) {
541
+ subscribed.set(spaceKey, routeKey);
542
+ lastError = undefined;
543
+ settleReady();
544
+ }
545
+ else if (typeof response.error === "string") {
546
+ lastError = response.error.slice(0, 240);
547
+ }
548
+ void persistStatus();
549
+ });
550
+ }
551
+ settleReady();
552
+ void persistStatus();
553
+ };
554
+ const stopSocket = () => {
555
+ if (!socket)
556
+ return;
557
+ socketGeneration += 1;
558
+ socket.removeAllListeners();
559
+ socket.disconnect();
560
+ socket = null;
561
+ tokenInUse = null;
562
+ subscribed.clear();
563
+ subscribing.clear();
564
+ };
565
+ const reconcileAfterRotation = () => {
566
+ const generation = socketGeneration;
567
+ void reconcileMemberships().then(({ bindingChanged, routingChanged }) => {
568
+ if (closed || generation !== socketGeneration || socket?.connected !== true)
569
+ return;
570
+ if (bindingChanged || routingChanged)
571
+ connectionId = randomUUID();
572
+ if (!desired.size) {
573
+ stopSocket();
574
+ settleReady();
575
+ void persistStatus();
576
+ return;
577
+ }
578
+ syncSubscriptions();
579
+ }, (error) => {
580
+ lastError = `team key rotation: ${errorLine(error)}`;
581
+ void persistStatus();
582
+ });
583
+ };
584
+ const connect = (token) => {
585
+ if (socket && tokenInUse === token) {
586
+ if (!socket.connected && !socket.active)
587
+ socket.connect();
588
+ return;
589
+ }
590
+ stopSocket();
591
+ tokenInUse = token;
592
+ socket = io(endpoint.origin, {
593
+ path: endpoint.path,
594
+ auth: { token },
595
+ // Begin with the broadly proxy-safe Engine.IO polling handshake, then
596
+ // upgrade when the hosted edge supports WebSocket. Forcing WebSocket as
597
+ // the initial transport failed behind the real Spala/Cloudflare route
598
+ // even though its polling handshake and later upgrade are available.
599
+ transports: ["polling", "websocket"],
600
+ // Reconnects are owned by refresh(), which checks the shared durable 429
601
+ // receipt first. Letting Manager reconnect independently would create a
602
+ // second path capable of bypassing the server's Retry-After instruction.
603
+ reconnection: false,
604
+ timeout: 10_000,
605
+ });
606
+ socket.on("connect", () => {
607
+ socketGeneration += 1;
608
+ const generation = socketGeneration;
609
+ subscribed.clear();
610
+ subscribing.clear();
611
+ connectionId = randomUUID();
612
+ nextTokenAttemptAt = 0;
613
+ reconnectDelayMs = 500;
614
+ if (reconnectTimer)
615
+ clearTimeout(reconnectTimer);
616
+ reconnectTimer = undefined;
617
+ lastError = undefined;
618
+ // Do not join the route remembered before an offline rotation. First ask
619
+ // the membership-scoped REST endpoint for this account's current route.
620
+ void reconcileMemberships().then(() => {
621
+ if (closed || generation !== socketGeneration || socket?.connected !== true)
622
+ return;
623
+ if (!desired.size) {
624
+ stopSocket();
625
+ settleReady();
626
+ void persistStatus();
627
+ return;
628
+ }
629
+ syncSubscriptions();
630
+ }, (error) => {
631
+ if (closed || generation !== socketGeneration || socket?.connected !== true)
632
+ return;
633
+ // A transient REST failure must not destroy a healthy live lane. Join
634
+ // the last durable route; a reconnect or key_rotated notice retries.
635
+ lastError = `team membership reconcile: ${errorLine(error)}`;
636
+ syncSubscriptions();
637
+ });
638
+ });
639
+ socket.on("disconnect", (reason) => {
640
+ socketGeneration += 1;
641
+ subscribed.clear();
642
+ subscribing.clear();
643
+ lastError = `disconnected: ${reason}`;
644
+ scheduleReconnect();
645
+ void persistStatus();
646
+ });
647
+ socket.on("connect_error", (error) => {
648
+ subscribed.clear();
649
+ subscribing.clear();
650
+ lastError = errorLine(error);
651
+ const limited = realtimeRateLimit(error);
652
+ if (limited) {
653
+ const until = Date.now() + limited.retryAfterMs;
654
+ stopSocket();
655
+ nextTokenAttemptAt = Math.max(nextTokenAttemptAt, until);
656
+ lastError = `rate limited until ${new Date(until).toISOString()}`;
657
+ const backoff = rememberCloudBackoff(dataDir, limited.retryAfterMs).then(() => persistStatus(), () => persistStatus());
658
+ activeDrains.add(backoff);
659
+ void backoff.then(() => activeDrains.delete(backoff));
660
+ }
661
+ else if (/auth|token|unauthor|forbid|jwt/i.test(lastError)) {
662
+ stopSocket();
663
+ nextTokenAttemptAt = Date.now() + TOKEN_RECHECK_MS;
664
+ }
665
+ else
666
+ scheduleReconnect();
667
+ void persistStatus();
668
+ });
669
+ // The native relay may send this server-authored control event directly,
670
+ // while proxy-compatible deployments wrap it in `broadcast` below. It
671
+ // carries no new route or key; authorized REST state remains the authority.
672
+ socket.on("key_rotated", (message) => {
673
+ const value = objectOf(message);
674
+ const spaceKey = typeof value?.space_key === "string" ? value.space_key : "";
675
+ if (!SPACE_RE.test(spaceKey) || !subscribed.has(spaceKey) || desired.get(spaceKey) !== subscribed.get(spaceKey))
676
+ return;
677
+ reconcileAfterRotation();
678
+ });
679
+ socket.on("broadcast", (message) => {
680
+ if (!message || typeof message !== "object" || Array.isArray(message))
681
+ return;
682
+ const envelope = message;
683
+ if (typeof envelope.channel !== "string" || !envelope.channel.startsWith(CHANNEL_PREFIX))
684
+ return;
685
+ const routeKey = envelope.channel.slice(CHANNEL_PREFIX.length);
686
+ const logicalSpaces = [...subscribed].flatMap(([spaceKey, subscribedRoute]) => subscribedRoute === routeKey && desired.get(spaceKey) === routeKey ? [spaceKey] : []);
687
+ if (!logicalSpaces.length)
688
+ return;
689
+ if (envelope.event === "presence") {
690
+ for (const spaceKey of logicalSpaces)
691
+ enqueuePresence(spaceKey, envelope.payload);
692
+ return;
693
+ }
694
+ if (envelope.event !== "key_rotated")
695
+ return;
696
+ const payload = objectOf(envelope.payload);
697
+ const spaceKey = typeof payload?.space_key === "string" ? payload.space_key : "";
698
+ // The old subscribed route and the stable logical id must agree. Ignore
699
+ // every other payload field: the server-authored membership listing is
700
+ // the only authority for whether we remain a member and which room wins.
701
+ if (!SPACE_RE.test(spaceKey) || !logicalSpaces.includes(spaceKey))
702
+ return;
703
+ reconcileAfterRotation();
704
+ });
705
+ };
706
+ const refreshOnce = async () => {
707
+ if (closed)
708
+ return;
709
+ try {
710
+ const routingChanged = await reloadDesired();
711
+ if (closed)
712
+ return;
713
+ if (routingChanged && socket?.connected === true)
714
+ connectionId = randomUUID();
715
+ if (!desired.size) {
716
+ stopSocket();
717
+ lastError = undefined;
718
+ settleReady();
719
+ await persistStatus();
720
+ return;
721
+ }
722
+ // A 429 learned by REST or a prior realtime handshake is a control-plane
723
+ // instruction, not a hint. Do not let Socket.IO's reconnect manager make
724
+ // background attempts during that durable cooldown. An already-open push
725
+ // socket is kept because it consumes no new handshake requests.
726
+ const backoffUntil = await cloudBackoffUntil(dataDir);
727
+ if (closed)
728
+ return;
729
+ if (socket?.connected !== true && backoffUntil > Date.now()) {
730
+ stopSocket();
731
+ nextTokenAttemptAt = Math.max(nextTokenAttemptAt, backoffUntil);
732
+ lastError = `rate limited until ${new Date(backoffUntil).toISOString()}`;
733
+ await persistStatus();
734
+ return;
735
+ }
736
+ // Space discovery is cheap JSON and runs every five seconds so enabling
737
+ // team mode needs no restart. Native token retrieval is deliberately not
738
+ // on that loop: probing Keychain/Secret Service hundreds of times per
739
+ // hour would turn an idle daemon into needless process churn.
740
+ const currentAuthGeneration = await cloudAuthGeneration(dataDir);
741
+ if (closed)
742
+ return;
743
+ if (currentAuthGeneration !== authGeneration)
744
+ nextTokenAttemptAt = 0;
745
+ if (socket && currentAuthGeneration === authGeneration && Date.now() - lastTokenCheckAt < TOKEN_RECHECK_MS) {
746
+ if (!socket.connected && !socket.active && Date.now() >= nextTokenAttemptAt)
747
+ socket.connect();
748
+ syncSubscriptions();
749
+ return;
750
+ }
751
+ if (!socket && Date.now() < nextTokenAttemptAt)
752
+ return;
753
+ const token = await cloudToken(dataDir, options.preferred);
754
+ if (closed)
755
+ return;
756
+ lastTokenCheckAt = Date.now();
757
+ nextTokenAttemptAt = 0;
758
+ authGeneration = currentAuthGeneration;
759
+ connect(token);
760
+ syncSubscriptions();
761
+ }
762
+ catch (error) {
763
+ // A missing/revoked token must not leave an authenticated socket alive.
764
+ // Transient loss degrades to REST and the next refresh retries cleanly.
765
+ stopSocket();
766
+ lastTokenCheckAt = Date.now();
767
+ nextTokenAttemptAt = Date.now() + TOKEN_RECHECK_MS;
768
+ authGeneration = await cloudAuthGeneration(dataDir).catch(() => null);
769
+ lastError = errorLine(error);
770
+ await persistStatus();
771
+ }
772
+ };
773
+ refresh = () => {
774
+ if (closed)
775
+ return Promise.resolve();
776
+ if (refreshInFlight)
777
+ return refreshInFlight;
778
+ const work = refreshOnce();
779
+ refreshInFlight = work;
780
+ void work.then(() => { if (refreshInFlight === work)
781
+ refreshInFlight = undefined; }, () => { if (refreshInFlight === work)
782
+ refreshInFlight = undefined; });
783
+ return work;
784
+ };
785
+ await refresh();
786
+ const refreshTimer = setInterval(() => { void refresh(); }, Math.max(250, options.refreshMs ?? DEFAULT_REFRESH_MS));
787
+ const heartbeatTimer = setInterval(() => { void persistStatus(); }, STATUS_HEARTBEAT_MS);
788
+ refreshTimer.unref?.();
789
+ heartbeatTimer.unref?.();
790
+ return {
791
+ ready,
792
+ refresh,
793
+ close: () => {
794
+ if (closeInFlight)
795
+ return closeInFlight;
796
+ closeInFlight = (async () => {
797
+ closed = true;
798
+ clearInterval(refreshTimer);
799
+ clearInterval(heartbeatTimer);
800
+ if (reconnectTimer)
801
+ clearTimeout(reconnectTimer);
802
+ reconnectTimer = undefined;
803
+ stopSocket();
804
+ await refreshInFlight;
805
+ await reconciliation?.catch(() => undefined);
806
+ await Promise.all([...activeDrains]);
807
+ lastError = undefined;
808
+ // Queue the final disconnected status after every owned producer has
809
+ // stopped. No refresh may recreate a socket or write after close.
810
+ await writeStatus(dataDir, snapshot()).catch(() => undefined);
811
+ })();
812
+ return closeInFlight;
813
+ },
814
+ status: snapshot,
815
+ };
816
+ }