@apifuse/provider-sdk 2.2.0-beta.59 → 2.2.0-beta.61

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 (124) hide show
  1. package/AUTHORING.md +33 -16
  2. package/CHANGELOG.md +8 -0
  3. package/bin/apifuse-dev.ts +7 -8
  4. package/bin/apifuse-pack-types.ts +22 -0
  5. package/bin/apifuse-perf.ts +45 -10
  6. package/bin/apifuse-record.ts +6 -8
  7. package/bin/apifuse-submit-check.ts +2 -2
  8. package/dist/config/loader.js +13 -12
  9. package/dist/define.d.ts +3 -2
  10. package/dist/define.js +1 -1
  11. package/dist/dev.js +3 -1
  12. package/dist/engine.d.ts +7 -6
  13. package/dist/engine.js +6 -6
  14. package/dist/error-resolution.js +21 -4
  15. package/dist/errors.d.ts +3 -0
  16. package/dist/errors.js +9 -0
  17. package/dist/handle-meta.d.ts +39 -0
  18. package/dist/handle-meta.js +29 -0
  19. package/dist/handle.d.ts +187 -0
  20. package/dist/handle.js +254 -0
  21. package/dist/i18n/catalog.js +21 -1
  22. package/dist/index.d.ts +6 -4
  23. package/dist/index.js +4 -3
  24. package/dist/lint.d.ts +3 -0
  25. package/dist/lint.js +388 -62
  26. package/dist/provider.d.ts +4 -4
  27. package/dist/provider.js +3 -3
  28. package/dist/runtime/browser.js +2 -1
  29. package/dist/runtime/cache.js +3 -2
  30. package/dist/runtime/diagnostic-env-values.d.ts +5 -0
  31. package/dist/runtime/diagnostic-env-values.js +57 -0
  32. package/dist/runtime/diagnostic-env.d.ts +11 -0
  33. package/dist/runtime/diagnostic-env.js +35 -0
  34. package/dist/runtime/diagnostic-redactor.d.ts +49 -0
  35. package/dist/runtime/diagnostic-redactor.js +569 -0
  36. package/dist/runtime/egress-lease.d.ts +2 -0
  37. package/dist/runtime/egress-lease.js +4 -1
  38. package/dist/runtime/env.js +2 -1
  39. package/dist/runtime/handle-wordlist.d.ts +43 -0
  40. package/dist/runtime/{choice-wordlist.js → handle-wordlist.js} +127 -9
  41. package/dist/runtime/handle.d.ts +71 -0
  42. package/dist/runtime/handle.js +895 -0
  43. package/dist/runtime/instrumentation.js +3 -0
  44. package/dist/runtime/native-network.d.ts +10 -1
  45. package/dist/runtime/native-network.js +230 -29
  46. package/dist/runtime/native-telemetry.d.ts +142 -0
  47. package/dist/runtime/native-telemetry.js +231 -0
  48. package/dist/runtime/ocr.js +2 -1
  49. package/dist/runtime/otlp.js +5 -4
  50. package/dist/runtime/proxy-nodemaven.js +3 -2
  51. package/dist/runtime/request-options.d.ts +7 -0
  52. package/dist/runtime/request-options.js +62 -22
  53. package/dist/runtime/resolver-vendors/browser.js +0 -1
  54. package/dist/runtime/resolver.js +3 -2
  55. package/dist/runtime/state.d.ts +4 -1
  56. package/dist/runtime/state.js +32 -17
  57. package/dist/runtime/stt.js +3 -2
  58. package/dist/runtime/trace.d.ts +5 -0
  59. package/dist/runtime/trace.js +115 -20
  60. package/dist/schema.d.ts +11 -0
  61. package/dist/schema.js +56 -2
  62. package/dist/server/index.d.ts +1 -0
  63. package/dist/server/index.js +1 -0
  64. package/dist/server/self-test-redaction.js +8 -33
  65. package/dist/server/sensitive-values.d.ts +26 -0
  66. package/dist/server/sensitive-values.js +128 -0
  67. package/dist/server/serve-implementation.d.ts +4 -11
  68. package/dist/server/serve-implementation.js +305 -96
  69. package/dist/server/trace-output.d.ts +2 -1
  70. package/dist/server/trace-output.js +46 -14
  71. package/dist/testing/run.js +10 -7
  72. package/dist/trace-sanitization.d.ts +4 -3
  73. package/dist/trace-sanitization.js +45 -22
  74. package/dist/types.d.ts +4 -107
  75. package/package.json +2 -1
  76. package/src/config/loader.ts +13 -12
  77. package/src/define.ts +4 -3
  78. package/src/dev.ts +3 -1
  79. package/src/engine.ts +8 -8
  80. package/src/error-resolution.ts +21 -4
  81. package/src/errors.ts +13 -0
  82. package/src/handle-meta.ts +58 -0
  83. package/src/handle.ts +529 -0
  84. package/src/i18n/catalog.ts +23 -1
  85. package/src/index.ts +28 -13
  86. package/src/lint.ts +456 -71
  87. package/src/provider.ts +10 -20
  88. package/src/runtime/browser.ts +2 -1
  89. package/src/runtime/cache.ts +3 -2
  90. package/src/runtime/diagnostic-env-values.ts +56 -0
  91. package/src/runtime/diagnostic-env.ts +55 -0
  92. package/src/runtime/diagnostic-redactor.ts +662 -0
  93. package/src/runtime/egress-lease.ts +6 -1
  94. package/src/runtime/env.ts +2 -1
  95. package/src/runtime/{choice-wordlist.ts → handle-wordlist.ts} +130 -9
  96. package/src/runtime/handle.ts +1162 -0
  97. package/src/runtime/instrumentation.ts +3 -0
  98. package/src/runtime/native-network.ts +281 -33
  99. package/src/runtime/native-telemetry.ts +366 -0
  100. package/src/runtime/ocr.ts +2 -1
  101. package/src/runtime/otlp.ts +5 -4
  102. package/src/runtime/proxy-nodemaven.ts +3 -2
  103. package/src/runtime/request-options.ts +76 -28
  104. package/src/runtime/resolver-vendors/browser.ts +0 -1
  105. package/src/runtime/resolver.ts +3 -2
  106. package/src/runtime/state.ts +44 -16
  107. package/src/runtime/stt.ts +3 -2
  108. package/src/runtime/trace.ts +168 -26
  109. package/src/schema.ts +63 -2
  110. package/src/server/index.ts +17 -0
  111. package/src/server/self-test-redaction.ts +10 -40
  112. package/src/server/sensitive-values.ts +197 -0
  113. package/src/server/serve-implementation.ts +388 -128
  114. package/src/server/trace-output.ts +77 -17
  115. package/src/testing/run.ts +11 -7
  116. package/src/trace-sanitization.ts +70 -23
  117. package/src/types.ts +4 -137
  118. package/dist/choice-token.d.ts +0 -24
  119. package/dist/choice-token.js +0 -74
  120. package/dist/runtime/choice-wordlist.d.ts +0 -9
  121. package/dist/runtime/choice.d.ts +0 -27
  122. package/dist/runtime/choice.js +0 -801
  123. package/src/choice-token.ts +0 -165
  124. package/src/runtime/choice.ts +0 -1197
