@apifuse/provider-sdk 2.2.0-beta.29 → 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.
@@ -4,6 +4,7 @@ import {
4
4
  createHash,
5
5
  createHmac,
6
6
  randomBytes,
7
+ randomInt,
7
8
  timingSafeEqual,
8
9
  } from "node:crypto";
9
10
  import {
@@ -12,11 +13,21 @@ import {
12
13
  type ProviderChoiceTokenPayload,
13
14
  } from "../choice-token.js";
14
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,
@@ -31,6 +42,9 @@ export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV =
31
42
 
32
43
  const PRIMARY_CHOICE_TOKEN_KID = "v1";
33
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.";
34
48
 
35
49
  type ManagedChoiceEnvelope = {
36
50
  readonly v: typeof MANAGED_CHOICE_TOKEN_VERSION;
@@ -52,6 +66,32 @@ type ServerChoiceHandlePayload = {
52
66
  readonly created_at_ms: number;
53
67
  };
54
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
+
55
95
  export type CreateProviderChoiceContextOptions = {
56
96
  readonly providerId: string;
57
97
  readonly env?: EnvContext;
@@ -60,6 +100,8 @@ export type CreateProviderChoiceContextOptions = {
60
100
  readonly state?: ProviderRuntimeState;
61
101
  readonly masterSecret?: string;
62
102
  readonly kid?: string;
103
+ /** Receives allowlisted metadata only; token and payload values are never included. */
104
+ readonly onTelemetry?: (event: ProviderChoiceTelemetryEvent) => void;
63
105
  };
64
106
 
65
107
  export function createProviderChoiceContext(
@@ -87,6 +129,36 @@ export function createProviderChoiceContext(
87
129
  issueOptions: ProviderChoiceIssueOptions<TPayload>,
88
130
  ): string | Promise<string> {
89
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
+ }
90
162
  const keys = deriveManagedChoiceKeys({
91
163
  masterSecret: resolveMasterSecret(),
92
164
  providerId: options.providerId,
@@ -107,18 +179,6 @@ export function createProviderChoiceContext(
107
179
  required: true,
108
180
  }),
109
181
  };
110
- const resolvedStorage = resolveIssueStorage(issueOptions.storage, issueOptions.payload);
111
- if (resolvedStorage.mode === "server") {
112
- return issueServerStoredChoice({
113
- baseEnvelope,
114
- issueOptions,
115
- storage: resolvedStorage.storage,
116
- contextState: options.state,
117
- kid,
118
- keys,
119
- issuedAtMs,
120
- });
121
- }
122
182
  const envelope: ManagedChoiceEnvelope = {
123
183
  ...baseEnvelope,
124
184
  payload: issueOptions.payload,
@@ -131,6 +191,9 @@ export function createProviderChoiceContext(
131
191
  });
132
192
  }
133
193
 
194
+ function parse(
195
+ parseOptions: ProviderChoiceParseOptions & { readonly consume: "explicit" },
196
+ ): Promise<ProviderChoiceExplicitParseResult>;
134
197
  function parse(
135
198
  parseOptions: ProviderChoiceParseOptions & {
136
199
  readonly storage?: { readonly mode: "inline" };
@@ -148,68 +211,160 @@ export function createProviderChoiceContext(
148
211
  ): ProviderChoiceTokenPayload | Promise<ProviderChoiceTokenPayload>;
149
212
  function parse(
150
213
  parseOptions: ProviderChoiceParseOptions,
151
- ): ProviderChoiceTokenPayload | Promise<ProviderChoiceTokenPayload> {
152
- const [actualPrefix, tokenKid, encodedIv, encryptedPayload, authTag, signature] =
153
- parseManagedChoiceTokenParts(parseOptions.token);
154
- if (
155
- actualPrefix !== parseOptions.prefix ||
156
- tokenKid !== kid ||
157
- !encodedIv ||
158
- !encryptedPayload ||
159
- !authTag ||
160
- !signature
161
- ) {
162
- throw new ProviderChoiceTokenError(
163
- "invalid_shape",
164
- "Provider choice token shape is invalid.",
165
- );
166
- }
167
-
168
- const keys = deriveManagedChoiceKeys({
169
- masterSecret: resolveMasterSecret(),
170
- providerId: options.providerId,
171
- purpose: parseOptions.purpose,
172
- kid: tokenKid,
173
- });
174
- const signedBody = [parseOptions.prefix, tokenKid, encodedIv, encryptedPayload, authTag].join(
175
- ".",
176
- );
177
- assertManagedChoiceSignature({
178
- signedBody,
179
- signature,
180
- signingKey: keys.signing,
181
- });
182
- const envelope = decryptManagedChoiceToken({
183
- encodedIv,
184
- encryptedPayload,
185
- authTag,
186
- encryptionKey: keys.encryption,
187
- });
188
- assertManagedChoiceEnvelope(envelope, {
189
- providerId: options.providerId,
190
- purpose: parseOptions.purpose,
191
- ttlMs: parseOptions.ttlMs,
192
- nowMs: parseOptions.nowMs,
193
- 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,
194
222
  });
195
- assertChoiceBindingMatches({
196
- actual: envelope.binding,
197
- expected: createChoiceBinding({
198
- keys,
199
- options: parseOptions.bind,
223
+ if (wordStateKey) {
224
+ const parsed = parseWordServerStoredChoice({
225
+ stateKey: wordStateKey,
226
+ parseOptions,
227
+ contextState: options.state,
228
+ providerId: options.providerId,
200
229
  request: options.request,
201
230
  credential: options.credential,
202
- required: true,
203
- }),
204
- });
205
- if (isServerChoiceHandlePayload(envelope.payload)) {
206
- return parseServerStoredChoice({
207
- handle: envelope.payload,
208
- storage: parseOptions.storage,
209
- 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
+ }),
210
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
+ }),
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;
211
367
  }
212
- return envelope.payload;
213
368
  }
214
369
 
215
370
  return { issue, parse };
@@ -227,6 +382,109 @@ export function createTestProviderChoiceContext(
227
382
  });
228
383
  }
229
384
 
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
+
230
488
  function resolveChoiceMasterSecret(options: CreateProviderChoiceContextOptions): string {
231
489
  const configured =
232
490
  options.masterSecret ?? options.env?.get(PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV);
@@ -306,51 +564,200 @@ async function issueServerStoredChoice<TPayload extends ProviderChoiceTokenPaylo
306
564
  readonly issueOptions: ProviderChoiceIssueOptions<TPayload>;
307
565
  readonly storage: ServerProviderChoiceStorageOptions;
308
566
  readonly contextState?: ProviderRuntimeState;
309
- readonly kid: string;
310
- readonly keys: ManagedChoiceKeys;
311
- readonly issuedAtMs: number;
312
567
  }): Promise<string> {
313
568
  const serializedPayload = serializeChoicePayload(options.issueOptions.payload);
314
- const payloadBytes = Buffer.byteLength(serializedPayload, "utf8");
315
- if (payloadBytes > options.storage.maxValueBytes) {
316
- throw new ProviderError("Provider choice payload exceeds state storage policy.", {
317
- code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
318
- category: "input_validation",
319
- retryable: false,
320
- details: {
321
- maxValueBytes: options.storage.maxValueBytes,
322
- payloadBytes,
323
- },
324
- });
325
- }
326
- const stateId = `choice_${randomBytes(16).toString("base64url")}`;
327
- const digest = digestChoicePayload(serializedPayload);
569
+ const payloadDigest = digestChoicePayload(serializedPayload);
328
570
  const namespace = resolveChoiceStateNamespace({
329
571
  storage: options.storage,
330
572
  contextState: options.contextState,
331
573
  ttlMs: options.issueOptions.ttlMs,
332
574
  });
333
- await namespace.set(optionsStateKey(stateId), options.issueOptions.payload, {
334
- ttl: stateTtl(options.storage, options.issueOptions.ttlMs),
335
- });
336
- const envelope: ManagedChoiceEnvelope = {
337
- ...options.baseEnvelope,
338
- payload: {
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,
339
582
  storage: "server",
340
- state_id: stateId,
341
- payload_digest: digest,
342
- created_at_ms: options.issuedAtMs,
343
- },
344
- };
345
- return encryptManagedChoiceToken({
346
- prefix: options.issueOptions.prefix,
347
- kid: options.kid,
348
- envelope,
349
- keys: options.keys,
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.", {
597
+ code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
598
+ category: "input_validation",
599
+ retryable: false,
600
+ details: {
601
+ maxValueBytes: options.storage.maxValueBytes,
602
+ valueBytes,
603
+ },
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;
610
+ }
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);
629
+ const namespace = resolveChoiceStateNamespace({
630
+ storage,
631
+ contextState: options.contextState,
632
+ ttlMs: options.parseOptions.ttlMs,
633
+ });
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,
719
+ });
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,
350
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
+ }
351
758
  }
