@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.
- package/CHANGELOG.md +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/provider.d.ts +1 -1
- package/dist/runtime/choice-wordlist.d.ts +9 -0
- package/dist/runtime/choice-wordlist.js +138 -0
- package/dist/runtime/choice.d.ts +13 -1
- package/dist/runtime/choice.js +485 -91
- package/dist/server/serve-implementation.d.ts +11 -0
- package/dist/server/serve-implementation.js +5 -0
- package/dist/types.d.ts +28 -1
- package/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/provider.ts +3 -0
- package/src/runtime/choice-wordlist.ts +145 -0
- package/src/runtime/choice.ts +625 -103
- package/src/server/serve-implementation.ts +18 -0
- package/src/types.ts +34 -1
package/dist/runtime/choice.js
CHANGED
|
@@ -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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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;
|