@@ -1,1197 +0,0 @@
1
- import {
2
- createCipheriv,
3
- createDecipheriv,
4
- createHash,
5
- createHmac,
6
- randomBytes,
7
- randomInt,
8
- timingSafeEqual,
9
- } from "node:crypto";
10
- import {
11
- assertFreshProviderChoiceIssuedAt,
12
- ProviderChoiceTokenError,
13
- type ProviderChoiceTokenPayload,
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";
23
- import type {
24
- CredentialContext,
25
- EnvContext,
26
- ProviderChoiceBindingOptions,
27
- ProviderChoiceConsumeMode,
28
- ProviderChoiceConsumeResult,
29
- ProviderChoiceContext,
30
- ProviderChoiceExplicitParseResult,
31
- ProviderChoiceIssueOptions,
32
- ProviderChoiceParseOptions,
33
- ProviderChoiceStorageOptions,
34
- ProviderRequestContext,
35
- ProviderRuntimeState,
36
- ProviderStateDurationString,
37
- StateValue,
38
- } from "../types.js";
39
-
40
- export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV =
41
- "APIFUSE__PROVIDER_RUNTIME__CHOICE_TOKEN_MASTER_SECRET";
42
-
43
- const PRIMARY_CHOICE_TOKEN_KID = "v1";
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.";
48
-
49
- type ManagedChoiceEnvelope = {
50
- readonly v: typeof MANAGED_CHOICE_TOKEN_VERSION;
51
- readonly provider_id: string;
52
- readonly purpose: string;
53
- readonly issued_at_ms: number;
54
- readonly ttl_ms: number;
55
- readonly binding?: {
56
- readonly connection_hash?: string;
57
- readonly credential_hash?: string;
58
- };
59
- readonly payload: ProviderChoiceTokenPayload;
60
- };
61
-
62
- type ServerChoiceHandlePayload = {
63
- readonly storage: "server";
64
- readonly state_id: string;
65
- readonly payload_digest: string;
66
- readonly created_at_ms: number;
67
- };
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
-
95
- export type CreateProviderChoiceContextOptions = {
96
- readonly providerId: string;
97
- readonly env?: EnvContext;
98
- readonly request?: ProviderRequestContext;
99
- readonly credential?: CredentialContext;
100
- readonly state?: ProviderRuntimeState;
101
- readonly masterSecret?: string;
102
- readonly kid?: string;
103
- /** Receives allowlisted metadata only; token and payload values are never included. */
104
- readonly onTelemetry?: (event: ProviderChoiceTelemetryEvent) => void;
105
- };
106
-
107
- export function createProviderChoiceContext(
108
- options: CreateProviderChoiceContextOptions,
109
- ): ProviderChoiceContext {
110
- const kid = options.kid ?? PRIMARY_CHOICE_TOKEN_KID;
111
- const resolveMasterSecret = () => resolveChoiceMasterSecret(options);
112
-
113
- function issue<TPayload extends ProviderChoiceTokenPayload>(
114
- issueOptions: ProviderChoiceIssueOptions<TPayload> & {
115
- readonly storage?: { readonly mode: "inline" };
116
- },
117
- ): string;
118
- function issue<TPayload extends ProviderChoiceTokenPayload>(
119
- issueOptions: ProviderChoiceIssueOptions<TPayload> & {
120
- readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "server" }>;
121
- },
122
- ): Promise<string>;
123
- function issue<TPayload extends ProviderChoiceTokenPayload>(
124
- issueOptions: ProviderChoiceIssueOptions<TPayload> & {
125
- readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "auto" }>;
126
- },
127
- ): string | Promise<string>;
128
- function issue<TPayload extends ProviderChoiceTokenPayload>(
129
- issueOptions: ProviderChoiceIssueOptions<TPayload>,
130
- ): string | Promise<string> {
131
- const issuedAtMs = issueOptions.nowMs ?? Date.now();
132
- const resolvedStorage = resolveIssueStorage(issueOptions.storage, issueOptions.payload);
133
- if (resolvedStorage.mode === "server") {
134
- const keys = hasRequestedChoiceBinding(issueOptions.bind)
135
- ? deriveManagedChoiceKeys({
136
- masterSecret: resolveMasterSecret(),
137
- providerId: options.providerId,
138
- purpose: issueOptions.purpose,
139
- kid,
140
- })
141
- : undefined;
142
- const binding = hasRequestedChoiceBinding(issueOptions.bind)
143
- ? createChoiceBinding({
144
- keys: keys!,
145
- options: issueOptions.bind,
146
- request: options.request,
147
- credential: options.credential,
148
- required: true,
149
- })
150
- : undefined;
151
- const baseEnvelope = {
152
- v: MANAGED_CHOICE_TOKEN_VERSION,
153
- provider_id: options.providerId,
154
- purpose: issueOptions.purpose,
155
- issued_at_ms: issuedAtMs,
156
- ttl_ms: issueOptions.ttlMs,
157
- binding,
158
- } satisfies Omit<ManagedChoiceEnvelope, "payload">;
159
- return issueWordServerStoredChoice({
160
- baseEnvelope,
161
- issueOptions,
162
- storage: resolvedStorage.storage,
163
- contextState: options.state,
164
- });
165
- }
166
- const keys = deriveManagedChoiceKeys({
167
- masterSecret: resolveMasterSecret(),
168
- providerId: options.providerId,
169
- purpose: issueOptions.purpose,
170
- kid,
171
- });
172
- const baseEnvelope: Omit<ManagedChoiceEnvelope, "payload"> = {
173
- v: MANAGED_CHOICE_TOKEN_VERSION,
174
- provider_id: options.providerId,
175
- purpose: issueOptions.purpose,
176
- issued_at_ms: issuedAtMs,
177
- ttl_ms: issueOptions.ttlMs,
178
- binding: createChoiceBinding({
179
- keys,
180
- options: issueOptions.bind,
181
- request: options.request,
182
- credential: options.credential,
183
- required: true,
184
- }),
185
- };
186
- const envelope: ManagedChoiceEnvelope = {
187
- ...baseEnvelope,
188
- payload: issueOptions.payload,
189
- };
190
- return encryptManagedChoiceToken({
191
- prefix: issueOptions.prefix,
192
- kid,
193
- envelope,
194
- keys,
195
- });
196
- }
197
-
198
- function parse(
199
- parseOptions: ProviderChoiceParseOptions & { readonly consume: "explicit" },
200
- ): Promise<ProviderChoiceExplicitParseResult>;
201
- function parse(
202
- parseOptions: ProviderChoiceParseOptions & {
203
- readonly storage?: { readonly mode: "inline" };
204
- },
205
- ): ProviderChoiceTokenPayload;
206
- function parse(
207
- parseOptions: ProviderChoiceParseOptions & {
208
- readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "server" }>;
209
- },
210
- ): Promise<ProviderChoiceTokenPayload>;
211
- function parse(
212
- parseOptions: ProviderChoiceParseOptions & {
213
- readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "auto" }>;
214
- },
215
- ): ProviderChoiceTokenPayload | Promise<ProviderChoiceTokenPayload>;
216
- function parse(
217
- parseOptions: ProviderChoiceParseOptions,
218
- ):
219
- | ProviderChoiceTokenPayload
220
- | ProviderChoiceExplicitParseResult
221
- | Promise<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult> {
222
- const consumeMode = parseOptions.consume ?? "never";
223
- const wordStateKey = parseWordChoiceStateKey({
224
- token: parseOptions.token,
225
- prefix: parseOptions.prefix,
226
- });
227
- if (wordStateKey) {
228
- const parsed = parseWordServerStoredChoice({
229
- stateKey: wordStateKey,
230
- parseOptions,
231
- contextState: options.state,
232
- providerId: options.providerId,
233
- request: options.request,
234
- credential: options.credential,
235
- resolveBindingKeys: () =>
236
- deriveManagedChoiceKeys({
237
- masterSecret: resolveMasterSecret(),
238
- providerId: options.providerId,
239
- purpose: parseOptions.purpose,
240
- kid,
241
- }),
242
- onConsume: (result) =>
243
- emitChoiceTelemetry(options.onTelemetry, {
244
- providerId: options.providerId,
245
- purpose: parseOptions.purpose,
246
- operation: "consume",
247
- format: "word",
248
- outcome: "success",
249
- consumeMode,
250
- consumed: result.status === "consumed",
251
- replay: result.status === "already-consumed",
252
- }),
253
- });
254
- return observeChoiceParse<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult>(
255
- parsed,
256
- {
257
- onTelemetry: options.onTelemetry,
258
- providerId: options.providerId,
259
- purpose: parseOptions.purpose,
260
- format: "word",
261
- consumeMode,
262
- },
263
- );
264
- }
265
-
266
- // Inline choices continue to use the encrypted envelope. A structurally
267
- // valid word token returns above, so lookup, expiry, consumption, and
268
- // binding failures can never enter this branch.
269
- try {
270
- const [actualPrefix, tokenKid, encodedIv, encryptedPayload, authTag, signature] =
271
- parseManagedChoiceTokenParts(parseOptions.token);
272
- if (
273
- actualPrefix !== parseOptions.prefix ||
274
- tokenKid !== kid ||
275
- !encodedIv ||
276
- !encryptedPayload ||
277
- !authTag ||
278
- !signature
279
- ) {
280
- throw new ProviderChoiceTokenError(
281
- "invalid_shape",
282
- "Provider choice token shape is invalid.",
283
- );
284
- }
285
-
286
- const keys = deriveManagedChoiceKeys({
287
- masterSecret: resolveMasterSecret(),
288
- providerId: options.providerId,
289
- purpose: parseOptions.purpose,
290
- kid: tokenKid,
291
- });
292
- const signedBody = [parseOptions.prefix, tokenKid, encodedIv, encryptedPayload, authTag].join(
293
- ".",
294
- );
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
- }),
322
- });
323
- if (isServerChoiceHandlePayload(envelope.payload)) {
324
- throw wordChoiceNotFoundError();
325
- }
326
- const payload = envelope.payload;
327
- const parsed =
328
- consumeMode === "explicit"
329
- ? Promise.resolve(payload).then((resolvedPayload) =>
330
- createInlineExplicitParseResult({
331
- payload: resolvedPayload,
332
- replayKey: digestChoiceReplayKey(parseOptions.token),
333
- onConsume: () =>
334
- emitChoiceTelemetry(options.onTelemetry, {
335
- providerId: options.providerId,
336
- purpose: parseOptions.purpose,
337
- operation: "consume",
338
- format: "legacy",
339
- outcome: "unsupported",
340
- consumeMode,
341
- consumed: false,
342
- replay: false,
343
- }),
344
- }),
345
- )
346
- : payload;
347
- return observeChoiceParse<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult>(
348
- parsed,
349
- {
350
- onTelemetry: options.onTelemetry,
351
- providerId: options.providerId,
352
- purpose: parseOptions.purpose,
353
- format: "legacy",
354
- consumeMode,
355
- },
356
- );
357
- } catch (error) {
358
- emitChoiceParseFailure(options.onTelemetry, error, {
359
- providerId: options.providerId,
360
- purpose: parseOptions.purpose,
361
- format: "legacy",
362
- consumeMode,
363
- });
364
- throw error;
365
- }
366
- }
367
-
368
- return { issue, parse };
369
- }
370
-
371
- export function createTestProviderChoiceContext(
372
- options: Omit<CreateProviderChoiceContextOptions, "masterSecret"> & {
373
- readonly masterSecret?: string;
374
- },
375
- ): ProviderChoiceContext {
376
- return createProviderChoiceContext({
377
- ...options,
378
- masterSecret:
379
- options.masterSecret ?? "apifuse-test-provider-runtime-choice-token-master-secret",
380
- });
381
- }
382
-
383
- type ChoiceParseTelemetryBase = {
384
- readonly onTelemetry?: (event: ProviderChoiceTelemetryEvent) => void;
385
- readonly providerId: string;
386
- readonly purpose: string;
387
- readonly format: "word" | "legacy";
388
- readonly consumeMode: ProviderChoiceConsumeMode;
389
- };
390
-
391
- function observeChoiceParse<T>(
392
- result: T | Promise<T>,
393
- base: ChoiceParseTelemetryBase,
394
- ): T | Promise<T> {
395
- if (result instanceof Promise) {
396
- return result.then(
397
- (value) => {
398
- emitChoiceParseSuccess(base, value);
399
- return value;
400
- },
401
- (error: unknown) => {
402
- emitChoiceParseFailure(base.onTelemetry, error, base);
403
- throw error;
404
- },
405
- );
406
- }
407
- emitChoiceParseSuccess(base, result);
408
- return result;
409
- }
410
-
411
- function emitChoiceParseSuccess(base: ChoiceParseTelemetryBase, result: unknown): void {
412
- const replay = isConsumedChoiceReplay(result);
413
- emitChoiceTelemetry(base.onTelemetry, {
414
- providerId: base.providerId,
415
- purpose: base.purpose,
416
- operation: "parse",
417
- format: base.format,
418
- outcome: "success",
419
- consumeMode: base.consumeMode,
420
- consumed: replay || (base.format === "word" && base.consumeMode === "on-parse"),
421
- replay,
422
- });
423
- }
424
-
425
- function isConsumedChoiceReplay(value: unknown): boolean {
426
- return (
427
- value !== null &&
428
- typeof value === "object" &&
429
- "status" in value &&
430
- value.status === "consumed" &&
431
- "replayKey" in value &&
432
- typeof value.replayKey === "string"
433
- );
434
- }
435
-
436
- function emitChoiceParseFailure(
437
- onTelemetry: CreateProviderChoiceContextOptions["onTelemetry"],
438
- error: unknown,
439
- base: Omit<ChoiceParseTelemetryBase, "onTelemetry">,
440
- ): void {
441
- const outcome =
442
- error instanceof ProviderChoiceTokenError
443
- ? base.format === "word" && error.message === WORD_CHOICE_NOT_FOUND_MESSAGE
444
- ? "not-found"
445
- : "invalid"
446
- : "error";
447
- emitChoiceTelemetry(onTelemetry, {
448
- providerId: base.providerId,
449
- purpose: base.purpose,
450
- operation: "parse",
451
- format: base.format,
452
- outcome,
453
- consumeMode: base.consumeMode,
454
- consumed: false,
455
- replay: false,
456
- });
457
- }
458
-
459
- function emitChoiceTelemetry(
460
- onTelemetry: CreateProviderChoiceContextOptions["onTelemetry"],
461
- event: ProviderChoiceTelemetryEvent,
462
- ): void {
463
- try {
464
- onTelemetry?.(event);
465
- } catch {
466
- // Observability must never change provider token semantics.
467
- }
468
- }
469
-
470
- function createInlineExplicitParseResult(options: {
471
- readonly payload: ProviderChoiceTokenPayload;
472
- readonly replayKey: string;
473
- readonly onConsume: () => void;
474
- }): ProviderChoiceExplicitParseResult {
475
- return {
476
- status: "active",
477
- payload: options.payload,
478
- replayKey: options.replayKey,
479
- consume: async () => {
480
- options.onConsume();
481
- return { status: "unsupported" };
482
- },
483
- };
484
- }
485
-
486
- function resolveChoiceMasterSecret(options: CreateProviderChoiceContextOptions): string {
487
- const configured =
488
- options.masterSecret ?? options.env?.get(PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV);
489
- const trimmed = configured?.trim();
490
- if (trimmed) return trimmed;
491
- throw new ProviderError("Provider runtime choice-token master secret is not configured.", {
492
- code: "CHOICE_TOKEN_MASTER_SECRET_NOT_CONFIGURED",
493
- category: "internal_error",
494
- retryable: false,
495
- details: {
496
- secret: PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV,
497
- },
498
- });
499
- }
500
-
501
- type ManagedChoiceKeyInput = {
502
- readonly masterSecret: string;
503
- readonly providerId: string;
504
- readonly purpose: string;
505
- readonly kid: string;
506
- };
507
-
508
- type ManagedChoiceKeys = {
509
- readonly encryption: Buffer;
510
- readonly signing: Buffer;
511
- readonly binding: Buffer;
512
- };
513
-
514
- function deriveManagedChoiceKeys(input: ManagedChoiceKeyInput): ManagedChoiceKeys {
515
- return {
516
- encryption: deriveManagedChoiceKey(input, "encryption"),
517
- signing: deriveManagedChoiceKey(input, "signing"),
518
- binding: deriveManagedChoiceKey(input, "binding"),
519
- };
520
- }
521
-
522
- function deriveManagedChoiceKey(
523
- input: ManagedChoiceKeyInput,
524
- usage: "encryption" | "signing" | "binding",
525
- ): Buffer {
526
- return createHmac("sha256", input.masterSecret)
527
- .update("apifuse-provider-choice-token")
528
- .update("\0")
529
- .update(input.providerId)
530
- .update("\0")
531
- .update(input.purpose)
532
- .update("\0")
533
- .update(input.kid)
534
- .update("\0")
535
- .update(usage)
536
- .digest();
537
- }
538
-
539
- function encryptManagedChoiceToken(options: {
540
- readonly prefix: string;
541
- readonly kid: string;
542
- readonly envelope: ManagedChoiceEnvelope;
543
- readonly keys: ManagedChoiceKeys;
544
- }): string {
545
- const iv = randomBytes(12);
546
- const cipher = createCipheriv("aes-256-gcm", options.keys.encryption, iv);
547
- const encryptedPayload = Buffer.concat([
548
- cipher.update(JSON.stringify(options.envelope), "utf8"),
549
- cipher.final(),
550
- ]).toString("base64url");
551
- const authTag = cipher.getAuthTag().toString("base64url");
552
- const encodedIv = iv.toString("base64url");
553
- const signedBody = [options.prefix, options.kid, encodedIv, encryptedPayload, authTag].join(".");
554
- const signature = createHmac("sha256", options.keys.signing)
555
- .update(signedBody)
556
- .digest("base64url");
557
- return `${signedBody}.${signature}`;
558
- }
559
-
560
- async function issueWordServerStoredChoice<TPayload extends ProviderChoiceTokenPayload>(options: {
561
- readonly baseEnvelope: Omit<ManagedChoiceEnvelope, "payload">;
562
- readonly issueOptions: ProviderChoiceIssueOptions<TPayload>;
563
- readonly storage: ServerProviderChoiceStorageOptions;
564
- readonly contextState?: ProviderRuntimeState;
565
- }): Promise<string> {
566
- const serializedPayload = serializeChoicePayload(options.issueOptions.payload);
567
- const payloadDigest = digestChoicePayload(serializedPayload);
568
- const namespace = resolveChoiceStateNamespace({
569
- storage: options.storage,
570
- contextState: options.contextState,
571
- ttlMs: options.issueOptions.ttlMs,
572
- });
573
- const wordCount =
574
- options.issueOptions.strength === "high" ? HIGH_CHOICE_WORD_COUNT : STANDARD_CHOICE_WORD_COUNT;
575
- for (let attempt = 0; attempt < SERVER_STORED_CHOICE_ISSUE_ATTEMPTS; attempt += 1) {
576
- const stateKey = generateChoiceWordSequence(wordCount);
577
- const token = `${options.issueOptions.prefix}${stateKey}`;
578
- const record: ServerStoredChoiceRecord = {
579
- v: SERVER_STORED_CHOICE_RECORD_VERSION,
580
- storage: "server",
581
- status: "active",
582
- provider_id: options.baseEnvelope.provider_id,
583
- purpose: options.baseEnvelope.purpose,
584
- issued_at_ms: options.baseEnvelope.issued_at_ms,
585
- ttl_ms: options.baseEnvelope.ttl_ms,
586
- binding: options.baseEnvelope.binding,
587
- prefix: options.issueOptions.prefix,
588
- payload: options.issueOptions.payload,
589
- payload_digest: payloadDigest,
590
- replay_key: digestChoiceReplayKey(token),
591
- };
592
- const valueBytes = Buffer.byteLength(JSON.stringify(record), "utf8");
593
- if (valueBytes > options.storage.maxValueBytes) {
594
- throw new ProviderError("Provider choice payload exceeds state storage policy.", {
595
- code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
596
- category: "input_validation",
597
- retryable: false,
598
- details: {
599
- maxValueBytes: options.storage.maxValueBytes,
600
- valueBytes,
601
- },
602
- });
603
- }
604
- const result = await namespace.compareAndSet(optionsStateKey(stateKey), 0, record, {
605
- ttl: stateTtl(options.storage, options.issueOptions.ttlMs),
606
- });
607
- if (result.ok) return token;
608
- }
609
- throw new ProviderError("Provider choice state storage is not available.", {
610
- code: "CHOICE_STATE_UNAVAILABLE",
611
- category: "internal_error",
612
- retryable: false,
613
- });
614
- }
615
-
616
- async function parseWordServerStoredChoice(options: {
617
- readonly stateKey: string;
618
- readonly parseOptions: ProviderChoiceParseOptions;
619
- readonly contextState?: ProviderRuntimeState;
620
- readonly providerId: string;
621
- readonly request?: ProviderRequestContext;
622
- readonly credential?: CredentialContext;
623
- readonly resolveBindingKeys: () => ManagedChoiceKeys;
624
- readonly onConsume: (result: ProviderChoiceConsumeResult) => void;
625
- }): Promise<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult> {
626
- const storage = resolveParseStorage(options.parseOptions.storage);
627
- const namespace = resolveChoiceStateNamespace({
628
- storage,
629
- contextState: options.contextState,
630
- ttlMs: options.parseOptions.ttlMs,
631
- });
632
- let stored: StateValue<ServerStoredChoiceRecord> | null;
633
- try {
634
- stored = await namespace.get<ServerStoredChoiceRecord>(optionsStateKey(options.stateKey));
635
- } catch (error) {
636
- if (isProviderError(error)) throw error;
637
- throw wordChoiceNotFoundError();
638
- }
639
- if (!stored || !isServerStoredChoiceRecord(stored.value)) {
640
- throw wordChoiceNotFoundError();
641
- }
642
-
643
- const record = stored.value;
644
- const expectedReplayKey = digestChoiceReplayKey(
645
- `${options.parseOptions.prefix}${options.stateKey}`,
646
- );
647
- try {
648
- if (
649
- record.provider_id !== options.providerId ||
650
- record.purpose !== options.parseOptions.purpose ||
651
- record.prefix !== options.parseOptions.prefix
652
- ) {
653
- throw wordChoiceNotFoundError();
654
- }
655
- assertPayloadDigestMatches({
656
- actual: digestChoicePayload(serializeChoicePayload(record.payload)),
657
- expected: record.payload_digest,
658
- });
659
- assertPayloadDigestMatches({ actual: expectedReplayKey, expected: record.replay_key });
660
- assertWordChoiceBindingMatches({
661
- actual: record.binding,
662
- requested: options.parseOptions.bind,
663
- request: options.request,
664
- credential: options.credential,
665
- resolveKeys: options.resolveBindingKeys,
666
- });
667
- } catch (error) {
668
- if (
669
- error instanceof ProviderChoiceTokenError ||
670
- (isProviderError(error) && error.code === "CHOICE_CONTEXT_REQUIRED")
671
- ) {
672
- throw wordChoiceNotFoundError();
673
- }
674
- throw error;
675
- }
676
- // Freshness is classified last, reachable only after every identity,
677
- // integrity, and binding check above has passed (ADR 0006, amended
678
- // 2026-08-20): a caller that proved the record's binding may observe the
679
- // canonical stale error, while an unbound record keeps the collapsed
680
- // not-found error so expiry never becomes an existence signal for
681
- // guessable tokens.
682
- try {
683
- assertFreshProviderChoiceIssuedAt(record.issued_at_ms, {
684
- ttlMs:
685
- options.parseOptions.ttlMs != null
686
- ? Math.min(options.parseOptions.ttlMs, record.ttl_ms)
687
- : record.ttl_ms,
688
- nowMs: options.parseOptions.nowMs,
689
- futureToleranceMs: options.parseOptions.futureToleranceMs,
690
- });
691
- } catch (error) {
692
- const recordIsBound = Boolean(
693
- record.binding?.connection_hash || record.binding?.credential_hash,
694
- );
695
- if (recordIsBound && error instanceof ProviderChoiceTokenError && error.reason === "stale") {
696
- throw error;
697
- }
698
- if (error instanceof ProviderChoiceTokenError) throw wordChoiceNotFoundError();
699
- throw error;
700
- }
701
-
702
- const consumeMode = options.parseOptions.consume ?? "never";
703
- if (record.status === "consumed") {
704
- if (consumeMode === "explicit") {
705
- return { status: "consumed", replayKey: record.replay_key };
706
- }
707
- throw wordChoiceNotFoundError();
708
- }
709
- if (consumeMode === "never") return record.payload;
710
- if (consumeMode === "explicit") {
711
- return {
712
- status: "active",
713
- payload: record.payload,
714
- replayKey: record.replay_key,
715
- consume: async () => {
716
- const result = await consumeWordServerStoredChoice({
717
- stateKey: options.stateKey,
718
- stored,
719
- record,
720
- storage,
721
- contextState: options.contextState,
722
- });
723
- options.onConsume(result);
724
- return result;
725
- },
726
- };
727
- }
728
- const consumed = await consumeWordServerStoredChoice({
729
- stateKey: options.stateKey,
730
- stored,
731
- record,
732
- storage,
733
- contextState: options.contextState,
734
- });
735
- if (consumed.status !== "consumed") throw wordChoiceNotFoundError();
736
- return record.payload;
737
- }
738
-
739
- async function consumeWordServerStoredChoice(options: {
740
- readonly stateKey: string;
741
- readonly stored: StateValue<ServerStoredChoiceRecord>;
742
- readonly record: ServerStoredChoiceRecord;
743
- readonly storage: ServerProviderChoiceStorageOptions;
744
- readonly contextState?: ProviderRuntimeState;
745
- }): Promise<ProviderChoiceConsumeResult> {
746
- const namespace = resolveChoiceStateNamespace({
747
- storage: options.storage,
748
- contextState: options.contextState,
749
- ttlMs: options.record.ttl_ms,
750
- });
751
- try {
752
- const consumed = await namespace.compareAndSet(
753
- optionsStateKey(options.stateKey),
754
- options.stored.version,
755
- { ...options.record, status: "consumed" } satisfies ServerStoredChoiceRecord,
756
- { ttl: remainingStateTtl(options.stored.expiresAt) },
757
- );
758
- if (consumed.ok) return { status: "consumed" };
759
- if (
760
- consumed.current &&
761
- isServerStoredChoiceRecord(consumed.current.value) &&
762
- consumed.current.value.status === "consumed" &&
763
- consumed.current.value.replay_key === options.record.replay_key
764
- ) {
765
- return { status: "already-consumed" };
766
- }
767
- throw wordChoiceNotFoundError();
768
- } catch (error) {
769
- if (isProviderError(error)) throw error;
770
- if (error instanceof ProviderChoiceTokenError) throw error;
771
- throw wordChoiceNotFoundError();
772
- }
773
- }
774
-
775
- function generateChoiceWordSequence(wordCount: number): string {
776
- return Array.from({ length: wordCount }, () =>
777
- choiceWordAt(randomInt(CHOICE_WORDLIST_SIZE)),
778
- ).join("-");
779
- }
780
-
781
- function parseWordChoiceStateKey(options: {
782
- readonly token: string;
783
- readonly prefix: string;
784
- }): string | null {
785
- if (!options.token.startsWith(options.prefix)) return null;
786
- const body = options.token.slice(options.prefix.length);
787
- // The official list contains one hyphenated entry (`yo-yo`), so structural
788
- // recognition uses dictionary-aware segmentation instead of assuming every
789
- // hyphen is a word boundary.
790
- if (!/^[a-z]+(?:-[a-z]+){3,9}$/.test(body)) return null;
791
- const segments = body.split("-");
792
- if (
793
- !canSegmentChoiceWords(segments, 0, STANDARD_CHOICE_WORD_COUNT) &&
794
- !canSegmentChoiceWords(segments, 0, HIGH_CHOICE_WORD_COUNT)
795
- ) {
796
- return null;
797
- }
798
- return body;
799
- }
800
-
801
- function canSegmentChoiceWords(
802
- segments: readonly string[],
803
- segmentIndex: number,
804
- wordsRemaining: number,
805
- ): boolean {
806
- if (wordsRemaining === 0) return segmentIndex === segments.length;
807
- const segmentsRemaining = segments.length - segmentIndex;
808
- if (segmentsRemaining < wordsRemaining) return false;
809
- for (let end = segmentIndex + 1; end <= segments.length - (wordsRemaining - 1); end += 1) {
810
- const candidate = segments.slice(segmentIndex, end).join("-");
811
- if (candidate.length > 10) break;
812
- if (isChoiceWord(candidate) && canSegmentChoiceWords(segments, end, wordsRemaining - 1)) {
813
- return true;
814
- }
815
- }
816
- return false;
817
- }
818
-
819
- function isServerStoredChoiceRecord(value: unknown): value is ServerStoredChoiceRecord {
820
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
821
- return (
822
- "v" in value &&
823
- value.v === SERVER_STORED_CHOICE_RECORD_VERSION &&
824
- "storage" in value &&
825
- value.storage === "server" &&
826
- "status" in value &&
827
- (value.status === "active" || value.status === "consumed") &&
828
- "provider_id" in value &&
829
- typeof value.provider_id === "string" &&
830
- "purpose" in value &&
831
- typeof value.purpose === "string" &&
832
- "issued_at_ms" in value &&
833
- typeof value.issued_at_ms === "number" &&
834
- "ttl_ms" in value &&
835
- typeof value.ttl_ms === "number" &&
836
- (!("binding" in value) || value.binding === undefined || isChoiceBinding(value.binding)) &&
837
- "prefix" in value &&
838
- typeof value.prefix === "string" &&
839
- "payload" in value &&
840
- isChoicePayload(value.payload) &&
841
- "payload_digest" in value &&
842
- typeof value.payload_digest === "string" &&
843
- "replay_key" in value &&
844
- typeof value.replay_key === "string"
845
- );
846
- }
847
-
848
- function wordChoiceNotFoundError(): ProviderChoiceTokenError {
849
- return new ProviderChoiceTokenError("invalid_payload", WORD_CHOICE_NOT_FOUND_MESSAGE);
850
- }
851
-
852
- type ServerProviderChoiceStorageOptions = Extract<
853
- ProviderChoiceStorageOptions,
854
- { readonly mode: "server" | "auto" }
855
- >;
856
-
857
- function resolveIssueStorage<TPayload extends ProviderChoiceTokenPayload>(
858
- storage: ProviderChoiceStorageOptions | undefined,
859
- payload: TPayload,
860
- ):
861
- | { readonly mode: "inline" }
862
- | {
863
- readonly mode: "server";
864
- readonly storage: ServerProviderChoiceStorageOptions;
865
- } {
866
- if (!storage || storage.mode === "inline") return { mode: "inline" };
867
- if (storage.mode === "server") return { mode: "server", storage };
868
- const payloadBytes = Buffer.byteLength(serializeChoicePayload(payload), "utf8");
869
- if (payloadBytes <= storage.maxInlineBytes) return { mode: "inline" };
870
- return { mode: "server", storage };
871
- }
872
-
873
- function resolveParseStorage(
874
- storage: ProviderChoiceStorageOptions | undefined,
875
- ): ServerProviderChoiceStorageOptions {
876
- if (!storage || storage.mode === "inline") {
877
- throw new ProviderChoiceTokenError(
878
- "invalid_payload",
879
- "Provider choice token requires server-side choice storage.",
880
- );
881
- }
882
- return storage;
883
- }
884
-
885
- function resolveChoiceStateNamespace(options: {
886
- readonly storage: ServerProviderChoiceStorageOptions;
887
- readonly contextState?: ProviderRuntimeState;
888
- readonly ttlMs?: number;
889
- }) {
890
- const state = options.storage.state ?? options.contextState;
891
- if (!state) {
892
- throw new ProviderError("Provider choice state storage is not available.", {
893
- code: "CHOICE_STATE_UNAVAILABLE",
894
- category: "internal_error",
895
- retryable: false,
896
- });
897
- }
898
- return state.namespace(options.storage.namespace, {
899
- defaultTtl: stateTtl(options.storage, options.ttlMs),
900
- maxTtl: stateTtl(options.storage, options.ttlMs),
901
- maxEntries: options.storage.maxEntries,
902
- maxValueBytes: options.storage.maxValueBytes,
903
- });
904
- }
905
-
906
- function stateTtl(
907
- storage: ServerProviderChoiceStorageOptions,
908
- ttlMs?: number,
909
- ): ProviderStateDurationString {
910
- return storage.ttl ?? `${ttlMs ?? 1}ms`;
911
- }
912
-
913
- function remainingStateTtl(expiresAt: string): ProviderStateDurationString {
914
- const remainingMs = Date.parse(expiresAt) - Date.now();
915
- return `${Number.isFinite(remainingMs) ? Math.max(1, Math.floor(remainingMs)) : 1}ms`;
916
- }
917
-
918
- function optionsStateKey(stateId: string): string {
919
- return stateId;
920
- }
921
-
922
- function serializeChoicePayload(payload: ProviderChoiceTokenPayload): string {
923
- return JSON.stringify(payload);
924
- }
925
-
926
- function digestChoicePayload(serializedPayload: string): string {
927
- return createHash("sha256").update(serializedPayload).digest("base64url");
928
- }
929
-
930
- function digestChoiceReplayKey(token: string): string {
931
- return createHash("sha256").update(token).digest("hex");
932
- }
933
-
934
- function isServerChoiceHandlePayload(
935
- value: ProviderChoiceTokenPayload,
936
- ): value is ServerChoiceHandlePayload {
937
- return (
938
- value.storage === "server" &&
939
- typeof value.state_id === "string" &&
940
- typeof value.payload_digest === "string" &&
941
- typeof value.created_at_ms === "number"
942
- );
943
- }
944
-
945
- function assertPayloadDigestMatches(options: {
946
- readonly actual: string;
947
- readonly expected: string;
948
- }): void {
949
- const actual = Buffer.from(options.actual);
950
- const expected = Buffer.from(options.expected);
951
- if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
952
- throw new ProviderChoiceTokenError(
953
- "invalid_payload",
954
- "Provider choice token state payload digest is invalid.",
955
- );
956
- }
957
- }
958
-
959
- function parseManagedChoiceTokenParts(
960
- token: string,
961
- ): readonly [
962
- string | undefined,
963
- string | undefined,
964
- string | undefined,
965
- string | undefined,
966
- string | undefined,
967
- string | undefined,
968
- ] {
969
- const parts = token.split(".");
970
- if (parts.length !== 6) {
971
- throw new ProviderChoiceTokenError("invalid_shape", "Provider choice token shape is invalid.");
972
- }
973
- return [parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]];
974
- }
975
-
976
- function assertManagedChoiceSignature(options: {
977
- readonly signedBody: string;
978
- readonly signature: string;
979
- readonly signingKey: Buffer;
980
- }): void {
981
- const expected = createHmac("sha256", options.signingKey)
982
- .update(options.signedBody)
983
- .digest("base64url");
984
- const actualBuffer = Buffer.from(options.signature);
985
- const expectedBuffer = Buffer.from(expected);
986
- if (
987
- actualBuffer.length !== expectedBuffer.length ||
988
- !timingSafeEqual(actualBuffer, expectedBuffer)
989
- ) {
990
- throw new ProviderChoiceTokenError(
991
- "invalid_signature",
992
- "Provider choice token signature is invalid.",
993
- );
994
- }
995
- }
996
-
997
- function decryptManagedChoiceToken(options: {
998
- readonly encodedIv: string;
999
- readonly encryptedPayload: string;
1000
- readonly authTag: string;
1001
- readonly encryptionKey: Buffer;
1002
- }): ManagedChoiceEnvelope {
1003
- try {
1004
- const decipher = createDecipheriv(
1005
- "aes-256-gcm",
1006
- options.encryptionKey,
1007
- Buffer.from(options.encodedIv, "base64url"),
1008
- );
1009
- decipher.setAuthTag(Buffer.from(options.authTag, "base64url"));
1010
- const decrypted = Buffer.concat([
1011
- decipher.update(Buffer.from(options.encryptedPayload, "base64url")),
1012
- decipher.final(),
1013
- ]).toString("utf8");
1014
- const parsed: unknown = JSON.parse(decrypted);
1015
- if (!isManagedChoiceEnvelope(parsed)) {
1016
- throw new ProviderChoiceTokenError(
1017
- "invalid_payload",
1018
- "Provider choice token payload is invalid.",
1019
- );
1020
- }
1021
- return parsed;
1022
- } catch (error) {
1023
- if (error instanceof ProviderChoiceTokenError) {
1024
- throw error;
1025
- }
1026
- throw new ProviderChoiceTokenError(
1027
- "invalid_payload",
1028
- "Provider choice token payload is invalid.",
1029
- );
1030
- }
1031
- }
1032
-
1033
- function isManagedChoiceEnvelope(value: unknown): value is ManagedChoiceEnvelope {
1034
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1035
- if (!("payload" in value) || !isChoicePayload(value.payload)) return false;
1036
- return (
1037
- "v" in value &&
1038
- value.v === MANAGED_CHOICE_TOKEN_VERSION &&
1039
- "provider_id" in value &&
1040
- typeof value.provider_id === "string" &&
1041
- "purpose" in value &&
1042
- typeof value.purpose === "string" &&
1043
- "issued_at_ms" in value &&
1044
- typeof value.issued_at_ms === "number" &&
1045
- "ttl_ms" in value &&
1046
- typeof value.ttl_ms === "number" &&
1047
- (!("binding" in value) || value.binding === undefined || isChoiceBinding(value.binding))
1048
- );
1049
- }
1050
-
1051
- function isChoicePayload(value: unknown): value is ProviderChoiceTokenPayload {
1052
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
1053
- }
1054
-
1055
- function isChoiceBinding(value: unknown): value is ManagedChoiceEnvelope["binding"] {
1056
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1057
- return (
1058
- (!("connection_hash" in value) || typeof value.connection_hash === "string") &&
1059
- (!("credential_hash" in value) || typeof value.credential_hash === "string")
1060
- );
1061
- }
1062
-
1063
- function assertManagedChoiceEnvelope(
1064
- envelope: ManagedChoiceEnvelope,
1065
- options: {
1066
- readonly providerId: string;
1067
- readonly purpose: string;
1068
- readonly ttlMs?: number;
1069
- readonly nowMs?: number;
1070
- readonly futureToleranceMs?: number;
1071
- },
1072
- ): void {
1073
- if (envelope.provider_id !== options.providerId || envelope.purpose !== options.purpose) {
1074
- throw new ProviderChoiceTokenError(
1075
- "invalid_payload",
1076
- "Provider choice token payload is invalid.",
1077
- );
1078
- }
1079
- assertFreshProviderChoiceIssuedAt(envelope.issued_at_ms, {
1080
- // Clamp to the issuer's embedded TTL so a caller-supplied value cannot
1081
- // silently extend token validity past the deadline the issuer intended.
1082
- ttlMs: options.ttlMs != null ? Math.min(options.ttlMs, envelope.ttl_ms) : envelope.ttl_ms,
1083
- nowMs: options.nowMs,
1084
- futureToleranceMs: options.futureToleranceMs,
1085
- });
1086
- }
1087
-
1088
- function createChoiceBinding(options: {
1089
- readonly keys: ManagedChoiceKeys;
1090
- readonly options?: ProviderChoiceBindingOptions;
1091
- readonly request?: ProviderRequestContext;
1092
- readonly credential?: CredentialContext;
1093
- readonly required: boolean;
1094
- }): ManagedChoiceEnvelope["binding"] {
1095
- const connectionHash = options.options?.connection ? hashRequiredConnection(options) : undefined;
1096
- const credentialHash = options.options?.credentialKeys?.length
1097
- ? hashCredentialKeys(options)
1098
- : undefined;
1099
- if (!connectionHash && !credentialHash) return undefined;
1100
- return {
1101
- ...(connectionHash ? { connection_hash: connectionHash } : {}),
1102
- ...(credentialHash ? { credential_hash: credentialHash } : {}),
1103
- };
1104
- }
1105
-
1106
- function hasRequestedChoiceBinding(options?: ProviderChoiceBindingOptions): boolean {
1107
- return options?.connection === true || Boolean(options?.credentialKeys?.length);
1108
- }
1109
-
1110
- function assertWordChoiceBindingMatches(options: {
1111
- readonly actual: ManagedChoiceEnvelope["binding"];
1112
- readonly requested?: ProviderChoiceBindingOptions;
1113
- readonly request?: ProviderRequestContext;
1114
- readonly credential?: CredentialContext;
1115
- readonly resolveKeys: () => ManagedChoiceKeys;
1116
- }): void {
1117
- const hasStoredBinding = Boolean(
1118
- options.actual?.connection_hash || options.actual?.credential_hash,
1119
- );
1120
- const hasRequestedBinding = hasRequestedChoiceBinding(options.requested);
1121
- if (!hasStoredBinding && !hasRequestedBinding) return;
1122
- if (hasStoredBinding !== hasRequestedBinding) throw wordChoiceNotFoundError();
1123
- assertChoiceBindingMatches({
1124
- actual: options.actual,
1125
- expected: createChoiceBinding({
1126
- keys: options.resolveKeys(),
1127
- options: options.requested,
1128
- request: options.request,
1129
- credential: options.credential,
1130
- required: true,
1131
- }),
1132
- });
1133
- }
1134
-
1135
- function hashRequiredConnection(options: {
1136
- readonly keys: ManagedChoiceKeys;
1137
- readonly request?: ProviderRequestContext;
1138
- readonly required: boolean;
1139
- }): string | undefined {
1140
- const connectionId = options.request?.connectionId;
1141
- if (!connectionId) {
1142
- if (!options.required) return undefined;
1143
- throw new ProviderError("Provider choice tokens require connection context.", {
1144
- code: "CHOICE_CONTEXT_REQUIRED",
1145
- category: "input_validation",
1146
- retryable: false,
1147
- });
1148
- }
1149
- return createHmac("sha256", options.keys.binding)
1150
- .update("connection")
1151
- .update("\0")
1152
- .update(connectionId)
1153
- .digest("base64url");
1154
- }
1155
-
1156
- function hashCredentialKeys(options: {
1157
- readonly keys: ManagedChoiceKeys;
1158
- readonly options?: ProviderChoiceBindingOptions;
1159
- readonly credential?: CredentialContext;
1160
- }): string {
1161
- const credentialKeys = options.options?.credentialKeys ?? [];
1162
- const material = credentialKeys.map((key) => {
1163
- const value = options.credential?.get(key);
1164
- if (typeof value !== "string" || value.length === 0) {
1165
- throw new ProviderError("Provider choice tokens require configured credential binding.", {
1166
- code: "CHOICE_CONTEXT_REQUIRED",
1167
- category: "input_validation",
1168
- retryable: false,
1169
- details: { credentialKey: key },
1170
- });
1171
- }
1172
- return [key, value];
1173
- });
1174
- return createHmac("sha256", options.keys.binding)
1175
- .update("credential")
1176
- .update("\0")
1177
- .update(JSON.stringify(material))
1178
- .digest("base64url");
1179
- }
1180
-
1181
- function assertChoiceBindingMatches(options: {
1182
- readonly actual: ManagedChoiceEnvelope["binding"];
1183
- readonly expected: ManagedChoiceEnvelope["binding"];
1184
- }): void {
1185
- if (options.actual?.connection_hash !== options.expected?.connection_hash) {
1186
- throw new ProviderChoiceTokenError(
1187
- "invalid_binding",
1188
- "Provider choice token connection binding is invalid.",
1189
- );
1190
- }
1191
- if (options.actual?.credential_hash !== options.expected?.credential_hash) {
1192
- throw new ProviderChoiceTokenError(
1193
- "invalid_binding",
1194
- "Provider choice token credential binding is invalid.",
1195
- );
1196
- }
1197
- }