@apifuse/provider-sdk 2.2.0-beta.3 → 2.2.0-beta.30

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 (342) hide show
  1. package/AUTHORING.md +493 -5
  2. package/CHANGELOG.md +127 -1
  3. package/README.md +52 -6
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +106 -62
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +63 -55
  8. package/bin/apifuse-pack-check.ts +22 -2
  9. package/bin/apifuse-pack-smoke.ts +78 -82
  10. package/bin/apifuse-pack-types.ts +583 -0
  11. package/bin/apifuse-perf.ts +59 -140
  12. package/bin/apifuse-record.ts +698 -113
  13. package/bin/apifuse-submit-check.ts +517 -44
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml.ts +1 -1
  18. package/dist/auth-turn/index.d.ts +4 -4
  19. package/dist/auth-turn/index.js +1 -1
  20. package/dist/auth.d.ts +16 -2
  21. package/dist/auth.js +76 -18
  22. package/dist/ceremonies/index.d.ts +9 -1
  23. package/dist/ceremonies/index.js +117 -29
  24. package/dist/cli/commands.d.ts +1 -1
  25. package/dist/cli/commands.js +8 -0
  26. package/dist/cli/create.d.ts +3 -0
  27. package/dist/cli/create.js +34 -35
  28. package/dist/cli/prompt-assets.d.ts +80 -0
  29. package/dist/cli/prompt-assets.js +743 -0
  30. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  31. package/dist/cli/templates/provider/README.md.tpl +4 -4
  32. package/dist/config/loader.d.ts +176 -17
  33. package/dist/config/loader.js +434 -161
  34. package/dist/contract-serialization.d.ts +2 -2
  35. package/dist/contract-serialization.js +7 -14
  36. package/dist/contract-types.d.ts +3 -2
  37. package/dist/contract.d.ts +3 -3
  38. package/dist/contract.js +6 -6
  39. package/dist/declaration-validation.d.ts +23 -0
  40. package/dist/declaration-validation.js +159 -0
  41. package/dist/define.d.ts +13 -1
  42. package/dist/define.js +391 -122
  43. package/dist/dev.d.ts +1 -1
  44. package/dist/dev.js +1 -1
  45. package/dist/error-resolution.d.ts +4 -0
  46. package/dist/error-resolution.js +122 -0
  47. package/dist/errors.d.ts +19 -1
  48. package/dist/errors.js +41 -3
  49. package/dist/fixture-sanitization.d.ts +26 -0
  50. package/dist/fixture-sanitization.js +216 -0
  51. package/dist/i18n/catalog.d.ts +2 -2
  52. package/dist/i18n/catalog.js +4 -10
  53. package/dist/i18n/index.d.ts +2 -2
  54. package/dist/i18n/index.js +2 -2
  55. package/dist/i18n/keys.d.ts +2 -2
  56. package/dist/index.d.ts +50 -42
  57. package/dist/index.js +41 -37
  58. package/dist/lint.d.ts +6 -1
  59. package/dist/lint.js +370 -18
  60. package/dist/native-address.d.ts +43 -0
  61. package/dist/native-address.js +281 -0
  62. package/dist/native-egress-policy.d.ts +31 -0
  63. package/dist/native-egress-policy.js +288 -0
  64. package/dist/observability.d.ts +5 -2
  65. package/dist/observability.js +48 -1
  66. package/dist/provider.d.ts +13 -11
  67. package/dist/provider.js +10 -9
  68. package/dist/public-schema-field-lint.d.ts +1 -1
  69. package/dist/recipes/gov-api.js +1 -1
  70. package/dist/runtime/auth-flow.d.ts +3 -1
  71. package/dist/runtime/auth-flow.js +8 -3
  72. package/dist/runtime/browser.d.ts +1 -1
  73. package/dist/runtime/browser.js +138 -40
  74. package/dist/runtime/cache.d.ts +2 -1
  75. package/dist/runtime/cache.js +173 -23
  76. package/dist/runtime/choice-wordlist.d.ts +9 -0
  77. package/dist/runtime/choice-wordlist.js +138 -0
  78. package/dist/runtime/choice.d.ts +13 -1
  79. package/dist/runtime/choice.js +514 -124
  80. package/dist/runtime/credential.d.ts +1 -1
  81. package/dist/runtime/credential.js +1 -1
  82. package/dist/runtime/env.d.ts +1 -1
  83. package/dist/runtime/executor.d.ts +1 -1
  84. package/dist/runtime/executor.js +25 -3
  85. package/dist/runtime/http.d.ts +3 -2
  86. package/dist/runtime/http.js +517 -55
  87. package/dist/runtime/insights.d.ts +1 -1
  88. package/dist/runtime/insights.js +6 -13
  89. package/dist/runtime/instrumentation.d.ts +2 -2
  90. package/dist/runtime/instrumentation.js +371 -23
  91. package/dist/runtime/keyring.js +1 -1
  92. package/dist/runtime/namespace.js +1 -1
  93. package/dist/runtime/native-network-errors.d.ts +33 -0
  94. package/dist/runtime/native-network-errors.js +69 -0
  95. package/dist/runtime/native-network.d.ts +96 -0
  96. package/dist/runtime/native-network.js +1232 -0
  97. package/dist/runtime/ocr.d.ts +29 -0
  98. package/dist/runtime/ocr.js +440 -0
  99. package/dist/runtime/otlp.d.ts +1 -1
  100. package/dist/runtime/perf.d.ts +1 -1
  101. package/dist/runtime/provider.d.ts +1 -1
  102. package/dist/runtime/provider.js +1 -2
  103. package/dist/runtime/proxy-errors.d.ts +1 -1
  104. package/dist/runtime/proxy-errors.js +9 -7
  105. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  106. package/dist/runtime/proxy-nodemaven.js +146 -0
  107. package/dist/runtime/proxy-retry-policy.d.ts +2 -2
  108. package/dist/runtime/proxy-retry-policy.js +2 -2
  109. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  110. package/dist/runtime/proxy-telemetry.js +58 -52
  111. package/dist/runtime/redirects.d.ts +29 -0
  112. package/dist/runtime/redirects.js +36 -0
  113. package/dist/runtime/redis.d.ts +1 -1
  114. package/dist/runtime/redis.js +5 -5
  115. package/dist/runtime/request-options.d.ts +68 -1
  116. package/dist/runtime/request-options.js +548 -0
  117. package/dist/runtime/resolver-config.d.ts +6 -0
  118. package/dist/runtime/resolver-config.js +6 -0
  119. package/dist/runtime/resolver-public.d.ts +1 -0
  120. package/dist/runtime/resolver-public.js +1 -0
  121. package/dist/runtime/resolver-shared.d.ts +3 -0
  122. package/dist/runtime/resolver-shared.js +12 -0
  123. package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
  124. package/dist/runtime/resolver-vendors/bindings.js +40 -0
  125. package/dist/runtime/resolver-vendors/browser.d.ts +20 -0
  126. package/dist/runtime/resolver-vendors/browser.js +282 -0
  127. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  128. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  129. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  130. package/dist/runtime/resolver-vendors/twocaptcha.js +368 -0
  131. package/dist/runtime/resolver-vendors/types.d.ts +83 -0
  132. package/dist/runtime/resolver-vendors/types.js +69 -0
  133. package/dist/runtime/resolver.d.ts +59 -0
  134. package/dist/runtime/resolver.js +705 -0
  135. package/dist/runtime/secrets.d.ts +27 -0
  136. package/dist/runtime/secrets.js +51 -0
  137. package/dist/runtime/state.d.ts +5 -2
  138. package/dist/runtime/state.js +280 -74
  139. package/dist/runtime/stealth-cookies.d.ts +20 -0
  140. package/dist/runtime/stealth-cookies.js +111 -0
  141. package/dist/runtime/stealth.d.ts +30 -5
  142. package/dist/runtime/stealth.js +523 -259
  143. package/dist/runtime/stt.d.ts +1 -1
  144. package/dist/runtime/stt.js +12 -27
  145. package/dist/runtime/timeout.d.ts +5 -0
  146. package/dist/runtime/timeout.js +12 -0
  147. package/dist/runtime/trace.d.ts +2 -2
  148. package/dist/runtime/trace.js +2 -4
  149. package/dist/runtime/waterfall.d.ts +1 -1
  150. package/dist/schema.d.ts +1 -1
  151. package/dist/schema.js +7 -15
  152. package/dist/serve.d.ts +1 -1
  153. package/dist/serve.js +1 -1
  154. package/dist/server/index.d.ts +7 -7
  155. package/dist/server/index.js +6 -6
  156. package/dist/server/self-test-input-tokens.d.ts +2 -1
  157. package/dist/server/self-test-input-tokens.js +18 -14
  158. package/dist/server/self-test-redaction.d.ts +1 -1
  159. package/dist/server/self-test-redaction.js +1 -1
  160. package/dist/server/self-test.d.ts +117 -3
  161. package/dist/server/self-test.js +787 -151
  162. package/dist/server/serve-implementation.d.ts +210 -0
  163. package/dist/server/serve-implementation.js +2077 -0
  164. package/dist/server/serve.d.ts +1 -70
  165. package/dist/server/serve.js +1 -1143
  166. package/dist/server/types.d.ts +34 -9
  167. package/dist/server/types.js +8 -1
  168. package/dist/stateful/errors.d.ts +19 -0
  169. package/dist/stateful/errors.js +24 -0
  170. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  171. package/dist/stateful/http-provider-event-emitter.js +237 -0
  172. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  173. package/dist/stateful/http-session-owner-registry.js +210 -0
  174. package/dist/stateful/index.d.ts +18 -0
  175. package/dist/stateful/index.js +18 -0
  176. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  177. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  178. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  179. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  180. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  181. package/dist/stateful/provider-event-pipeline.js +1 -0
  182. package/dist/stateful/provider-events.d.ts +101 -0
  183. package/dist/stateful/provider-events.js +289 -0
  184. package/dist/stateful/session-key.d.ts +15 -0
  185. package/dist/stateful/session-key.js +86 -0
  186. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  187. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  188. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  189. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  190. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  191. package/dist/stateful/stateful-provider-adapter.js +287 -0
  192. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  193. package/dist/stateful/stateful-provider-observability.js +161 -0
  194. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  195. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  196. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  197. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  198. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  199. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  200. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  201. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  202. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  203. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  204. package/dist/stateful-signing.d.ts +18 -0
  205. package/dist/stateful-signing.js +27 -0
  206. package/dist/stealth/profiles.d.ts +1 -1
  207. package/dist/stealth/profiles.js +21 -21
  208. package/dist/stream-evidence.d.ts +74 -0
  209. package/dist/stream-evidence.js +785 -0
  210. package/dist/stream.d.ts +1 -1
  211. package/dist/stream.js +7 -1
  212. package/dist/testing/index.d.ts +3 -2
  213. package/dist/testing/index.js +3 -2
  214. package/dist/testing/run.d.ts +32 -2
  215. package/dist/testing/run.js +488 -28
  216. package/dist/types.d.ts +566 -19
  217. package/dist/types.js +1 -0
  218. package/dist/user-input.d.ts +30 -0
  219. package/dist/user-input.js +66 -0
  220. package/package.json +42 -7
  221. package/src/auth-turn/index.ts +2 -2
  222. package/src/auth.ts +146 -86
  223. package/src/ceremonies/index.ts +167 -92
  224. package/src/cli/commands.ts +10 -0
  225. package/src/cli/create.ts +42 -35
  226. package/src/cli/prompt-assets.ts +865 -0
  227. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  228. package/src/cli/templates/provider/README.md.tpl +4 -4
  229. package/src/config/loader.ts +667 -289
  230. package/src/contract-serialization.ts +10 -18
  231. package/src/contract-types.ts +3 -2
  232. package/src/contract.ts +14 -28
  233. package/src/declaration-validation.ts +202 -0
  234. package/src/define.ts +631 -495
  235. package/src/dev.ts +4 -9
  236. package/src/error-resolution.ts +127 -0
  237. package/src/errors.ts +56 -11
  238. package/src/fixture-sanitization.ts +247 -0
  239. package/src/i18n/catalog.ts +10 -32
  240. package/src/i18n/index.ts +2 -2
  241. package/src/i18n/keys.ts +5 -11
  242. package/src/index.ts +157 -44
  243. package/src/lint.ts +488 -154
  244. package/src/native-address.ts +340 -0
  245. package/src/native-egress-policy.ts +358 -0
  246. package/src/observability.ts +51 -1
  247. package/src/provider.ts +65 -11
  248. package/src/public-schema-field-lint.ts +7 -33
  249. package/src/recipes/gov-api.ts +2 -5
  250. package/src/runtime/auth-flow.ts +13 -7
  251. package/src/runtime/browser.ts +252 -207
  252. package/src/runtime/cache.ts +209 -81
  253. package/src/runtime/choice-wordlist.ts +145 -0
  254. package/src/runtime/choice.ts +685 -216
  255. package/src/runtime/credential.ts +2 -2
  256. package/src/runtime/env.ts +1 -1
  257. package/src/runtime/executor.ts +37 -19
  258. package/src/runtime/http.ts +645 -65
  259. package/src/runtime/insights.ts +15 -53
  260. package/src/runtime/instrumentation.ts +530 -67
  261. package/src/runtime/keyring.ts +7 -19
  262. package/src/runtime/namespace.ts +2 -7
  263. package/src/runtime/native-network-errors.ts +99 -0
  264. package/src/runtime/native-network.ts +1605 -0
  265. package/src/runtime/ocr.ts +523 -0
  266. package/src/runtime/otlp.ts +12 -23
  267. package/src/runtime/perf.ts +1 -1
  268. package/src/runtime/provider.ts +4 -9
  269. package/src/runtime/proxy-errors.ts +29 -42
  270. package/src/runtime/proxy-nodemaven.ts +221 -0
  271. package/src/runtime/proxy-retry-policy.ts +3 -3
  272. package/src/runtime/proxy-telemetry.ts +84 -77
  273. package/src/runtime/redirects.ts +66 -0
  274. package/src/runtime/redis.ts +10 -13
  275. package/src/runtime/request-options.ts +679 -9
  276. package/src/runtime/resolver-config.ts +6 -0
  277. package/src/runtime/resolver-public.ts +18 -0
  278. package/src/runtime/resolver-shared.ts +17 -0
  279. package/src/runtime/resolver-vendors/bindings.ts +56 -0
  280. package/src/runtime/resolver-vendors/browser.ts +408 -0
  281. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  282. package/src/runtime/resolver-vendors/twocaptcha.ts +500 -0
  283. package/src/runtime/resolver-vendors/types.ts +173 -0
  284. package/src/runtime/resolver.ts +1060 -0
  285. package/src/runtime/secrets.ts +64 -0
  286. package/src/runtime/state.ts +399 -161
  287. package/src/runtime/stealth-cookies.ts +132 -0
  288. package/src/runtime/stealth.ts +681 -295
  289. package/src/runtime/stt.ts +39 -113
  290. package/src/runtime/timeout.ts +18 -0
  291. package/src/runtime/trace.ts +14 -44
  292. package/src/runtime/waterfall.ts +5 -18
  293. package/src/schema.ts +23 -84
  294. package/src/serve.ts +6 -1
  295. package/src/server/index.ts +30 -7
  296. package/src/server/self-test-input-tokens.ts +29 -14
  297. package/src/server/self-test-redaction.ts +2 -2
  298. package/src/server/self-test.ts +1030 -180
  299. package/src/server/serve-implementation.ts +3060 -0
  300. package/src/server/serve.ts +1 -1781
  301. package/src/server/types.ts +12 -13
  302. package/src/stateful/README.md +146 -0
  303. package/src/stateful/errors.ts +35 -0
  304. package/src/stateful/http-provider-event-emitter.ts +314 -0
  305. package/src/stateful/http-session-owner-registry.ts +306 -0
  306. package/src/stateful/index.ts +18 -0
  307. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  308. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  309. package/src/stateful/provider-event-pipeline.ts +61 -0
  310. package/src/stateful/provider-events.ts +462 -0
  311. package/src/stateful/session-key.ts +111 -0
  312. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  313. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  314. package/src/stateful/stateful-provider-adapter.ts +562 -0
  315. package/src/stateful/stateful-provider-observability.ts +261 -0
  316. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  317. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  318. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  319. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  320. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  321. package/src/stateful-signing.ts +46 -0
  322. package/src/stealth/profiles.ts +27 -33
  323. package/src/stream-evidence.ts +988 -0
  324. package/src/stream.ts +16 -20
  325. package/src/testing/index.ts +11 -2
  326. package/src/testing/run.ts +668 -74
  327. package/src/types.ts +665 -35
  328. package/src/user-input.ts +118 -0
  329. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  330. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  331. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  332. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  333. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  334. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  335. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  336. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  337. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  338. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  339. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  340. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  341. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  342. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -4,32 +4,47 @@ import {
4
4
  createHash,
5
5
  createHmac,
6
6
  randomBytes,
7
+ randomInt,
7
8
  timingSafeEqual,
8
9
  } from "node:crypto";
