@apifuse/provider-sdk 2.2.0-beta.4 → 2.2.0-beta.40

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 (303) hide show
  1. package/AUTHORING.md +501 -13
  2. package/CHANGELOG.md +165 -1
  3. package/README.md +72 -14
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +88 -4
  6. package/bin/apifuse-dev.ts +38 -5
  7. package/bin/apifuse-pack-check.ts +22 -2
  8. package/bin/apifuse-pack-smoke.ts +57 -2
  9. package/bin/apifuse-pack-types.ts +356 -38
  10. package/bin/apifuse-perf.ts +14 -13
  11. package/bin/apifuse-record.ts +691 -68
  12. package/bin/apifuse-submit-check.ts +518 -37
  13. package/bin/apifuse-sync-assets.ts +117 -0
  14. package/bin/submit-check-delimited-text.ts +50 -0
  15. package/dist/auth-turn/index.d.ts +3 -3
  16. package/dist/auth-turn/index.js +1 -1
  17. package/dist/auth.d.ts +14 -0
  18. package/dist/auth.js +67 -0
  19. package/dist/ceremonies/index.d.ts +16 -0
  20. package/dist/ceremonies/index.js +141 -36
  21. package/dist/cli/commands.d.ts +1 -1
  22. package/dist/cli/commands.js +8 -0
  23. package/dist/cli/create.d.ts +3 -0
  24. package/dist/cli/create.js +34 -35
  25. package/dist/cli/prompt-assets.d.ts +80 -0
  26. package/dist/cli/prompt-assets.js +743 -0
  27. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  28. package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
  29. package/dist/cli/templates/provider/README.md.tpl +4 -4
  30. package/dist/cli/templates/provider/index.ts.tpl +6 -3
  31. package/dist/cli/templates/provider/operations/ping.ts.tpl +2 -1
  32. package/dist/config/loader.d.ts +177 -16
  33. package/dist/config/loader.js +424 -127
  34. package/dist/contract-serialization.js +4 -8
  35. package/dist/contract-types.d.ts +1 -0
  36. package/dist/contract.js +2 -0
  37. package/dist/declaration-validation.d.ts +32 -0
  38. package/dist/declaration-validation.js +207 -0
  39. package/dist/define.d.ts +51 -25
  40. package/dist/define.js +752 -38
  41. package/dist/error-resolution.d.ts +4 -0
  42. package/dist/error-resolution.js +122 -0
  43. package/dist/errors.d.ts +18 -0
  44. package/dist/errors.js +40 -0
  45. package/dist/fixture-sanitization.d.ts +28 -0
  46. package/dist/fixture-sanitization.js +217 -0
  47. package/dist/health-scenario.d.ts +1842 -0
  48. package/dist/health-scenario.js +624 -0
  49. package/dist/index.d.ts +19 -9
  50. package/dist/index.js +10 -6
  51. package/dist/lint.d.ts +6 -1
  52. package/dist/lint.js +362 -3
  53. package/dist/native-address.d.ts +43 -0
  54. package/dist/native-address.js +281 -0
  55. package/dist/native-egress-policy.d.ts +31 -0
  56. package/dist/native-egress-policy.js +288 -0
  57. package/dist/observability.d.ts +5 -2
  58. package/dist/observability.js +48 -1
  59. package/dist/provider.d.ts +8 -2
  60. package/dist/provider.js +3 -1
  61. package/dist/runtime/auth-flow.d.ts +5 -1
  62. package/dist/runtime/auth-flow.js +6 -0
  63. package/dist/runtime/browser.d.ts +1 -0
  64. package/dist/runtime/browser.js +492 -49
  65. package/dist/runtime/cache.d.ts +1 -0
  66. package/dist/runtime/cache.js +169 -15
  67. package/dist/runtime/choice-wordlist.d.ts +9 -0
  68. package/dist/runtime/choice-wordlist.js +138 -0
  69. package/dist/runtime/choice.d.ts +13 -1
  70. package/dist/runtime/choice.js +490 -102
  71. package/dist/runtime/executor.d.ts +2 -2
  72. package/dist/runtime/executor.js +26 -2
  73. package/dist/runtime/http.d.ts +1 -0
  74. package/dist/runtime/http.js +515 -53
  75. package/dist/runtime/instrumentation.d.ts +2 -2
  76. package/dist/runtime/instrumentation.js +366 -8
  77. package/dist/runtime/native-network-errors.d.ts +33 -0
  78. package/dist/runtime/native-network-errors.js +69 -0
  79. package/dist/runtime/native-network.d.ts +96 -0
  80. package/dist/runtime/native-network.js +1232 -0
  81. package/dist/runtime/ocr.d.ts +29 -0
  82. package/dist/runtime/ocr.js +440 -0
  83. package/dist/runtime/proxy-errors.js +6 -2
  84. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  85. package/dist/runtime/proxy-nodemaven.js +146 -0
  86. package/dist/runtime/proxy-telemetry.d.ts +80 -1
  87. package/dist/runtime/proxy-telemetry.js +154 -47
  88. package/dist/runtime/redirects.d.ts +29 -0
  89. package/dist/runtime/redirects.js +36 -0
  90. package/dist/runtime/redis.d.ts +1 -1
  91. package/dist/runtime/redis.js +4 -2
  92. package/dist/runtime/request-options.d.ts +68 -1
  93. package/dist/runtime/request-options.js +548 -0
  94. package/dist/runtime/resolver-config.d.ts +6 -0
  95. package/dist/runtime/resolver-config.js +6 -0
  96. package/dist/runtime/resolver-public.d.ts +1 -0
  97. package/dist/runtime/resolver-public.js +1 -0
  98. package/dist/runtime/resolver-shared.d.ts +3 -0
  99. package/dist/runtime/resolver-shared.js +12 -0
  100. package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
  101. package/dist/runtime/resolver-vendors/bindings.js +40 -0
  102. package/dist/runtime/resolver-vendors/browser.d.ts +22 -0
  103. package/dist/runtime/resolver-vendors/browser.js +377 -0
  104. package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
  105. package/dist/runtime/resolver-vendors/capsolver.js +526 -0
  106. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  107. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  108. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  109. package/dist/runtime/resolver-vendors/twocaptcha.js +407 -0
  110. package/dist/runtime/resolver-vendors/types.d.ts +94 -0
  111. package/dist/runtime/resolver-vendors/types.js +96 -0
  112. package/dist/runtime/resolver.d.ts +60 -0
  113. package/dist/runtime/resolver.js +737 -0
  114. package/dist/runtime/secrets.d.ts +27 -0
  115. package/dist/runtime/secrets.js +51 -0
  116. package/dist/runtime/state.d.ts +3 -0
  117. package/dist/runtime/state.js +277 -71
  118. package/dist/runtime/stealth-cookies.d.ts +20 -0
  119. package/dist/runtime/stealth-cookies.js +111 -0
  120. package/dist/runtime/stealth.d.ts +28 -3
  121. package/dist/runtime/stealth.js +519 -255
  122. package/dist/runtime/stt.js +1 -12
  123. package/dist/runtime/timeout.d.ts +5 -0
  124. package/dist/runtime/timeout.js +12 -0
  125. package/dist/runtime/trace-config.d.ts +12 -0
  126. package/dist/runtime/trace-config.js +61 -0
  127. package/dist/serve.d.ts +1 -1
  128. package/dist/serve.js +1 -1
  129. package/dist/server/index.d.ts +5 -3
  130. package/dist/server/index.js +3 -3
  131. package/dist/server/self-test-input-tokens.d.ts +2 -1
  132. package/dist/server/self-test-input-tokens.js +18 -14
  133. package/dist/server/self-test.d.ts +114 -0
  134. package/dist/server/self-test.js +784 -148
  135. package/dist/server/serve-implementation.d.ts +213 -0
  136. package/dist/server/serve-implementation.js +2173 -0
  137. package/dist/server/serve.d.ts +1 -70
  138. package/dist/server/serve.js +1 -1130
  139. package/dist/server/trace-output.d.ts +4 -0
  140. package/dist/server/trace-output.js +20 -0
  141. package/dist/server/types.d.ts +30 -5
  142. package/dist/server/types.js +13 -1
  143. package/dist/stateful/errors.d.ts +19 -0
  144. package/dist/stateful/errors.js +24 -0
  145. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  146. package/dist/stateful/http-provider-event-emitter.js +237 -0
  147. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  148. package/dist/stateful/http-session-owner-registry.js +210 -0
  149. package/dist/stateful/index.d.ts +18 -0
  150. package/dist/stateful/index.js +18 -0
  151. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  152. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  153. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  154. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  155. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  156. package/dist/stateful/provider-event-pipeline.js +1 -0
  157. package/dist/stateful/provider-events.d.ts +101 -0
  158. package/dist/stateful/provider-events.js +289 -0
  159. package/dist/stateful/session-key.d.ts +15 -0
  160. package/dist/stateful/session-key.js +86 -0
  161. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  162. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  163. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  164. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  165. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  166. package/dist/stateful/stateful-provider-adapter.js +287 -0
  167. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  168. package/dist/stateful/stateful-provider-observability.js +161 -0
  169. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  170. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  171. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  172. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  173. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  174. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  175. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  176. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  177. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  178. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  179. package/dist/stateful-signing.d.ts +18 -0
  180. package/dist/stateful-signing.js +27 -0
  181. package/dist/stealth/profiles.js +16 -7
  182. package/dist/stream-evidence.d.ts +74 -0
  183. package/dist/stream-evidence.js +785 -0
  184. package/dist/stream.js +7 -1
  185. package/dist/testing/index.d.ts +2 -1
  186. package/dist/testing/index.js +2 -1
  187. package/dist/testing/run.d.ts +32 -2
  188. package/dist/testing/run.js +489 -21
  189. package/dist/trace-sanitization.d.ts +5 -0
  190. package/dist/trace-sanitization.js +45 -0
  191. package/dist/types.d.ts +545 -23
  192. package/dist/types.js +1 -0
  193. package/package.json +44 -5
  194. package/src/auth-turn/index.ts +1 -1
  195. package/src/auth.ts +118 -0
  196. package/src/ceremonies/index.ts +189 -46
  197. package/src/cli/commands.ts +10 -0
  198. package/src/cli/create.ts +42 -35
  199. package/src/cli/prompt-assets.ts +865 -0
  200. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  201. package/src/cli/templates/provider/Dockerfile.tpl +1 -1
  202. package/src/cli/templates/provider/README.md.tpl +4 -4
  203. package/src/cli/templates/provider/index.ts.tpl +6 -3
  204. package/src/cli/templates/provider/operations/ping.ts.tpl +2 -1
  205. package/src/config/loader.ts +665 -163
  206. package/src/contract-serialization.ts +5 -7
  207. package/src/contract-types.ts +1 -0
  208. package/src/contract.ts +2 -0
  209. package/src/declaration-validation.ts +266 -0
  210. package/src/define.ts +970 -87
  211. package/src/error-resolution.ts +127 -0
  212. package/src/errors.ts +52 -0
  213. package/src/fixture-sanitization.ts +248 -0
  214. package/src/health-scenario.ts +875 -0
  215. package/src/index.ts +204 -8
  216. package/src/lint.ts +408 -4
  217. package/src/native-address.ts +340 -0
  218. package/src/native-egress-policy.ts +358 -0
  219. package/src/observability.ts +51 -1
  220. package/src/provider.ts +133 -0
  221. package/src/runtime/auth-flow.ts +12 -0
  222. package/src/runtime/browser.ts +661 -63
  223. package/src/runtime/cache.ts +189 -14
  224. package/src/runtime/choice-wordlist.ts +145 -0
  225. package/src/runtime/choice.ts +631 -120
  226. package/src/runtime/executor.ts +40 -7
  227. package/src/runtime/http.ts +641 -61
  228. package/src/runtime/instrumentation.ts +520 -15
  229. package/src/runtime/native-network-errors.ts +99 -0
  230. package/src/runtime/native-network.ts +1605 -0
  231. package/src/runtime/ocr.ts +523 -0
  232. package/src/runtime/proxy-errors.ts +12 -4
  233. package/src/runtime/proxy-nodemaven.ts +221 -0
  234. package/src/runtime/proxy-telemetry.ts +244 -75
  235. package/src/runtime/redirects.ts +66 -0
  236. package/src/runtime/redis.ts +7 -2
  237. package/src/runtime/request-options.ts +680 -1
  238. package/src/runtime/resolver-config.ts +6 -0
  239. package/src/runtime/resolver-public.ts +20 -0
  240. package/src/runtime/resolver-shared.ts +17 -0
  241. package/src/runtime/resolver-vendors/bindings.ts +56 -0
  242. package/src/runtime/resolver-vendors/browser.ts +533 -0
  243. package/src/runtime/resolver-vendors/capsolver.ts +700 -0
  244. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  245. package/src/runtime/resolver-vendors/twocaptcha.ts +539 -0
  246. package/src/runtime/resolver-vendors/types.ts +212 -0
  247. package/src/runtime/resolver.ts +1103 -0
  248. package/src/runtime/secrets.ts +64 -0
  249. package/src/runtime/state.ts +394 -77
  250. package/src/runtime/stealth-cookies.ts +132 -0
  251. package/src/runtime/stealth.ts +675 -289
  252. package/src/runtime/stt.ts +1 -19
  253. package/src/runtime/timeout.ts +18 -0
  254. package/src/runtime/trace-config.ts +77 -0
  255. package/src/serve.ts +6 -1
  256. package/src/server/index.ts +37 -2
  257. package/src/server/self-test-input-tokens.ts +29 -14
  258. package/src/server/self-test.ts +1025 -175
  259. package/src/server/serve-implementation.ts +3250 -0
  260. package/src/server/serve.ts +1 -1626
  261. package/src/server/trace-output.ts +32 -0
  262. package/src/server/types.ts +13 -1
  263. package/src/stateful/README.md +146 -0
  264. package/src/stateful/errors.ts +35 -0
  265. package/src/stateful/http-provider-event-emitter.ts +314 -0
  266. package/src/stateful/http-session-owner-registry.ts +306 -0
  267. package/src/stateful/index.ts +18 -0
  268. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  269. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  270. package/src/stateful/provider-event-pipeline.ts +61 -0
  271. package/src/stateful/provider-events.ts +462 -0
  272. package/src/stateful/session-key.ts +111 -0
  273. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  274. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  275. package/src/stateful/stateful-provider-adapter.ts +562 -0
  276. package/src/stateful/stateful-provider-observability.ts +261 -0
  277. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  278. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  279. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  280. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  281. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  282. package/src/stateful-signing.ts +46 -0
  283. package/src/stealth/profiles.ts +17 -7
  284. package/src/stream-evidence.ts +988 -0
  285. package/src/stream.ts +8 -1
  286. package/src/testing/index.ts +10 -1
  287. package/src/testing/run.ts +658 -15
  288. package/src/trace-sanitization.ts +63 -0
  289. package/src/types.ts +660 -38
  290. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  291. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  292. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  293. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  294. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  295. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  296. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  297. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  298. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  299. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  300. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  301. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  302. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  303. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { providerStateRedisUrlFromEnv } from "../config/loader.js";