352
759
 
353
- async function parseServerStoredChoice(options: {
760
+ async function parseLegacyServerStoredChoice(options: {
354
761
  readonly handle: ServerChoiceHandlePayload;
355
762
  readonly storage?: ProviderChoiceStorageOptions;
356
763
  readonly contextState?: ProviderRuntimeState;
@@ -397,6 +804,83 @@ async function parseServerStoredChoice(options: {
397
804
  return record.value;
398
805
  }
399
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
+
400
884
  type ServerProviderChoiceStorageOptions = Extract<
401
885
  ProviderChoiceStorageOptions,
402
886
  { readonly mode: "server" | "auto" }
@@ -458,6 +942,11 @@ function stateTtl(
458
942
  return storage.ttl ?? `${ttlMs ?? 1}ms`;
459
943
  }
460
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
+
461
950
  function optionsStateKey(stateId: string): string {
462
951
  return stateId;
463
952
  }
@@ -470,6 +959,10 @@ function digestChoicePayload(serializedPayload: string): string {
470
959
  return createHash("sha256").update(serializedPayload).digest("base64url");
471
960
  }
472
961
 
962
+ function digestChoiceReplayKey(token: string): string {
963
+ return createHash("sha256").update(token).digest("hex");
964
+ }
965
+
473
966
  function isServerChoiceHandlePayload(
474
967
  value: ProviderChoiceTokenPayload,
475
968
  ): value is ServerChoiceHandlePayload {
@@ -642,6 +1135,35 @@ function createChoiceBinding(options: {
642
1135
  };
643
1136
  }
644
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
+
645
1167
  function hashRequiredConnection(options: {
646
1168
  readonly keys: ManagedChoiceKeys;
647
1169
  readonly request?: ProviderRequestContext;