9
10
  import {
10
11
  assertFreshProviderChoiceIssuedAt,
11
12
  ProviderChoiceTokenError,
12
13
  type ProviderChoiceTokenPayload,
13
- } from "../choice-token";
14
- import { ProviderError } from "../errors";
14
+ } from "../choice-token.js";
15
+ import { isProviderError, ProviderError } from "../errors.js";
16
+ import {
17
+ CHOICE_WORDLIST_SIZE,
18
+ choiceWordAt,
19
+ HIGH_CHOICE_WORD_COUNT,
20
+ isChoiceWord,
21
+ STANDARD_CHOICE_WORD_COUNT,
22
+ } from "./choice-wordlist.js";
15
23
  import type {
16
24
  CredentialContext,
17
25
  EnvContext,
18
26
  ProviderChoiceBindingOptions,
27
+ ProviderChoiceConsumeMode,
28
+ ProviderChoiceConsumeResult,
19
29
  ProviderChoiceContext,
30
+ ProviderChoiceExplicitParseResult,
20
31
  ProviderChoiceIssueOptions,
21
32
  ProviderChoiceParseOptions,
22
33
  ProviderChoiceStorageOptions,
23
34
  ProviderRequestContext,
24
35
  ProviderRuntimeState,
25
36
  ProviderStateDurationString,
26
- } from "../types";
37
+ StateValue,
38
+ } from "../types.js";
27
39
 
