@apifuse/provider-sdk 2.2.0-beta.27 → 2.2.0-beta.28

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/bin/apifuse-dev.ts +2 -2
  3. package/bin/apifuse-pack-smoke.ts +1 -1
  4. package/bin/apifuse-pack-types.ts +2 -1
  5. package/bin/apifuse-perf.ts +2 -4
  6. package/bin/apifuse-record.ts +1 -1
  7. package/dist/auth-turn/index.d.ts +1 -1
  8. package/dist/auth-turn/index.js +1 -1
  9. package/dist/ceremonies/index.js +52 -11
  10. package/dist/config/loader.d.ts +1 -1
  11. package/dist/config/loader.js +4 -2
  12. package/dist/index.d.ts +7 -6
  13. package/dist/index.js +4 -6
  14. package/dist/provider.d.ts +2 -1
  15. package/dist/provider.js +1 -1
  16. package/dist/runtime/auth-flow.js +1 -1
  17. package/dist/runtime/http.d.ts +1 -0
  18. package/dist/runtime/http.js +135 -12
  19. package/dist/runtime/instrumentation.js +1 -1
  20. package/dist/runtime/native-network-errors.d.ts +33 -0
  21. package/dist/runtime/native-network-errors.js +69 -0
  22. package/dist/runtime/native-network.d.ts +2 -33
  23. package/dist/runtime/native-network.js +2 -68
  24. package/dist/runtime/redis.d.ts +1 -1
  25. package/dist/runtime/redis.js +4 -2
  26. package/dist/runtime/resolver-config.d.ts +6 -0
  27. package/dist/runtime/resolver-config.js +6 -0
  28. package/dist/runtime/resolver-shared.d.ts +3 -0
  29. package/dist/runtime/resolver-shared.js +12 -0
  30. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +2 -1
  31. package/dist/runtime/resolver-vendors/twocaptcha.js +80 -43
  32. package/dist/runtime/resolver-vendors/types.d.ts +1 -1
  33. package/dist/runtime/resolver.d.ts +6 -10
  34. package/dist/runtime/resolver.js +19 -18
  35. package/dist/runtime/state.js +5 -115
  36. package/dist/runtime/stealth-cookies.d.ts +20 -0
  37. package/dist/runtime/stealth-cookies.js +111 -0
  38. package/dist/runtime/stealth.js +7 -130
  39. package/dist/serve.d.ts +1 -1
  40. package/dist/serve.js +1 -1
  41. package/dist/server/index.d.ts +1 -1
  42. package/dist/server/index.js +1 -1
  43. package/dist/server/self-test.d.ts +13 -0
  44. package/dist/server/self-test.js +124 -46
  45. package/dist/server/serve-implementation.d.ts +199 -0
  46. package/dist/server/serve-implementation.js +2054 -0
  47. package/dist/server/serve.d.ts +1 -187
  48. package/dist/server/serve.js +1 -1827
  49. package/dist/stateful/errors.d.ts +5 -0
  50. package/dist/stateful/errors.js +10 -0
  51. package/dist/stateful/stateful-provider-session-routing.d.ts +1 -5
  52. package/dist/stateful/stateful-provider-session-routing.js +2 -10
  53. package/dist/stream.js +7 -1
  54. package/package.json +27 -2
  55. package/src/auth-turn/index.ts +1 -1
  56. package/src/ceremonies/index.ts +68 -18
  57. package/src/config/loader.ts +5 -2
  58. package/src/index.ts +18 -23
  59. package/src/provider.ts +12 -14
  60. package/src/runtime/auth-flow.ts +1 -1
  61. package/src/runtime/http.ts +155 -11
  62. package/src/runtime/instrumentation.ts +1 -1
  63. package/src/runtime/native-network-errors.ts +99 -0
  64. package/src/runtime/native-network.ts +16 -97
  65. package/src/runtime/redis.ts +7 -2
  66. package/src/runtime/resolver-config.ts +6 -0
  67. package/src/runtime/resolver-shared.ts +17 -0
  68. package/src/runtime/resolver-vendors/twocaptcha.ts +100 -49
  69. package/src/runtime/resolver-vendors/types.ts +1 -0
  70. package/src/runtime/resolver.ts +47 -22
  71. package/src/runtime/state.ts +5 -144
  72. package/src/runtime/stealth-cookies.ts +132 -0
  73. package/src/runtime/stealth.ts +14 -157
  74. package/src/serve.ts +6 -1
  75. package/src/server/index.ts +1 -0
  76. package/src/server/self-test.ts +184 -59
  77. package/src/server/serve-implementation.ts +3024 -0
  78. package/src/server/serve.ts +1 -2661
  79. package/src/stateful/errors.ts +12 -0
  80. package/src/stateful/stateful-provider-session-routing.ts +2 -11
  81. package/src/stream.ts +8 -1
