@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/src/runtime/choice.ts
CHANGED
|
@@ -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
|
-
):
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
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;
|