sealkeep 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1698 -163
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -0,0 +1,447 @@
1
+ import { readSourceBytes } from "./source-reader.js";
2
+ import { isRecalledMemoryLine } from "./search.js";
3
+ import { fail, isSealkeepError } from "./errors.js";
4
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
5
+ const string = (value) => typeof value === "string" ? value : undefined;
6
+ function textContent(value) {
7
+ if (typeof value === "string")
8
+ return value;
9
+ if (!Array.isArray(value))
10
+ return "";
11
+ return value.flatMap((part) => {
12
+ const item = object(part);
13
+ if (!item)
14
+ return [];
15
+ if (typeof item.text === "string")
16
+ return [item.text];
17
+ if (item.type === "tool_result")
18
+ return [textContent(item.content)];
19
+ if (item.type === "tool_use")
20
+ return [JSON.stringify(item.input ?? {})];
21
+ return [];
22
+ }).join("\n");
23
+ }
24
+ /** Select an honest excerpt of a long message, not a synthetic summary.
25
+ * Bound candidate work; explicit cursors always retain their exact offset. */
26
+ function queryTextOffset(text, terms, width) {
27
+ if (!terms.length || text.length <= width)
28
+ return 0;
29
+ const lower = text.toLowerCase();
30
+ const positions = new Set([0]);
31
+ for (const term of terms) {
32
+ let at = -1;
33
+ for (let count = 0; count < 64; count++) {
34
+ at = lower.indexOf(term, at + 1);
35
+ if (at < 0)
36
+ break;
37
+ positions.add(Math.max(0, at - Math.floor(width / 4)));
38
+ }
39
+ }
40
+ let selected = 0, coverage = -1;
41
+ for (const at of positions) {
42
+ const passage = lower.slice(at, at + width);
43
+ const score = terms.filter(term => passage.includes(term)).length;
44
+ if (score > coverage) {
45
+ coverage = score;
46
+ selected = at;
47
+ }
48
+ }
49
+ // Never cut a UTF-16 surrogate pair in half.
50
+ if (selected > 0 && /[\uDC00-\uDFFF]/.test(text[selected]))
51
+ selected--;
52
+ return selected;
53
+ }
54
+ /** Newer native Codex logs distinguish real input from injected role:user blocks. */
55
+ function annotatedUserText(payload) {
56
+ if (payload.type !== "message" || payload.role !== "user")
57
+ return;
58
+ const metadata = object(payload.internal_chat_message_metadata_passthrough);
59
+ const kinds = metadata?.content_item_kinds;
60
+ if (!Array.isArray(payload.content) || !Array.isArray(kinds) || kinds.length !== payload.content.length
61
+ || !kinds.every(kind => typeof kind === "string"))
62
+ return;
63
+ const outerTurn = string(payload.turn_id);
64
+ const nativeTurn = string(metadata?.turn_id);
65
+ if (outerTurn && nativeTurn && outerTurn !== nativeTurn)
66
+ return;
67
+ const indexes = [];
68
+ const text = [];
69
+ for (let at = 0; at < payload.content.length; at++) {
70
+ const part = object(payload.content[at]);
71
+ if (kinds[at] !== "user.text" || part?.type !== "input_text" || typeof part.text !== "string"
72
+ || !part.text || isRecalledMemoryLine(part.text))
73
+ continue;
74
+ indexes.push(at);
75
+ text.push(part.text);
76
+ }
77
+ if (!indexes.length)
78
+ return;
79
+ return { text: text.join("\n"), indexes, ...(nativeTurn || outerTurn ? { turnId: nativeTurn || outerTurn } : {}) };
80
+ }
81
+ const retrievalName = (name) => !!name
82
+ && /(?:^|__|[.:/])sealkeep_(?:search|read_context)$/.test(name);
83
+ /** Native direct MCP calls and code-mode calls retain the actual tool identity. */
84
+ function retrievalCall(name, input) {
85
+ if (retrievalName(name))
86
+ return true;
87
+ let args = object(input);
88
+ if (!args && typeof input === "string") {
89
+ try {
90
+ args = object(JSON.parse(input));
91
+ }
92
+ catch { /* code-mode input, not JSON */ }
93
+ }
94
+ if (retrievalName(string(args?.tool) ?? string(args?.name)))
95
+ return true;
96
+ if (name && /(?:^|[.:])exec$/.test(name) && typeof input === "string") {
97
+ // Match an executed tool member, not incidental prose naming a tool.
98
+ return /\btools\.[\w]*sealkeep_(?:search|read_context)\s*\(/.test(input);
99
+ }
100
+ return false;
101
+ }
102
+ /** Recognize typed search/context replies when a bounded window omits the call. */
103
+ function retrievedMemoryResult(text) {
104
+ let remaining = 256 * 1024;
105
+ let nodes = 32;
106
+ const inspect = (value, depth) => {
107
+ if (depth > 12 || --nodes < 0)
108
+ return false;
109
+ if (typeof value === "string") {
110
+ if (value.length > remaining)
111
+ return false;
112
+ remaining -= value.length;
113
+ let candidate = value.trim();
114
+ const output = candidate.indexOf("\nOutput:\n");
115
+ if (output >= 0)
116
+ candidate = candidate.slice(output + "\nOutput:\n".length).trim();
117
+ if (!/^[{[\"]/.test(candidate))
118
+ return false;
119
+ try {
120
+ return inspect(JSON.parse(candidate), depth + 1);
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ }
126
+ if (Array.isArray(value))
127
+ return value.slice(0, 16).some(item => inspect(item, depth + 1));
128
+ const row = object(value);
129
+ if (!row)
130
+ return false;
131
+ if ((row.mode === "content" || row.mode === "metadata") && Array.isArray(row.hits))
132
+ return true;
133
+ const source = object(row.source);
134
+ if (source && typeof source.sourceSha256 === "string" && typeof source.archiveId === "string"
135
+ && Array.isArray(row.messages) && (row.status === "ok" || row.status === "unsupported"))
136
+ return true;
137
+ // Only known response envelopes are traversed, not arbitrary code/input.
138
+ return [row.content, row.structuredContent, row.result, row.output, row.type === "text" ? row.text : undefined]
139
+ .some(item => item !== undefined && inspect(item, depth + 1));
140
+ };
141
+ return inspect(text, 0);
142
+ }
143
+ /** Byte locations refer to the immutable original JSONL, never reconstructed prose. */
144
+ export function parseContextMessages(bytes, source, byteStart = 0) {
145
+ const messages = [];
146
+ let offset = 0;
147
+ let turnId;
148
+ const calls = new Map();
149
+ while (offset < bytes.length) {
150
+ const newline = bytes.indexOf(10, offset);
151
+ const end = newline < 0 ? bytes.length : newline;
152
+ const raw = bytes.subarray(offset, end).toString("utf8");
153
+ const from = byteStart + offset;
154
+ offset = newline < 0 ? bytes.length : newline + 1;
155
+ if (!raw.trim())
156
+ continue;
157
+ let row;
158
+ try {
159
+ row = object(JSON.parse(raw));
160
+ }
161
+ catch {
162
+ continue;
163
+ }
164
+ if (!row)
165
+ continue;
166
+ const payload = object(row.payload) ?? row;
167
+ const nativeUser = row.type === "response_item" ? annotatedUserText(payload) : undefined;
168
+ // Mixed native rows may contain both genuine user text and hook context.
169
+ // Only positively annotated, individually filtered user text survives.
170
+ if (isRecalledMemoryLine(raw) && !nativeUser)
171
+ continue;
172
+ if (row.type === "turn_context") {
173
+ const nativeTurn = string(payload.turn_id);
174
+ if (nativeTurn) {
175
+ turnId = nativeTurn;
176
+ const pendingUser = messages[messages.length - 1];
177
+ // Only the immediately pending, provisionally identified user event
178
+ // can acquire this native turn. A new turn cannot relabel old history.
179
+ if (pendingUser?.actualUserMessage && pendingUser.turnId === pendingUser.messageId)
180
+ pendingUser.turnId = nativeTurn;
181
+ }
182
+ continue;
183
+ }
184
+ let role = "unknown";
185
+ let text = "";
186
+ let actualUserMessage = false;
187
+ let userEvidence;
188
+ let callId;
189
+ let parentCallId;
190
+ let kind = "message";
191
+ let toolName;
192
+ let recalled = false;
193
+ let unresolvedToolResult = false;
194
+ if (row.type === "event_msg") {
195
+ if (payload.type === "user_message") {
196
+ role = "user";
197
+ text = string(payload.message) ?? "";
198
+ actualUserMessage = true;
199
+ userEvidence = "native_user_event";
200
+ }
201
+ else if (payload.type === "agent_message") {
202
+ role = "assistant";
203
+ text = string(payload.message) ?? "";
204
+ }
205
+ else
206
+ continue;
207
+ }
208
+ else if (row.type === "response_item") {
209
+ if (payload.type === "function_call" || payload.type === "custom_tool_call") {
210
+ role = "assistant";
211
+ text = string(payload.arguments) ?? string(payload.input) ?? "";
212
+ callId = string(payload.call_id);
213
+ kind = "tool_call";
214
+ toolName = string(payload.name);
215
+ recalled = retrievalCall(toolName, text);
216
+ }
217
+ else if (payload.type === "function_call_output" || payload.type === "custom_tool_call_output") {
218
+ role = "tool";
219
+ text = textContent(payload.output);
220
+ parentCallId = string(payload.call_id);
221
+ kind = "tool_result";
222
+ }
223
+ else {
224
+ const rawRole = string(payload.role);
225
+ if (rawRole === "user" || rawRole === "assistant" || rawRole === "tool")
226
+ role = rawRole;
227
+ else if (rawRole === "system" || rawRole === "developer")
228
+ continue;
229
+ if (rawRole === "tool") {
230
+ kind = "tool_result";
231
+ parentCallId = string(payload.call_id) ?? string(payload.tool_call_id);
232
+ }
233
+ text = nativeUser?.text ?? textContent(payload.content);
234
+ if (nativeUser) {
235
+ actualUserMessage = true;
236
+ userEvidence = "native_user_text";
237
+ }
238
+ else if (rawRole === "user") {
239
+ const kinds = object(payload.internal_chat_message_metadata_passthrough)?.content_item_kinds;
240
+ if (Array.isArray(kinds) && kinds.length && kinds.every(kind => typeof kind === "string" && /^(?:plugins|environments|hooks)\./.test(kind)))
241
+ continue;
242
+ }
243
+ }
244
+ }
245
+ else if (row.type === "user" || row.type === "assistant") {
246
+ const message = object(row.message);
247
+ const content = message?.content ?? row.message;
248
+ const result = Array.isArray(content) ? content.map(object).find((part) => part?.type === "tool_result") : undefined;
249
+ const call = Array.isArray(content) ? content.map(object).find((part) => part?.type === "tool_use") : undefined;
250
+ role = result ? "tool" : row.type;
251
+ text = textContent(content);
252
+ parentCallId = string(result?.tool_use_id);
253
+ callId = string(call?.id);
254
+ kind = result ? "tool_result" : call ? "tool_call" : "message";
255
+ toolName = string(call?.name);
256
+ recalled = !!call && retrievalCall(toolName, call.input);
257
+ // Claude also transports automated notifications in role:user rows.
258
+ // Preserve their contents, but never present them as a human instruction
259
+ // or let them start a new user turn. Some historical rows lack isMeta.
260
+ const automatedUser = row.type === "user" && (row.isMeta === true || row.isCompactSummary === true
261
+ || /^\s*<(?:task-notification|local-command-stdout|local-command-stderr|system-reminder)(?:\s|>)/.test(text));
262
+ if (automatedUser && !result)
263
+ role = "unknown";
264
+ actualUserMessage = row.type === "user" && !result && !automatedUser;
265
+ if (actualUserMessage)
266
+ userEvidence = "claude_user_message";
267
+ }
268
+ else
269
+ continue;
270
+ if (callId)
271
+ calls.set(callId, { name: toolName, recalled });
272
+ if (parentCallId) {
273
+ const call = calls.get(parentCallId);
274
+ toolName = call?.name;
275
+ recalled = call?.recalled ?? false;
276
+ }
277
+ // A byte window may start after its tool call. Absence of a retrieval
278
+ // marker is not proof that this output was generated by the current task.
279
+ unresolvedToolResult = kind === "tool_result" && !toolName;
280
+ if (kind === "tool_result" && !recalled)
281
+ recalled = retrievedMemoryResult(text);
282
+ if (!text)
283
+ continue;
284
+ const nativeId = string(payload.id) ?? string(row.uuid) ?? string(object(row.message)?.id);
285
+ const messageId = `${source.sourceSha256}:${from}${nativeId ? `:${nativeId}` : ""}`;
286
+ const previous = messages[messages.length - 1];
287
+ // Codex records both event and response representations of some messages.
288
+ // Never promote an earlier unproven row or collapse identical new prompts.
289
+ const explicitUserTurn = nativeUser?.turnId ?? string(payload.turn_id);
290
+ const userDuplicate = actualUserMessage && previous?.actualUserMessage && previous.userEvidence !== userEvidence
291
+ && (!explicitUserTurn || previous.turnId === previous.messageId || previous.turnId === explicitUserTurn);
292
+ if (previous && previous.role === role && previous.kind === kind && previous.text === text
293
+ && (!actualUserMessage || userDuplicate)) {
294
+ if (userDuplicate) {
295
+ if (explicitUserTurn && previous.turnId === previous.messageId)
296
+ previous.turnId = explicitUserTurn;
297
+ turnId = previous.turnId;
298
+ }
299
+ continue;
300
+ }
301
+ if (actualUserMessage)
302
+ turnId = nativeUser?.turnId ?? string(payload.turn_id) ?? messageId;
303
+ messages.push({ messageId, role, kind, provenance: recalled ? "retrieved_memory" : unresolvedToolResult ? "unknown" : "session", text, byteStart: from, byteEnd: byteStart + end,
304
+ ...(turnId ? { turnId } : {}), ...(actualUserMessage ? { actualUserMessage: true } : {}),
305
+ ...(userEvidence ? { userEvidence } : {}), ...(nativeUser ? { userContentIndexes: nativeUser.indexes } : {}),
306
+ ...(toolName ? { toolName } : {}),
307
+ ...(callId ? { callId } : {}), ...(parentCallId ? { parentCallId } : {}) });
308
+ }
309
+ return messages;
310
+ }
311
+ /** Conservative byte accounting bounds text tokens without depending on a model tokenizer. */
312
+ export async function readContext(dataDir, phrase, options) {
313
+ const maxTokens = options.maxTokens ?? 8192;
314
+ if (!Number.isSafeInteger(maxTokens) || maxTokens < 2048 || maxTokens > 32768)
315
+ fail("invalid_argument", "maxTokens must be between 2048 and 32768");
316
+ if (options.cursor && options.cursor.sourceSha256 !== options.source.sourceSha256)
317
+ fail("invalid_argument", "The continuation belongs to a different immutable source");
318
+ const requestedStart = options.cursor?.byteStart ?? options.byteStart ?? 0;
319
+ if (!Number.isSafeInteger(requestedStart) || requestedStart < 0 || requestedStart > options.source.sourceBytes)
320
+ fail("invalid_argument", "The context byte position must be a safe integer within this source");
321
+ if (options.cursor?.textOffset !== undefined && (!Number.isSafeInteger(options.cursor.textOffset) || options.cursor.textOffset < 0))
322
+ fail("invalid_argument", "The continuation text offset must be a non-negative safe integer");
323
+ const maximum = options.maxDecodedBytes ?? 16 * 1024 * 1024;
324
+ // Reparse bounded small sources from their beginning so continuations retain
325
+ // observed turn/call provenance instead of relabeling an isolated tool echo.
326
+ const range = options.source.sourceBytes <= maximum
327
+ ? { start: 0, end: options.source.sourceBytes }
328
+ : { start: requestedStart, end: Math.min(options.source.sourceBytes, requestedStart + Math.max(1, Math.min(Math.floor(maximum / 2), 256 * 1024))) };
329
+ let read;
330
+ try {
331
+ read = await readSourceBytes(dataDir, phrase, options.source, { ...options, range });
332
+ }
333
+ catch (error) {
334
+ if (isSealkeepError(error) && error.code === "provider_unsupported") {
335
+ return { status: "unsupported", source: options.source, messages: [],
336
+ reason: error.details.reason ?? "partial_read_unsupported", message: error.message };
337
+ }
338
+ throw error;
339
+ }
340
+ try {
341
+ const lastNewline = read.bytes.lastIndexOf(10);
342
+ const hasIncompleteTail = read.byteEnd < read.source.sourceBytes && lastNewline !== read.bytes.length - 1;
343
+ const incompleteByteStart = hasIncompleteTail ? read.byteStart + lastNewline + 1 : undefined;
344
+ if (hasIncompleteTail && lastNewline < 0) {
345
+ await read.assertCurrent();
346
+ return { status: "unsupported", source: read.source, messages: [], reason: "record_exceeds_context_window",
347
+ message: "The JSONL message crosses this bounded window. Increase the decoded budget or explicitly restore the source.",
348
+ continuation: { sourceSha256: read.source.sourceSha256, byteStart: read.byteStart }, truncated: true };
349
+ }
350
+ const all = parseContextMessages(hasIncompleteTail ? read.bytes.subarray(0, lastNewline + 1) : read.bytes, read.source, read.byteStart);
351
+ const terms = [...new Set((options.query ?? "").toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [])];
352
+ let anchor = 0;
353
+ if (options.cursor || options.byteStart !== undefined) {
354
+ const next = all.findIndex((message) => message.byteStart >= requestedStart);
355
+ anchor = next < 0 ? all.length : next;
356
+ }
357
+ else if (terms.length) {
358
+ const frequency = terms.map(term => all.filter(message => message.provenance === "session"
359
+ && message.text.toLowerCase().includes(term)).length);
360
+ let best = [];
361
+ for (let index = 0; index < all.length; index++) {
362
+ const message = all[index];
363
+ const matches = terms.filter((term) => message.text.toLowerCase().includes(term)).length;
364
+ const rarity = terms.reduce((sum, term, at) => sum + (message.text.toLowerCase().includes(term)
365
+ ? 1 / Math.max(1, frequency[at]) : 0), 0);
366
+ // Imported memory is useful context but not this source's answer.
367
+ // Among genuine evidence, coverage still lets a patch/test beat prose;
368
+ // prose wins only equal coverage, with later decisions breaking ties.
369
+ const score = [matches > 0 ? 1 : 0, message.provenance === "session" ? 1 : 0,
370
+ matches, rarity, message.role === "assistant" || message.role === "tool" ? 1 : 0,
371
+ message.kind === "message" ? 1 : 0, index];
372
+ const difference = score.findIndex((value, at) => value !== (best[at] ?? -1));
373
+ if (difference >= 0 && score[difference] > (best[difference] ?? -1)) {
374
+ anchor = index;
375
+ best = score;
376
+ }
377
+ }
378
+ }
379
+ const selected = all[anchor];
380
+ if (options.cursor?.textOffset !== undefined && options.cursor.textOffset > 0
381
+ && (!selected || selected.byteStart !== requestedStart || options.cursor.textOffset > selected.text.length)) {
382
+ fail("invalid_argument", "The continuation text offset is outside its source message");
383
+ }
384
+ const user = selected ? all.slice(0, anchor + 1).reverse().find((message) => message.actualUserMessage
385
+ && (!selected.turnId || message.turnId === selected.turnId)) : undefined;
386
+ const neighbors = Math.max(0, Math.min(options.neighboringMessages ?? 3, 12));
387
+ const start = options.cursor || !selected ? anchor : Math.max(0, anchor - neighbors);
388
+ const end = Math.min(all.length, anchor + neighbors + 1);
389
+ // A continuation is forward-only. Keep its initiating-user metadata, but
390
+ // do not spend the next page on earlier prompt text and jump its cursor
391
+ // backwards. Continuing within that prompt itself remains valid.
392
+ const includeUserText = user && (!options.cursor || user.byteStart >= requestedStart);
393
+ const candidates = [...new Set([...(selected ? [selected] : []), ...(includeUserText ? [user] : []), ...all.slice(start, end)])];
394
+ const messages = [];
395
+ const base = { status: "ok", source: read.source, extractionVersion: 3,
396
+ userAnchor: user ? { status: "observed", messageId: user.messageId, byteStart: user.byteStart }
397
+ : { status: "unavailable", reason: "No actual user event is present in this verified source window" },
398
+ range: { byteStart: read.byteStart, byteEnd: read.byteEnd },
399
+ usage: { wireBytes: read.wireBytes, decodedBytes: read.decodedBytes, tokenBudget: maxTokens, accounting: "conservative_utf8_bytes" } };
400
+ let remaining = maxTokens - Buffer.byteLength(JSON.stringify({ ...base, messages: [], truncated: true })) - 220;
401
+ if (remaining < 128)
402
+ fail("invalid_argument", "The token budget is too small for this citation's provenance");
403
+ let continuation;
404
+ for (const message of candidates) {
405
+ const offset = options.cursor && message.byteStart === options.cursor.byteStart ? options.cursor.textOffset ?? 0
406
+ : !options.cursor && options.byteStart === undefined && message === selected
407
+ ? queryTextOffset(message.text, terms, Math.max(128, Math.floor((remaining - 700) / 2))) : 0;
408
+ const text = message.text.slice(offset);
409
+ const overhead = Buffer.byteLength(JSON.stringify({ ...message, text: "", textOffset: offset, truncated: true }), "utf8") + 4;
410
+ if (remaining <= overhead) {
411
+ continuation = { sourceSha256: read.source.sourceSha256, byteStart: message.byteStart, ...(offset ? { textOffset: offset } : {}) };
412
+ break;
413
+ }
414
+ const allowance = remaining - overhead;
415
+ let low = 0;
416
+ let high = text.length;
417
+ while (low < high) {
418
+ const midpoint = Math.ceil((low + high) / 2);
419
+ if (Buffer.byteLength(JSON.stringify(text.slice(0, midpoint))) - 2 <= allowance)
420
+ low = midpoint;
421
+ else
422
+ high = midpoint - 1;
423
+ }
424
+ let kept = text.slice(0, low);
425
+ if (kept.length && /[\uD800-\uDBFF]$/.test(kept))
426
+ kept = kept.slice(0, -1);
427
+ const truncated = kept.length < text.length;
428
+ messages.push({ ...message, text: kept, ...(offset ? { textOffset: offset, omittedPrefix: true } : {}), ...(truncated ? { truncated: true } : {}) });
429
+ remaining -= overhead + Buffer.byteLength(JSON.stringify(kept)) - 2;
430
+ if (truncated) {
431
+ continuation = { sourceSha256: read.source.sourceSha256, byteStart: message.byteStart, textOffset: offset + kept.length };
432
+ break;
433
+ }
434
+ }
435
+ if (!continuation && end < all.length)
436
+ continuation = { sourceSha256: read.source.sourceSha256, byteStart: all[end].byteStart };
437
+ if (!continuation && read.byteEnd < read.source.sourceBytes)
438
+ continuation = { sourceSha256: read.source.sourceSha256,
439
+ byteStart: incompleteByteStart ?? read.byteEnd };
440
+ await read.assertCurrent();
441
+ return { ...base, messages,
442
+ ...(continuation ? { continuation, truncated: true } : { truncated: false }) };
443
+ }
444
+ finally {
445
+ read.bytes.fill(0);
446
+ }
447
+ }
@@ -9,13 +9,33 @@ import type { ControlPlaneStore, Device } from "./store.js";
9
9
  * changes the string, and replaying it verbatim is caught by the nonce cache.
10
10
  */
11
11
  export declare const SIGNATURE_WINDOW_MS: number;
12
+ /**
13
+ * The request-signing contract, mid-rename.
14
+ *
15
+ * These four header names and the signing prefix below are the one place where
16
+ * a rename cannot be done on one side alone: the prefix is INSIDE the signed
17
+ * message, so changing it changes every signature. Rename the client first and
18
+ * every request 401s; rename the server first and nothing breaks, because a
19
+ * server that accepts both is compatible with a client that has not moved yet.
20
+ *
21
+ * So the server half lands first and reads either spelling. The client keeps
22
+ * sending the old names until the deployed plane is known to accept the new
23
+ * ones — flipping `SIGNS_AS` is the whole of that second step.
24
+ */
12
25
  export declare const HEADERS: {
26
+ readonly device: "x-sealkeep-device";
27
+ readonly timestamp: "x-sealkeep-timestamp";
28
+ readonly nonce: "x-sealkeep-nonce";
29
+ readonly signature: "x-sealkeep-signature";
30
+ };
31
+ export declare const LEGACY_HEADERS: {
13
32
  readonly device: "x-vaultline-device";
14
33
  readonly timestamp: "x-vaultline-timestamp";
15
34
  readonly nonce: "x-vaultline-nonce";
16
35
  readonly signature: "x-vaultline-signature";
17
36
  };
18
- export declare function signingString(method: string, path: string, timestamp: string, nonce: string, body: Buffer | string): string;
37
+ export declare const SIGNING_PREFIXES: readonly ["sealkeep-request:v1", "vaultline-request:v1"];
38
+ export declare function signingString(method: string, path: string, timestamp: string, nonce: string, body: Buffer | string, prefix?: string): string;
19
39
  /** Client helper: produces the four headers a signed request needs. */
20
40
  export declare function signRequest(input: {
21
41
  method: string;
@@ -54,9 +74,17 @@ export declare function hashEnrollmentToken(token: string): string;
54
74
  export declare function enrollmentTokenMatches(token: string, hash: string): boolean;
55
75
  /** A token bucket per key. Abuse control that does not need shared state. */
56
76
  export declare class RateLimiter {
57
- private readonly capacity;
58
- private readonly refillPerSecond;
77
+ readonly capacity: number;
78
+ readonly refillPerSecond: number;
79
+ private readonly maxBuckets;
80
+ private readonly idleTtlMs;
59
81
  private readonly buckets;
60
- constructor(capacity?: number, refillPerSecond?: number);
82
+ constructor(capacity?: number, refillPerSecond?: number, maxBuckets?: number, idleTtlMs?: number);
83
+ consume(key: string, now?: number): {
84
+ allowed: boolean;
85
+ remaining: number;
86
+ retryAfterMs: number;
87
+ resetAt: number;
88
+ };
61
89
  take(key: string, now?: number): boolean;
62
90
  }
@@ -7,15 +7,43 @@ import { createHash, createPublicKey, sign as edSign, verify as edVerify, timing
7
7
  * changes the string, and replaying it verbatim is caught by the nonce cache.
8
8
  */
9
9
  export const SIGNATURE_WINDOW_MS = 5 * 60_000;
10
+ /**
11
+ * The request-signing contract, mid-rename.
12
+ *
13
+ * These four header names and the signing prefix below are the one place where
14
+ * a rename cannot be done on one side alone: the prefix is INSIDE the signed
15
+ * message, so changing it changes every signature. Rename the client first and
16
+ * every request 401s; rename the server first and nothing breaks, because a
17
+ * server that accepts both is compatible with a client that has not moved yet.
18
+ *
19
+ * So the server half lands first and reads either spelling. The client keeps
20
+ * sending the old names until the deployed plane is known to accept the new
21
+ * ones — flipping `SIGNS_AS` is the whole of that second step.
22
+ */
10
23
  export const HEADERS = {
24
+ device: "x-sealkeep-device",
25
+ timestamp: "x-sealkeep-timestamp",
26
+ nonce: "x-sealkeep-nonce",
27
+ signature: "x-sealkeep-signature"
28
+ };
29
+ export const LEGACY_HEADERS = {
11
30
  device: "x-vaultline-device",
12
31
  timestamp: "x-vaultline-timestamp",
13
32
  nonce: "x-vaultline-nonce",
14
33
  signature: "x-vaultline-signature"
15
34
  };
35
+ export const SIGNING_PREFIXES = ["sealkeep-request:v1", "vaultline-request:v1"];
36
+ /**
37
+ * What the CLIENT signs and sends today. Still the old spelling, deliberately:
38
+ * the deployed control plane has not been updated yet, and a client that signs
39
+ * with a prefix the server does not know is a client that cannot talk to its
40
+ * own account. Flip to index 0 in the same change that deploys the plane.
41
+ */
42
+ const SIGNS_AS = SIGNING_PREFIXES[1];
43
+ const SENDS = LEGACY_HEADERS;
16
44
  const sha256hex = (value) => createHash("sha256").update(value).digest("hex");
17
- export function signingString(method, path, timestamp, nonce, body) {
18
- return `vaultline-request:v1\n${method.toUpperCase()}\n${path}\n${timestamp}\n${nonce}\n${sha256hex(body)}`;
45
+ export function signingString(method, path, timestamp, nonce, body, prefix = SIGNS_AS) {
46
+ return `${prefix}\n${method.toUpperCase()}\n${path}\n${timestamp}\n${nonce}\n${sha256hex(body)}`;
19
47
  }
20
48
  /** Client helper: produces the four headers a signed request needs. */
21
49
  export function signRequest(input) {
@@ -23,10 +51,10 @@ export function signRequest(input) {
23
51
  const message = signingString(input.method, input.path, timestamp, input.nonce, input.body ?? "");
24
52
  const key = typeof input.privateKey === "string" ? createPublicKey(input.privateKey) : input.privateKey;
25
53
  return {
26
- [HEADERS.device]: input.deviceId,
27
- [HEADERS.timestamp]: timestamp,
28
- [HEADERS.nonce]: input.nonce,
29
- [HEADERS.signature]: edSign(null, Buffer.from(message), key).toString("base64")
54
+ [SENDS.device]: input.deviceId,
55
+ [SENDS.timestamp]: timestamp,
56
+ [SENDS.nonce]: input.nonce,
57
+ [SENDS.signature]: edSign(null, Buffer.from(message), key).toString("base64")
30
58
  };
31
59
  }
32
60
  export class NonceCache {
@@ -52,10 +80,11 @@ export class NonceCache {
52
80
  }
53
81
  export function authenticate(store, request, path, body, nonces, now = Date.now()) {
54
82
  const header = (name) => { const value = request.headers[name]; return typeof value === "string" ? value : undefined; };
55
- const deviceId = header(HEADERS.device);
56
- const timestamp = header(HEADERS.timestamp);
57
- const nonce = header(HEADERS.nonce);
58
- const signature = header(HEADERS.signature);
83
+ const either = (name) => header(HEADERS[name]) ?? header(LEGACY_HEADERS[name]);
84
+ const deviceId = either("device");
85
+ const timestamp = either("timestamp");
86
+ const nonce = either("nonce");
87
+ const signature = either("signature");
59
88
  if (!deviceId || !timestamp || !nonce || !signature)
60
89
  return { ok: false, status: 401, code: "unauthorized", message: "A signed device request is required" };
61
90
  const at = Date.parse(timestamp);
@@ -70,13 +99,21 @@ export function authenticate(store, request, path, body, nonces, now = Date.now(
70
99
  return { ok: false, status: 401, code: "unauthorized", message: "Unknown device", deviceId };
71
100
  if (device.revokedAt)
72
101
  return { ok: false, status: 403, code: "forbidden", message: `Device revoked at ${device.revokedAt}`, deviceId };
73
- const message = Buffer.from(signingString(request.method ?? "GET", path, timestamp, nonce, body));
102
+ // Either prefix verifies, so a client on the old spelling keeps working while
103
+ // the plane is ahead of it. Both are checked because the prefix is signed:
104
+ // there is no way to tell from the outside which one the client used.
105
+ const key = publicKeyFromRaw(device.publicKey);
106
+ const claimed = Buffer.from(signature, "base64");
74
107
  let valid = false;
75
- try {
76
- valid = edVerify(null, message, publicKeyFromRaw(device.publicKey), Buffer.from(signature, "base64"));
77
- }
78
- catch {
79
- valid = false;
108
+ for (const prefix of SIGNING_PREFIXES) {
109
+ const message = Buffer.from(signingString(request.method ?? "GET", path, timestamp, nonce, body, prefix));
110
+ try {
111
+ if (edVerify(null, message, key, claimed)) {
112
+ valid = true;
113
+ break;
114
+ }
115
+ }
116
+ catch { /* try the other */ }
80
117
  }
81
118
  if (!valid)
82
119
  return { ok: false, status: 401, code: "unauthorized", message: "Request signature does not verify", deviceId };
@@ -104,20 +141,44 @@ export function enrollmentTokenMatches(token, hash) {
104
141
  export class RateLimiter {
105
142
  capacity;
106
143
  refillPerSecond;
144
+ maxBuckets;
145
+ idleTtlMs;
107
146
  buckets = new Map();
108
- constructor(capacity = 60, refillPerSecond = 1) {
147
+ constructor(capacity = 60, refillPerSecond = 1, maxBuckets = 10_000, idleTtlMs = 15 * 60_000) {
109
148
  this.capacity = capacity;
110
149
  this.refillPerSecond = refillPerSecond;
150
+ this.maxBuckets = maxBuckets;
151
+ this.idleTtlMs = idleTtlMs;
111
152
  }
112
- take(key, now = Date.now()) {
153
+ consume(key, now = Date.now()) {
154
+ // The key is an untrusted network cardinality even when each value is an
155
+ // unspoofable remote address. Bound memory under distributed scans. The
156
+ // O(n) sweep runs only when a new key would cross the cap, never on the
157
+ // normal hot path.
158
+ if (!this.buckets.has(key) && this.buckets.size >= this.maxBuckets) {
159
+ for (const [candidate, bucket] of this.buckets) {
160
+ if (now - bucket.updatedAt > this.idleTtlMs)
161
+ this.buckets.delete(candidate);
162
+ }
163
+ while (this.buckets.size >= this.maxBuckets) {
164
+ const oldest = this.buckets.keys().next().value;
165
+ if (oldest === undefined)
166
+ break;
167
+ this.buckets.delete(oldest);
168
+ }
169
+ }
113
170
  const bucket = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now };
114
171
  const refill = ((now - bucket.updatedAt) / 1000) * this.refillPerSecond;
115
172
  const tokens = Math.min(this.capacity, bucket.tokens + refill);
116
- if (tokens < 1) {
117
- this.buckets.set(key, { tokens, updatedAt: now });
118
- return false;
119
- }
120
- this.buckets.set(key, { tokens: tokens - 1, updatedAt: now });
121
- return true;
173
+ const allowed = tokens >= 1;
174
+ const settled = allowed ? tokens - 1 : tokens;
175
+ // Refresh insertion order so emergency eviction removes the least recently
176
+ // consumed bucket instead of an arbitrary long-lived customer.
177
+ this.buckets.delete(key);
178
+ this.buckets.set(key, { tokens: settled, updatedAt: now });
179
+ const retryAfterMs = allowed ? 0 : this.refillPerSecond > 0 ? Math.ceil(((1 - settled) / this.refillPerSecond) * 1_000) : 60_000;
180
+ const resetMs = this.refillPerSecond > 0 ? Math.ceil(((this.capacity - settled) / this.refillPerSecond) * 1_000) : 60_000;
181
+ return { allowed, remaining: Math.max(0, Math.floor(settled)), retryAfterMs, resetAt: now + resetMs };
122
182
  }
183
+ take(key, now = Date.now()) { return this.consume(key, now).allowed; }
123
184
  }