2
3
  import { ProviderError } from "../errors.js";
3
4
  import type {
@@ -16,11 +17,85 @@ import {
16
17
  } from "./redis.js";
17
18
 
18
19
  const DEFAULT_REDIS_TIMEOUT_MS = 250;
19
- const REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
20
+ const REDIS_STATE_PREFIX = "apifuse:provider-state:v2";
21
+ const PROVIDER_SCOPE_DISCRIMINATOR = "scope:provider";
22
+ const MISSING_CONNECTION_SCOPE_DISCRIMINATOR = "scope:connection:missing";
23
+ const SET_WITH_QUOTA_SCRIPT = `
24
+ local now = tonumber(ARGV[1])
25
+ local max_entries = tonumber(ARGV[2])
26
+ local expires_at = tonumber(ARGV[3])
27
+ local index_ttl = tonumber(ARGV[4])
28
+ local envelope = ARGV[5]
29
+
30
+ redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
31
+ local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
32
+ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
33
+ return {0, false}
34
+ end
35
+
36
+ redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
37
+ redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
38
+ redis.call("PEXPIRE", KEYS[2], index_ttl)
39
+ return {1, envelope}
40
+ `;
41
+
42
+ const COMPARE_AND_SET_WITH_QUOTA_SCRIPT = `
43
+ local current = redis.call("GET", KEYS[1])
44
+ if current then
45
+ local ok, decoded = pcall(cjson.decode, current)
46
+ if ok and type(decoded) == "table" and decoded.deleted == true then
47
+ current = false
48
+ end
49
+ end
50
+
51
+ local now = tonumber(ARGV[2])
52
+ local max_entries = tonumber(ARGV[3])
53
+ local expires_at = tonumber(ARGV[4])
54
+ local index_ttl = tonumber(ARGV[5])
55
+ local envelope = ARGV[6]
56
+
57
+ local current_version = 0
58
+ local current_decoded = false
59
+ if current then
60
+ local ok, decoded = pcall(cjson.decode, current)
61
+ if not ok or type(decoded) ~= "table" or type(decoded.version) ~= "number" then
62
+ return {-2, current}
63
+ end
64
+ current_version = decoded.version
65
+ current_decoded = decoded
66
+ end
67
+ if current_version ~= tonumber(ARGV[1]) then
68
+ return {-1, current or false}
69
+ end
70
+
71
+ redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
72
+ local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
73
+ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
74
+ return {0, false}
75
+ end
76
+
77
+ if current_decoded and type(current_decoded.createdAt) == "string" then
78
+ local next_decoded = cjson.decode(envelope)
79
+ next_decoded.createdAt = current_decoded.createdAt
80
+ envelope = cjson.encode(next_decoded)
81
+ end
82
+ redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
83
+ redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
84
+ redis.call("PEXPIRE", KEYS[2], index_ttl)
85
+ return {1, envelope}
86
+ `;
87
+
88
+ const DELETE_WITH_INDEX_SCRIPT = `
89
+ redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2])
90
+ redis.call("ZREM", KEYS[2], KEYS[1])
91
+ return 1
92
+ `;
20
93
 
21
94
  type RedisProviderRuntimeStateOptions = {
22
95
  readonly redisUrl: string;
23
96
  readonly providerId?: string;
97
+ /** Test seam; production callers use redisUrl-backed client sharing. */
98
+ readonly __redisClient?: ProviderRedisClient;
24
99
  };
25
100
 
26
101
  type RedisStateEnvelope = {
@@ -31,23 +106,32 @@ type RedisStateEnvelope = {
31
106
  readonly updatedAt: string;
32
107
  };
33
108
 
109
+ type RedisStateTombstone = {
110
+ readonly deleted: true;
111
+ };
112
+
34
113
  type RedisBackend = {
35
114
  readonly redis: ProviderRedisClient;
36
115
  };
37
116
 
38
117
  const redisBackends = new Map<string, RedisBackend>();
39
118
 
40
- function getRedisBackend(redisUrl: string): RedisBackend {
119
+ function getRedisBackend(
120
+ redisUrl: string,
121
+ injectedRedis?: ProviderRedisClient,
122
+ ): RedisBackend {
41
123
  const existing = redisBackends.get(redisUrl);
42
124
  if (existing) return existing;
43
- const redis = createProviderRedisClient({
44
- redisUrl,
45
- timeoutMs: DEFAULT_REDIS_TIMEOUT_MS,
46
- onError: () => {
47
- // Runtime state operations fail closed at their call sites. Avoid noisy
48
- // unhandled Redis errors from background reconnect attempts.
49
- },
50
- });
125
+ const redis =
126
+ injectedRedis ??
127
+ createProviderRedisClient({
128
+ redisUrl,
129
+ timeoutMs: DEFAULT_REDIS_TIMEOUT_MS,
130
+ onError: () => {
131
+ // Runtime state operations fail closed at their call sites. Avoid noisy
132
+ // unhandled Redis errors from background reconnect attempts.
133
+ },
134
+ });
51
135
  const backend = { redis };
52
136
  redisBackends.set(redisUrl, backend);
53
137
  return backend;
@@ -70,21 +154,37 @@ async function requireRedisReady(redis: ProviderRedisClient): Promise<void> {
70
154
  throw new UnsupportedProviderStateError("Provider runtime state Redis is unavailable");
71
155
  }
72
156
 
73
- function providerStatePrefix(providerId: string | undefined, namespace: string): string {
74
- return `${REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}`;
157
+ function connectionScopeDiscriminator(connectionId: string | undefined): string {
158
+ if (connectionId === undefined) return MISSING_CONNECTION_SCOPE_DISCRIMINATOR;
159
+ const digest = createHash("sha256").update(connectionId, "utf8").digest("hex");
160
+ return `scope:connection:sha256:${digest}`;
75
161
  }
76
162
 
77
- function providerStateKey(providerId: string | undefined, namespace: string, key: string): string {
78
- return `${providerStatePrefix(providerId, namespace)}:${key}`;
163
+ function providerStatePrefix(
164
+ providerId: string | undefined,
165
+ namespace: string,
166
+ scopeDiscriminator: string,
167
+ ): string {
168
+ return `${REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}:${scopeDiscriminator}`;
79
169
  }
80
170
 
81
- function publicStateKey(
171
+ function providerStateKey(
82
172
  providerId: string | undefined,
83
173
  namespace: string,
84
- redisKey: string,
174
+ scopeDiscriminator: string,
175
+ key: string,
85
176
  ): string {
86
- const prefix = `${providerStatePrefix(providerId, namespace)}:`;
87
- return redisKey.startsWith(prefix) ? redisKey.slice(prefix.length) : redisKey;
177
+ return `${providerStatePrefix(providerId, namespace, scopeDiscriminator)}:${key}`;
178
+ }
179
+
180
+ function publicStateKey(redisKey: string, prefixes: readonly string[]): string {
181
+ for (const prefix of prefixes) {
182
+ const prefixWithSeparator = `${prefix}:`;
183
+ if (redisKey.startsWith(prefixWithSeparator)) {
184
+ return redisKey.slice(prefixWithSeparator.length);
185
+ }
186
+ }
187
+ return redisKey;
88
188
  }
89
189
 
90
190
  function parseStateDurationMs(ttl: StateWriteOptions["ttl"]): number {
@@ -105,17 +205,23 @@ function parseStateDurationMs(ttl: StateWriteOptions["ttl"]): number {
105
205
  return Math.max(1, amount * multiplier);
106
206
  }
107
207
 
108
- function resolveExpiresAt(ttl: StateWriteOptions["ttl"]): string {
109
- return new Date(Date.now() + parseStateDurationMs(ttl)).toISOString();
110
- }
111
-
112
208
  function envelopeFromJson(
113
209
  key: string,
114
210
  raw: string | null,
115
211
  // biome-ignore lint/suspicious/noExplicitAny: state envelopes deserialize caller-owned generic values.
116
212
  ): StateValue<any> | null {
117
213
  if (!raw) return null;
118
- const parsed: unknown = JSON.parse(raw);
214
+ // A corrupt/undecodable persisted envelope must be treated as absent rather
215
+ // than throwing a raw JSON.parse SyntaxError: an uncaught SyntaxError escapes
216
+ // the provider error taxonomy, is masked as internal_error 500, and is then
217
+ // retried by the hub (2026-07-22 catchtable reserve RCA, candidate A). Returning
218
+ // null also keeps list() from aborting the whole scan on a single bad entry.
219
+ let parsed: unknown;
220
+ try {
221
+ parsed = JSON.parse(raw);
222
+ } catch {
223
+ return null;
224
+ }
119
225
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
120
226
  return null;
121
227
  }
@@ -139,6 +245,21 @@ function envelopeFromJson(
139
245
  };
140
246
  }
141
247
 
248
+ function isStateTombstone(raw: string | null): boolean {
249
+ if (!raw) return false;
250
+ try {
251
+ const parsed: unknown = JSON.parse(raw);
252
+ return (
253
+ parsed !== null &&
254
+ typeof parsed === "object" &&
255
+ !Array.isArray(parsed) &&
256
+ (parsed as { deleted?: unknown }).deleted === true
257
+ );
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
142
263
  function redisEnvelope(
143
264
  value: unknown,
144
265
  version: number,
@@ -155,19 +276,60 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
155
276
  private readonly providerId: string | undefined,
156
277
  private readonly namespaceName: string,
157
278
  private readonly options: StateNamespaceOptions,
279
+ private readonly scopeDiscriminator: string,
158
280
  ) {}
159
281
 
160
282
  private redisKey(key: string): string {
161
- return providerStateKey(this.providerId, this.namespaceName, key);
283
+ return providerStateKey(
284
+ this.providerId,
285
+ this.namespaceName,
286
+ this.scopeDiscriminator,
287
+ key,
288
+ );
289
+ }
290
+
291
+ private statePrefix(): string {
292
+ return providerStatePrefix(
293
+ this.providerId,
294
+ this.namespaceName,
295
+ this.scopeDiscriminator,
296
+ );
162
297
  }
163
298
 
164
- private prefix(): string {
165
- return `${providerStatePrefix(this.providerId, this.namespaceName)}:`;
299
+ private indexKey(): string {
300
+ // Keep bookkeeping outside the caller-owned keyspace. A provider may use
301
+ // any state key (including "__index"), so a suffix inside the namespace
302
+ // could turn the ZSET into a string and break every subsequent write.
303
+ const namespaceIdentity = Buffer.from(
304
+ providerStatePrefix(
305
+ this.providerId,
306
+ this.namespaceName,
307
+ this.scopeDiscriminator,
308
+ ),
309
+ "utf8",
310
+ ).toString("base64url");
311
+ return `${REDIS_STATE_PREFIX}:index:${namespaceIdentity}`;
166
312
  }
167
313
 
168
- private async activeKeys(): Promise<string[]> {
314
+ private async indexedKeys(limit: number): Promise<string[]> {
169
315
  await requireRedisReady(this.backend.redis);
170
- return await withRequiredRedis(() => this.backend.redis.keys(`${this.prefix()}*`));
316
+ const now = Date.now();
317
+ return await withRequiredRedis(async () => {
318
+ await this.backend.redis.zremrangebyscore(this.indexKey(), "-inf", now);
319
+ return await this.backend.redis.zrangebyscore(
320
+ this.indexKey(),
321
+ now + 1,
322
+ "+inf",
323
+ "LIMIT",
324
+ 0,
325
+ limit,
326
+ );
327
+ });
328
+ }
329
+
330
+ private async readRaw(key: string): Promise<string | null> {
331
+ await requireRedisReady(this.backend.redis);
332
+ return await withRequiredRedis(() => this.backend.redis.get(this.redisKey(key)));
171
333
  }
172
334
 
173
335
  private enforceValueSize(value: unknown): void {
@@ -179,57 +341,86 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
179
341
  }
180
342
  }
181
343
 
182
- private async enforceMaxEntries(key: string): Promise<void> {
183
- const keys = await this.activeKeys();
184
- const redisKey = this.redisKey(key);
185
- const otherKeys = keys.filter((candidate) => candidate !== redisKey);
186
- if (otherKeys.length >= this.options.maxEntries) {
344
+ private quotaExceeded(): UnsupportedProviderStateError {
345
+ return new UnsupportedProviderStateError(
346
+ `Provider runtime state namespace quota exceeded (${this.options.maxEntries + 1} > ${this.options.maxEntries})`,
347
+ );
348
+ }
349
+
350
+ private writeTiming(ttl: StateWriteOptions["ttl"]): {
351
+ expiresAt: string;
352
+ expiresAtMs: number;
353
+ indexTtlMs: number;
354
+ } {
355
+ const ttlMs = parseStateDurationMs(ttl ?? this.options.defaultTtl);
356
+ const maxTtlMs = parseStateDurationMs(this.options.maxTtl);
357
+ if (ttlMs > maxTtlMs) {
187
358
  throw new UnsupportedProviderStateError(
188
- `Provider runtime state namespace quota exceeded (${otherKeys.length + 1} > ${this.options.maxEntries})`,
359
+ `Provider runtime state ttl exceeds maxTtl (${ttlMs} > ${maxTtlMs})`,
189
360
  );
190
361
  }
362
+ const expiresAtMs = Date.now() + ttlMs;
363
+ return {
364
+ expiresAt: new Date(expiresAtMs).toISOString(),
365
+ expiresAtMs,
366
+ indexTtlMs: maxTtlMs,
367
+ };
191
368
  }
192
369
 
193
370
  async list<T>(options?: { limit?: number; prefix?: string }): Promise<StateValue<T>[]> {
194
- const keys = (await this.activeKeys()).filter((key) => {
195
- const publicKey = publicStateKey(this.providerId, this.namespaceName, key);
196
- return options?.prefix ? publicKey.startsWith(options.prefix) : true;
197
- });
198
- const limited = keys.slice(0, Math.max(0, options?.limit ?? keys.length));
199
- if (limited.length === 0) return [];
200
- const values = await withRequiredRedis(() => this.backend.redis.mget(limited));
201
- return values.flatMap((raw, index) => {
202
- const key = limited[index];
203
- if (!key) return [];
204
- const value = envelopeFromJson(publicStateKey(this.providerId, this.namespaceName, key), raw);
205
- return value ? [value] : [];
206
- });
371
+ const requestedLimit = Math.max(0, options?.limit ?? this.options.maxEntries);
372
+ if (requestedLimit === 0) return [];
373
+ const keys = await this.indexedKeys(this.options.maxEntries);
374
+ if (keys.length === 0) return [];
375
+ const values = await withRequiredRedis(() => this.backend.redis.mget(keys));
376
+ const rows: StateValue<T>[] = [];
377
+ for (const [index, raw] of values.entries()) {
378
+ const redisKey = keys[index];
379
+ if (!redisKey) continue;
380
+ const publicKey = publicStateKey(redisKey, [this.statePrefix()]);
381
+ if (options?.prefix && !publicKey.startsWith(options.prefix)) continue;
382
+ const value = envelopeFromJson(publicKey, raw);
383
+ if (!value) continue;
384
+ rows.push(value);
385
+ }
386
+ return rows.slice(0, requestedLimit);
207
387
  }
208
388
 
209
389
  async get<T>(key: string): Promise<StateValue<T> | null> {
210
- await requireRedisReady(this.backend.redis);
211
- const raw = await withRequiredRedis(() => this.backend.redis.get(this.redisKey(key)));
390
+ const raw = await this.readRaw(key);
391
+ if (isStateTombstone(raw)) return null;
212
392
  return envelopeFromJson(key, raw);
213
393
  }
214
394
 
215
395
  async set<T>(key: string, value: T, options?: StateWriteOptions): Promise<StateValue<T>> {
216
396
  this.enforceValueSize(value);
217
- await this.enforceMaxEntries(key);
218
397
  const current = await this.get<T>(key);
219
398
  const createdAt = current?.createdAt ?? new Date().toISOString();
220
399
  const version = (current?.version ?? 0) + 1;
221
- const ttl = options?.ttl ?? this.options.defaultTtl;
222
- const ttlMs = parseStateDurationMs(ttl);
223
- const expiresAt = resolveExpiresAt(ttl);
224
- const envelope = redisEnvelope(value, version, createdAt, expiresAt);
225
- await withRequiredRedis(() =>
226
- this.backend.redis.set(this.redisKey(key), JSON.stringify(envelope), "PX", ttlMs),
400
+ const timing = this.writeTiming(options?.ttl);
401
+ const envelope = redisEnvelope(value, version, createdAt, timing.expiresAt);
402
+ await requireRedisReady(this.backend.redis);
403
+ const result = await withRequiredRedis(() =>
404
+ this.backend.redis.eval(
405
+ SET_WITH_QUOTA_SCRIPT,
406
+ 2,
407
+ this.redisKey(key),
408
+ this.indexKey(),
409
+ String(Date.now()),
410
+ String(this.options.maxEntries),
411
+ String(timing.expiresAtMs),
412
+ String(timing.indexTtlMs),
413
+ JSON.stringify(envelope),
414
+ ),
227
415
  );
416
+ if (!Array.isArray(result) || Number(result[0]) !== 1) {
417
+ throw this.quotaExceeded();
418
+ }
228
419
  return {
229
420
  key,
230
421
  value,
231
422
  version,
232
- expiresAt,
423
+ expiresAt: timing.expiresAt,
233
424
  createdAt,
234
425
  updatedAt: envelope.updatedAt,
235
426
  };
@@ -253,16 +444,59 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
253
444
  options?: StateWriteOptions,
254
445
  ): Promise<StateCasResult<T>> {
255
446
  this.enforceValueSize(value);
256
- const current = await this.get<T>(key);
257
- if ((current?.version ?? 0) !== expectedVersion) {
258
- return { ok: false, current };
447
+ const createdAt = new Date().toISOString();
448
+ const timing = this.writeTiming(options?.ttl);
449
+ const envelope = redisEnvelope(
450
+ value,
451
+ expectedVersion + 1,
452
+ createdAt,
453
+ timing.expiresAt,
454
+ );
455
+ await requireRedisReady(this.backend.redis);
456
+ const result = await withRequiredRedis(() =>
457
+ this.backend.redis.eval(
458
+ COMPARE_AND_SET_WITH_QUOTA_SCRIPT,
459
+ 2,
460
+ this.redisKey(key),
461
+ this.indexKey(),
462
+ String(expectedVersion),
463
+ String(Date.now()),
464
+ String(this.options.maxEntries),
465
+ String(timing.expiresAtMs),
466
+ String(timing.indexTtlMs),
467
+ JSON.stringify(envelope),
468
+ ),
469
+ );
470
+ if (Array.isArray(result) && Number(result[0]) === 0) {
471
+ throw this.quotaExceeded();
472
+ }
473
+ if (!Array.isArray(result) || Number(result[0]) !== 1) {
474
+ const rawCurrent = Array.isArray(result) && typeof result[1] === "string" ? result[1] : null;
475
+ return { ok: false, current: envelopeFromJson(key, rawCurrent) };
476
+ }
477
+ const rawWritten = typeof result[1] === "string" ? result[1] : null;
478
+ const written = envelopeFromJson(key, rawWritten) as StateValue<T> | null;
479
+ if (!written) {
480
+ throw new UnsupportedProviderStateError(
481
+ "Provider runtime state CAS returned an invalid value",
482
+ );
259
483
  }
260
- return { ok: true, value: await this.set(key, value, options) };
484
+ return { ok: true, value: written };
261
485
  }
262
486
 
263
487
  async delete(key: string): Promise<void> {
264
488
  await requireRedisReady(this.backend.redis);
265
- await withRequiredRedis(() => this.backend.redis.del(this.redisKey(key)));
489
+ const tombstone = JSON.stringify({ deleted: true } satisfies RedisStateTombstone);
490
+ await withRequiredRedis(() =>
491
+ this.backend.redis.eval(
492
+ DELETE_WITH_INDEX_SCRIPT,
493
+ 2,
494
+ this.redisKey(key),
495
+ this.indexKey(),
496
+ tombstone,
497
+ String(parseStateDurationMs(this.options.maxTtl)),
498
+ ),
499
+ );
266
500
  }
267
501
 
268
502
  async increment(
@@ -280,14 +514,34 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
280
514
  class RedisProviderRuntimeState implements ProviderRuntimeState {
281
515
  readonly backend: RedisBackend;
282
516
  readonly providerId?: string;
517
+ readonly redisUrl: string;
518
+ readonly scopeDiscriminator: string;
283
519
 
284
- constructor(options: RedisProviderRuntimeStateOptions) {
285
- this.backend = getRedisBackend(options.redisUrl);
520
+ constructor(
521
+ options: RedisProviderRuntimeStateOptions,
522
+ scopeDiscriminator = MISSING_CONNECTION_SCOPE_DISCRIMINATOR,
523
+ ) {
524
+ this.backend = getRedisBackend(options.redisUrl, options.__redisClient);
286
525
  this.providerId = options.providerId;
526
+ this.redisUrl = options.redisUrl;
527
+ this.scopeDiscriminator = scopeDiscriminator;
528
+ }
529
+
530
+ forConnection(connectionId: string | undefined): ProviderRuntimeState {
531
+ return new RedisProviderRuntimeState(
532
+ { redisUrl: this.redisUrl, providerId: this.providerId },
533
+ connectionScopeDiscriminator(connectionId),
534
+ );
287
535
  }
288
536
 
289
537
  namespace(name: string, options: StateNamespaceOptions): ProviderStateNamespace {
290
- return new RedisProviderStateNamespace(this.backend, this.providerId, name, options);
538
+ return new RedisProviderStateNamespace(
539
+ this.backend,
540
+ this.providerId,
541
+ name,
542
+ options,
543
+ options.scope === "provider" ? PROVIDER_SCOPE_DISCRIMINATOR : this.scopeDiscriminator,
544
+ );
291
545
  }
292
546
  }
293
547
 
@@ -337,6 +591,10 @@ class UnsupportedProviderStateNamespace implements ProviderStateNamespace {
337
591
  }
338
592
 
339
593
  class UnsupportedProviderRuntimeState implements ProviderRuntimeState {
594
+ forConnection(_connectionId: string | undefined): ProviderRuntimeState {
595
+ return new UnsupportedProviderRuntimeState();
596
+ }
597
+
340
598
  namespace(_name: string, _options: StateNamespaceOptions): ProviderStateNamespace {
341
599
  return new UnsupportedProviderStateNamespace();
342
600
  }
@@ -348,6 +606,31 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
348
606
 
349
607
  constructor(private readonly options: StateNamespaceOptions) {}
350
608
 
609
+ private enforceValueSize(value: unknown): void {
610
+ const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
611
+ if (bytes > this.options.maxValueBytes) {
612
+ throw new UnsupportedProviderStateError(
613
+ `Provider runtime state value exceeds maxValueBytes (${bytes} > ${this.options.maxValueBytes})`,
614
+ );
615
+ }
616
+ }
617
+
618
+ private enforceWritePolicy(key: string, value: unknown, ttl: StateWriteOptions["ttl"]): void {
619
+ this.enforceValueSize(value);
620
+ const ttlMs = parseStateDurationMs(ttl ?? this.options.defaultTtl);
621
+ const maxTtlMs = parseStateDurationMs(this.options.maxTtl);
622
+ if (ttlMs > maxTtlMs) {
623
+ throw new UnsupportedProviderStateError(
624
+ `Provider runtime state ttl exceeds maxTtl (${ttlMs} > ${maxTtlMs})`,
625
+ );
626
+ }
627
+ if (!this.values.has(key) && this.values.size >= this.options.maxEntries) {
628
+ throw new UnsupportedProviderStateError(
629
+ `Provider runtime state namespace quota exceeded (${this.options.maxEntries + 1} > ${this.options.maxEntries})`,
630
+ );
631
+ }
632
+ }
633
+
351
634
  private pruneExpired(nowMs = Date.now()): void {
352
635
  for (const [key, row] of this.values.entries()) {
353
636
  if (row.expiresAt && Date.parse(row.expiresAt) <= nowMs) {
@@ -371,6 +654,7 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
371
654
 
372
655
  async set<T>(key: string, value: T, options?: StateWriteOptions): Promise<StateValue<T>> {
373
656
  this.pruneExpired();
657
+ this.enforceWritePolicy(key, value, options?.ttl);
374
658
  const now = new Date().toISOString();
375
659
  const current = this.values.get(key);
376
660
  const expiresAt = resolveMemoryStateExpiresAt(options?.ttl ?? this.options.defaultTtl);
@@ -397,14 +681,30 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
397
681
  }
398
682
 
399
683
  async compareAndSet<T>(
400
- _key: string,
401
- _expectedVersion: number,
402
- _value: T,
403
- _options?: StateWriteOptions,
684
+ key: string,
685
+ expectedVersion: number,
686
+ value: T,
687
+ options?: StateWriteOptions,
404
688
  ): Promise<StateCasResult<T>> {
405
- throw new UnsupportedProviderStateError(
406
- "In-memory provider runtime state does not support compareAndSet",
407
- );
689
+ this.pruneExpired();
690
+ const current = this.values.get(key) as StateValue<T> | undefined;
691
+ if ((current?.version ?? 0) !== expectedVersion) {
692
+ return { ok: false, current: current ?? null };
693
+ }
694
+ this.enforceWritePolicy(key, value, options?.ttl);
695
+ const now = new Date().toISOString();
696
+ const stored = {
697
+ key,
698
+ value,
699
+ version: expectedVersion + 1,
700
+ expiresAt: resolveMemoryStateExpiresAt(
701
+ options?.ttl ?? this.options.defaultTtl,
702
+ ),
703
+ createdAt: current?.createdAt ?? now,
704
+ updatedAt: now,
705
+ } satisfies StateValue<T>;
706
+ this.values.set(key, stored);
707
+ return { ok: true, value: stored };
408
708
  }
409
709
 
410
710
  async delete(key: string): Promise<void> {
@@ -423,14 +723,31 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
423
723
  }
424
724
  }
425
725
 
726
+ type MemoryProviderStateBackend = {
727
+ readonly namespaces: Map<string, MemoryProviderStateNamespace>;
728
+ };
729
+
426
730
  class MemoryProviderRuntimeState implements ProviderRuntimeState {
427
- readonly namespaces = new Map<string, MemoryProviderStateNamespace>();
731
+ constructor(
732
+ private readonly backend: MemoryProviderStateBackend = { namespaces: new Map() },
733
+ private readonly scopeDiscriminator = MISSING_CONNECTION_SCOPE_DISCRIMINATOR,
734
+ ) {}
428
735
 
429
- namespace(name: string, _options: StateNamespaceOptions): ProviderStateNamespace {
430
- const existing = this.namespaces.get(name);
736
+ forConnection(connectionId: string | undefined): ProviderRuntimeState {
737
+ return new MemoryProviderRuntimeState(
738
+ this.backend,
739
+ connectionScopeDiscriminator(connectionId),
740
+ );
741
+ }
742
+
743
+ namespace(name: string, options: StateNamespaceOptions): ProviderStateNamespace {
744
+ const scopeDiscriminator =
745
+ options.scope === "provider" ? PROVIDER_SCOPE_DISCRIMINATOR : this.scopeDiscriminator;
746
+ const namespaceIdentity = `${scopeDiscriminator}\0${name}`;
747
+ const existing = this.backend.namespaces.get(namespaceIdentity);
431
748
  if (existing) return existing;
432
- const created = new MemoryProviderStateNamespace(_options);
433
- this.namespaces.set(name, created);
749
+ const created = new MemoryProviderStateNamespace(options);
750
+ this.backend.namespaces.set(namespaceIdentity, created);
434
751
  return created;
435
752
  }
436
753
  }