@@ -1,4 +1,5 @@
1
1
  import type { ChallengeSolution, ProviderChallenge } from "../../types.js";
2
+ import type { TraceRecorder } from "../trace.js";
2
3
  import { assertResolverHostAllowed } from "./hosts.js";
3
4
  import {
4
5
  type ResolverIdentity,
@@ -34,6 +35,7 @@ export interface TwoCaptchaResolverVendorAdapter extends ResolverVendorAdapter {
34
35
  challenge: ProviderChallenge,
35
36
  identity: ResolverIdentity | undefined,
36
37
  signal: AbortSignal,
38
+ traceRecorder?: TraceRecorder,
37
39
  ): Promise<Extract<ChallengeSolution, { readonly form: "token" }>>;
38
40
  }
39
41
 
@@ -231,6 +233,28 @@ function endpoint(baseUrl: string, path: string): string {
231
233
  return `${baseUrl.replace(/\/+$/u, "")}/${path}`;
232
234
  }
233
235
 
236
+ function spanErrorAttributes(
237
+ error: unknown,
238
+ phase: TwoCaptchaOperationPhase,
239
+ ): Record<string, unknown> | undefined {
240
+ if (error instanceof ResolverVendorUnavailableError) {
241
+ return {
242
+ unavailability_reason: error.reason,
243
+ transport_phase: error.phase,
244
+ };
245
+ }
246
+ // The solve budget raises TwoCaptchaSolveTimeoutError inside the spanned closure and
247
+ // the outer catch converts it only after the span has been finalized, so the span has
248
+ // to recognize it here or slow solves lose exactly the attribution these spans add.
249
+ if (error instanceof TwoCaptchaSolveTimeoutError) {
250
+ return {
251
+ unavailability_reason: "timeout",
252
+ transport_phase: phase,
253
+ };
254
+ }
255
+ return undefined;
256
+ }
257
+
234
258
  export function createTwoCaptchaResolverVendorAdapter(
235
259
  options: TwoCaptchaResolverVendorOptions,
236
260
  ): TwoCaptchaResolverVendorAdapter {
@@ -248,7 +272,7 @@ export function createTwoCaptchaResolverVendorAdapter(
248
272
  return resolverVendorSupports(TWOCAPTCHA_VENDOR_ID, kind);
249
273
  },
250
274
 
251
- async solve(challenge, identity, callerSignal) {
275
+ async solve(challenge, identity, callerSignal, traceRecorder) {
252
276
  const apiKey = options.apiKey?.trim();
253
277
  if (!apiKey) {
254
278
  throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "missing_credentials", {
@@ -285,62 +309,89 @@ export function createTwoCaptchaResolverVendorAdapter(
285
309
  let phase: TwoCaptchaOperationPhase = "create_task";
286
310
 
287
311
  try {
288
- const createResult = await postJson(
289
- fetchImpl,
290
- endpoint(baseUrl, "createTask"),
291
- {
292
- clientKey: apiKey,
293
- task: {
294
- type: proxy ? "RecaptchaV2Task" : "RecaptchaV2TaskProxyless",
295
- websiteURL: challenge.pageUrl,
296
- websiteKey: challenge.siteKey,
297
- isInvisible: false,
298
- ...(identity ? { userAgent: identity.userAgent } : {}),
299
- ...(proxy ?? {}),
300
- },
301
- },
302
- solveController.signal,
303
- phase,
304
- );
305
- const taskId = taskIdFrom(createResult.payload);
306
- if (!createResult.ok || createResult.payload.errorId !== 0 || taskId === undefined) {
307
- throw unavailableForPayload(createResult.payload, phase);
308
- }
309
-
310
- phase = "poll_result";
311
- while (true) {
312
- callerSignal.throwIfAborted();
313
- const remainingMs = timeoutMs - (now() - startedAt);
314
- if (remainingMs <= 0) throw new TwoCaptchaSolveTimeoutError();
315
- await delay(Math.min(pollIntervalMs, remainingMs), solveController.signal);
316
- callerSignal.throwIfAborted();
317
- if (now() - startedAt >= timeoutMs) throw new TwoCaptchaSolveTimeoutError();
318
-
319
- const pollResult = await postJson(
312
+ const createTask = async () => {
313
+ const createResult = await postJson(
320
314
  fetchImpl,
321
- endpoint(baseUrl, "getTaskResult"),
322
- { clientKey: apiKey, taskId },
315
+ endpoint(baseUrl, "createTask"),
316
+ {
317
+ clientKey: apiKey,
318
+ task: {
319
+ type: proxy ? "RecaptchaV2Task" : "RecaptchaV2TaskProxyless",
320
+ websiteURL: challenge.pageUrl,
321
+ websiteKey: challenge.siteKey,
322
+ isInvisible: false,
323
+ ...(identity ? { userAgent: identity.userAgent } : {}),
324
+ ...(proxy ?? {}),
325
+ },
326
+ },
323
327
  solveController.signal,
324
328
  phase,
325
329
  );
326
- if (!pollResult.ok || pollResult.payload.errorId !== 0) {
327
- throw unavailableForPayload(pollResult.payload, phase);
328
- }
329
- if (pollResult.payload.status === "processing") continue;
330
- if (pollResult.payload.status !== "ready") {
331
- throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
332
- phase,
333
- });
330
+ const taskId = taskIdFrom(createResult.payload);
331
+ if (!createResult.ok || createResult.payload.errorId !== 0 || taskId === undefined) {
332
+ throw unavailableForPayload(createResult.payload, phase);
334
333
  }
334
+ return taskId;
335
+ };
336
+ const taskId = traceRecorder
337
+ ? await traceRecorder.runSpan("resolver.vendor.create_task", createTask, {
338
+ attributes: {
339
+ vendor: TWOCAPTCHA_VENDOR_ID,
340
+ challenge_kind: challenge.kind,
341
+ },
342
+ onError: (error) => spanErrorAttributes(error, "create_task"),
343
+ })
344
+ : await createTask();
335
345
 
336
- const token = tokenFrom(pollResult.payload);
337
- if (!token?.trim()) {
338
- throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
346
+ phase = "poll_result";
347
+ const pollResult = async () => {
348
+ while (true) {
349
+ callerSignal.throwIfAborted();
350
+ const remainingMs = timeoutMs - (now() - startedAt);
351
+ if (remainingMs <= 0) throw new TwoCaptchaSolveTimeoutError();
352
+ await delay(Math.min(pollIntervalMs, remainingMs), solveController.signal);
353
+ callerSignal.throwIfAborted();
354
+ if (now() - startedAt >= timeoutMs) throw new TwoCaptchaSolveTimeoutError();
355
+
356
+ const pollResult = await postJson(
357
+ fetchImpl,
358
+ endpoint(baseUrl, "getTaskResult"),
359
+ { clientKey: apiKey, taskId },
360
+ solveController.signal,
339
361
  phase,
340
- });
362
+ );
363
+ if (!pollResult.ok || pollResult.payload.errorId !== 0) {
364
+ throw unavailableForPayload(pollResult.payload, phase);
365
+ }
366
+ if (pollResult.payload.status === "processing") continue;
367
+ if (pollResult.payload.status !== "ready") {
368
+ throw new ResolverVendorUnavailableError(
369
+ TWOCAPTCHA_VENDOR_ID,
370
+ "transport_failure",
371
+ { phase },
372
+ );
373
+ }
374
+
375
+ const token = tokenFrom(pollResult.payload);
376
+ if (!token?.trim()) {
377
+ throw new ResolverVendorUnavailableError(
378
+ TWOCAPTCHA_VENDOR_ID,
379
+ "transport_failure",
380
+ { phase },
381
+ );
382
+ }
383
+ return { form: "token" as const, token };
341
384
  }
342
- return { form: "token", token };
343
- }
385
+ };
386
+ return traceRecorder
387
+ ? await traceRecorder.runSpan("resolver.vendor.poll_result", pollResult, {
388
+ attributes: {
389
+ vendor: TWOCAPTCHA_VENDOR_ID,
390
+ challenge_kind: challenge.kind,
391
+ },
392
+ onError: (error) => spanErrorAttributes(error, "poll_result"),
393
+ })
394
+ : await pollResult();
344
395
  } catch (error) {
345
396
  if (callerSignal.aborted) throw abortReason(callerSignal);
346
397
  if (
@@ -112,6 +112,7 @@ export interface ResolverVendorAdapter {
112
112
 
113
113
  export type ResolverVendorUnavailableReason =
114
114
  | "missing_credentials"
115
+ | "missing_proxy_identity"
115
116
  | "missing_transport"
116
117
  | "allocation_exhausted"
117
118
  | "transport_failure"
@@ -6,6 +6,7 @@ import type {
6
6
  ProviderCache,
7
7
  ProviderChallenge,
8
8
  ProviderChallengeKind,
9
+ ProviderProxyMode,
9
10
  ProviderResolverConfig,
10
11
  ProviderResolverVendor,
11
12
  ResolverContext,
@@ -29,14 +30,32 @@ import {
29
30
  type ResolverVendorUnavailableReason,
30
31
  resolverVendorSupports,
31
32
  } from "./resolver-vendors/types.js";
33
+ import {
34
+ createUnsupportedResolverClient,
35
+ RESOLVER_INSTRUMENTATION_METADATA,
36
+ } from "./resolver-shared.js";
37
+ import {
38
+ APIFUSE__CDP_POOL__URL,
39
+ APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
40
+ APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
41
+ APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
42
+ APIFUSE__RESOLVER__TIMEOUT_MS,
43
+ DEFAULT_RESOLVER_TIMEOUT_MS,
44
+ } from "./resolver-config.js";
32
45
  import type { TraceRecorder } from "./trace.js";
33
46
 
34
- export const APIFUSE__RESOLVER__2CAPTCHA__API_KEY = "APIFUSE__RESOLVER__2CAPTCHA__API_KEY";
35
- export const APIFUSE__RESOLVER__CAPSOLVER__API_KEY = "APIFUSE__RESOLVER__CAPSOLVER__API_KEY";
36
- export const APIFUSE__RESOLVER__CAPMONSTER__API_KEY = "APIFUSE__RESOLVER__CAPMONSTER__API_KEY";
37
- export const APIFUSE__RESOLVER__TIMEOUT_MS = "APIFUSE__RESOLVER__TIMEOUT_MS";
38
- export const APIFUSE__CDP_POOL__URL = "APIFUSE__CDP_POOL__URL";
39
- export const DEFAULT_RESOLVER_TIMEOUT_MS = 180_000;
47
+ export {
48
+ createUnsupportedResolverClient,
49
+ RESOLVER_INSTRUMENTATION_METADATA,
50
+ } from "./resolver-shared.js";
51
+ export {
52
+ APIFUSE__CDP_POOL__URL,
53
+ APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
54
+ APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
55
+ APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
56
+ APIFUSE__RESOLVER__TIMEOUT_MS,
57
+ DEFAULT_RESOLVER_TIMEOUT_MS,
58
+ } from "./resolver-config.js";
40
59
 
41
60
  type EnvLike = Record<string, string | undefined>;
42
61
 
@@ -75,6 +94,8 @@ type ResolverChainClient = ResolverContext & {
75
94
  export interface ResolverRuntimeOptions {
76
95
  readonly allowedHosts?: readonly string[];
77
96
  readonly cache?: ProviderCache;
97
+ /** Provider-declared proxy intent. The SDK never accepts a caller-built identity. */
98
+ readonly proxyMode?: ProviderProxyMode;
78
99
  /** Server-owned context/proxy scope used only for identity-bound cache entries. */
79
100
  readonly identityScope?: string;
80
101
  /** SDK-owned transport already bound to the resolved proxy lease and client profile. */
@@ -143,10 +164,6 @@ const SAFE_CAUSE_MESSAGE_WORDS: ReadonlySet<string> = new Set([
143
164
  "writing",
144
165
  ]);
145
166
 
146
- export const RESOLVER_INSTRUMENTATION_METADATA = Symbol.for(
147
- "@apifuse/provider-sdk/runtime/resolver-instrumentation-metadata",
148
- );
149
-
150
167
  export type ResolverInstrumentationMetadata = {
151
168
  readonly target: ResolverContext;
152
169
  readonly traceRecorder: TraceRecorder;
@@ -340,7 +357,9 @@ function sanitizeCauseMessage(message: string): string {
340
357
  .map((token) => {
341
358
  if (token === "[REDACTED]" || /^[a-z][a-z\d+.-]*:\/\/[^\s]+$/i.test(token)) return token;
342
359
  const word = token.replace(/^[^a-z\d]+|[^a-z\d]+$/gi, "");
343
- return word.length > 0 && word.length <= 32 && SAFE_CAUSE_MESSAGE_WORDS.has(word.toLowerCase())
360
+ return word.length > 0 &&
361
+ word.length <= 32 &&
362
+ SAFE_CAUSE_MESSAGE_WORDS.has(word.toLowerCase())
344
363
  ? token
345
364
  : "[REDACTED]";
346
365
  });
@@ -691,6 +710,7 @@ function createResolverChainClient(options: {
691
710
  readonly unavailableReason?: string;
692
711
  readonly cache?: ProviderCache;
693
712
  readonly identity?: ResolverIdentity;
713
+ readonly proxyMode?: ProviderProxyMode;
694
714
  readonly identityScope?: string;
695
715
  readonly transport?: ResolverVendorTransport;
696
716
  readonly createTransport?: ResolverRuntimeOptions["createTransport"];
@@ -715,6 +735,19 @@ function createResolverChainClient(options: {
715
735
  const supportingEntries = options.entries.filter((entry) => entry.supports(challenge.kind));
716
736
  if (supportingEntries.length === 0) throwUnsupportedKind(challenge.kind);
717
737
  signal.throwIfAborted();
738
+ // A required proxy policy is checked before the cache. Solutions minted under a
739
+ // previous release are shared and long-lived, so consulting the cache first would
740
+ // keep reporting success without a proxy identity until every old entry expired.
741
+ const requiredProxyIdentityMissing =
742
+ options.proxyMode === "required" && options.identity === undefined;
743
+ if (requiredProxyIdentityMissing) {
744
+ throwExhausted(
745
+ supportingEntries.map((entry) => ({
746
+ vendor: entry.id,
747
+ reason: "missing_proxy_identity" as const,
748
+ })),
749
+ );
750
+ }
718
751
  if (options.cache && resolverChallengeIsCacheable(challenge)) {
719
752
  const cached = await findCachedSolution(
720
753
  options.cache,
@@ -804,6 +837,7 @@ export function createResolverClient(options: {
804
837
  readonly unavailableReason?: string;
805
838
  readonly cache?: ProviderCache;
806
839
  readonly identity?: ResolverIdentity;
840
+ readonly proxyMode?: ProviderProxyMode;
807
841
  readonly transport?: ResolverVendorTransport;
808
842
  readonly createTransport?: ResolverRuntimeOptions["createTransport"];
809
843
  readonly clientProfile?: string;
@@ -819,6 +853,7 @@ export function createResolverClient(options: {
819
853
  unavailableReason: options.unavailableReason,
820
854
  cache: options.cache,
821
855
  identity: options.identity,
856
+ proxyMode: options.proxyMode,
822
857
  transport: options.transport,
823
858
  createTransport: options.createTransport,
824
859
  clientProfile: options.clientProfile,
@@ -853,17 +888,6 @@ function resolveVendorAvailability(
853
888
  : { vendor, available: false, reason: "missing_credentials" };
854
889
  }
855
890
 
856
- export function createUnsupportedResolverClient(reason?: string): ResolverContext {
857
- return {
858
- async solve() {
859
- throw new ProviderError(reason ?? "Resolver runtime is not configured", {
860
- code: "RESOLVER_UNAVAILABLE",
861
- fix: "Declare resolver on the provider definition and configure vendor credentials.",
862
- });
863
- },
864
- };
865
- }
866
-
867
891
  export function bindResolverSignal(
868
892
  resolver: ResolverContext,
869
893
  defaultSignal: AbortSignal | undefined,
@@ -921,6 +945,7 @@ function createResolverClientFromEnvInternal(
921
945
  };
922
946
  }),
923
947
  cache: options.cache,
948
+ proxyMode: options.proxyMode,
924
949
  identityScope: options.identityScope,
925
950
  transport: options.transport,
926
951
  createTransport: options.createTransport,
@@ -18,7 +18,6 @@ import {
18
18
 
19
19
  const DEFAULT_REDIS_TIMEOUT_MS = 250;
20
20
  const REDIS_STATE_PREFIX = "apifuse:provider-state:v2";
21
- const LEGACY_REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
22
21
  const PROVIDER_SCOPE_DISCRIMINATOR = "scope:provider";
23
22
  const MISSING_CONNECTION_SCOPE_DISCRIMINATOR = "scope:connection:missing";
24
23
  const SET_WITH_QUOTA_SCRIPT = `
@@ -29,7 +28,6 @@ local index_ttl = tonumber(ARGV[4])
29
28
  local envelope = ARGV[5]
30
29
 
31
30
  redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
32
- redis.call("ZREM", KEYS[2], KEYS[3])
33
31
  local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
34
32
  if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
35
33
  return {0, false}
@@ -38,15 +36,11 @@ end
38
36
  redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
39
37
  redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
40
38
  redis.call("PEXPIRE", KEYS[2], index_ttl)
41
- if ARGV[6] == "1" then
42
- redis.call("SET", KEYS[3], ARGV[7], "PX", index_ttl)
43
- end
44
39
  return {1, envelope}
45
40
  `;
46
41
 
47
42
  const COMPARE_AND_SET_WITH_QUOTA_SCRIPT = `
48
43
  local current = redis.call("GET", KEYS[1])
49
- local scoped_present = current and true or false
50
44
  if current then
51
45
  local ok, decoded = pcall(cjson.decode, current)
52
46
  if ok and type(decoded) == "table" and decoded.deleted == true then
@@ -59,42 +53,6 @@ local max_entries = tonumber(ARGV[3])
59
53
  local expires_at = tonumber(ARGV[4])
60
54
  local index_ttl = tonumber(ARGV[5])
61
55
  local envelope = ARGV[6]
62
- local allow_legacy_claim = ARGV[7] == "1"
63
- local legacy_tombstone = ARGV[8]
64
-
65
- if not scoped_present then
66
- local legacy = redis.call("GET", KEYS[3])
67
- if legacy then
68
- local ok, decoded = pcall(cjson.decode, legacy)
69
- if ok and type(decoded) == "table" and decoded.deleted == true then
70
- legacy = false
71
- end
72
- end
73
- if legacy and not allow_legacy_claim then
74
- return {-1, false}
75
- end
76
- if legacy then
77
- local legacy_ttl = redis.call("PTTL", KEYS[3])
78
- if legacy_ttl == -1 then
79
- legacy_ttl = index_ttl
80
- elseif legacy_ttl < 1 then
81
- legacy_ttl = 1
82
- end
83
- if legacy_ttl > 0 then
84
- redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
85
- redis.call("ZREM", KEYS[2], KEYS[3])
86
- local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
87
- if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
88
- return {0, false}
89
- end
90
- redis.call("SET", KEYS[1], legacy, "PX", legacy_ttl)
91
- redis.call("ZADD", KEYS[2], now + legacy_ttl, KEYS[1])
92
- redis.call("PEXPIRE", KEYS[2], index_ttl)
93
- redis.call("SET", KEYS[3], legacy_tombstone, "PX", index_ttl)
94
- current = legacy
95
- end
96
- end
97
- end
98
56
 
99
57
  local current_version = 0
100
58
  local current_decoded = false
@@ -111,7 +69,6 @@ if current_version ~= tonumber(ARGV[1]) then
111
69
  end
112
70
 
113
71
  redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
114
- redis.call("ZREM", KEYS[2], KEYS[3])
115
72
  local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
116
73
  if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
117
74
  return {0, false}
@@ -125,63 +82,15 @@ end
125
82
  redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
126
83
  redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
127
84
  redis.call("PEXPIRE", KEYS[2], index_ttl)
128
- if allow_legacy_claim then
129
- redis.call("SET", KEYS[3], legacy_tombstone, "PX", index_ttl)
130
- end
131
85
  return {1, envelope}
132
86
  `;
133
87
 
134
88
  const DELETE_WITH_INDEX_SCRIPT = `
135
89
  redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2])
136
- if ARGV[3] == "1" then
137
- redis.call("SET", KEYS[3], ARGV[1], "PX", ARGV[2])
138
- end
139
- redis.call("ZREM", KEYS[2], KEYS[1], KEYS[3])
90
+ redis.call("ZREM", KEYS[2], KEYS[1])
140
91
  return 1
141
92
  `;
142
93
 
143
- // A v1 key has no connection discriminator, so it cannot safely be exposed to
144
- // every v2 scope. The first concrete connection (or explicit provider scope) to
145
- // request a key atomically adopts that value and tombstones v1. The missing-
146
- // connection sentinel never claims legacy state.
147
- const CLAIM_LEGACY_SCRIPT = `
148
- local scoped = redis.call("GET", KEYS[1])
149
- if scoped then
150
- return {1, scoped}
151
- end
152
-
153
- local legacy = redis.call("GET", KEYS[3])
154
- if not legacy then
155
- return {1, false}
156
- end
157
- local ok, decoded = pcall(cjson.decode, legacy)
158
- if ok and type(decoded) == "table" and decoded.deleted == true then
159
- return {1, false}
160
- end
161
- local legacy_ttl = redis.call("PTTL", KEYS[3])
162
- if legacy_ttl == -1 then
163
- legacy_ttl = tonumber(ARGV[3])
164
- elseif legacy_ttl < 1 then
165
- legacy_ttl = 1
166
- end
167
-
168
- local now = tonumber(ARGV[1])
169
- local max_entries = tonumber(ARGV[2])
170
- local index_ttl = tonumber(ARGV[3])
171
- redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
172
- redis.call("ZREM", KEYS[2], KEYS[3])
173
- local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
174
- if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
175
- return {0, false}
176
- end
177
-
178
- redis.call("SET", KEYS[1], legacy, "PX", legacy_ttl)
179
- redis.call("ZADD", KEYS[2], now + legacy_ttl, KEYS[1])
180
- redis.call("PEXPIRE", KEYS[2], index_ttl)
181
- redis.call("SET", KEYS[3], ARGV[4], "PX", index_ttl)
182
- return {1, legacy}
183
- `;
184
-
185
94
  type RedisProviderRuntimeStateOptions = {
186
95
  readonly redisUrl: string;
187
96
  readonly providerId?: string;
@@ -268,18 +177,6 @@ function providerStateKey(
268
177
  return `${providerStatePrefix(providerId, namespace, scopeDiscriminator)}:${key}`;
269
178
  }
270
179
 
271
- function legacyProviderStatePrefix(providerId: string | undefined, namespace: string): string {
272
- return `${LEGACY_REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}`;
273
- }
274
-
275
- function legacyProviderStateKey(
276
- providerId: string | undefined,
277
- namespace: string,
278
- key: string,
279
- ): string {
280
- return `${legacyProviderStatePrefix(providerId, namespace)}:${key}`;
281
- }
282
-
283
180
  function publicStateKey(redisKey: string, prefixes: readonly string[]): string {
284
181
  for (const prefix of prefixes) {
285
182
  const prefixWithSeparator = `${prefix}:`;
@@ -391,10 +288,6 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
391
288
  );
392
289
  }
393
290
 
394
- private legacyRedisKey(key: string): string {
395
- return legacyProviderStateKey(this.providerId, this.namespaceName, key);
396
- }
397
-
398
291
  private statePrefix(): string {
399
292
  return providerStatePrefix(
400
293
  this.providerId,
@@ -418,10 +311,6 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
418
311
  return `${REDIS_STATE_PREFIX}:index:${namespaceIdentity}`;
419
312
  }
420
313
 
421
- private canClaimLegacy(): boolean {
422
- return this.scopeDiscriminator !== MISSING_CONNECTION_SCOPE_DISCRIMINATOR;
423
- }
424
-
425
314
  private async indexedKeys(limit: number): Promise<string[]> {
426
315
  await requireRedisReady(this.backend.redis);
427
316
  const now = Date.now();
@@ -440,27 +329,7 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
440
329
 
441
330
  private async readRaw(key: string): Promise<string | null> {
442
331
  await requireRedisReady(this.backend.redis);
443
- if (!this.canClaimLegacy()) {
444
- return await withRequiredRedis(() => this.backend.redis.get(this.redisKey(key)));
445
- }
446
- const tombstone = JSON.stringify({ deleted: true } satisfies RedisStateTombstone);
447
- const result = await withRequiredRedis(() =>
448
- this.backend.redis.eval(
449
- CLAIM_LEGACY_SCRIPT,
450
- 3,
451
- this.redisKey(key),
452
- this.indexKey(),
453
- this.legacyRedisKey(key),
454
- String(Date.now()),
455
- String(this.options.maxEntries),
456
- String(parseStateDurationMs(this.options.maxTtl)),
457
- tombstone,
458
- ),
459
- );
460
- if (Array.isArray(result) && Number(result[0]) === 0) {
461
- throw this.quotaExceeded();
462
- }
463
- return Array.isArray(result) && typeof result[1] === "string" ? result[1] : null;
332
+ return await withRequiredRedis(() => this.backend.redis.get(this.redisKey(key)));
464
333
  }
465
334
 
466
335
  private enforceValueSize(value: unknown): void {
@@ -534,17 +403,14 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
534
403
  const result = await withRequiredRedis(() =>
535
404
  this.backend.redis.eval(
536
405
  SET_WITH_QUOTA_SCRIPT,
537
- 3,
406
+ 2,
538
407
  this.redisKey(key),
539
408
  this.indexKey(),
540
- this.legacyRedisKey(key),
541
409
  String(Date.now()),
542
410
  String(this.options.maxEntries),
543
411
  String(timing.expiresAtMs),
544
412
  String(timing.indexTtlMs),
545
413
  JSON.stringify(envelope),
546
- this.canClaimLegacy() ? "1" : "0",
547
- JSON.stringify({ deleted: true } satisfies RedisStateTombstone),
548
414
  ),
549
415
  );
550
416
  if (!Array.isArray(result) || Number(result[0]) !== 1) {
@@ -590,18 +456,15 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
590
456
  const result = await withRequiredRedis(() =>
591
457
  this.backend.redis.eval(
592
458
  COMPARE_AND_SET_WITH_QUOTA_SCRIPT,
593
- 3,
459
+ 2,
594
460
  this.redisKey(key),
595
461
  this.indexKey(),
596
- this.legacyRedisKey(key),
597
462
  String(expectedVersion),
598
463
  String(Date.now()),
599
464
  String(this.options.maxEntries),
600
465
  String(timing.expiresAtMs),
601
466
  String(timing.indexTtlMs),
602
467
  JSON.stringify(envelope),
603
- this.canClaimLegacy() ? "1" : "0",
604
- JSON.stringify({ deleted: true } satisfies RedisStateTombstone),
605
468
  ),
606
469
  );
607
470
  if (Array.isArray(result) && Number(result[0]) === 0) {
@@ -627,13 +490,11 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
627
490
  await withRequiredRedis(() =>
628
491
  this.backend.redis.eval(
629
492
  DELETE_WITH_INDEX_SCRIPT,
630
- 3,
493
+ 2,
631
494
  this.redisKey(key),
632
495
  this.indexKey(),
633
- this.legacyRedisKey(key),
634
496
  tombstone,
635
497
  String(parseStateDurationMs(this.options.maxTtl)),
636
- this.canClaimLegacy() ? "1" : "0",
637
498
  ),
638
499
  );
639
500
  }