28
40
  export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV =
29
41
  "APIFUSE__PROVIDER_RUNTIME__CHOICE_TOKEN_MASTER_SECRET";
30
42
 
31
43
  const PRIMARY_CHOICE_TOKEN_KID = "v1";
32
44
  const MANAGED_CHOICE_TOKEN_VERSION = 1;
45
+ const SERVER_STORED_CHOICE_RECORD_VERSION = 1;
46
+ const SERVER_STORED_CHOICE_ISSUE_ATTEMPTS = 5;
47
+ const WORD_CHOICE_NOT_FOUND_MESSAGE = "Provider choice token was not found.";
33
48
 
34
49
  type ManagedChoiceEnvelope = {
35
50
  readonly v: typeof MANAGED_CHOICE_TOKEN_VERSION;
@@ -51,6 +66,32 @@ type ServerChoiceHandlePayload = {
51
66
  readonly created_at_ms: number;
52
67
  };
53
68
 
69
+ type ServerStoredChoiceRecord = {
70
+ readonly v: typeof SERVER_STORED_CHOICE_RECORD_VERSION;
71
+ readonly storage: "server";
72
+ readonly status: "active" | "consumed";
73
+ readonly provider_id: string;
74
+ readonly purpose: string;
75
+ readonly issued_at_ms: number;
76
+ readonly ttl_ms: number;
77
+ readonly binding?: ManagedChoiceEnvelope["binding"];
78
+ readonly prefix: string;
79
+ readonly payload: ProviderChoiceTokenPayload;
80
+ readonly payload_digest: string;
81
+ readonly replay_key: string;
82
+ };
83
+
84
+ export type ProviderChoiceTelemetryEvent = {
85
+ readonly providerId: string;
86
+ readonly purpose: string;
87
+ readonly operation: "parse" | "consume";
88
+ readonly format: "word" | "legacy";
89
+ readonly outcome: "success" | "not-found" | "invalid" | "unsupported" | "error";
90
+ readonly consumeMode: ProviderChoiceConsumeMode;
91
+ readonly consumed: boolean;
92
+ readonly replay: boolean;
93
+ };
94
+
54
95
  export type CreateProviderChoiceContextOptions = {
55
96
  readonly providerId: string;
56
97
  readonly env?: EnvContext;
@@ -59,6 +100,8 @@ export type CreateProviderChoiceContextOptions = {
59
100
  readonly state?: ProviderRuntimeState;
60
101
  readonly masterSecret?: string;
61
102
  readonly kid?: string;
103
+ /** Receives allowlisted metadata only; token and payload values are never included. */
104
+ readonly onTelemetry?: (event: ProviderChoiceTelemetryEvent) => void;
62
105
  };
63
106
 
64
107
  export function createProviderChoiceContext(
@@ -74,24 +117,48 @@ export function createProviderChoiceContext(
74
117
  ): string;
75
118
  function issue<TPayload extends ProviderChoiceTokenPayload>(
76
119
  issueOptions: ProviderChoiceIssueOptions<TPayload> & {
77
- readonly storage: Extract<
78
- ProviderChoiceStorageOptions,
79
- { readonly mode: "server" }
80
- >;
120
+ readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "server" }>;
81
121
  },
82
122
  ): Promise<string>;
83
123
  function issue<TPayload extends ProviderChoiceTokenPayload>(
84
124
  issueOptions: ProviderChoiceIssueOptions<TPayload> & {
85
- readonly storage: Extract<
86
- ProviderChoiceStorageOptions,
87
- { readonly mode: "auto" }
88
- >;
125
+ readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "auto" }>;
89
126
  },
90
127
  ): string | Promise<string>;
91
128
  function issue<TPayload extends ProviderChoiceTokenPayload>(
92
129
  issueOptions: ProviderChoiceIssueOptions<TPayload>,
93
130
  ): string | Promise<string> {
94
131
  const issuedAtMs = issueOptions.nowMs ?? Date.now();
132
+ const resolvedStorage = resolveIssueStorage(issueOptions.storage, issueOptions.payload);
133
+ if (resolvedStorage.mode === "server") {
134
+ const binding = hasRequestedChoiceBinding(issueOptions.bind)
135
+ ? createChoiceBinding({
136
+ keys: deriveManagedChoiceKeys({
137
+ masterSecret: resolveMasterSecret(),
138
+ providerId: options.providerId,
139
+ purpose: issueOptions.purpose,
140
+ kid,
141
+ }),
142
+ options: issueOptions.bind,
143
+ request: options.request,
144
+ credential: options.credential,
145
+ required: true,
146
+ })
147
+ : undefined;
148
+ return issueServerStoredChoice({
149
+ baseEnvelope: {
150
+ v: MANAGED_CHOICE_TOKEN_VERSION,
151
+ provider_id: options.providerId,
152
+ purpose: issueOptions.purpose,
153
+ issued_at_ms: issuedAtMs,
154
+ ttl_ms: issueOptions.ttlMs,
155
+ binding,
156
+ },
157
+ issueOptions,
158
+ storage: resolvedStorage.storage,
159
+ contextState: options.state,
160
+ });
161
+ }
95
162
  const keys = deriveManagedChoiceKeys({
96
163
  masterSecret: resolveMasterSecret(),
97
164
  providerId: options.providerId,
@@ -112,21 +179,6 @@ export function createProviderChoiceContext(
112
179
  required: true,
113
180
  }),
114
181
  };
115
- const resolvedStorage = resolveIssueStorage(
116
- issueOptions.storage,
117
- issueOptions.payload,
118
- );
119
- if (resolvedStorage.mode === "server") {
120
- return issueServerStoredChoice({
121
- baseEnvelope,
122
- issueOptions,
123
- storage: resolvedStorage.storage,
124
- contextState: options.state,
125
- kid,
126
- keys,
127
- issuedAtMs,
128
- });
129
- }
130
182
  const envelope: ManagedChoiceEnvelope = {
131
183
  ...baseEnvelope,
132
184
  payload: issueOptions.payload,
@@ -139,6 +191,9 @@ export function createProviderChoiceContext(
139
191
  });
140
192
  }
141
193
 
194
+ function parse(
195
+ parseOptions: ProviderChoiceParseOptions & { readonly consume: "explicit" },
196
+ ): Promise<ProviderChoiceExplicitParseResult>;
142
197
  function parse(
143
198
  parseOptions: ProviderChoiceParseOptions & {
144
199
  readonly storage?: { readonly mode: "inline" };
@@ -146,94 +201,170 @@ export function createProviderChoiceContext(
146
201
  ): ProviderChoiceTokenPayload;
147
202
  function parse(
148
203
  parseOptions: ProviderChoiceParseOptions & {
149
- readonly storage: Extract<
150
- ProviderChoiceStorageOptions,
151
- { readonly mode: "server" }
152
- >;
204
+ readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "server" }>;
153
205
  },
154
206
  ): Promise<ProviderChoiceTokenPayload>;
155
207
  function parse(
156
208
  parseOptions: ProviderChoiceParseOptions & {
157
- readonly storage: Extract<
158
- ProviderChoiceStorageOptions,
159
- { readonly mode: "auto" }
160
- >;
209
+ readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "auto" }>;
161
210
  },
162
211
  ): ProviderChoiceTokenPayload | Promise<ProviderChoiceTokenPayload>;
163
212
  function parse(
164
213
  parseOptions: ProviderChoiceParseOptions,
165
- ): ProviderChoiceTokenPayload | Promise<ProviderChoiceTokenPayload> {
166
- const [
167
- actualPrefix,
168
- tokenKid,
169
- encodedIv,
170
- encryptedPayload,
171
- authTag,
172
- signature,
173
- ] = parseManagedChoiceTokenParts(parseOptions.token);
174
- if (
175
- actualPrefix !== parseOptions.prefix ||
176
- tokenKid !== kid ||
177
- !encodedIv ||
178
- !encryptedPayload ||
179
- !authTag ||
180
- !signature
181
- ) {
182
- throw new ProviderChoiceTokenError(
183
- "invalid_shape",
184
- "Provider choice token shape is invalid.",
185
- );
186
- }
187
-
188
- const keys = deriveManagedChoiceKeys({
189
- masterSecret: resolveMasterSecret(),
190
- providerId: options.providerId,
191
- purpose: parseOptions.purpose,
192
- kid: tokenKid,
193
- });
194
- const signedBody = [
195
- parseOptions.prefix,
196
- tokenKid,
197
- encodedIv,
198
- encryptedPayload,
199
- authTag,
200
- ].join(".");
201
- assertManagedChoiceSignature({
202
- signedBody,
203
- signature,
204
- signingKey: keys.signing,
205
- });
206
- const envelope = decryptManagedChoiceToken({
207
- encodedIv,
208
- encryptedPayload,
209
- authTag,
210
- encryptionKey: keys.encryption,
211
- });
212
- assertManagedChoiceEnvelope(envelope, {
213
- providerId: options.providerId,
214
- purpose: parseOptions.purpose,
215
- ttlMs: parseOptions.ttlMs,
216
- nowMs: parseOptions.nowMs,
217
- futureToleranceMs: parseOptions.futureToleranceMs,
214
+ ):
215
+ | ProviderChoiceTokenPayload
216
+ | ProviderChoiceExplicitParseResult
217
+ | Promise<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult> {
218
+ const consumeMode = parseOptions.consume ?? "never";
219
+ const wordStateKey = parseWordChoiceStateKey({
220
+ token: parseOptions.token,
221
+ prefix: parseOptions.prefix,
218
222
  });
219
- assertChoiceBindingMatches({
220
- actual: envelope.binding,
221
- expected: createChoiceBinding({
222
- keys,
223
- options: parseOptions.bind,
223
+ if (wordStateKey) {
224
+ const parsed = parseWordServerStoredChoice({
225
+ stateKey: wordStateKey,
226
+ parseOptions,
227
+ contextState: options.state,
228
+ providerId: options.providerId,
224
229
  request: options.request,
225
230
  credential: options.credential,
226
- required: true,
227
- }),
228
- });
229
- if (isServerChoiceHandlePayload(envelope.payload)) {
230
- return parseServerStoredChoice({
231
- handle: envelope.payload,
232
- storage: parseOptions.storage,
233
- contextState: options.state,
231
+ resolveBindingKeys: () =>
232
+ deriveManagedChoiceKeys({
233
+ masterSecret: resolveMasterSecret(),
234
+ providerId: options.providerId,
235
+ purpose: parseOptions.purpose,
236
+ kid,
237
+ }),
238
+ onConsume: (result) =>
239
+ emitChoiceTelemetry(options.onTelemetry, {
240
+ providerId: options.providerId,
241
+ purpose: parseOptions.purpose,
242
+ operation: "consume",
243
+ format: "word",
244
+ outcome: "success",
245
+ consumeMode,
246
+ consumed: result.status === "consumed",
247
+ replay: result.status === "already-consumed",
248
+ }),
249
+ });
250
+ return observeChoiceParse<
251
+ ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult
252
+ >(parsed, {
253
+ onTelemetry: options.onTelemetry,
254
+ providerId: options.providerId,
255
+ purpose: parseOptions.purpose,
256
+ format: "word",
257
+ consumeMode,
258
+ });
259
+ }
260
+
261
+ // Legacy encrypted-envelope compatibility fallback. Removal is gated on
262
+ // the last legacy mint plus the maximum issued TTL; see ADR 0006.
263
+ // A structurally valid word token returns above, so lookup, expiry,
264
+ // consumption, and binding failures can never enter this branch.
265
+ try {
266
+ const [actualPrefix, tokenKid, encodedIv, encryptedPayload, authTag, signature] =
267
+ parseManagedChoiceTokenParts(parseOptions.token);
268
+ if (
269
+ actualPrefix !== parseOptions.prefix ||
270
+ tokenKid !== kid ||
271
+ !encodedIv ||
272
+ !encryptedPayload ||
273
+ !authTag ||
274
+ !signature
275
+ ) {
276
+ throw new ProviderChoiceTokenError(
277
+ "invalid_shape",
278
+ "Provider choice token shape is invalid.",
279
+ );
280
+ }
281
+
282
+ const keys = deriveManagedChoiceKeys({
283
+ masterSecret: resolveMasterSecret(),
284
+ providerId: options.providerId,
285
+ purpose: parseOptions.purpose,
286
+ kid: tokenKid,
287
+ });
288
+ const signedBody = [
289
+ parseOptions.prefix,
290
+ tokenKid,
291
+ encodedIv,
292
+ encryptedPayload,
293
+ authTag,
294
+ ].join(".");
295
+ assertManagedChoiceSignature({
296
+ signedBody,
297
+ signature,
298
+ signingKey: keys.signing,
299
+ });
300
+ const envelope = decryptManagedChoiceToken({
301
+ encodedIv,
302
+ encryptedPayload,
303
+ authTag,
304
+ encryptionKey: keys.encryption,
305
+ });
306
+ assertManagedChoiceEnvelope(envelope, {
307
+ providerId: options.providerId,
308
+ purpose: parseOptions.purpose,
309
+ ttlMs: parseOptions.ttlMs,
310
+ nowMs: parseOptions.nowMs,
311
+ futureToleranceMs: parseOptions.futureToleranceMs,
312
+ });
313
+ assertChoiceBindingMatches({
314
+ actual: envelope.binding,
315
+ expected: createChoiceBinding({
316
+ keys,
317
+ options: parseOptions.bind,
318
+ request: options.request,
319
+ credential: options.credential,
320
+ required: true,
321
+ }),
234
322
  });
323
+ const payload = isServerChoiceHandlePayload(envelope.payload)
324
+ ? parseLegacyServerStoredChoice({
325
+ handle: envelope.payload,
326
+ storage: parseOptions.storage,
327
+ contextState: options.state,
328
+ })
329
+ : envelope.payload;
330
+ const parsed =
331
+ consumeMode === "explicit"
332
+ ? Promise.resolve(payload).then((resolvedPayload) =>
333
+ createLegacyExplicitParseResult({
334
+ payload: resolvedPayload,
335
+ replayKey: digestChoiceReplayKey(parseOptions.token),
336
+ onConsume: () =>
337
+ emitChoiceTelemetry(options.onTelemetry, {
338
+ providerId: options.providerId,
339
+ purpose: parseOptions.purpose,
340
+ operation: "consume",
341
+ format: "legacy",
342
+ outcome: "unsupported",
343
+ consumeMode,
344
+ consumed: false,
345
+ replay: false,
346
+ }),
347
+ }),
348
+ )
349
+ : payload;
350
+ return observeChoiceParse<
351
+ ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult
352
+ >(parsed, {
353
+ onTelemetry: options.onTelemetry,
354
+ providerId: options.providerId,
355
+ purpose: parseOptions.purpose,
356
+ format: "legacy",
357
+ consumeMode,
358
+ });
359
+ } catch (error) {
360
+ emitChoiceParseFailure(options.onTelemetry, error, {
361
+ providerId: options.providerId,
362
+ purpose: parseOptions.purpose,
363
+ format: "legacy",
364
+ consumeMode,
365
+ });
366
+ throw error;
235
367
  }
236
- return envelope.payload;
237
368
  }
238
369
 
239
370
  return { issue, parse };
@@ -247,30 +378,126 @@ export function createTestProviderChoiceContext(
247
378
  return createProviderChoiceContext({
248
379
  ...options,
249
380
  masterSecret:
250
- options.masterSecret ??
251
- "apifuse-test-provider-runtime-choice-token-master-secret",
381
+ options.masterSecret ?? "apifuse-test-provider-runtime-choice-token-master-secret",
252
382
  });
253
383
  }
254
384
 
255
- function resolveChoiceMasterSecret(
256
- options: CreateProviderChoiceContextOptions,
257
- ): string {
385
+ type ChoiceParseTelemetryBase = {
386
+ readonly onTelemetry?: (event: ProviderChoiceTelemetryEvent) => void;
387
+ readonly providerId: string;
388
+ readonly purpose: string;
389
+ readonly format: "word" | "legacy";
390
+ readonly consumeMode: ProviderChoiceConsumeMode;
391
+ };
392
+
393
+ function observeChoiceParse<T>(
394
+ result: T | Promise<T>,
395
+ base: ChoiceParseTelemetryBase,
396
+ ): T | Promise<T> {
397
+ if (result instanceof Promise) {
398
+ return result.then(
399
+ (value) => {
400
+ emitChoiceParseSuccess(base, value);
401
+ return value;
402
+ },
403
+ (error: unknown) => {
404
+ emitChoiceParseFailure(base.onTelemetry, error, base);
405
+ throw error;
406
+ },
407
+ );
408
+ }
409
+ emitChoiceParseSuccess(base, result);
410
+ return result;
411
+ }
412
+
413
+ function emitChoiceParseSuccess(base: ChoiceParseTelemetryBase, result: unknown): void {
414
+ const replay = isConsumedChoiceReplay(result);
415
+ emitChoiceTelemetry(base.onTelemetry, {
416
+ providerId: base.providerId,
417
+ purpose: base.purpose,
418
+ operation: "parse",
419
+ format: base.format,
420
+ outcome: "success",
421
+ consumeMode: base.consumeMode,
422
+ consumed: replay || (base.format === "word" && base.consumeMode === "on-parse"),
423
+ replay,
424
+ });
425
+ }
426
+
427
+ function isConsumedChoiceReplay(value: unknown): boolean {
428
+ return (
429
+ value !== null &&
430
+ typeof value === "object" &&
431
+ "status" in value &&
432
+ value.status === "consumed" &&
433
+ "replayKey" in value &&
434
+ typeof value.replayKey === "string"
435
+ );
436
+ }
437
+
438
+ function emitChoiceParseFailure(
439
+ onTelemetry: CreateProviderChoiceContextOptions["onTelemetry"],
440
+ error: unknown,
441
+ base: Omit<ChoiceParseTelemetryBase, "onTelemetry">,
442
+ ): void {
443
+ const outcome =
444
+ error instanceof ProviderChoiceTokenError
445
+ ? base.format === "word" && error.message === WORD_CHOICE_NOT_FOUND_MESSAGE
446
+ ? "not-found"
447
+ : "invalid"
448
+ : "error";
449
+ emitChoiceTelemetry(onTelemetry, {
450
+ providerId: base.providerId,
451
+ purpose: base.purpose,
452
+ operation: "parse",
453
+ format: base.format,
454
+ outcome,
455
+ consumeMode: base.consumeMode,
456
+ consumed: false,
457
+ replay: false,
458
+ });
459
+ }
460
+
461
+ function emitChoiceTelemetry(
462
+ onTelemetry: CreateProviderChoiceContextOptions["onTelemetry"],
463
+ event: ProviderChoiceTelemetryEvent,
464
+ ): void {
465
+ try {
466
+ onTelemetry?.(event);
467
+ } catch {
468
+ // Observability must never change provider token semantics.
469
+ }
470
+ }
471
+
472
+ function createLegacyExplicitParseResult(options: {
473
+ readonly payload: ProviderChoiceTokenPayload;
474
+ readonly replayKey: string;
475
+ readonly onConsume: () => void;
476
+ }): ProviderChoiceExplicitParseResult {
477
+ return {
478
+ status: "active",
479
+ payload: options.payload,
480
+ replayKey: options.replayKey,
481
+ consume: async () => {
482
+ options.onConsume();
483
+ return { status: "unsupported" };
484
+ },
485
+ };
486
+ }
487
+
488
+ function resolveChoiceMasterSecret(options: CreateProviderChoiceContextOptions): string {
258
489
  const configured =
259
- options.masterSecret ??
260
- options.env?.get(PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV);
490
+ options.masterSecret ?? options.env?.get(PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV);
261
491
  const trimmed = configured?.trim();
262
492
  if (trimmed) return trimmed;
263
- throw new ProviderError(
264
- "Provider runtime choice-token master secret is not configured.",
265
- {
266
- code: "CHOICE_TOKEN_MASTER_SECRET_NOT_CONFIGURED",
267
- category: "internal_error",
268
- retryable: false,
269
- details: {
270
- secret: PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV,
271
- },
493
+ throw new ProviderError("Provider runtime choice-token master secret is not configured.", {
494
+ code: "CHOICE_TOKEN_MASTER_SECRET_NOT_CONFIGURED",
495
+ category: "internal_error",
496
+ retryable: false,
497
+ details: {
498
+ secret: PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV,
272
499
  },
273
- );
500
+ });
274
501
  }
275
502
 
276
503
  type ManagedChoiceKeyInput = {
@@ -286,9 +513,7 @@ type ManagedChoiceKeys = {
286
513
  readonly binding: Buffer;
287
514
  };
288
515
 
289
- function deriveManagedChoiceKeys(
290
- input: ManagedChoiceKeyInput,
291
- ): ManagedChoiceKeys {
516
+ function deriveManagedChoiceKeys(input: ManagedChoiceKeyInput): ManagedChoiceKeys {
292
517
  return {
293
518
  encryption: deriveManagedChoiceKey(input, "encryption"),
294
519
  signing: deriveManagedChoiceKey(input, "signing"),
@@ -327,76 +552,212 @@ function encryptManagedChoiceToken(options: {
327
552
  ]).toString("base64url");
328
553
  const authTag = cipher.getAuthTag().toString("base64url");
329
554
  const encodedIv = iv.toString("base64url");
330
- const signedBody = [
331
- options.prefix,
332
- options.kid,
333
- encodedIv,
334
- encryptedPayload,
335
- authTag,
336
- ].join(".");
555
+ const signedBody = [options.prefix, options.kid, encodedIv, encryptedPayload, authTag].join(".");
337
556
  const signature = createHmac("sha256", options.keys.signing)
338
557
  .update(signedBody)
339
558
  .digest("base64url");
340
559
  return `${signedBody}.${signature}`;
341
560
  }
342
561
 
343
- async function issueServerStoredChoice<
344
- TPayload extends ProviderChoiceTokenPayload,
345
- >(options: {
562
+ async function issueServerStoredChoice<TPayload extends ProviderChoiceTokenPayload>(options: {
346
563
  readonly baseEnvelope: Omit<ManagedChoiceEnvelope, "payload">;
347
564
  readonly issueOptions: ProviderChoiceIssueOptions<TPayload>;
348
565
  readonly storage: ServerProviderChoiceStorageOptions;
349
566
  readonly contextState?: ProviderRuntimeState;
350
- readonly kid: string;
351
- readonly keys: ManagedChoiceKeys;
352
- readonly issuedAtMs: number;
353
567
  }): Promise<string> {
354
- const serializedPayload = serializeChoicePayload(
355
- options.issueOptions.payload,
356
- );
357
- const payloadBytes = Buffer.byteLength(serializedPayload, "utf8");
358
- if (payloadBytes > options.storage.maxValueBytes) {
359
- throw new ProviderError(
360
- "Provider choice payload exceeds state storage policy.",
361
- {
568
+ const serializedPayload = serializeChoicePayload(options.issueOptions.payload);
569
+ const payloadDigest = digestChoicePayload(serializedPayload);
570
+ const namespace = resolveChoiceStateNamespace({
571
+ storage: options.storage,
572
+ contextState: options.contextState,
573
+ ttlMs: options.issueOptions.ttlMs,
574
+ });
575
+ const wordCount =
576
+ options.issueOptions.strength === "high" ? HIGH_CHOICE_WORD_COUNT : STANDARD_CHOICE_WORD_COUNT;
577
+ for (let attempt = 0; attempt < SERVER_STORED_CHOICE_ISSUE_ATTEMPTS; attempt += 1) {
578
+ const stateKey = generateChoiceWordSequence(wordCount);
579
+ const token = `${options.issueOptions.prefix}${stateKey}`;
580
+ const record: ServerStoredChoiceRecord = {
581
+ v: SERVER_STORED_CHOICE_RECORD_VERSION,
582
+ storage: "server",
583
+ status: "active",
584
+ provider_id: options.baseEnvelope.provider_id,
585
+ purpose: options.baseEnvelope.purpose,
586
+ issued_at_ms: options.baseEnvelope.issued_at_ms,
587
+ ttl_ms: options.baseEnvelope.ttl_ms,
588
+ binding: options.baseEnvelope.binding,
589
+ prefix: options.issueOptions.prefix,
590
+ payload: options.issueOptions.payload,
591
+ payload_digest: payloadDigest,
592
+ replay_key: digestChoiceReplayKey(token),
593
+ };
594
+ const valueBytes = Buffer.byteLength(JSON.stringify(record), "utf8");
595
+ if (valueBytes > options.storage.maxValueBytes) {
596
+ throw new ProviderError("Provider choice payload exceeds state storage policy.", {
362
597
  code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
363
598
  category: "input_validation",
364
599
  retryable: false,
365
600
  details: {
366
601
  maxValueBytes: options.storage.maxValueBytes,
367
- payloadBytes,
602
+ valueBytes,
368
603
  },
369
- },
370
- );
604
+ });
605
+ }
606
+ const result = await namespace.compareAndSet(optionsStateKey(stateKey), 0, record, {
607
+ ttl: stateTtl(options.storage, options.issueOptions.ttlMs),
608
+ });
609
+ if (result.ok) return token;
371
610
  }
372
- const stateId = `choice_${randomBytes(16).toString("base64url")}`;
373
- const digest = digestChoicePayload(serializedPayload);
611
+ throw new ProviderError("Provider choice state storage is not available.", {
612
+ code: "CHOICE_STATE_UNAVAILABLE",
613
+ category: "internal_error",
614
+ retryable: false,
615
+ });
616
+ }
617
+
618
+ async function parseWordServerStoredChoice(options: {
619
+ readonly stateKey: string;
620
+ readonly parseOptions: ProviderChoiceParseOptions;
621
+ readonly contextState?: ProviderRuntimeState;
622
+ readonly providerId: string;
623
+ readonly request?: ProviderRequestContext;
624
+ readonly credential?: CredentialContext;
625
+ readonly resolveBindingKeys: () => ManagedChoiceKeys;
626
+ readonly onConsume: (result: ProviderChoiceConsumeResult) => void;
627
+ }): Promise<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult> {
628
+ const storage = resolveParseStorage(options.parseOptions.storage);
374
629
  const namespace = resolveChoiceStateNamespace({
375
- storage: options.storage,
630
+ storage,
376
631
  contextState: options.contextState,
377
- ttlMs: options.issueOptions.ttlMs,
632
+ ttlMs: options.parseOptions.ttlMs,
378
633
  });
379
- await namespace.set(optionsStateKey(stateId), options.issueOptions.payload, {
380
- ttl: stateTtl(options.storage, options.issueOptions.ttlMs),
634
+ let stored: StateValue<ServerStoredChoiceRecord> | null;
635
+ try {
636
+ stored = await namespace.get<ServerStoredChoiceRecord>(optionsStateKey(options.stateKey));
637
+ } catch (error) {
638
+ if (isProviderError(error)) throw error;
639
+ throw wordChoiceNotFoundError();
640
+ }
641
+ if (!stored || !isServerStoredChoiceRecord(stored.value)) {
642
+ throw wordChoiceNotFoundError();
643
+ }
644
+
645
+ const record = stored.value;
646
+ const expectedReplayKey = digestChoiceReplayKey(
647
+ `${options.parseOptions.prefix}${options.stateKey}`,
648
+ );
649
+ try {
650
+ if (
651
+ record.provider_id !== options.providerId ||
652
+ record.purpose !== options.parseOptions.purpose ||
653
+ record.prefix !== options.parseOptions.prefix
654
+ ) {
655
+ throw wordChoiceNotFoundError();
656
+ }
657
+ assertFreshProviderChoiceIssuedAt(record.issued_at_ms, {
658
+ ttlMs:
659
+ options.parseOptions.ttlMs != null
660
+ ? Math.min(options.parseOptions.ttlMs, record.ttl_ms)
661
+ : record.ttl_ms,
662
+ nowMs: options.parseOptions.nowMs,
663
+ futureToleranceMs: options.parseOptions.futureToleranceMs,
664
+ });
665
+ assertPayloadDigestMatches({
666
+ actual: digestChoicePayload(serializeChoicePayload(record.payload)),
667
+ expected: record.payload_digest,
668
+ });
669
+ assertPayloadDigestMatches({ actual: expectedReplayKey, expected: record.replay_key });
670
+ assertWordChoiceBindingMatches({
671
+ actual: record.binding,
672
+ requested: options.parseOptions.bind,
673
+ request: options.request,
674
+ credential: options.credential,
675
+ resolveKeys: options.resolveBindingKeys,
676
+ });
677
+ } catch (error) {
678
+ if (
679
+ error instanceof ProviderChoiceTokenError ||
680
+ (isProviderError(error) && error.code === "CHOICE_CONTEXT_REQUIRED")
681
+ ) {
682
+ throw wordChoiceNotFoundError();
683
+ }
684
+ throw error;
685
+ }
686
+
687
+ const consumeMode = options.parseOptions.consume ?? "never";
688
+ if (record.status === "consumed") {
689
+ if (consumeMode === "explicit") {
690
+ return { status: "consumed", replayKey: record.replay_key };
691
+ }
692
+ throw wordChoiceNotFoundError();
693
+ }
694
+ if (consumeMode === "never") return record.payload;
695
+ if (consumeMode === "explicit") {
696
+ return {
697
+ status: "active",
698
+ payload: record.payload,
699
+ replayKey: record.replay_key,
700
+ consume: async () => {
701
+ const result = await consumeWordServerStoredChoice({
702
+ stateKey: options.stateKey,
703
+ stored,
704
+ record,
705
+ storage,
706
+ contextState: options.contextState,
707
+ });
708
+ options.onConsume(result);
709
+ return result;
710
+ },
711
+ };
712
+ }
713
+ const consumed = await consumeWordServerStoredChoice({
714
+ stateKey: options.stateKey,
715
+ stored,
716
+ record,
717
+ storage,
718
+ contextState: options.contextState,
381
719
  });
382
- const envelope: ManagedChoiceEnvelope = {
383
- ...options.baseEnvelope,
384
- payload: {
385
- storage: "server",
386
- state_id: stateId,
387
- payload_digest: digest,
388
- created_at_ms: options.issuedAtMs,
389
- },
390
- };
391
- return encryptManagedChoiceToken({
392
- prefix: options.issueOptions.prefix,
393
- kid: options.kid,
394
- envelope,
395
- keys: options.keys,
720
+ if (consumed.status !== "consumed") throw wordChoiceNotFoundError();
721
+ return record.payload;
722
+ }
723
+
724
+ async function consumeWordServerStoredChoice(options: {
725
+ readonly stateKey: string;
726
+ readonly stored: StateValue<ServerStoredChoiceRecord>;
727
+ readonly record: ServerStoredChoiceRecord;
728
+ readonly storage: ServerProviderChoiceStorageOptions;
729
+ readonly contextState?: ProviderRuntimeState;
730
+ }): Promise<ProviderChoiceConsumeResult> {
731
+ const namespace = resolveChoiceStateNamespace({
732
+ storage: options.storage,
733
+ contextState: options.contextState,
734
+ ttlMs: options.record.ttl_ms,
396
735
  });
736
+ try {
737
+ const consumed = await namespace.compareAndSet(
738
+ optionsStateKey(options.stateKey),
739
+ options.stored.version,
740
+ { ...options.record, status: "consumed" } satisfies ServerStoredChoiceRecord,
741
+ { ttl: remainingStateTtl(options.stored.expiresAt) },
742
+ );
743
+ if (consumed.ok) return { status: "consumed" };
744
+ if (
745
+ consumed.current &&
746
+ isServerStoredChoiceRecord(consumed.current.value) &&
747
+ consumed.current.value.status === "consumed" &&
748
+ consumed.current.value.replay_key === options.record.replay_key
749
+ ) {
750
+ return { status: "already-consumed" };
751
+ }
752
+ throw wordChoiceNotFoundError();
753
+ } catch (error) {
754
+ if (isProviderError(error)) throw error;
755
+ if (error instanceof ProviderChoiceTokenError) throw error;
756
+ throw wordChoiceNotFoundError();
757
+ }
397
758
  }
398
759
 
399
- async function parseServerStoredChoice(options: {
760
+ async function parseLegacyServerStoredChoice(options: {
400
761
  readonly handle: ServerChoiceHandlePayload;
401
762
  readonly storage?: ProviderChoiceStorageOptions;
402
763
  readonly contextState?: ProviderRuntimeState;
@@ -406,9 +767,29 @@ async function parseServerStoredChoice(options: {
406
767
  storage,
407
768
  contextState: options.contextState,
408
769
  });
409
- const record = await namespace.get<ProviderChoiceTokenPayload>(
410
- optionsStateKey(options.handle.state_id),
411
- );
770
+ // Reading a server-stored choice back deserializes a persisted value. A
771
+ // corrupt/undecodable value would otherwise surface as a raw JSON.parse
772
+ // SyntaxError (or another unexpected throwable) that escapes the choice error
773
+ // taxonomy, gets masked as internal_error 500, and is treated as retryable by
774
+ // the hub -> reservation restart loop (2026-07-22 catchtable RCA, candidate A).
775
+ // Convert any non-branded throwable into a branded invalid_payload so it maps
776
+ // to a clean, non-retryable 400. Branded ProviderChoiceTokenError and genuine
777
+ // ProviderError (e.g. Redis-unavailable / state-unavailable) pass through so
778
+ // their category/retryable semantics are preserved.
779
+ let record: StateValue<ProviderChoiceTokenPayload> | null;
780
+ try {
781
+ record = await namespace.get<ProviderChoiceTokenPayload>(
782
+ optionsStateKey(options.handle.state_id),
783
+ );
784
+ } catch (error) {
785
+ if (error instanceof ProviderChoiceTokenError || isProviderError(error)) {
786
+ throw error;
787
+ }
788
+ throw new ProviderChoiceTokenError(
789
+ "invalid_payload",
790
+ "Provider choice token state payload could not be decoded.",
791
+ );
792
+ }
412
793
  if (!record) {
413
794
  throw new ProviderChoiceTokenError(
414
795
  "invalid_payload",
@@ -423,6 +804,83 @@ async function parseServerStoredChoice(options: {
423
804
  return record.value;
424
805
  }
425
806
 
807
+ function generateChoiceWordSequence(wordCount: number): string {
808
+ return Array.from({ length: wordCount }, () =>
809
+ choiceWordAt(randomInt(CHOICE_WORDLIST_SIZE)),
810
+ ).join("-");
811
+ }
812
+
813
+ function parseWordChoiceStateKey(options: {
814
+ readonly token: string;
815
+ readonly prefix: string;
816
+ }): string | null {
817
+ if (!options.token.startsWith(options.prefix)) return null;
818
+ const body = options.token.slice(options.prefix.length);
819
+ // The official list contains one hyphenated entry (`yo-yo`), so structural
820
+ // recognition uses dictionary-aware segmentation instead of assuming every
821
+ // hyphen is a word boundary.
822
+ if (!/^[a-z]+(?:-[a-z]+){3,9}$/.test(body)) return null;
823
+ const segments = body.split("-");
824
+ if (
825
+ !canSegmentChoiceWords(segments, 0, STANDARD_CHOICE_WORD_COUNT) &&
826
+ !canSegmentChoiceWords(segments, 0, HIGH_CHOICE_WORD_COUNT)
827
+ ) {
828
+ return null;
829
+ }
830
+ return body;
831
+ }
832
+
833
+ function canSegmentChoiceWords(
834
+ segments: readonly string[],
835
+ segmentIndex: number,
836
+ wordsRemaining: number,
837
+ ): boolean {
838
+ if (wordsRemaining === 0) return segmentIndex === segments.length;
839
+ const segmentsRemaining = segments.length - segmentIndex;
840
+ if (segmentsRemaining < wordsRemaining) return false;
841
+ for (let end = segmentIndex + 1; end <= segments.length - (wordsRemaining - 1); end += 1) {
842
+ const candidate = segments.slice(segmentIndex, end).join("-");
843
+ if (candidate.length > 10) break;
844
+ if (isChoiceWord(candidate) && canSegmentChoiceWords(segments, end, wordsRemaining - 1)) {
845
+ return true;
846
+ }
847
+ }
848
+ return false;
849
+ }
850
+
851
+ function isServerStoredChoiceRecord(value: unknown): value is ServerStoredChoiceRecord {
852
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
853
+ return (
854
+ "v" in value &&
855
+ value.v === SERVER_STORED_CHOICE_RECORD_VERSION &&
856
+ "storage" in value &&
857
+ value.storage === "server" &&
858
+ "status" in value &&
859
+ (value.status === "active" || value.status === "consumed") &&
860
+ "provider_id" in value &&
861
+ typeof value.provider_id === "string" &&
862
+ "purpose" in value &&
863
+ typeof value.purpose === "string" &&
864
+ "issued_at_ms" in value &&
865
+ typeof value.issued_at_ms === "number" &&
866
+ "ttl_ms" in value &&
867
+ typeof value.ttl_ms === "number" &&
868
+ (!("binding" in value) || value.binding === undefined || isChoiceBinding(value.binding)) &&
869
+ "prefix" in value &&
870
+ typeof value.prefix === "string" &&
871
+ "payload" in value &&
872
+ isChoicePayload(value.payload) &&
873
+ "payload_digest" in value &&
874
+ typeof value.payload_digest === "string" &&
875
+ "replay_key" in value &&
876
+ typeof value.replay_key === "string"
877
+ );
878
+ }
879
+
880
+ function wordChoiceNotFoundError(): ProviderChoiceTokenError {
881
+ return new ProviderChoiceTokenError("invalid_payload", WORD_CHOICE_NOT_FOUND_MESSAGE);
882
+ }
883
+
426
884
  type ServerProviderChoiceStorageOptions = Extract<
427
885
  ProviderChoiceStorageOptions,
428
886
  { readonly mode: "server" | "auto" }
@@ -439,10 +897,7 @@ function resolveIssueStorage<TPayload extends ProviderChoiceTokenPayload>(
439
897
  } {
440
898
  if (!storage || storage.mode === "inline") return { mode: "inline" };
441
899
  if (storage.mode === "server") return { mode: "server", storage };
442
- const payloadBytes = Buffer.byteLength(
443
- serializeChoicePayload(payload),
444
- "utf8",
445
- );
900
+ const payloadBytes = Buffer.byteLength(serializeChoicePayload(payload), "utf8");
446
901
  if (payloadBytes <= storage.maxInlineBytes) return { mode: "inline" };
447
902
  return { mode: "server", storage };
448
903
  }
@@ -487,6 +942,11 @@ function stateTtl(
487
942
  return storage.ttl ?? `${ttlMs ?? 1}ms`;
488
943
  }
489
944
 
945
+ function remainingStateTtl(expiresAt: string): ProviderStateDurationString {
946
+ const remainingMs = Date.parse(expiresAt) - Date.now();
947
+ return `${Number.isFinite(remainingMs) ? Math.max(1, Math.floor(remainingMs)) : 1}ms`;
948
+ }
949
+
490
950
  function optionsStateKey(stateId: string): string {
491
951
  return stateId;
492
952
  }
@@ -499,6 +959,10 @@ function digestChoicePayload(serializedPayload: string): string {
499
959
  return createHash("sha256").update(serializedPayload).digest("base64url");
500
960
  }
501
961
 
962
+ function digestChoiceReplayKey(token: string): string {
963
+ return createHash("sha256").update(token).digest("hex");
964
+ }
965
+
502
966
  function isServerChoiceHandlePayload(
503
967
  value: ProviderChoiceTokenPayload,
504
968
  ): value is ServerChoiceHandlePayload {
@@ -536,10 +1000,7 @@ function parseManagedChoiceTokenParts(
536
1000
  ] {
537
1001
  const parts = token.split(".");
538
1002
  if (parts.length !== 6) {
539
- throw new ProviderChoiceTokenError(
540
- "invalid_shape",
541
- "Provider choice token shape is invalid.",
542
- );
1003
+ throw new ProviderChoiceTokenError("invalid_shape", "Provider choice token shape is invalid.");
543
1004
  }
544
1005
  return [parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]];
545
1006
  }
@@ -601,9 +1062,7 @@ function decryptManagedChoiceToken(options: {
601
1062
  }
602
1063
  }
603
1064
 
604
- function isManagedChoiceEnvelope(
605
- value: unknown,
606
- ): value is ManagedChoiceEnvelope {
1065
+ function isManagedChoiceEnvelope(value: unknown): value is ManagedChoiceEnvelope {
607
1066
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
608
1067
  if (!("payload" in value) || !isChoicePayload(value.payload)) return false;
609
1068
  return (
@@ -617,9 +1076,7 @@ function isManagedChoiceEnvelope(
617
1076
  typeof value.issued_at_ms === "number" &&
618
1077
  "ttl_ms" in value &&
619
1078
  typeof value.ttl_ms === "number" &&
620
- (!("binding" in value) ||
621
- value.binding === undefined ||
622
- isChoiceBinding(value.binding))
1079
+ (!("binding" in value) || value.binding === undefined || isChoiceBinding(value.binding))
623
1080
  );
624
1081
  }
625
1082
 
@@ -627,13 +1084,10 @@ function isChoicePayload(value: unknown): value is ProviderChoiceTokenPayload {
627
1084
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
628
1085
  }
629
1086
 
630
- function isChoiceBinding(
631
- value: unknown,
632
- ): value is ManagedChoiceEnvelope["binding"] {
1087
+ function isChoiceBinding(value: unknown): value is ManagedChoiceEnvelope["binding"] {
633
1088
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
634
1089
  return (
635
- (!("connection_hash" in value) ||
636
- typeof value.connection_hash === "string") &&
1090
+ (!("connection_hash" in value) || typeof value.connection_hash === "string") &&
637
1091
  (!("credential_hash" in value) || typeof value.credential_hash === "string")
638
1092
  );
639
1093
  }
@@ -648,10 +1102,7 @@ function assertManagedChoiceEnvelope(
648
1102
  readonly futureToleranceMs?: number;
649
1103
  },
650
1104
  ): void {
651
- if (
652
- envelope.provider_id !== options.providerId ||
653
- envelope.purpose !== options.purpose
654
- ) {
1105
+ if (envelope.provider_id !== options.providerId || envelope.purpose !== options.purpose) {
655
1106
  throw new ProviderChoiceTokenError(
656
1107
  "invalid_payload",
657
1108
  "Provider choice token payload is invalid.",
@@ -660,10 +1111,7 @@ function assertManagedChoiceEnvelope(
660
1111
  assertFreshProviderChoiceIssuedAt(envelope.issued_at_ms, {
661
1112
  // Clamp to the issuer's embedded TTL so a caller-supplied value cannot
662
1113
  // silently extend token validity past the deadline the issuer intended.
663
- ttlMs:
664
- options.ttlMs != null
665
- ? Math.min(options.ttlMs, envelope.ttl_ms)
666
- : envelope.ttl_ms,
1114
+ ttlMs: options.ttlMs != null ? Math.min(options.ttlMs, envelope.ttl_ms) : envelope.ttl_ms,
667
1115
  nowMs: options.nowMs,
668
1116
  futureToleranceMs: options.futureToleranceMs,
669
1117
  });
@@ -676,9 +1124,7 @@ function createChoiceBinding(options: {
676
1124
  readonly credential?: CredentialContext;
677
1125
  readonly required: boolean;
678
1126
  }): ManagedChoiceEnvelope["binding"] {
679
- const connectionHash = options.options?.connection
680
- ? hashRequiredConnection(options)
681
- : undefined;
1127
+ const connectionHash = options.options?.connection ? hashRequiredConnection(options) : undefined;
682
1128
  const credentialHash = options.options?.credentialKeys?.length
683
1129
  ? hashCredentialKeys(options)
684
1130
  : undefined;
@@ -689,6 +1135,35 @@ function createChoiceBinding(options: {
689
1135
  };
690
1136
  }
691
1137
 
1138
+ function hasRequestedChoiceBinding(options?: ProviderChoiceBindingOptions): boolean {
1139
+ return options?.connection === true || Boolean(options?.credentialKeys?.length);
1140
+ }
1141
+
1142
+ function assertWordChoiceBindingMatches(options: {
1143
+ readonly actual: ManagedChoiceEnvelope["binding"];
1144
+ readonly requested?: ProviderChoiceBindingOptions;
1145
+ readonly request?: ProviderRequestContext;
1146
+ readonly credential?: CredentialContext;
1147
+ readonly resolveKeys: () => ManagedChoiceKeys;
1148
+ }): void {
1149
+ const hasStoredBinding = Boolean(
1150
+ options.actual?.connection_hash || options.actual?.credential_hash,
1151
+ );
1152
+ const hasRequestedBinding = hasRequestedChoiceBinding(options.requested);
1153
+ if (!hasStoredBinding && !hasRequestedBinding) return;
1154
+ if (hasStoredBinding !== hasRequestedBinding) throw wordChoiceNotFoundError();
1155
+ assertChoiceBindingMatches({
1156
+ actual: options.actual,
1157
+ expected: createChoiceBinding({
1158
+ keys: options.resolveKeys(),
1159
+ options: options.requested,
1160
+ request: options.request,
1161
+ credential: options.credential,
1162
+ required: true,
1163
+ }),
1164
+ });
1165
+ }
1166
+
692
1167
  function hashRequiredConnection(options: {
693
1168
  readonly keys: ManagedChoiceKeys;
694
1169
  readonly request?: ProviderRequestContext;
@@ -697,14 +1172,11 @@ function hashRequiredConnection(options: {
697
1172
  const connectionId = options.request?.connectionId;
698
1173
  if (!connectionId) {
699
1174
  if (!options.required) return undefined;
700
- throw new ProviderError(
701
- "Provider choice tokens require connection context.",
702
- {
703
- code: "CHOICE_CONTEXT_REQUIRED",
704
- category: "input_validation",
705
- retryable: false,
706
- },
707
- );
1175
+ throw new ProviderError("Provider choice tokens require connection context.", {
1176
+ code: "CHOICE_CONTEXT_REQUIRED",
1177
+ category: "input_validation",
1178
+ retryable: false,
1179
+ });
708
1180
  }
709
1181
  return createHmac("sha256", options.keys.binding)
710
1182
  .update("connection")
@@ -722,15 +1194,12 @@ function hashCredentialKeys(options: {
722
1194
  const material = credentialKeys.map((key) => {
723
1195
  const value = options.credential?.get(key);
724
1196
  if (typeof value !== "string" || value.length === 0) {
725
- throw new ProviderError(
726
- "Provider choice tokens require configured credential binding.",
727
- {
728
- code: "CHOICE_CONTEXT_REQUIRED",
729
- category: "input_validation",
730
- retryable: false,
731
- details: { credentialKey: key },
732
- },
733
- );
1197
+ throw new ProviderError("Provider choice tokens require configured credential binding.", {
1198
+ code: "CHOICE_CONTEXT_REQUIRED",
1199
+ category: "input_validation",
1200
+ retryable: false,
1201
+ details: { credentialKey: key },
1202
+ });
734
1203
  }
735
1204
  return [key, value];
736
1205
  });