@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.
@@ -1,14 +1,48 @@
1
- import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual, } from "node:crypto";
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, timingSafeEqual, } from "node:crypto";
2
2
  import { assertFreshProviderChoiceIssuedAt, ProviderChoiceTokenError, } from "../choice-token.js";
3
3
  import { isProviderError, ProviderError } from "../errors.js";
4
+ import { CHOICE_WORDLIST_SIZE, choiceWordAt, HIGH_CHOICE_WORD_COUNT, isChoiceWord, STANDARD_CHOICE_WORD_COUNT, } from "./choice-wordlist.js";
4
5
  export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV = "APIFUSE__PROVIDER_RUNTIME__CHOICE_TOKEN_MASTER_SECRET";
5
6
  const PRIMARY_CHOICE_TOKEN_KID = "v1";
6
7
  const MANAGED_CHOICE_TOKEN_VERSION = 1;
8
+ const SERVER_STORED_CHOICE_RECORD_VERSION = 1;
9
+ const SERVER_STORED_CHOICE_ISSUE_ATTEMPTS = 5;
10
+ const WORD_CHOICE_NOT_FOUND_MESSAGE = "Provider choice token was not found.";
7
11
  export function createProviderChoiceContext(options) {
8
12
  const kid = options.kid ?? PRIMARY_CHOICE_TOKEN_KID;
9
13
  const resolveMasterSecret = () => resolveChoiceMasterSecret(options);
10
14
  function issue(issueOptions) {
11
15
  const issuedAtMs = issueOptions.nowMs ?? Date.now();
16
+ const resolvedStorage = resolveIssueStorage(issueOptions.storage, issueOptions.payload);
17
+ if (resolvedStorage.mode === "server") {
18
+ const binding = hasRequestedChoiceBinding(issueOptions.bind)
19
+ ? createChoiceBinding({
20
+ keys: deriveManagedChoiceKeys({
21
+ masterSecret: resolveMasterSecret(),
22
+ providerId: options.providerId,
23
+ purpose: issueOptions.purpose,
24
+ kid,
25
+ }),
26
+ options: issueOptions.bind,
27
+ request: options.request,
28
+ credential: options.credential,
29
+ required: true,
30
+ })
31
+ : undefined;
32
+ return issueServerStoredChoice({
33
+ baseEnvelope: {
34
+ v: MANAGED_CHOICE_TOKEN_VERSION,
35
+ provider_id: options.providerId,
36
+ purpose: issueOptions.purpose,
37
+ issued_at_ms: issuedAtMs,
38
+ ttl_ms: issueOptions.ttlMs,
39
+ binding,
40
+ },
41
+ issueOptions,
42
+ storage: resolvedStorage.storage,
43
+ contextState: options.state,
44
+ });
45
+ }
12
46
  const keys = deriveManagedChoiceKeys({
13
47
  masterSecret: resolveMasterSecret(),
14
48
  providerId: options.providerId,
@@ -29,18 +63,6 @@ export function createProviderChoiceContext(options) {
29
63
  required: true,
30
64
  }),
31
65
  };
32
- const resolvedStorage = resolveIssueStorage(issueOptions.storage, issueOptions.payload);
33
- if (resolvedStorage.mode === "server") {
34
- return issueServerStoredChoice({
35
- baseEnvelope,
36
- issueOptions,
37
- storage: resolvedStorage.storage,
38
- contextState: options.state,
39
- kid,
40
- keys,
41
- issuedAtMs,
42
- });
43
- }
44
66
  const envelope = {
45
67
  ...baseEnvelope,
46
68
  payload: issueOptions.payload,
@@ -53,58 +75,139 @@ export function createProviderChoiceContext(options) {
53
75
  });
54
76
  }
55
77
  function parse(parseOptions) {
56
- const [actualPrefix, tokenKid, encodedIv, encryptedPayload, authTag, signature] = parseManagedChoiceTokenParts(parseOptions.token);
57
- if (actualPrefix !== parseOptions.prefix ||
58
- tokenKid !== kid ||
59
- !encodedIv ||
60
- !encryptedPayload ||
61
- !authTag ||
62
- !signature) {
63
- throw new ProviderChoiceTokenError("invalid_shape", "Provider choice token shape is invalid.");
64
- }
65
- const keys = deriveManagedChoiceKeys({
66
- masterSecret: resolveMasterSecret(),
67
- providerId: options.providerId,
68
- purpose: parseOptions.purpose,
69
- kid: tokenKid,
70
- });
71
- const signedBody = [parseOptions.prefix, tokenKid, encodedIv, encryptedPayload, authTag].join(".");
72
- assertManagedChoiceSignature({
73
- signedBody,
74
- signature,
75
- signingKey: keys.signing,
76
- });
77
- const envelope = decryptManagedChoiceToken({
78
- encodedIv,
79
- encryptedPayload,
80
- authTag,
81
- encryptionKey: keys.encryption,
78
+ const consumeMode = parseOptions.consume ?? "never";
79
+ const wordStateKey = parseWordChoiceStateKey({
80
+ token: parseOptions.token,
81
+ prefix: parseOptions.prefix,
82
82
  });
83
- assertManagedChoiceEnvelope(envelope, {
84
- providerId: options.providerId,
85
- purpose: parseOptions.purpose,
86
- ttlMs: parseOptions.ttlMs,
87
- nowMs: parseOptions.nowMs,
88
- futureToleranceMs: parseOptions.futureToleranceMs,
89
- });
90
- assertChoiceBindingMatches({
91
- actual: envelope.binding,
92
- expected: createChoiceBinding({
93
- keys,
94
- options: parseOptions.bind,
83
+ if (wordStateKey) {
84
+ const parsed = parseWordServerStoredChoice({
85
+ stateKey: wordStateKey,
86
+ parseOptions,
87
+ contextState: options.state,
88
+ providerId: options.providerId,
95
89
  request: options.request,
96
90
  credential: options.credential,
97
- required: true,
98
- }),
99
- });
100
- if (isServerChoiceHandlePayload(envelope.payload)) {
101
- return parseServerStoredChoice({
102
- handle: envelope.payload,
103
- storage: parseOptions.storage,
104
- contextState: options.state,
91
+ resolveBindingKeys: () => deriveManagedChoiceKeys({
92
+ masterSecret: resolveMasterSecret(),
93
+ providerId: options.providerId,
94
+ purpose: parseOptions.purpose,
95
+ kid,
96
+ }),
97
+ onConsume: (result) => emitChoiceTelemetry(options.onTelemetry, {
98
+ providerId: options.providerId,
99
+ purpose: parseOptions.purpose,
100
+ operation: "consume",
101
+ format: "word",
102
+ outcome: "success",
103
+ consumeMode,
104
+ consumed: result.status === "consumed",
105
+ replay: result.status === "already-consumed",
106
+ }),
107
+ });
108
+ return observeChoiceParse(parsed, {
109
+ onTelemetry: options.onTelemetry,
110
+ providerId: options.providerId,
111
+ purpose: parseOptions.purpose,
112
+ format: "word",
113
+ consumeMode,
105
114
  });
106
115
  }
107
- return envelope.payload;
116
+ // Legacy encrypted-envelope compatibility fallback. Removal is gated on
117
+ // the last legacy mint plus the maximum issued TTL; see ADR 0006.
118
+ // A structurally valid word token returns above, so lookup, expiry,
119
+ // consumption, and binding failures can never enter this branch.
120
+ try {
121
+ const [actualPrefix, tokenKid, encodedIv, encryptedPayload, authTag, signature] = parseManagedChoiceTokenParts(parseOptions.token);
122
+ if (actualPrefix !== parseOptions.prefix ||
123
+ tokenKid !== kid ||
124
+ !encodedIv ||
125
+ !encryptedPayload ||
126
+ !authTag ||
127
+ !signature) {
128
+ throw new ProviderChoiceTokenError("invalid_shape", "Provider choice token shape is invalid.");
129
+ }
130
+ const keys = deriveManagedChoiceKeys({
131
+ masterSecret: resolveMasterSecret(),
132
+ providerId: options.providerId,
133
+ purpose: parseOptions.purpose,
134
+ kid: tokenKid,
135
+ });
136
+ const signedBody = [
137
+ parseOptions.prefix,
138
+ tokenKid,
139
+ encodedIv,
140
+ encryptedPayload,
141
+ authTag,
142
+ ].join(".");
143
+ assertManagedChoiceSignature({
144
+ signedBody,
145
+ signature,
146
+ signingKey: keys.signing,
147
+ });
148
+ const envelope = decryptManagedChoiceToken({
149
+ encodedIv,
150
+ encryptedPayload,
151
+ authTag,
152
+ encryptionKey: keys.encryption,
153
+ });
154
+ assertManagedChoiceEnvelope(envelope, {
155
+ providerId: options.providerId,
156
+ purpose: parseOptions.purpose,
157
+ ttlMs: parseOptions.ttlMs,
158
+ nowMs: parseOptions.nowMs,
159
+ futureToleranceMs: parseOptions.futureToleranceMs,
160
+ });
161
+ assertChoiceBindingMatches({
162
+ actual: envelope.binding,
163
+ expected: createChoiceBinding({
164
+ keys,
165
+ options: parseOptions.bind,
166
+ request: options.request,
167
+ credential: options.credential,
168
+ required: true,
169
+ }),
170
+ });
171
+ const payload = isServerChoiceHandlePayload(envelope.payload)
172
+ ? parseLegacyServerStoredChoice({
173
+ handle: envelope.payload,
174
+ storage: parseOptions.storage,
175
+ contextState: options.state,
176
+ })
177
+ : envelope.payload;
178
+ const parsed = consumeMode === "explicit"
179
+ ? Promise.resolve(payload).then((resolvedPayload) => createLegacyExplicitParseResult({
180
+ payload: resolvedPayload,
181
+ replayKey: digestChoiceReplayKey(parseOptions.token),
182
+ onConsume: () => emitChoiceTelemetry(options.onTelemetry, {
183
+ providerId: options.providerId,
184
+ purpose: parseOptions.purpose,
185
+ operation: "consume",
186
+ format: "legacy",
187
+ outcome: "unsupported",
188
+ consumeMode,
189
+ consumed: false,
190
+ replay: false,
191
+ }),
192
+ }))
193
+ : payload;
194
+ return observeChoiceParse(parsed, {
195
+ onTelemetry: options.onTelemetry,
196
+ providerId: options.providerId,
197
+ purpose: parseOptions.purpose,
198
+ format: "legacy",
199
+ consumeMode,
200
+ });
201
+ }
202
+ catch (error) {
203
+ emitChoiceParseFailure(options.onTelemetry, error, {
204
+ providerId: options.providerId,
205
+ purpose: parseOptions.purpose,
206
+ format: "legacy",
207
+ consumeMode,
208
+ });
209
+ throw error;
210
+ }
108
211
  }
109
212
  return { issue, parse };
110
213
  }
@@ -114,6 +217,76 @@ export function createTestProviderChoiceContext(options) {
114
217
  masterSecret: options.masterSecret ?? "apifuse-test-provider-runtime-choice-token-master-secret",
115
218
  });
116
219
  }
220
+ function observeChoiceParse(result, base) {
221
+ if (result instanceof Promise) {
222
+ return result.then((value) => {
223
+ emitChoiceParseSuccess(base, value);
224
+ return value;
225
+ }, (error) => {
226
+ emitChoiceParseFailure(base.onTelemetry, error, base);
227
+ throw error;
228
+ });
229
+ }
230
+ emitChoiceParseSuccess(base, result);
231
+ return result;
232
+ }
233
+ function emitChoiceParseSuccess(base, result) {
234
+ const replay = isConsumedChoiceReplay(result);
235
+ emitChoiceTelemetry(base.onTelemetry, {
236
+ providerId: base.providerId,
237
+ purpose: base.purpose,
238
+ operation: "parse",
239
+ format: base.format,
240
+ outcome: "success",
241
+ consumeMode: base.consumeMode,
242
+ consumed: replay || (base.format === "word" && base.consumeMode === "on-parse"),
243
+ replay,
244
+ });
245
+ }
246
+ function isConsumedChoiceReplay(value) {
247
+ return (value !== null &&
248
+ typeof value === "object" &&
249
+ "status" in value &&
250
+ value.status === "consumed" &&
251
+ "replayKey" in value &&
252
+ typeof value.replayKey === "string");
253
+ }
254
+ function emitChoiceParseFailure(onTelemetry, error, base) {
255
+ const outcome = error instanceof ProviderChoiceTokenError
256
+ ? base.format === "word" && error.message === WORD_CHOICE_NOT_FOUND_MESSAGE
257
+ ? "not-found"
258
+ : "invalid"
259
+ : "error";
260
+ emitChoiceTelemetry(onTelemetry, {
261
+ providerId: base.providerId,
262
+ purpose: base.purpose,
263
+ operation: "parse",
264
+ format: base.format,
265
+ outcome,
266
+ consumeMode: base.consumeMode,
267
+ consumed: false,
268
+ replay: false,
269
+ });
270
+ }
271
+ function emitChoiceTelemetry(onTelemetry, event) {
272
+ try {
273
+ onTelemetry?.(event);
274
+ }
275
+ catch {
276
+ // Observability must never change provider token semantics.
277
+ }
278
+ }
279
+ function createLegacyExplicitParseResult(options) {
280
+ return {
281
+ status: "active",
282
+ payload: options.payload,
283
+ replayKey: options.replayKey,
284
+ consume: async () => {
285
+ options.onConsume();
286
+ return { status: "unsupported" };
287
+ },
288
+ };
289
+ }
117
290
  function resolveChoiceMasterSecret(options) {
118
291
  const configured = options.masterSecret ?? options.env?.get(PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV);
119
292
  const trimmed = configured?.trim();
@@ -165,45 +338,173 @@ function encryptManagedChoiceToken(options) {
165
338
  }
166
339
  async function issueServerStoredChoice(options) {
167
340
  const serializedPayload = serializeChoicePayload(options.issueOptions.payload);
168
- const payloadBytes = Buffer.byteLength(serializedPayload, "utf8");
169
- if (payloadBytes > options.storage.maxValueBytes) {
170
- throw new ProviderError("Provider choice payload exceeds state storage policy.", {
171
- code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
172
- category: "input_validation",
173
- retryable: false,
174
- details: {
175
- maxValueBytes: options.storage.maxValueBytes,
176
- payloadBytes,
177
- },
178
- });
179
- }
180
- const stateId = `choice_${randomBytes(16).toString("base64url")}`;
181
- const digest = digestChoicePayload(serializedPayload);
341
+ const payloadDigest = digestChoicePayload(serializedPayload);
182
342
  const namespace = resolveChoiceStateNamespace({
183
343
  storage: options.storage,
184
344
  contextState: options.contextState,
185
345
  ttlMs: options.issueOptions.ttlMs,
186
346
  });
187
- await namespace.set(optionsStateKey(stateId), options.issueOptions.payload, {
188
- ttl: stateTtl(options.storage, options.issueOptions.ttlMs),
189
- });
190
- const envelope = {
191
- ...options.baseEnvelope,
192
- payload: {
347
+ const wordCount = options.issueOptions.strength === "high" ? HIGH_CHOICE_WORD_COUNT : STANDARD_CHOICE_WORD_COUNT;
348
+ for (let attempt = 0; attempt < SERVER_STORED_CHOICE_ISSUE_ATTEMPTS; attempt += 1) {
349
+ const stateKey = generateChoiceWordSequence(wordCount);
350
+ const token = `${options.issueOptions.prefix}${stateKey}`;
351
+ const record = {
352
+ v: SERVER_STORED_CHOICE_RECORD_VERSION,
193
353
  storage: "server",
194
- state_id: stateId,
195
- payload_digest: digest,
196
- created_at_ms: options.issuedAtMs,
197
- },
198
- };
199
- return encryptManagedChoiceToken({
200
- prefix: options.issueOptions.prefix,
201
- kid: options.kid,
202
- envelope,
203
- keys: options.keys,
354
+ status: "active",
355
+ provider_id: options.baseEnvelope.provider_id,
356
+ purpose: options.baseEnvelope.purpose,
357
+ issued_at_ms: options.baseEnvelope.issued_at_ms,
358
+ ttl_ms: options.baseEnvelope.ttl_ms,
359
+ binding: options.baseEnvelope.binding,
360
+ prefix: options.issueOptions.prefix,
361
+ payload: options.issueOptions.payload,
362
+ payload_digest: payloadDigest,
363
+ replay_key: digestChoiceReplayKey(token),
364
+ };
365
+ const valueBytes = Buffer.byteLength(JSON.stringify(record), "utf8");
366
+ if (valueBytes > options.storage.maxValueBytes) {
367
+ throw new ProviderError("Provider choice payload exceeds state storage policy.", {
368
+ code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
369
+ category: "input_validation",
370
+ retryable: false,
371
+ details: {
372
+ maxValueBytes: options.storage.maxValueBytes,
373
+ valueBytes,
374
+ },
375
+ });
376
+ }
377
+ const result = await namespace.compareAndSet(optionsStateKey(stateKey), 0, record, {
378
+ ttl: stateTtl(options.storage, options.issueOptions.ttlMs),
379
+ });
380
+ if (result.ok)
381
+ return token;
382
+ }
383
+ throw new ProviderError("Provider choice state storage is not available.", {
384
+ code: "CHOICE_STATE_UNAVAILABLE",
385
+ category: "internal_error",
386
+ retryable: false,
387
+ });
388
+ }
389
+ async function parseWordServerStoredChoice(options) {
390
+ const storage = resolveParseStorage(options.parseOptions.storage);
391
+ const namespace = resolveChoiceStateNamespace({
392
+ storage,
393
+ contextState: options.contextState,
394
+ ttlMs: options.parseOptions.ttlMs,
395
+ });
396
+ let stored;
397
+ try {
398
+ stored = await namespace.get(optionsStateKey(options.stateKey));
399
+ }
400
+ catch (error) {
401
+ if (isProviderError(error))
402
+ throw error;
403
+ throw wordChoiceNotFoundError();
404
+ }
405
+ if (!stored || !isServerStoredChoiceRecord(stored.value)) {
406
+ throw wordChoiceNotFoundError();
407
+ }
408
+ const record = stored.value;
409
+ const expectedReplayKey = digestChoiceReplayKey(`${options.parseOptions.prefix}${options.stateKey}`);
410
+ try {
411
+ if (record.provider_id !== options.providerId ||
412
+ record.purpose !== options.parseOptions.purpose ||
413
+ record.prefix !== options.parseOptions.prefix) {
414
+ throw wordChoiceNotFoundError();
415
+ }
416
+ assertFreshProviderChoiceIssuedAt(record.issued_at_ms, {
417
+ ttlMs: options.parseOptions.ttlMs != null
418
+ ? Math.min(options.parseOptions.ttlMs, record.ttl_ms)
419
+ : record.ttl_ms,
420
+ nowMs: options.parseOptions.nowMs,
421
+ futureToleranceMs: options.parseOptions.futureToleranceMs,
422
+ });
423
+ assertPayloadDigestMatches({
424
+ actual: digestChoicePayload(serializeChoicePayload(record.payload)),
425
+ expected: record.payload_digest,
426
+ });
427
+ assertPayloadDigestMatches({ actual: expectedReplayKey, expected: record.replay_key });
428
+ assertWordChoiceBindingMatches({
429
+ actual: record.binding,
430
+ requested: options.parseOptions.bind,
431
+ request: options.request,
432
+ credential: options.credential,
433
+ resolveKeys: options.resolveBindingKeys,
434
+ });
435
+ }
436
+ catch (error) {
437
+ if (error instanceof ProviderChoiceTokenError ||
438
+ (isProviderError(error) && error.code === "CHOICE_CONTEXT_REQUIRED")) {
439
+ throw wordChoiceNotFoundError();
440
+ }
441
+ throw error;
442
+ }
443
+ const consumeMode = options.parseOptions.consume ?? "never";
444
+ if (record.status === "consumed") {
445
+ if (consumeMode === "explicit") {
446
+ return { status: "consumed", replayKey: record.replay_key };
447
+ }
448
+ throw wordChoiceNotFoundError();
449
+ }
450
+ if (consumeMode === "never")
451
+ return record.payload;
452
+ if (consumeMode === "explicit") {
453
+ return {
454
+ status: "active",
455
+ payload: record.payload,
456
+ replayKey: record.replay_key,
457
+ consume: async () => {
458
+ const result = await consumeWordServerStoredChoice({
459
+ stateKey: options.stateKey,
460
+ stored,
461
+ record,
462
+ storage,
463
+ contextState: options.contextState,
464
+ });
465
+ options.onConsume(result);
466
+ return result;
467
+ },
468
+ };
469
+ }
470
+ const consumed = await consumeWordServerStoredChoice({
471
+ stateKey: options.stateKey,
472
+ stored,
473
+ record,
474
+ storage,
475
+ contextState: options.contextState,
204
476
  });
477
+ if (consumed.status !== "consumed")
478
+ throw wordChoiceNotFoundError();
479
+ return record.payload;
205
480
  }
206
- async function parseServerStoredChoice(options) {
481
+ async function consumeWordServerStoredChoice(options) {
482
+ const namespace = resolveChoiceStateNamespace({
483
+ storage: options.storage,
484
+ contextState: options.contextState,
485
+ ttlMs: options.record.ttl_ms,
486
+ });
487
+ try {
488
+ const consumed = await namespace.compareAndSet(optionsStateKey(options.stateKey), options.stored.version, { ...options.record, status: "consumed" }, { ttl: remainingStateTtl(options.stored.expiresAt) });
489
+ if (consumed.ok)
490
+ return { status: "consumed" };
491
+ if (consumed.current &&
492
+ isServerStoredChoiceRecord(consumed.current.value) &&
493
+ consumed.current.value.status === "consumed" &&
494
+ consumed.current.value.replay_key === options.record.replay_key) {
495
+ return { status: "already-consumed" };
496
+ }
497
+ throw wordChoiceNotFoundError();
498
+ }
499
+ catch (error) {
500
+ if (isProviderError(error))
501
+ throw error;
502
+ if (error instanceof ProviderChoiceTokenError)
503
+ throw error;
504
+ throw wordChoiceNotFoundError();
505
+ }
506
+ }
507
+ async function parseLegacyServerStoredChoice(options) {
207
508
  const storage = resolveParseStorage(options.storage);
208
509
  const namespace = resolveChoiceStateNamespace({
209
510
  storage,
@@ -238,6 +539,71 @@ async function parseServerStoredChoice(options) {
238
539
  });
239
540
  return record.value;
240
541
  }
542
+ function generateChoiceWordSequence(wordCount) {
543
+ return Array.from({ length: wordCount }, () => choiceWordAt(randomInt(CHOICE_WORDLIST_SIZE))).join("-");
544
+ }
545
+ function parseWordChoiceStateKey(options) {
546
+ if (!options.token.startsWith(options.prefix))
547
+ return null;
548
+ const body = options.token.slice(options.prefix.length);
549
+ // The official list contains one hyphenated entry (`yo-yo`), so structural
550
+ // recognition uses dictionary-aware segmentation instead of assuming every
551
+ // hyphen is a word boundary.
552
+ if (!/^[a-z]+(?:-[a-z]+){3,9}$/.test(body))
553
+ return null;
554
+ const segments = body.split("-");
555
+ if (!canSegmentChoiceWords(segments, 0, STANDARD_CHOICE_WORD_COUNT) &&
556
+ !canSegmentChoiceWords(segments, 0, HIGH_CHOICE_WORD_COUNT)) {
557
+ return null;
558
+ }
559
+ return body;
560
+ }
561
+ function canSegmentChoiceWords(segments, segmentIndex, wordsRemaining) {
562
+ if (wordsRemaining === 0)
563
+ return segmentIndex === segments.length;
564
+ const segmentsRemaining = segments.length - segmentIndex;
565
+ if (segmentsRemaining < wordsRemaining)
566
+ return false;
567
+ for (let end = segmentIndex + 1; end <= segments.length - (wordsRemaining - 1); end += 1) {
568
+ const candidate = segments.slice(segmentIndex, end).join("-");
569
+ if (candidate.length > 10)
570
+ break;
571
+ if (isChoiceWord(candidate) && canSegmentChoiceWords(segments, end, wordsRemaining - 1)) {
572
+ return true;
573
+ }
574
+ }
575
+ return false;
576
+ }
577
+ function isServerStoredChoiceRecord(value) {
578
+ if (!value || typeof value !== "object" || Array.isArray(value))
579
+ return false;
580
+ return ("v" in value &&
581
+ value.v === SERVER_STORED_CHOICE_RECORD_VERSION &&
582
+ "storage" in value &&
583
+ value.storage === "server" &&
584
+ "status" in value &&
585
+ (value.status === "active" || value.status === "consumed") &&
586
+ "provider_id" in value &&
587
+ typeof value.provider_id === "string" &&
588
+ "purpose" in value &&
589
+ typeof value.purpose === "string" &&
590
+ "issued_at_ms" in value &&
591
+ typeof value.issued_at_ms === "number" &&
592
+ "ttl_ms" in value &&
593
+ typeof value.ttl_ms === "number" &&
594
+ (!("binding" in value) || value.binding === undefined || isChoiceBinding(value.binding)) &&
595
+ "prefix" in value &&
596
+ typeof value.prefix === "string" &&
597
+ "payload" in value &&
598
+ isChoicePayload(value.payload) &&
599
+ "payload_digest" in value &&
600
+ typeof value.payload_digest === "string" &&
601
+ "replay_key" in value &&
602
+ typeof value.replay_key === "string");
603
+ }
604
+ function wordChoiceNotFoundError() {
605
+ return new ProviderChoiceTokenError("invalid_payload", WORD_CHOICE_NOT_FOUND_MESSAGE);
606
+ }
241
607
  function resolveIssueStorage(storage, payload) {
242
608
  if (!storage || storage.mode === "inline")
243
609
  return { mode: "inline" };
@@ -273,6 +639,10 @@ function resolveChoiceStateNamespace(options) {
273
639
  function stateTtl(storage, ttlMs) {
274
640
  return storage.ttl ?? `${ttlMs ?? 1}ms`;
275
641
  }
642
+ function remainingStateTtl(expiresAt) {
643
+ const remainingMs = Date.parse(expiresAt) - Date.now();
644
+ return `${Number.isFinite(remainingMs) ? Math.max(1, Math.floor(remainingMs)) : 1}ms`;
645
+ }
276
646
  function optionsStateKey(stateId) {
277
647
  return stateId;
278
648
  }
@@ -282,6 +652,9 @@ function serializeChoicePayload(payload) {
282
652
  function digestChoicePayload(serializedPayload) {
283
653
  return createHash("sha256").update(serializedPayload).digest("base64url");
284
654
  }
655
+ function digestChoiceReplayKey(token) {
656
+ return createHash("sha256").update(token).digest("hex");
657
+ }
285
658
  function isServerChoiceHandlePayload(value) {
286
659
  return (value.storage === "server" &&
287
660
  typeof value.state_id === "string" &&
@@ -384,6 +757,27 @@ function createChoiceBinding(options) {
384
757
  ...(credentialHash ? { credential_hash: credentialHash } : {}),
385
758
  };
386
759
  }
760
+ function hasRequestedChoiceBinding(options) {
761
+ return options?.connection === true || Boolean(options?.credentialKeys?.length);
762
+ }
763
+ function assertWordChoiceBindingMatches(options) {
764
+ const hasStoredBinding = Boolean(options.actual?.connection_hash || options.actual?.credential_hash);
765
+ const hasRequestedBinding = hasRequestedChoiceBinding(options.requested);
766
+ if (!hasStoredBinding && !hasRequestedBinding)
767
+ return;
768
+ if (hasStoredBinding !== hasRequestedBinding)
769
+ throw wordChoiceNotFoundError();
770
+ assertChoiceBindingMatches({
771
+ actual: options.actual,
772
+ expected: createChoiceBinding({
773
+ keys: options.resolveKeys(),
774
+ options: options.requested,
775
+ request: options.request,
776
+ credential: options.credential,
777
+ required: true,
778
+ }),
779
+ });
780
+ }
387
781
  function hashRequiredConnection(options) {
388
782
  const connectionId = options.request?.connectionId;
389
783
  if (!connectionId) {
@@ -101,6 +101,17 @@ export type ProviderServerLogEvent = (ProviderServerLogEventBase & {
101
101
  event: "provider_secrets_missing";
102
102
  providerId: string;
103
103
  missingSecrets: string[];
104
+ } | {
105
+ level: "info";
106
+ event: "provider_choice_token";
107
+ providerId: string;
108
+ purpose: string;
109
+ operation: "parse" | "consume";
110
+ format: "word" | "legacy";
111
+ outcome: "success" | "not-found" | "invalid" | "unsupported" | "error";
112
+ consumeMode: "never" | "on-parse" | "explicit";
113
+ consumed: boolean;
114
+ replay: boolean;
104
115
  } | {
105
116
  level: "warn";
106
117
  event: "provider_cleanup_failed";
@@ -463,6 +463,11 @@ function createProviderContext(provider, request, operationId, options, state =
463
463
  request: requestContext,
464
464
  credential,
465
465
  state: requestState,
466
+ onTelemetry: (event) => (options.logger ?? defaultProviderServerLogger)({
467
+ level: "info",
468
+ event: "provider_choice_token",
469
+ ...event,
470
+ }),
466
471
  }),
467
472
  });
468
473
  wrappedContext = context;