@apifuse/provider-sdk 2.2.0-beta.4 → 2.2.0-beta.40
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/AUTHORING.md +501 -13
- package/CHANGELOG.md +165 -1
- package/README.md +72 -14
- package/SUBMISSION.md +1 -1
- package/bin/apifuse-check.ts +88 -4
- package/bin/apifuse-dev.ts +38 -5
- package/bin/apifuse-pack-check.ts +22 -2
- package/bin/apifuse-pack-smoke.ts +57 -2
- package/bin/apifuse-pack-types.ts +356 -38
- package/bin/apifuse-perf.ts +14 -13
- package/bin/apifuse-record.ts +691 -68
- package/bin/apifuse-submit-check.ts +518 -37
- package/bin/apifuse-sync-assets.ts +117 -0
- package/bin/submit-check-delimited-text.ts +50 -0
- package/dist/auth-turn/index.d.ts +3 -3
- package/dist/auth-turn/index.js +1 -1
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +67 -0
- package/dist/ceremonies/index.d.ts +16 -0
- package/dist/ceremonies/index.js +141 -36
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +8 -0
- package/dist/cli/create.d.ts +3 -0
- package/dist/cli/create.js +34 -35
- package/dist/cli/prompt-assets.d.ts +80 -0
- package/dist/cli/prompt-assets.js +743 -0
- package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
- package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
- package/dist/cli/templates/provider/README.md.tpl +4 -4
- package/dist/cli/templates/provider/index.ts.tpl +6 -3
- package/dist/cli/templates/provider/operations/ping.ts.tpl +2 -1
- package/dist/config/loader.d.ts +177 -16
- package/dist/config/loader.js +424 -127
- package/dist/contract-serialization.js +4 -8
- package/dist/contract-types.d.ts +1 -0
- package/dist/contract.js +2 -0
- package/dist/declaration-validation.d.ts +32 -0
- package/dist/declaration-validation.js +207 -0
- package/dist/define.d.ts +51 -25
- package/dist/define.js +752 -38
- package/dist/error-resolution.d.ts +4 -0
- package/dist/error-resolution.js +122 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +40 -0
- package/dist/fixture-sanitization.d.ts +28 -0
- package/dist/fixture-sanitization.js +217 -0
- package/dist/health-scenario.d.ts +1842 -0
- package/dist/health-scenario.js +624 -0
- package/dist/index.d.ts +19 -9
- package/dist/index.js +10 -6
- package/dist/lint.d.ts +6 -1
- package/dist/lint.js +362 -3
- package/dist/native-address.d.ts +43 -0
- package/dist/native-address.js +281 -0
- package/dist/native-egress-policy.d.ts +31 -0
- package/dist/native-egress-policy.js +288 -0
- package/dist/observability.d.ts +5 -2
- package/dist/observability.js +48 -1
- package/dist/provider.d.ts +8 -2
- package/dist/provider.js +3 -1
- package/dist/runtime/auth-flow.d.ts +5 -1
- package/dist/runtime/auth-flow.js +6 -0
- package/dist/runtime/browser.d.ts +1 -0
- package/dist/runtime/browser.js +492 -49
- package/dist/runtime/cache.d.ts +1 -0
- package/dist/runtime/cache.js +169 -15
- 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 +490 -102
- package/dist/runtime/executor.d.ts +2 -2
- package/dist/runtime/executor.js +26 -2
- package/dist/runtime/http.d.ts +1 -0
- package/dist/runtime/http.js +515 -53
- package/dist/runtime/instrumentation.d.ts +2 -2
- package/dist/runtime/instrumentation.js +366 -8
- package/dist/runtime/native-network-errors.d.ts +33 -0
- package/dist/runtime/native-network-errors.js +69 -0
- package/dist/runtime/native-network.d.ts +96 -0
- package/dist/runtime/native-network.js +1232 -0
- package/dist/runtime/ocr.d.ts +29 -0
- package/dist/runtime/ocr.js +440 -0
- package/dist/runtime/proxy-errors.js +6 -2
- package/dist/runtime/proxy-nodemaven.d.ts +56 -0
- package/dist/runtime/proxy-nodemaven.js +146 -0
- package/dist/runtime/proxy-telemetry.d.ts +80 -1
- package/dist/runtime/proxy-telemetry.js +154 -47
- package/dist/runtime/redirects.d.ts +29 -0
- package/dist/runtime/redirects.js +36 -0
- package/dist/runtime/redis.d.ts +1 -1
- package/dist/runtime/redis.js +4 -2
- package/dist/runtime/request-options.d.ts +68 -1
- package/dist/runtime/request-options.js +548 -0
- package/dist/runtime/resolver-config.d.ts +6 -0
- package/dist/runtime/resolver-config.js +6 -0
- package/dist/runtime/resolver-public.d.ts +1 -0
- package/dist/runtime/resolver-public.js +1 -0
- package/dist/runtime/resolver-shared.d.ts +3 -0
- package/dist/runtime/resolver-shared.js +12 -0
- package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
- package/dist/runtime/resolver-vendors/bindings.js +40 -0
- package/dist/runtime/resolver-vendors/browser.d.ts +22 -0
- package/dist/runtime/resolver-vendors/browser.js +377 -0
- package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
- package/dist/runtime/resolver-vendors/capsolver.js +526 -0
- package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
- package/dist/runtime/resolver-vendors/hosts.js +33 -0
- package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
- package/dist/runtime/resolver-vendors/twocaptcha.js +407 -0
- package/dist/runtime/resolver-vendors/types.d.ts +94 -0
- package/dist/runtime/resolver-vendors/types.js +96 -0
- package/dist/runtime/resolver.d.ts +60 -0
- package/dist/runtime/resolver.js +737 -0
- package/dist/runtime/secrets.d.ts +27 -0
- package/dist/runtime/secrets.js +51 -0
- package/dist/runtime/state.d.ts +3 -0
- package/dist/runtime/state.js +277 -71
- package/dist/runtime/stealth-cookies.d.ts +20 -0
- package/dist/runtime/stealth-cookies.js +111 -0
- package/dist/runtime/stealth.d.ts +28 -3
- package/dist/runtime/stealth.js +519 -255
- package/dist/runtime/stt.js +1 -12
- package/dist/runtime/timeout.d.ts +5 -0
- package/dist/runtime/timeout.js +12 -0
- package/dist/runtime/trace-config.d.ts +12 -0
- package/dist/runtime/trace-config.js +61 -0
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +1 -1
- package/dist/server/index.d.ts +5 -3
- package/dist/server/index.js +3 -3
- package/dist/server/self-test-input-tokens.d.ts +2 -1
- package/dist/server/self-test-input-tokens.js +18 -14
- package/dist/server/self-test.d.ts +114 -0
- package/dist/server/self-test.js +784 -148
- package/dist/server/serve-implementation.d.ts +213 -0
- package/dist/server/serve-implementation.js +2173 -0
- package/dist/server/serve.d.ts +1 -70
- package/dist/server/serve.js +1 -1130
- package/dist/server/trace-output.d.ts +4 -0
- package/dist/server/trace-output.js +20 -0
- package/dist/server/types.d.ts +30 -5
- package/dist/server/types.js +13 -1
- package/dist/stateful/errors.d.ts +19 -0
- package/dist/stateful/errors.js +24 -0
- package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
- package/dist/stateful/http-provider-event-emitter.js +237 -0
- package/dist/stateful/http-session-owner-registry.d.ts +44 -0
- package/dist/stateful/http-session-owner-registry.js +210 -0
- package/dist/stateful/index.d.ts +18 -0
- package/dist/stateful/index.js +18 -0
- package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
- package/dist/stateful/provider-event-delivery-failures.js +43 -0
- package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
- package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
- package/dist/stateful/provider-event-pipeline.d.ts +50 -0
- package/dist/stateful/provider-event-pipeline.js +1 -0
- package/dist/stateful/provider-events.d.ts +101 -0
- package/dist/stateful/provider-events.js +289 -0
- package/dist/stateful/session-key.d.ts +15 -0
- package/dist/stateful/session-key.js +86 -0
- package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
- package/dist/stateful/stateful-provider-adapter-context.js +42 -0
- package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
- package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
- package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
- package/dist/stateful/stateful-provider-adapter.js +287 -0
- package/dist/stateful/stateful-provider-observability.d.ts +62 -0
- package/dist/stateful/stateful-provider-observability.js +161 -0
- package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
- package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
- package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
- package/dist/stateful/stateful-provider-runtime-context.js +60 -0
- package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
- package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
- package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
- package/dist/stateful/stateful-provider-session-routing.js +345 -0
- package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
- package/dist/stateful/stateful-provider-session-runtime.js +245 -0
- package/dist/stateful-signing.d.ts +18 -0
- package/dist/stateful-signing.js +27 -0
- package/dist/stealth/profiles.js +16 -7
- package/dist/stream-evidence.d.ts +74 -0
- package/dist/stream-evidence.js +785 -0
- package/dist/stream.js +7 -1
- package/dist/testing/index.d.ts +2 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/run.d.ts +32 -2
- package/dist/testing/run.js +489 -21
- package/dist/trace-sanitization.d.ts +5 -0
- package/dist/trace-sanitization.js +45 -0
- package/dist/types.d.ts +545 -23
- package/dist/types.js +1 -0
- package/package.json +44 -5
- package/src/auth-turn/index.ts +1 -1
- package/src/auth.ts +118 -0
- package/src/ceremonies/index.ts +189 -46
- package/src/cli/commands.ts +10 -0
- package/src/cli/create.ts +42 -35
- package/src/cli/prompt-assets.ts +865 -0
- package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
- package/src/cli/templates/provider/Dockerfile.tpl +1 -1
- package/src/cli/templates/provider/README.md.tpl +4 -4
- package/src/cli/templates/provider/index.ts.tpl +6 -3
- package/src/cli/templates/provider/operations/ping.ts.tpl +2 -1
- package/src/config/loader.ts +665 -163
- package/src/contract-serialization.ts +5 -7
- package/src/contract-types.ts +1 -0
- package/src/contract.ts +2 -0
- package/src/declaration-validation.ts +266 -0
- package/src/define.ts +970 -87
- package/src/error-resolution.ts +127 -0
- package/src/errors.ts +52 -0
- package/src/fixture-sanitization.ts +248 -0
- package/src/health-scenario.ts +875 -0
- package/src/index.ts +204 -8
- package/src/lint.ts +408 -4
- package/src/native-address.ts +340 -0
- package/src/native-egress-policy.ts +358 -0
- package/src/observability.ts +51 -1
- package/src/provider.ts +133 -0
- package/src/runtime/auth-flow.ts +12 -0
- package/src/runtime/browser.ts +661 -63
- package/src/runtime/cache.ts +189 -14
- package/src/runtime/choice-wordlist.ts +145 -0
- package/src/runtime/choice.ts +631 -120
- package/src/runtime/executor.ts +40 -7
- package/src/runtime/http.ts +641 -61
- package/src/runtime/instrumentation.ts +520 -15
- package/src/runtime/native-network-errors.ts +99 -0
- package/src/runtime/native-network.ts +1605 -0
- package/src/runtime/ocr.ts +523 -0
- package/src/runtime/proxy-errors.ts +12 -4
- package/src/runtime/proxy-nodemaven.ts +221 -0
- package/src/runtime/proxy-telemetry.ts +244 -75
- package/src/runtime/redirects.ts +66 -0
- package/src/runtime/redis.ts +7 -2
- package/src/runtime/request-options.ts +680 -1
- package/src/runtime/resolver-config.ts +6 -0
- package/src/runtime/resolver-public.ts +20 -0
- package/src/runtime/resolver-shared.ts +17 -0
- package/src/runtime/resolver-vendors/bindings.ts +56 -0
- package/src/runtime/resolver-vendors/browser.ts +533 -0
- package/src/runtime/resolver-vendors/capsolver.ts +700 -0
- package/src/runtime/resolver-vendors/hosts.ts +38 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +539 -0
- package/src/runtime/resolver-vendors/types.ts +212 -0
- package/src/runtime/resolver.ts +1103 -0
- package/src/runtime/secrets.ts +64 -0
- package/src/runtime/state.ts +394 -77
- package/src/runtime/stealth-cookies.ts +132 -0
- package/src/runtime/stealth.ts +675 -289
- package/src/runtime/stt.ts +1 -19
- package/src/runtime/timeout.ts +18 -0
- package/src/runtime/trace-config.ts +77 -0
- package/src/serve.ts +6 -1
- package/src/server/index.ts +37 -2
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/server/self-test.ts +1025 -175
- package/src/server/serve-implementation.ts +3250 -0
- package/src/server/serve.ts +1 -1626
- package/src/server/trace-output.ts +32 -0
- package/src/server/types.ts +13 -1
- package/src/stateful/README.md +146 -0
- package/src/stateful/errors.ts +35 -0
- package/src/stateful/http-provider-event-emitter.ts +314 -0
- package/src/stateful/http-session-owner-registry.ts +306 -0
- package/src/stateful/index.ts +18 -0
- package/src/stateful/provider-event-delivery-failures.ts +80 -0
- package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
- package/src/stateful/provider-event-pipeline.ts +61 -0
- package/src/stateful/provider-events.ts +462 -0
- package/src/stateful/session-key.ts +111 -0
- package/src/stateful/stateful-provider-adapter-context.ts +59 -0
- package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
- package/src/stateful/stateful-provider-adapter.ts +562 -0
- package/src/stateful/stateful-provider-observability.ts +261 -0
- package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
- package/src/stateful/stateful-provider-runtime-context.ts +92 -0
- package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
- package/src/stateful/stateful-provider-session-routing.ts +546 -0
- package/src/stateful/stateful-provider-session-runtime.ts +403 -0
- package/src/stateful-signing.ts +46 -0
- package/src/stealth/profiles.ts +17 -7
- package/src/stream-evidence.ts +988 -0
- package/src/stream.ts +8 -1
- package/src/testing/index.ts +10 -1
- package/src/testing/run.ts +658 -15
- package/src/trace-sanitization.ts +63 -0
- package/src/types.ts +660 -38
- package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
- package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
- /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
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 {
|
|
@@ -11,18 +12,29 @@ import {
|
|
|
11
12
|
ProviderChoiceTokenError,
|
|
12
13
|
type ProviderChoiceTokenPayload,
|
|
13
14
|
} from "../choice-token.js";
|
|
14
|
-
import { ProviderError } from "../errors.js";
|
|
15
|
+
import { isProviderError, ProviderError } from "../errors.js";
|
|
16
|
+
import {
|
|
17
|
+
CHOICE_WORDLIST_SIZE,
|
|
18
|
+
choiceWordAt,
|
|
19
|
+
HIGH_CHOICE_WORD_COUNT,
|
|
20
|
+
isChoiceWord,
|
|
21
|
+
STANDARD_CHOICE_WORD_COUNT,
|
|
22
|
+
} from "./choice-wordlist.js";
|
|
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,
|
|
23
34
|
ProviderRequestContext,
|
|
24
35
|
ProviderRuntimeState,
|
|
25
36
|
ProviderStateDurationString,
|
|
37
|
+
StateValue,
|
|
26
38
|
} from "../types.js";
|
|
27
39
|
|
|
28
40
|
export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV =
|
|
@@ -30,6 +42,9 @@ export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV =
|
|
|
30
42
|
|
|
31
43
|
const PRIMARY_CHOICE_TOKEN_KID = "v1";
|
|
32
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.";
|
|
33
48
|
|
|
34
49
|
type ManagedChoiceEnvelope = {
|
|
35
50
|
readonly v: typeof MANAGED_CHOICE_TOKEN_VERSION;
|
|
@@ -51,6 +66,32 @@ type ServerChoiceHandlePayload = {
|
|
|
51
66
|
readonly created_at_ms: number;
|
|
52
67
|
};
|
|
53
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
|
+
|
|
54
95
|
export type CreateProviderChoiceContextOptions = {
|
|
55
96
|
readonly providerId: string;
|
|
56
97
|
readonly env?: EnvContext;
|
|
@@ -59,6 +100,8 @@ export type CreateProviderChoiceContextOptions = {
|
|
|
59
100
|
readonly state?: ProviderRuntimeState;
|
|
60
101
|
readonly masterSecret?: string;
|
|
61
102
|
readonly kid?: string;
|
|
103
|
+
/** Receives allowlisted metadata only; token and payload values are never included. */
|
|
104
|
+
readonly onTelemetry?: (event: ProviderChoiceTelemetryEvent) => void;
|
|
62
105
|
};
|
|
63
106
|
|
|
64
107
|
export function createProviderChoiceContext(
|
|
@@ -86,6 +129,40 @@ export function createProviderChoiceContext(
|
|
|
86
129
|
issueOptions: ProviderChoiceIssueOptions<TPayload>,
|
|
87
130
|
): string | Promise<string> {
|
|
88
131
|
const issuedAtMs = issueOptions.nowMs ?? Date.now();
|
|
132
|
+
const resolvedStorage = resolveIssueStorage(issueOptions.storage, issueOptions.payload);
|
|
133
|
+
if (resolvedStorage.mode === "server") {
|
|
134
|
+
const keys = hasRequestedChoiceBinding(issueOptions.bind)
|
|
135
|
+
? deriveManagedChoiceKeys({
|
|
136
|
+
masterSecret: resolveMasterSecret(),
|
|
137
|
+
providerId: options.providerId,
|
|
138
|
+
purpose: issueOptions.purpose,
|
|
139
|
+
kid,
|
|
140
|
+
})
|
|
141
|
+
: undefined;
|
|
142
|
+
const binding = hasRequestedChoiceBinding(issueOptions.bind)
|
|
143
|
+
? createChoiceBinding({
|
|
144
|
+
keys: keys!,
|
|
145
|
+
options: issueOptions.bind,
|
|
146
|
+
request: options.request,
|
|
147
|
+
credential: options.credential,
|
|
148
|
+
required: true,
|
|
149
|
+
})
|
|
150
|
+
: undefined;
|
|
151
|
+
const baseEnvelope = {
|
|
152
|
+
v: MANAGED_CHOICE_TOKEN_VERSION,
|
|
153
|
+
provider_id: options.providerId,
|
|
154
|
+
purpose: issueOptions.purpose,
|
|
155
|
+
issued_at_ms: issuedAtMs,
|
|
156
|
+
ttl_ms: issueOptions.ttlMs,
|
|
157
|
+
binding,
|
|
158
|
+
} satisfies Omit<ManagedChoiceEnvelope, "payload">;
|
|
159
|
+
return issueWordServerStoredChoice({
|
|
160
|
+
baseEnvelope,
|
|
161
|
+
issueOptions,
|
|
162
|
+
storage: resolvedStorage.storage,
|
|
163
|
+
contextState: options.state,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
89
166
|
const keys = deriveManagedChoiceKeys({
|
|
90
167
|
masterSecret: resolveMasterSecret(),
|
|
91
168
|
providerId: options.providerId,
|
|
@@ -106,18 +183,6 @@ export function createProviderChoiceContext(
|
|
|
106
183
|
required: true,
|
|
107
184
|
}),
|
|
108
185
|
};
|
|
109
|
-
const resolvedStorage = resolveIssueStorage(issueOptions.storage, issueOptions.payload);
|
|
110
|
-
if (resolvedStorage.mode === "server") {
|
|
111
|
-
return issueServerStoredChoice({
|
|
112
|
-
baseEnvelope,
|
|
113
|
-
issueOptions,
|
|
114
|
-
storage: resolvedStorage.storage,
|
|
115
|
-
contextState: options.state,
|
|
116
|
-
kid,
|
|
117
|
-
keys,
|
|
118
|
-
issuedAtMs,
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
186
|
const envelope: ManagedChoiceEnvelope = {
|
|
122
187
|
...baseEnvelope,
|
|
123
188
|
payload: issueOptions.payload,
|
|
@@ -130,6 +195,9 @@ export function createProviderChoiceContext(
|
|
|
130
195
|
});
|
|
131
196
|
}
|
|
132
197
|
|
|
198
|
+
function parse(
|
|
199
|
+
parseOptions: ProviderChoiceParseOptions & { readonly consume: "explicit" },
|
|
200
|
+
): Promise<ProviderChoiceExplicitParseResult>;
|
|
133
201
|
function parse(
|
|
134
202
|
parseOptions: ProviderChoiceParseOptions & {
|
|
135
203
|
readonly storage?: { readonly mode: "inline" };
|
|
@@ -147,68 +215,154 @@ export function createProviderChoiceContext(
|
|
|
147
215
|
): ProviderChoiceTokenPayload | Promise<ProviderChoiceTokenPayload>;
|
|
148
216
|
function parse(
|
|
149
217
|
parseOptions: ProviderChoiceParseOptions,
|
|
150
|
-
):
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
!authTag ||
|
|
159
|
-
!signature
|
|
160
|
-
) {
|
|
161
|
-
throw new ProviderChoiceTokenError(
|
|
162
|
-
"invalid_shape",
|
|
163
|
-
"Provider choice token shape is invalid.",
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const keys = deriveManagedChoiceKeys({
|
|
168
|
-
masterSecret: resolveMasterSecret(),
|
|
169
|
-
providerId: options.providerId,
|
|
170
|
-
purpose: parseOptions.purpose,
|
|
171
|
-
kid: tokenKid,
|
|
172
|
-
});
|
|
173
|
-
const signedBody = [parseOptions.prefix, tokenKid, encodedIv, encryptedPayload, authTag].join(
|
|
174
|
-
".",
|
|
175
|
-
);
|
|
176
|
-
assertManagedChoiceSignature({
|
|
177
|
-
signedBody,
|
|
178
|
-
signature,
|
|
179
|
-
signingKey: keys.signing,
|
|
180
|
-
});
|
|
181
|
-
const envelope = decryptManagedChoiceToken({
|
|
182
|
-
encodedIv,
|
|
183
|
-
encryptedPayload,
|
|
184
|
-
authTag,
|
|
185
|
-
encryptionKey: keys.encryption,
|
|
186
|
-
});
|
|
187
|
-
assertManagedChoiceEnvelope(envelope, {
|
|
188
|
-
providerId: options.providerId,
|
|
189
|
-
purpose: parseOptions.purpose,
|
|
190
|
-
ttlMs: parseOptions.ttlMs,
|
|
191
|
-
nowMs: parseOptions.nowMs,
|
|
192
|
-
futureToleranceMs: parseOptions.futureToleranceMs,
|
|
218
|
+
):
|
|
219
|
+
| ProviderChoiceTokenPayload
|
|
220
|
+
| ProviderChoiceExplicitParseResult
|
|
221
|
+
| Promise<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult> {
|
|
222
|
+
const consumeMode = parseOptions.consume ?? "never";
|
|
223
|
+
const wordStateKey = parseWordChoiceStateKey({
|
|
224
|
+
token: parseOptions.token,
|
|
225
|
+
prefix: parseOptions.prefix,
|
|
193
226
|
});
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
227
|
+
if (wordStateKey) {
|
|
228
|
+
const parsed = parseWordServerStoredChoice({
|
|
229
|
+
stateKey: wordStateKey,
|
|
230
|
+
parseOptions,
|
|
231
|
+
contextState: options.state,
|
|
232
|
+
providerId: options.providerId,
|
|
199
233
|
request: options.request,
|
|
200
234
|
credential: options.credential,
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
235
|
+
resolveBindingKeys: () =>
|
|
236
|
+
deriveManagedChoiceKeys({
|
|
237
|
+
masterSecret: resolveMasterSecret(),
|
|
238
|
+
providerId: options.providerId,
|
|
239
|
+
purpose: parseOptions.purpose,
|
|
240
|
+
kid,
|
|
241
|
+
}),
|
|
242
|
+
onConsume: (result) =>
|
|
243
|
+
emitChoiceTelemetry(options.onTelemetry, {
|
|
244
|
+
providerId: options.providerId,
|
|
245
|
+
purpose: parseOptions.purpose,
|
|
246
|
+
operation: "consume",
|
|
247
|
+
format: "word",
|
|
248
|
+
outcome: "success",
|
|
249
|
+
consumeMode,
|
|
250
|
+
consumed: result.status === "consumed",
|
|
251
|
+
replay: result.status === "already-consumed",
|
|
252
|
+
}),
|
|
209
253
|
});
|
|
254
|
+
return observeChoiceParse<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult>(
|
|
255
|
+
parsed,
|
|
256
|
+
{
|
|
257
|
+
onTelemetry: options.onTelemetry,
|
|
258
|
+
providerId: options.providerId,
|
|
259
|
+
purpose: parseOptions.purpose,
|
|
260
|
+
format: "word",
|
|
261
|
+
consumeMode,
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Inline choices continue to use the encrypted envelope. A structurally
|
|
267
|
+
// valid word token returns above, so lookup, expiry, consumption, and
|
|
268
|
+
// binding failures can never enter this branch.
|
|
269
|
+
try {
|
|
270
|
+
const [actualPrefix, tokenKid, encodedIv, encryptedPayload, authTag, signature] =
|
|
271
|
+
parseManagedChoiceTokenParts(parseOptions.token);
|
|
272
|
+
if (
|
|
273
|
+
actualPrefix !== parseOptions.prefix ||
|
|
274
|
+
tokenKid !== kid ||
|
|
275
|
+
!encodedIv ||
|
|
276
|
+
!encryptedPayload ||
|
|
277
|
+
!authTag ||
|
|
278
|
+
!signature
|
|
279
|
+
) {
|
|
280
|
+
throw new ProviderChoiceTokenError(
|
|
281
|
+
"invalid_shape",
|
|
282
|
+
"Provider choice token shape is invalid.",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const keys = deriveManagedChoiceKeys({
|
|
287
|
+
masterSecret: resolveMasterSecret(),
|
|
288
|
+
providerId: options.providerId,
|
|
289
|
+
purpose: parseOptions.purpose,
|
|
290
|
+
kid: tokenKid,
|
|
291
|
+
});
|
|
292
|
+
const signedBody = [parseOptions.prefix, tokenKid, encodedIv, encryptedPayload, authTag].join(
|
|
293
|
+
".",
|
|
294
|
+
);
|
|
295
|
+
assertManagedChoiceSignature({
|
|
296
|
+
signedBody,
|
|
297
|
+
signature,
|
|
298
|
+
signingKey: keys.signing,
|
|
299
|
+
});
|
|
300
|
+
const envelope = decryptManagedChoiceToken({
|
|
301
|
+
encodedIv,
|
|
302
|
+
encryptedPayload,
|
|
303
|
+
authTag,
|
|
304
|
+
encryptionKey: keys.encryption,
|
|
305
|
+
});
|
|
306
|
+
assertManagedChoiceEnvelope(envelope, {
|
|
307
|
+
providerId: options.providerId,
|
|
308
|
+
purpose: parseOptions.purpose,
|
|
309
|
+
ttlMs: parseOptions.ttlMs,
|
|
310
|
+
nowMs: parseOptions.nowMs,
|
|
311
|
+
futureToleranceMs: parseOptions.futureToleranceMs,
|
|
312
|
+
});
|
|
313
|
+
assertChoiceBindingMatches({
|
|
314
|
+
actual: envelope.binding,
|
|
315
|
+
expected: createChoiceBinding({
|
|
316
|
+
keys,
|
|
317
|
+
options: parseOptions.bind,
|
|
318
|
+
request: options.request,
|
|
319
|
+
credential: options.credential,
|
|
320
|
+
required: true,
|
|
321
|
+
}),
|
|
322
|
+
});
|
|
323
|
+
if (isServerChoiceHandlePayload(envelope.payload)) {
|
|
324
|
+
throw wordChoiceNotFoundError();
|
|
325
|
+
}
|
|
326
|
+
const payload = envelope.payload;
|
|
327
|
+
const parsed =
|
|
328
|
+
consumeMode === "explicit"
|
|
329
|
+
? Promise.resolve(payload).then((resolvedPayload) =>
|
|
330
|
+
createInlineExplicitParseResult({
|
|
331
|
+
payload: resolvedPayload,
|
|
332
|
+
replayKey: digestChoiceReplayKey(parseOptions.token),
|
|
333
|
+
onConsume: () =>
|
|
334
|
+
emitChoiceTelemetry(options.onTelemetry, {
|
|
335
|
+
providerId: options.providerId,
|
|
336
|
+
purpose: parseOptions.purpose,
|
|
337
|
+
operation: "consume",
|
|
338
|
+
format: "legacy",
|
|
339
|
+
outcome: "unsupported",
|
|
340
|
+
consumeMode,
|
|
341
|
+
consumed: false,
|
|
342
|
+
replay: false,
|
|
343
|
+
}),
|
|
344
|
+
}),
|
|
345
|
+
)
|
|
346
|
+
: payload;
|
|
347
|
+
return observeChoiceParse<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult>(
|
|
348
|
+
parsed,
|
|
349
|
+
{
|
|
350
|
+
onTelemetry: options.onTelemetry,
|
|
351
|
+
providerId: options.providerId,
|
|
352
|
+
purpose: parseOptions.purpose,
|
|
353
|
+
format: "legacy",
|
|
354
|
+
consumeMode,
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
emitChoiceParseFailure(options.onTelemetry, error, {
|
|
359
|
+
providerId: options.providerId,
|
|
360
|
+
purpose: parseOptions.purpose,
|
|
361
|
+
format: "legacy",
|
|
362
|
+
consumeMode,
|
|
363
|
+
});
|
|
364
|
+
throw error;
|
|
210
365
|
}
|
|
211
|
-
return envelope.payload;
|
|
212
366
|
}
|
|
213
367
|
|
|
214
368
|
return { issue, parse };
|
|
@@ -226,6 +380,109 @@ export function createTestProviderChoiceContext(
|
|
|
226
380
|
});
|
|
227
381
|
}
|
|
228
382
|
|
|
383
|
+
type ChoiceParseTelemetryBase = {
|
|
384
|
+
readonly onTelemetry?: (event: ProviderChoiceTelemetryEvent) => void;
|
|
385
|
+
readonly providerId: string;
|
|
386
|
+
readonly purpose: string;
|
|
387
|
+
readonly format: "word" | "legacy";
|
|
388
|
+
readonly consumeMode: ProviderChoiceConsumeMode;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
function observeChoiceParse<T>(
|
|
392
|
+
result: T | Promise<T>,
|
|
393
|
+
base: ChoiceParseTelemetryBase,
|
|
394
|
+
): T | Promise<T> {
|
|
395
|
+
if (result instanceof Promise) {
|
|
396
|
+
return result.then(
|
|
397
|
+
(value) => {
|
|
398
|
+
emitChoiceParseSuccess(base, value);
|
|
399
|
+
return value;
|
|
400
|
+
},
|
|
401
|
+
(error: unknown) => {
|
|
402
|
+
emitChoiceParseFailure(base.onTelemetry, error, base);
|
|
403
|
+
throw error;
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
emitChoiceParseSuccess(base, result);
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function emitChoiceParseSuccess(base: ChoiceParseTelemetryBase, result: unknown): void {
|
|
412
|
+
const replay = isConsumedChoiceReplay(result);
|
|
413
|
+
emitChoiceTelemetry(base.onTelemetry, {
|
|
414
|
+
providerId: base.providerId,
|
|
415
|
+
purpose: base.purpose,
|
|
416
|
+
operation: "parse",
|
|
417
|
+
format: base.format,
|
|
418
|
+
outcome: "success",
|
|
419
|
+
consumeMode: base.consumeMode,
|
|
420
|
+
consumed: replay || (base.format === "word" && base.consumeMode === "on-parse"),
|
|
421
|
+
replay,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isConsumedChoiceReplay(value: unknown): boolean {
|
|
426
|
+
return (
|
|
427
|
+
value !== null &&
|
|
428
|
+
typeof value === "object" &&
|
|
429
|
+
"status" in value &&
|
|
430
|
+
value.status === "consumed" &&
|
|
431
|
+
"replayKey" in value &&
|
|
432
|
+
typeof value.replayKey === "string"
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function emitChoiceParseFailure(
|
|
437
|
+
onTelemetry: CreateProviderChoiceContextOptions["onTelemetry"],
|
|
438
|
+
error: unknown,
|
|
439
|
+
base: Omit<ChoiceParseTelemetryBase, "onTelemetry">,
|
|
440
|
+
): void {
|
|
441
|
+
const outcome =
|
|
442
|
+
error instanceof ProviderChoiceTokenError
|
|
443
|
+
? base.format === "word" && error.message === WORD_CHOICE_NOT_FOUND_MESSAGE
|
|
444
|
+
? "not-found"
|
|
445
|
+
: "invalid"
|
|
446
|
+
: "error";
|
|
447
|
+
emitChoiceTelemetry(onTelemetry, {
|
|
448
|
+
providerId: base.providerId,
|
|
449
|
+
purpose: base.purpose,
|
|
450
|
+
operation: "parse",
|
|
451
|
+
format: base.format,
|
|
452
|
+
outcome,
|
|
453
|
+
consumeMode: base.consumeMode,
|
|
454
|
+
consumed: false,
|
|
455
|
+
replay: false,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function emitChoiceTelemetry(
|
|
460
|
+
onTelemetry: CreateProviderChoiceContextOptions["onTelemetry"],
|
|
461
|
+
event: ProviderChoiceTelemetryEvent,
|
|
462
|
+
): void {
|
|
463
|
+
try {
|
|
464
|
+
onTelemetry?.(event);
|
|
465
|
+
} catch {
|
|
466
|
+
// Observability must never change provider token semantics.
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function createInlineExplicitParseResult(options: {
|
|
471
|
+
readonly payload: ProviderChoiceTokenPayload;
|
|
472
|
+
readonly replayKey: string;
|
|
473
|
+
readonly onConsume: () => void;
|
|
474
|
+
}): ProviderChoiceExplicitParseResult {
|
|
475
|
+
return {
|
|
476
|
+
status: "active",
|
|
477
|
+
payload: options.payload,
|
|
478
|
+
replayKey: options.replayKey,
|
|
479
|
+
consume: async () => {
|
|
480
|
+
options.onConsume();
|
|
481
|
+
return { status: "unsupported" };
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
229
486
|
function resolveChoiceMasterSecret(options: CreateProviderChoiceContextOptions): string {
|
|
230
487
|
const configured =
|
|
231
488
|
options.masterSecret ?? options.env?.get(PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV);
|
|
@@ -300,80 +557,296 @@ function encryptManagedChoiceToken(options: {
|
|
|
300
557
|
return `${signedBody}.${signature}`;
|
|
301
558
|
}
|
|
302
559
|
|
|
303
|
-
async function
|
|
560
|
+
async function issueWordServerStoredChoice<TPayload extends ProviderChoiceTokenPayload>(options: {
|
|
304
561
|
readonly baseEnvelope: Omit<ManagedChoiceEnvelope, "payload">;
|
|
305
562
|
readonly issueOptions: ProviderChoiceIssueOptions<TPayload>;
|
|
306
563
|
readonly storage: ServerProviderChoiceStorageOptions;
|
|
307
564
|
readonly contextState?: ProviderRuntimeState;
|
|
308
|
-
readonly kid: string;
|
|
309
|
-
readonly keys: ManagedChoiceKeys;
|
|
310
|
-
readonly issuedAtMs: number;
|
|
311
565
|
}): Promise<string> {
|
|
312
566
|
const serializedPayload = serializeChoicePayload(options.issueOptions.payload);
|
|
313
|
-
const
|
|
314
|
-
if (payloadBytes > options.storage.maxValueBytes) {
|
|
315
|
-
throw new ProviderError("Provider choice payload exceeds state storage policy.", {
|
|
316
|
-
code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
|
|
317
|
-
category: "input_validation",
|
|
318
|
-
retryable: false,
|
|
319
|
-
details: {
|
|
320
|
-
maxValueBytes: options.storage.maxValueBytes,
|
|
321
|
-
payloadBytes,
|
|
322
|
-
},
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
const stateId = `choice_${randomBytes(16).toString("base64url")}`;
|
|
326
|
-
const digest = digestChoicePayload(serializedPayload);
|
|
567
|
+
const payloadDigest = digestChoicePayload(serializedPayload);
|
|
327
568
|
const namespace = resolveChoiceStateNamespace({
|
|
328
569
|
storage: options.storage,
|
|
329
570
|
contextState: options.contextState,
|
|
330
571
|
ttlMs: options.issueOptions.ttlMs,
|
|
331
572
|
});
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
573
|
+
const wordCount =
|
|
574
|
+
options.issueOptions.strength === "high" ? HIGH_CHOICE_WORD_COUNT : STANDARD_CHOICE_WORD_COUNT;
|
|
575
|
+
for (let attempt = 0; attempt < SERVER_STORED_CHOICE_ISSUE_ATTEMPTS; attempt += 1) {
|
|
576
|
+
const stateKey = generateChoiceWordSequence(wordCount);
|
|
577
|
+
const token = `${options.issueOptions.prefix}${stateKey}`;
|
|
578
|
+
const record: ServerStoredChoiceRecord = {
|
|
579
|
+
v: SERVER_STORED_CHOICE_RECORD_VERSION,
|
|
338
580
|
storage: "server",
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
581
|
+
status: "active",
|
|
582
|
+
provider_id: options.baseEnvelope.provider_id,
|
|
583
|
+
purpose: options.baseEnvelope.purpose,
|
|
584
|
+
issued_at_ms: options.baseEnvelope.issued_at_ms,
|
|
585
|
+
ttl_ms: options.baseEnvelope.ttl_ms,
|
|
586
|
+
binding: options.baseEnvelope.binding,
|
|
587
|
+
prefix: options.issueOptions.prefix,
|
|
588
|
+
payload: options.issueOptions.payload,
|
|
589
|
+
payload_digest: payloadDigest,
|
|
590
|
+
replay_key: digestChoiceReplayKey(token),
|
|
591
|
+
};
|
|
592
|
+
const valueBytes = Buffer.byteLength(JSON.stringify(record), "utf8");
|
|
593
|
+
if (valueBytes > options.storage.maxValueBytes) {
|
|
594
|
+
throw new ProviderError("Provider choice payload exceeds state storage policy.", {
|
|
595
|
+
code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
|
|
596
|
+
category: "input_validation",
|
|
597
|
+
retryable: false,
|
|
598
|
+
details: {
|
|
599
|
+
maxValueBytes: options.storage.maxValueBytes,
|
|
600
|
+
valueBytes,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
const result = await namespace.compareAndSet(optionsStateKey(stateKey), 0, record, {
|
|
605
|
+
ttl: stateTtl(options.storage, options.issueOptions.ttlMs),
|
|
606
|
+
});
|
|
607
|
+
if (result.ok) return token;
|
|
608
|
+
}
|
|
609
|
+
throw new ProviderError("Provider choice state storage is not available.", {
|
|
610
|
+
code: "CHOICE_STATE_UNAVAILABLE",
|
|
611
|
+
category: "internal_error",
|
|
612
|
+
retryable: false,
|
|
349
613
|
});
|
|
350
614
|
}
|
|
351
615
|
|
|
352
|
-
async function
|
|
353
|
-
readonly
|
|
354
|
-
readonly
|
|
616
|
+
async function parseWordServerStoredChoice(options: {
|
|
617
|
+
readonly stateKey: string;
|
|
618
|
+
readonly parseOptions: ProviderChoiceParseOptions;
|
|
355
619
|
readonly contextState?: ProviderRuntimeState;
|
|
356
|
-
|
|
357
|
-
|
|
620
|
+
readonly providerId: string;
|
|
621
|
+
readonly request?: ProviderRequestContext;
|
|
622
|
+
readonly credential?: CredentialContext;
|
|
623
|
+
readonly resolveBindingKeys: () => ManagedChoiceKeys;
|
|
624
|
+
readonly onConsume: (result: ProviderChoiceConsumeResult) => void;
|
|
625
|
+
}): Promise<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult> {
|
|
626
|
+
const storage = resolveParseStorage(options.parseOptions.storage);
|
|
358
627
|
const namespace = resolveChoiceStateNamespace({
|
|
359
628
|
storage,
|
|
360
629
|
contextState: options.contextState,
|
|
630
|
+
ttlMs: options.parseOptions.ttlMs,
|
|
361
631
|
});
|
|
362
|
-
|
|
363
|
-
|
|
632
|
+
let stored: StateValue<ServerStoredChoiceRecord> | null;
|
|
633
|
+
try {
|
|
634
|
+
stored = await namespace.get<ServerStoredChoiceRecord>(optionsStateKey(options.stateKey));
|
|
635
|
+
} catch (error) {
|
|
636
|
+
if (isProviderError(error)) throw error;
|
|
637
|
+
throw wordChoiceNotFoundError();
|
|
638
|
+
}
|
|
639
|
+
if (!stored || !isServerStoredChoiceRecord(stored.value)) {
|
|
640
|
+
throw wordChoiceNotFoundError();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const record = stored.value;
|
|
644
|
+
const expectedReplayKey = digestChoiceReplayKey(
|
|
645
|
+
`${options.parseOptions.prefix}${options.stateKey}`,
|
|
364
646
|
);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
647
|
+
try {
|
|
648
|
+
if (
|
|
649
|
+
record.provider_id !== options.providerId ||
|
|
650
|
+
record.purpose !== options.parseOptions.purpose ||
|
|
651
|
+
record.prefix !== options.parseOptions.prefix
|
|
652
|
+
) {
|
|
653
|
+
throw wordChoiceNotFoundError();
|
|
654
|
+
}
|
|
655
|
+
assertPayloadDigestMatches({
|
|
656
|
+
actual: digestChoicePayload(serializeChoicePayload(record.payload)),
|
|
657
|
+
expected: record.payload_digest,
|
|
658
|
+
});
|
|
659
|
+
assertPayloadDigestMatches({ actual: expectedReplayKey, expected: record.replay_key });
|
|
660
|
+
assertWordChoiceBindingMatches({
|
|
661
|
+
actual: record.binding,
|
|
662
|
+
requested: options.parseOptions.bind,
|
|
663
|
+
request: options.request,
|
|
664
|
+
credential: options.credential,
|
|
665
|
+
resolveKeys: options.resolveBindingKeys,
|
|
666
|
+
});
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (
|
|
669
|
+
error instanceof ProviderChoiceTokenError ||
|
|
670
|
+
(isProviderError(error) && error.code === "CHOICE_CONTEXT_REQUIRED")
|
|
671
|
+
) {
|
|
672
|
+
throw wordChoiceNotFoundError();
|
|
673
|
+
}
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
// Freshness is classified last, reachable only after every identity,
|
|
677
|
+
// integrity, and binding check above has passed (ADR 0006, amended
|
|
678
|
+
// 2026-08-20): a caller that proved the record's binding may observe the
|
|
679
|
+
// canonical stale error, while an unbound record keeps the collapsed
|
|
680
|
+
// not-found error so expiry never becomes an existence signal for
|
|
681
|
+
// guessable tokens.
|
|
682
|
+
try {
|
|
683
|
+
assertFreshProviderChoiceIssuedAt(record.issued_at_ms, {
|
|
684
|
+
ttlMs:
|
|
685
|
+
options.parseOptions.ttlMs != null
|
|
686
|
+
? Math.min(options.parseOptions.ttlMs, record.ttl_ms)
|
|
687
|
+
: record.ttl_ms,
|
|
688
|
+
nowMs: options.parseOptions.nowMs,
|
|
689
|
+
futureToleranceMs: options.parseOptions.futureToleranceMs,
|
|
690
|
+
});
|
|
691
|
+
} catch (error) {
|
|
692
|
+
const recordIsBound = Boolean(
|
|
693
|
+
record.binding?.connection_hash || record.binding?.credential_hash,
|
|
369
694
|
);
|
|
695
|
+
if (recordIsBound && error instanceof ProviderChoiceTokenError && error.reason === "stale") {
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
if (error instanceof ProviderChoiceTokenError) throw wordChoiceNotFoundError();
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const consumeMode = options.parseOptions.consume ?? "never";
|
|
703
|
+
if (record.status === "consumed") {
|
|
704
|
+
if (consumeMode === "explicit") {
|
|
705
|
+
return { status: "consumed", replayKey: record.replay_key };
|
|
706
|
+
}
|
|
707
|
+
throw wordChoiceNotFoundError();
|
|
708
|
+
}
|
|
709
|
+
if (consumeMode === "never") return record.payload;
|
|
710
|
+
if (consumeMode === "explicit") {
|
|
711
|
+
return {
|
|
712
|
+
status: "active",
|
|
713
|
+
payload: record.payload,
|
|
714
|
+
replayKey: record.replay_key,
|
|
715
|
+
consume: async () => {
|
|
716
|
+
const result = await consumeWordServerStoredChoice({
|
|
717
|
+
stateKey: options.stateKey,
|
|
718
|
+
stored,
|
|
719
|
+
record,
|
|
720
|
+
storage,
|
|
721
|
+
contextState: options.contextState,
|
|
722
|
+
});
|
|
723
|
+
options.onConsume(result);
|
|
724
|
+
return result;
|
|
725
|
+
},
|
|
726
|
+
};
|
|
370
727
|
}
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
728
|
+
const consumed = await consumeWordServerStoredChoice({
|
|
729
|
+
stateKey: options.stateKey,
|
|
730
|
+
stored,
|
|
731
|
+
record,
|
|
732
|
+
storage,
|
|
733
|
+
contextState: options.contextState,
|
|
375
734
|
});
|
|
376
|
-
|
|
735
|
+
if (consumed.status !== "consumed") throw wordChoiceNotFoundError();
|
|
736
|
+
return record.payload;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function consumeWordServerStoredChoice(options: {
|
|
740
|
+
readonly stateKey: string;
|
|
741
|
+
readonly stored: StateValue<ServerStoredChoiceRecord>;
|
|
742
|
+
readonly record: ServerStoredChoiceRecord;
|
|
743
|
+
readonly storage: ServerProviderChoiceStorageOptions;
|
|
744
|
+
readonly contextState?: ProviderRuntimeState;
|
|
745
|
+
}): Promise<ProviderChoiceConsumeResult> {
|
|
746
|
+
const namespace = resolveChoiceStateNamespace({
|
|
747
|
+
storage: options.storage,
|
|
748
|
+
contextState: options.contextState,
|
|
749
|
+
ttlMs: options.record.ttl_ms,
|
|
750
|
+
});
|
|
751
|
+
try {
|
|
752
|
+
const consumed = await namespace.compareAndSet(
|
|
753
|
+
optionsStateKey(options.stateKey),
|
|
754
|
+
options.stored.version,
|
|
755
|
+
{ ...options.record, status: "consumed" } satisfies ServerStoredChoiceRecord,
|
|
756
|
+
{ ttl: remainingStateTtl(options.stored.expiresAt) },
|
|
757
|
+
);
|
|
758
|
+
if (consumed.ok) return { status: "consumed" };
|
|
759
|
+
if (
|
|
760
|
+
consumed.current &&
|
|
761
|
+
isServerStoredChoiceRecord(consumed.current.value) &&
|
|
762
|
+
consumed.current.value.status === "consumed" &&
|
|
763
|
+
consumed.current.value.replay_key === options.record.replay_key
|
|
764
|
+
) {
|
|
765
|
+
return { status: "already-consumed" };
|
|
766
|
+
}
|
|
767
|
+
throw wordChoiceNotFoundError();
|
|
768
|
+
} catch (error) {
|
|
769
|
+
if (isProviderError(error)) throw error;
|
|
770
|
+
if (error instanceof ProviderChoiceTokenError) throw error;
|
|
771
|
+
throw wordChoiceNotFoundError();
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function generateChoiceWordSequence(wordCount: number): string {
|
|
776
|
+
return Array.from({ length: wordCount }, () =>
|
|
777
|
+
choiceWordAt(randomInt(CHOICE_WORDLIST_SIZE)),
|
|
778
|
+
).join("-");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function parseWordChoiceStateKey(options: {
|
|
782
|
+
readonly token: string;
|
|
783
|
+
readonly prefix: string;
|
|
784
|
+
}): string | null {
|
|
785
|
+
if (!options.token.startsWith(options.prefix)) return null;
|
|
786
|
+
const body = options.token.slice(options.prefix.length);
|
|
787
|
+
// The official list contains one hyphenated entry (`yo-yo`), so structural
|
|
788
|
+
// recognition uses dictionary-aware segmentation instead of assuming every
|
|
789
|
+
// hyphen is a word boundary.
|
|
790
|
+
if (!/^[a-z]+(?:-[a-z]+){3,9}$/.test(body)) return null;
|
|
791
|
+
const segments = body.split("-");
|
|
792
|
+
if (
|
|
793
|
+
!canSegmentChoiceWords(segments, 0, STANDARD_CHOICE_WORD_COUNT) &&
|
|
794
|
+
!canSegmentChoiceWords(segments, 0, HIGH_CHOICE_WORD_COUNT)
|
|
795
|
+
) {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
return body;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function canSegmentChoiceWords(
|
|
802
|
+
segments: readonly string[],
|
|
803
|
+
segmentIndex: number,
|
|
804
|
+
wordsRemaining: number,
|
|
805
|
+
): boolean {
|
|
806
|
+
if (wordsRemaining === 0) return segmentIndex === segments.length;
|
|
807
|
+
const segmentsRemaining = segments.length - segmentIndex;
|
|
808
|
+
if (segmentsRemaining < wordsRemaining) return false;
|
|
809
|
+
for (let end = segmentIndex + 1; end <= segments.length - (wordsRemaining - 1); end += 1) {
|
|
810
|
+
const candidate = segments.slice(segmentIndex, end).join("-");
|
|
811
|
+
if (candidate.length > 10) break;
|
|
812
|
+
if (isChoiceWord(candidate) && canSegmentChoiceWords(segments, end, wordsRemaining - 1)) {
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function isServerStoredChoiceRecord(value: unknown): value is ServerStoredChoiceRecord {
|
|
820
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
821
|
+
return (
|
|
822
|
+
"v" in value &&
|
|
823
|
+
value.v === SERVER_STORED_CHOICE_RECORD_VERSION &&
|
|
824
|
+
"storage" in value &&
|
|
825
|
+
value.storage === "server" &&
|
|
826
|
+
"status" in value &&
|
|
827
|
+
(value.status === "active" || value.status === "consumed") &&
|
|
828
|
+
"provider_id" in value &&
|
|
829
|
+
typeof value.provider_id === "string" &&
|
|
830
|
+
"purpose" in value &&
|
|
831
|
+
typeof value.purpose === "string" &&
|
|
832
|
+
"issued_at_ms" in value &&
|
|
833
|
+
typeof value.issued_at_ms === "number" &&
|
|
834
|
+
"ttl_ms" in value &&
|
|
835
|
+
typeof value.ttl_ms === "number" &&
|
|
836
|
+
(!("binding" in value) || value.binding === undefined || isChoiceBinding(value.binding)) &&
|
|
837
|
+
"prefix" in value &&
|
|
838
|
+
typeof value.prefix === "string" &&
|
|
839
|
+
"payload" in value &&
|
|
840
|
+
isChoicePayload(value.payload) &&
|
|
841
|
+
"payload_digest" in value &&
|
|
842
|
+
typeof value.payload_digest === "string" &&
|
|
843
|
+
"replay_key" in value &&
|
|
844
|
+
typeof value.replay_key === "string"
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function wordChoiceNotFoundError(): ProviderChoiceTokenError {
|
|
849
|
+
return new ProviderChoiceTokenError("invalid_payload", WORD_CHOICE_NOT_FOUND_MESSAGE);
|
|
377
850
|
}
|
|
378
851
|
|
|
379
852
|
type ServerProviderChoiceStorageOptions = Extract<
|
|
@@ -437,6 +910,11 @@ function stateTtl(
|
|
|
437
910
|
return storage.ttl ?? `${ttlMs ?? 1}ms`;
|
|
438
911
|
}
|
|
439
912
|
|
|
913
|
+
function remainingStateTtl(expiresAt: string): ProviderStateDurationString {
|
|
914
|
+
const remainingMs = Date.parse(expiresAt) - Date.now();
|
|
915
|
+
return `${Number.isFinite(remainingMs) ? Math.max(1, Math.floor(remainingMs)) : 1}ms`;
|
|
916
|
+
}
|
|
917
|
+
|
|
440
918
|
function optionsStateKey(stateId: string): string {
|
|
441
919
|
return stateId;
|
|
442
920
|
}
|
|
@@ -449,6 +927,10 @@ function digestChoicePayload(serializedPayload: string): string {
|
|
|
449
927
|
return createHash("sha256").update(serializedPayload).digest("base64url");
|
|
450
928
|
}
|
|
451
929
|
|
|
930
|
+
function digestChoiceReplayKey(token: string): string {
|
|
931
|
+
return createHash("sha256").update(token).digest("hex");
|
|
932
|
+
}
|
|
933
|
+
|
|
452
934
|
function isServerChoiceHandlePayload(
|
|
453
935
|
value: ProviderChoiceTokenPayload,
|
|
454
936
|
): value is ServerChoiceHandlePayload {
|
|
@@ -621,6 +1103,35 @@ function createChoiceBinding(options: {
|
|
|
621
1103
|
};
|
|
622
1104
|
}
|
|
623
1105
|
|
|
1106
|
+
function hasRequestedChoiceBinding(options?: ProviderChoiceBindingOptions): boolean {
|
|
1107
|
+
return options?.connection === true || Boolean(options?.credentialKeys?.length);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function assertWordChoiceBindingMatches(options: {
|
|
1111
|
+
readonly actual: ManagedChoiceEnvelope["binding"];
|
|
1112
|
+
readonly requested?: ProviderChoiceBindingOptions;
|
|
1113
|
+
readonly request?: ProviderRequestContext;
|
|
1114
|
+
readonly credential?: CredentialContext;
|
|
1115
|
+
readonly resolveKeys: () => ManagedChoiceKeys;
|
|
1116
|
+
}): void {
|
|
1117
|
+
const hasStoredBinding = Boolean(
|
|
1118
|
+
options.actual?.connection_hash || options.actual?.credential_hash,
|
|
1119
|
+
);
|
|
1120
|
+
const hasRequestedBinding = hasRequestedChoiceBinding(options.requested);
|
|
1121
|
+
if (!hasStoredBinding && !hasRequestedBinding) return;
|
|
1122
|
+
if (hasStoredBinding !== hasRequestedBinding) throw wordChoiceNotFoundError();
|
|
1123
|
+
assertChoiceBindingMatches({
|
|
1124
|
+
actual: options.actual,
|
|
1125
|
+
expected: createChoiceBinding({
|
|
1126
|
+
keys: options.resolveKeys(),
|
|
1127
|
+
options: options.requested,
|
|
1128
|
+
request: options.request,
|
|
1129
|
+
credential: options.credential,
|
|
1130
|
+
required: true,
|
|
1131
|
+
}),
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
624
1135
|
function hashRequiredConnection(options: {
|
|
625
1136
|
readonly keys: ManagedChoiceKeys;
|
|
626
1137
|
readonly request?: ProviderRequestContext;
|