@apifuse/provider-sdk 2.2.0-beta.20 → 2.2.0-beta.22

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.
@@ -17,6 +17,93 @@ import {
17
17
 
18
18
  const DEFAULT_REDIS_TIMEOUT_MS = 250;
19
19
  const REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
20
+ const LEGACY_INDEX_SCAN_COUNT = 256;
21
+ const LEGACY_INDEX_SCAN_MAX_PAGES = 8;
22
+ const SET_WITH_QUOTA_SCRIPT = `
23
+ local now = tonumber(ARGV[1])
24
+ local max_entries = tonumber(ARGV[2])
25
+ local expires_at = tonumber(ARGV[3])
26
+ local index_ttl = tonumber(ARGV[4])
27
+ local envelope = ARGV[5]
28
+
29
+ redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
30
+ local exists = redis.call("EXISTS", KEYS[1])
31
+ local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
32
+ if exists == 0 and not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
33
+ return {0, false}
34
+ end
35
+
36
+ redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
37
+ redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
38
+ redis.call("PEXPIRE", KEYS[2], index_ttl)
39
+ return {1, envelope}
40
+ `;
41
+
42
+ const COMPARE_AND_SET_WITH_QUOTA_SCRIPT = `
43
+ local current = redis.call("GET", KEYS[1])
44
+ local current_version = 0
45
+ if current then
46
+ local ok, decoded = pcall(cjson.decode, current)
47
+ if not ok or type(decoded) ~= "table" or type(decoded.version) ~= "number" then
48
+ return {-2, current}
49
+ end
50
+ current_version = decoded.version
51
+ end
52
+ if current_version ~= tonumber(ARGV[1]) then
53
+ return {-1, current or false}
54
+ end
55
+
56
+ local now = tonumber(ARGV[2])
57
+ local max_entries = tonumber(ARGV[3])
58
+ local expires_at = tonumber(ARGV[4])
59
+ local index_ttl = tonumber(ARGV[5])
60
+ local envelope = ARGV[6]
61
+ redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
62
+ local exists = current and 1 or 0
63
+ local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
64
+ if exists == 0 and not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
65
+ return {0, false}
66
+ end
67
+
68
+ redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
69
+ redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
70
+ redis.call("PEXPIRE", KEYS[2], index_ttl)
71
+ return {1, envelope}
72
+ `;
73
+
74
+ const DELETE_WITH_INDEX_SCRIPT = `
75
+ redis.call("DEL", KEYS[1])
76
+ redis.call("ZREM", KEYS[2], KEYS[1])
77
+ return 1
78
+ `;
79
+
80
+ // Older SDKs wrote only the value key. Every operation that depends on the
81
+ // namespace index advances a bounded SCAN cursor and lazily imports active
82
+ // legacy envelopes into the new ZSET. The cursor is
83
+ // deliberately cyclic rather than permanently "complete": an old pod may
84
+ // still write an unindexed key during a rolling deploy. Each list/write call
85
+ // does a fixed amount of migration work; Redis KEYS and unbounded scans remain
86
+ // forbidden.
87
+ const BACKFILL_LEGACY_INDEX_SCRIPT = `
88
+ local now = tonumber(ARGV[1])
89
+ local index_ttl = tonumber(ARGV[2])
90
+ local next_cursor = ARGV[3]
91
+
92
+ redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", now)
93
+ for i = 4, #ARGV, 3 do
94
+ local key = ARGV[i]
95
+ local expected = ARGV[i + 1]
96
+ local expires_at = tonumber(ARGV[i + 2])
97
+ if redis.call("GET", key) == expected then
98
+ redis.call("ZADD", KEYS[1], "NX", expires_at, key)
99
+ end
100
+ end
101
+ if redis.call("EXISTS", KEYS[1]) == 1 then
102
+ redis.call("PEXPIRE", KEYS[1], index_ttl)
103
+ end
104
+ redis.call("SET", KEYS[2], next_cursor, "PX", index_ttl)
105
+ return redis.call("ZCARD", KEYS[1])
106
+ `;
20
107
 
21
108
  type RedisProviderRuntimeStateOptions = {
22
109
  readonly redisUrl: string;
@@ -87,6 +174,10 @@ function publicStateKey(
87
174
  return redisKey.startsWith(prefix) ? redisKey.slice(prefix.length) : redisKey;
88
175
  }
89
176
 
177
+ function redisGlobLiteral(value: string): string {
178
+ return value.replace(/[\\*?\[\]]/g, "\\$&");
179
+ }
180
+
90
181
  function parseStateDurationMs(ttl: StateWriteOptions["ttl"]): number {
91
182
  const match = /^(\d+)(ms|s|m|h|d)$/.exec(ttl ?? "1h");
92
183
  if (!match) return 3_600_000;
@@ -171,13 +262,91 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
171
262
  return providerStateKey(this.providerId, this.namespaceName, key);
172
263
  }
173
264
 
174
- private prefix(): string {
265
+ private indexKey(): string {
266
+ // Keep bookkeeping outside the caller-owned keyspace. A provider may use
267
+ // any state key (including "__index"), so a suffix inside the namespace
268
+ // could turn the ZSET into a string and break every subsequent write.
269
+ const namespaceIdentity = Buffer.from(
270
+ providerStatePrefix(this.providerId, this.namespaceName),
271
+ "utf8",
272
+ ).toString("base64url");
273
+ return `${REDIS_STATE_PREFIX}:index:${namespaceIdentity}`;
274
+ }
275
+
276
+ private legacyScanCursorKey(): string {
277
+ return `${this.indexKey()}:legacy-scan-cursor`;
278
+ }
279
+
280
+ private legacyPrefix(): string {
175
281
  return `${providerStatePrefix(this.providerId, this.namespaceName)}:`;
176
282
  }
177
283
 
178
- private async activeKeys(): Promise<string[]> {
284
+ private async backfillLegacyIndex(): Promise<void> {
179
285
  await requireRedisReady(this.backend.redis);
180
- return await withRequiredRedis(() => this.backend.redis.keys(`${this.prefix()}*`));
286
+ const cursorKey = this.legacyScanCursorKey();
287
+ let cursor =
288
+ (await withRequiredRedis(() => this.backend.redis.get(cursorKey))) ?? "0";
289
+ const pattern = `${redisGlobLiteral(this.legacyPrefix())}*`;
290
+ const indexTtlMs = parseStateDurationMs(this.options.maxTtl);
291
+
292
+ for (let page = 0; page < LEGACY_INDEX_SCAN_MAX_PAGES; page += 1) {
293
+ const [nextCursor, keys] = await withRequiredRedis(() =>
294
+ this.backend.redis.scan(
295
+ cursor,
296
+ "MATCH",
297
+ pattern,
298
+ "COUNT",
299
+ LEGACY_INDEX_SCAN_COUNT,
300
+ ),
301
+ );
302
+ const rawValues =
303
+ keys.length > 0
304
+ ? await withRequiredRedis(() => this.backend.redis.mget(keys))
305
+ : [];
306
+ const now = Date.now();
307
+ const activeLegacyArgs: string[] = [];
308
+ for (const [index, raw] of rawValues.entries()) {
309
+ const key = keys[index];
310
+ if (!key || !raw) continue;
311
+ const envelope = envelopeFromJson(
312
+ publicStateKey(this.providerId, this.namespaceName, key),
313
+ raw,
314
+ );
315
+ const expiresAtMs = envelope ? Date.parse(envelope.expiresAt) : Number.NaN;
316
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) continue;
317
+ activeLegacyArgs.push(key, raw, String(expiresAtMs));
318
+ }
319
+ await withRequiredRedis(() =>
320
+ this.backend.redis.eval(
321
+ BACKFILL_LEGACY_INDEX_SCRIPT,
322
+ 2,
323
+ this.indexKey(),
324
+ cursorKey,
325
+ String(now),
326
+ String(indexTtlMs),
327
+ nextCursor,
328
+ ...activeLegacyArgs,
329
+ ),
330
+ );
331
+ cursor = nextCursor;
332
+ if (cursor === "0") break;
333
+ }
334
+ }
335
+
336
+ private async indexedKeys(limit: number): Promise<string[]> {
337
+ await requireRedisReady(this.backend.redis);
338
+ const now = Date.now();
339
+ return await withRequiredRedis(async () => {
340
+ await this.backend.redis.zremrangebyscore(this.indexKey(), "-inf", now);
341
+ return await this.backend.redis.zrangebyscore(
342
+ this.indexKey(),
343
+ now + 1,
344
+ "+inf",
345
+ "LIMIT",
346
+ 0,
347
+ limit,
348
+ );
349
+ });
181
350
  }
182
351
 
183
352
  private enforceValueSize(value: unknown): void {
@@ -189,23 +358,41 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
189
358
  }
190
359
  }
191
360
 
192
- private async enforceMaxEntries(key: string): Promise<void> {
193
- const keys = await this.activeKeys();
194
- const redisKey = this.redisKey(key);
195
- const otherKeys = keys.filter((candidate) => candidate !== redisKey);
196
- if (otherKeys.length >= this.options.maxEntries) {
361
+ private quotaExceeded(): UnsupportedProviderStateError {
362
+ return new UnsupportedProviderStateError(
363
+ `Provider runtime state namespace quota exceeded (${this.options.maxEntries + 1} > ${this.options.maxEntries})`,
364
+ );
365
+ }
366
+
367
+ private writeTiming(ttl: StateWriteOptions["ttl"]): {
368
+ expiresAt: string;
369
+ expiresAtMs: number;
370
+ indexTtlMs: number;
371
+ } {
372
+ const ttlMs = parseStateDurationMs(ttl ?? this.options.defaultTtl);
373
+ const maxTtlMs = parseStateDurationMs(this.options.maxTtl);
374
+ if (ttlMs > maxTtlMs) {
197
375
  throw new UnsupportedProviderStateError(
198
- `Provider runtime state namespace quota exceeded (${otherKeys.length + 1} > ${this.options.maxEntries})`,
376
+ `Provider runtime state ttl exceeds maxTtl (${ttlMs} > ${maxTtlMs})`,
199
377
  );
200
378
  }
379
+ const expiresAtMs = Date.now() + ttlMs;
380
+ return {
381
+ expiresAt: new Date(expiresAtMs).toISOString(),
382
+ expiresAtMs,
383
+ indexTtlMs: maxTtlMs,
384
+ };
201
385
  }
202
386
 
203
387
  async list<T>(options?: { limit?: number; prefix?: string }): Promise<StateValue<T>[]> {
204
- const keys = (await this.activeKeys()).filter((key) => {
388
+ const requestedLimit = Math.max(0, options?.limit ?? this.options.maxEntries);
389
+ if (requestedLimit === 0) return [];
390
+ await this.backfillLegacyIndex();
391
+ const keys = (await this.indexedKeys(this.options.maxEntries)).filter((key) => {
205
392
  const publicKey = publicStateKey(this.providerId, this.namespaceName, key);
206
393
  return options?.prefix ? publicKey.startsWith(options.prefix) : true;
207
394
  });
208
- const limited = keys.slice(0, Math.max(0, options?.limit ?? keys.length));
395
+ const limited = keys.slice(0, requestedLimit);
209
396
  if (limited.length === 0) return [];
210
397
  const values = await withRequiredRedis(() => this.backend.redis.mget(limited));
211
398
  return values.flatMap((raw, index) => {
@@ -224,22 +411,34 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
224
411
 
225
412
  async set<T>(key: string, value: T, options?: StateWriteOptions): Promise<StateValue<T>> {
226
413
  this.enforceValueSize(value);
227
- await this.enforceMaxEntries(key);
414
+ await this.backfillLegacyIndex();
228
415
  const current = await this.get<T>(key);
229
416
  const createdAt = current?.createdAt ?? new Date().toISOString();
230
417
  const version = (current?.version ?? 0) + 1;
231
- const ttl = options?.ttl ?? this.options.defaultTtl;
232
- const ttlMs = parseStateDurationMs(ttl);
233
- const expiresAt = resolveExpiresAt(ttl);
234
- const envelope = redisEnvelope(value, version, createdAt, expiresAt);
235
- await withRequiredRedis(() =>
236
- this.backend.redis.set(this.redisKey(key), JSON.stringify(envelope), "PX", ttlMs),
418
+ const timing = this.writeTiming(options?.ttl);
419
+ const envelope = redisEnvelope(value, version, createdAt, timing.expiresAt);
420
+ await requireRedisReady(this.backend.redis);
421
+ const result = await withRequiredRedis(() =>
422
+ this.backend.redis.eval(
423
+ SET_WITH_QUOTA_SCRIPT,
424
+ 2,
425
+ this.redisKey(key),
426
+ this.indexKey(),
427
+ String(Date.now()),
428
+ String(this.options.maxEntries),
429
+ String(timing.expiresAtMs),
430
+ String(timing.indexTtlMs),
431
+ JSON.stringify(envelope),
432
+ ),
237
433
  );
434
+ if (!Array.isArray(result) || Number(result[0]) !== 1) {
435
+ throw this.quotaExceeded();
436
+ }
238
437
  return {
239
438
  key,
240
439
  value,
241
440
  version,
242
- expiresAt,
441
+ expiresAt: timing.expiresAt,
243
442
  createdAt,
244
443
  updatedAt: envelope.updatedAt,
245
444
  };
@@ -263,16 +462,64 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
263
462
  options?: StateWriteOptions,
264
463
  ): Promise<StateCasResult<T>> {
265
464
  this.enforceValueSize(value);
465
+ await this.backfillLegacyIndex();
266
466
  const current = await this.get<T>(key);
267
467
  if ((current?.version ?? 0) !== expectedVersion) {
268
468
  return { ok: false, current };
269
469
  }
270
- return { ok: true, value: await this.set(key, value, options) };
470
+ const createdAt = current?.createdAt ?? new Date().toISOString();
471
+ const timing = this.writeTiming(options?.ttl);
472
+ const envelope = redisEnvelope(
473
+ value,
474
+ expectedVersion + 1,
475
+ createdAt,
476
+ timing.expiresAt,
477
+ );
478
+ await requireRedisReady(this.backend.redis);
479
+ const result = await withRequiredRedis(() =>
480
+ this.backend.redis.eval(
481
+ COMPARE_AND_SET_WITH_QUOTA_SCRIPT,
482
+ 2,
483
+ this.redisKey(key),
484
+ this.indexKey(),
485
+ String(expectedVersion),
486
+ String(Date.now()),
487
+ String(this.options.maxEntries),
488
+ String(timing.expiresAtMs),
489
+ String(timing.indexTtlMs),
490
+ JSON.stringify(envelope),
491
+ ),
492
+ );
493
+ if (Array.isArray(result) && Number(result[0]) === 0) {
494
+ throw this.quotaExceeded();
495
+ }
496
+ if (!Array.isArray(result) || Number(result[0]) !== 1) {
497
+ const rawCurrent = Array.isArray(result) && typeof result[1] === "string" ? result[1] : null;
498
+ return { ok: false, current: envelopeFromJson(key, rawCurrent) };
499
+ }
500
+ return {
501
+ ok: true,
502
+ value: {
503
+ key,
504
+ value,
505
+ version: envelope.version,
506
+ expiresAt: timing.expiresAt,
507
+ createdAt,
508
+ updatedAt: envelope.updatedAt,
509
+ },
510
+ };
271
511
  }
272
512
 
273
513
  async delete(key: string): Promise<void> {
274
514
  await requireRedisReady(this.backend.redis);
275
- await withRequiredRedis(() => this.backend.redis.del(this.redisKey(key)));
515
+ await withRequiredRedis(() =>
516
+ this.backend.redis.eval(
517
+ DELETE_WITH_INDEX_SCRIPT,
518
+ 2,
519
+ this.redisKey(key),
520
+ this.indexKey(),
521
+ ),
522
+ );
276
523
  }
277
524
 
278
525
  async increment(
@@ -358,6 +605,31 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
358
605
 
359
606
  constructor(private readonly options: StateNamespaceOptions) {}
360
607
 
608
+ private enforceValueSize(value: unknown): void {
609
+ const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
610
+ if (bytes > this.options.maxValueBytes) {
611
+ throw new UnsupportedProviderStateError(
612
+ `Provider runtime state value exceeds maxValueBytes (${bytes} > ${this.options.maxValueBytes})`,
613
+ );
614
+ }
615
+ }
616
+
617
+ private enforceWritePolicy(key: string, value: unknown, ttl: StateWriteOptions["ttl"]): void {
618
+ this.enforceValueSize(value);
619
+ const ttlMs = parseStateDurationMs(ttl ?? this.options.defaultTtl);
620
+ const maxTtlMs = parseStateDurationMs(this.options.maxTtl);
621
+ if (ttlMs > maxTtlMs) {
622
+ throw new UnsupportedProviderStateError(
623
+ `Provider runtime state ttl exceeds maxTtl (${ttlMs} > ${maxTtlMs})`,
624
+ );
625
+ }
626
+ if (!this.values.has(key) && this.values.size >= this.options.maxEntries) {
627
+ throw new UnsupportedProviderStateError(
628
+ `Provider runtime state namespace quota exceeded (${this.options.maxEntries + 1} > ${this.options.maxEntries})`,
629
+ );
630
+ }
631
+ }
632
+
361
633
  private pruneExpired(nowMs = Date.now()): void {
362
634
  for (const [key, row] of this.values.entries()) {
363
635
  if (row.expiresAt && Date.parse(row.expiresAt) <= nowMs) {
@@ -381,6 +653,7 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
381
653
 
382
654
  async set<T>(key: string, value: T, options?: StateWriteOptions): Promise<StateValue<T>> {
383
655
  this.pruneExpired();
656
+ this.enforceWritePolicy(key, value, options?.ttl);
384
657
  const now = new Date().toISOString();
385
658
  const current = this.values.get(key);
386
659
  const expiresAt = resolveMemoryStateExpiresAt(options?.ttl ?? this.options.defaultTtl);
@@ -407,14 +680,30 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
407
680
  }
408
681
 
409
682
  async compareAndSet<T>(
410
- _key: string,
411
- _expectedVersion: number,
412
- _value: T,
413
- _options?: StateWriteOptions,
683
+ key: string,
684
+ expectedVersion: number,
685
+ value: T,
686
+ options?: StateWriteOptions,
414
687
  ): Promise<StateCasResult<T>> {
415
- throw new UnsupportedProviderStateError(
416
- "In-memory provider runtime state does not support compareAndSet",
417
- );
688
+ this.pruneExpired();
689
+ const current = this.values.get(key) as StateValue<T> | undefined;
690
+ if ((current?.version ?? 0) !== expectedVersion) {
691
+ return { ok: false, current: current ?? null };
692
+ }
693
+ this.enforceWritePolicy(key, value, options?.ttl);
694
+ const now = new Date().toISOString();
695
+ const stored = {
696
+ key,
697
+ value,
698
+ version: expectedVersion + 1,
699
+ expiresAt: resolveMemoryStateExpiresAt(
700
+ options?.ttl ?? this.options.defaultTtl,
701
+ ),
702
+ createdAt: current?.createdAt ?? now,
703
+ updatedAt: now,
704
+ } satisfies StateValue<T>;
705
+ this.values.set(key, stored);
706
+ return { ok: true, value: stored };
418
707
  }
419
708
 
420
709
  async delete(key: string): Promise<void> {
@@ -7,6 +7,7 @@ import { AuthAbortError, createAuthFlowHelpers } from "../auth.js";
7
7
  import {
8
8
  SDK_OWNED_PROVIDER_ERROR_CODES,
9
9
  SDK_RUNTIME_OWNED_ERROR_CODES,
10
+ SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
10
11
  } from "../error-resolution.js";
11
12
  import {
12
13
  AuthError,
@@ -478,6 +479,7 @@ function createAuthFlowContext(
478
479
 
479
480
  return {
480
481
  context: {
482
+ flowId: request.flowId,
481
483
  connectionId: request.connectionId,
482
484
  externalRef: request.externalRef,
483
485
  tenantId: request.tenantId ?? "",
@@ -502,7 +504,12 @@ function createAuthFlowContext(
502
504
  },
503
505
  }
504
506
  : {}),
505
- env: createEnvContext(provider.secrets?.map((secret) => secret.name)),
507
+ env: createEnvContext([
508
+ ...(provider.secrets?.map((secret) => secret.name) ?? []),
509
+ ...(provider.auth?.mode === "oauth2_proxied"
510
+ ? ["APIFUSE__AUTH_PROXY__URL"]
511
+ : []),
512
+ ]),
506
513
  credential,
507
514
  context: flowContextStore.context,
508
515
  stt: options.stt ?? createSttClientFromEnv(provider.stt),
@@ -531,7 +538,7 @@ export type ProviderServerLogEvent =
531
538
  | (ProviderServerLogEventBase & {
532
539
  level: "info";
533
540
  event: "provider_request_completed";
534
- })
541
+ })
535
542
  | (ProviderServerLogEventBase & {
536
543
  level: "warn" | "error";
537
544
  event: "provider_request_failed";
@@ -545,13 +552,13 @@ export type ProviderServerLogEvent =
545
552
  signal?: "unregistered_provider_error_code";
546
553
  signalFix?: string;
547
554
  issues?: Array<{ path: string; code: string; message: string }>;
548
- })
555
+ })
549
556
  | {
550
557
  level: "warn";
551
558
  event: "provider_secrets_missing";
552
559
  providerId: string;
553
560
  missingSecrets: string[];
554
- }
561
+ }
555
562
  | {
556
563
  level: "warn";
557
564
  event: "provider_cleanup_failed";
@@ -562,7 +569,7 @@ export type ProviderServerLogEvent =
562
569
  resource: "browser" | "stealth";
563
570
  errorClass: string;
564
571
  message: string;
565
- }
572
+ }
566
573
  | {
567
574
  level: "error";
568
575
  event: "provider_shutdown_hook_failed";
@@ -570,7 +577,7 @@ export type ProviderServerLogEvent =
570
577
  hookIndex: number;
571
578
  errorClass: string;
572
579
  message: string;
573
- };
580
+ };
574
581
 
575
582
  export type ProviderServerLogger = (event: ProviderServerLogEvent) => void;
576
583
 
@@ -599,14 +606,14 @@ export type ProviderServerOptions = {
599
606
  * @example
600
607
  * ```ts
601
608
  * await serve(provider, {
602
- * shutdown: {
603
- * hooks: [
604
- * async () => { await emitter.flush(); },
605
- * async () => { await sessionManager.closeAll("server-shutdown"); },
606
- * async () => { await lease.release(); },
607
- * async () => { await router.close(); },
608
- * ],
609
- * },
609
+ * shutdown: {
610
+ * hooks: [
611
+ * async () => { await emitter.flush(); },
612
+ * async () => { await sessionManager.closeAll("server-shutdown"); },
613
+ * async () => { await lease.release(); },
614
+ * async () => { await router.close(); },
615
+ * ],
616
+ * },
610
617
  * });
611
618
  * ```
612
619
  */
@@ -942,34 +949,13 @@ function toStatusCode(error: unknown, declaredErrorCode?: OperationErrorCode): P
942
949
  ) {
943
950
  return declaredErrorCode.status;
944
951
  }
945
- switch (error.code) {
946
- case "AUTH_REQUIRED":
947
- case "reauth_required":
948
- return 401;
949
- // Unprovisioned declared secret: a deployment/config defect, never an
950
- // upstream failure — explicit 400 (was only reached via fallthrough).
951
- case MISSING_SECRET_CODE:
952
- return 400;
953
- case "NOT_FOUND":
954
- case "not_found":
955
- case "NO_DATA":
956
- return 404;
957
- case "RATE_LIMITED":
958
- case "UPSTREAM_RATE_LIMIT":
959
- case "LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR":
960
- return 429;
961
- // Deterministic upstream business refusal (honest-provider-error-
962
- // contract): the upstream evaluated the request and said no under
963
- // its own rules — a conflict with upstream state, never a 5xx.
964
- case "UPSTREAM_REJECTED":
965
- return 409;
966
- case "UPSTREAM_ERROR":
967
- case "BLOCKED":
968
- return 502;
969
- case "STT_UNAVAILABLE":
970
- case "UNSUPPORTED_STT_BACKEND":
971
- case "STATEFUL_FORWARDING_REPLAY_CACHE_FULL":
972
- return 503;
952
+ // Canonical SDK code → status mapping lives in error-resolution.ts so
953
+ // the authoring lint and this runtime path share one source of truth.
954
+ if (typeof error.code === "string") {
955
+ const mappedStatus = SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.get(error.code);
956
+ if (mappedStatus !== undefined) {
957
+ return mappedStatus;
958
+ }
973
959
  }
974
960
  if (isTransportError(error)) {
975
961
  return error.code === "transport_timeout" ? 504 : 502;
@@ -3,7 +3,13 @@ import { PROVIDER_ERROR_SOURCES } from "../observability.js";
3
3
 
4
4
  import { HttpRetryPreset } from "../types.js";
5
5
 
6
- export const ConnectionModeSchema = z.enum(["oauth2", "credentials", "platform-managed", "none"]);
6
+ export const ConnectionModeSchema = z.enum([
7
+ "oauth2",
8
+ "oauth2_proxied",
9
+ "credentials",
10
+ "platform-managed",
11
+ "none",
12
+ ]);
7
13
 
8
14
  export const OperationConnectionSchema = z.object({
9
15
  id: z.string(),
@@ -29,7 +29,13 @@ import type {
29
29
  // A single lowercase segment (no hyphen) is a valid id, so the trailing group
30
30
  // is optional (`*`), matching providers like `kakaomap`, `kstartup`, `triple`.
31
31
  const CONNECTOR_ID_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
32
- const VALID_AUTH_MODES = ["none", "platform-managed", "credentials", "oauth2"] as const;
32
+ const VALID_AUTH_MODES = [
33
+ "none",
34
+ "platform-managed",
35
+ "credentials",
36
+ "oauth2",
37
+ "oauth2_proxied",
38
+ ] as const;
33
39
  const UPDATE_SNAPSHOT_ARGS = new Set(["-u", "--update-snapshots"]);
34
40
  const snapshotCaptureStates = new WeakMap<
35
41
  ProviderContext,
@@ -754,7 +760,7 @@ function formatJsonDiff(current: unknown, expected: unknown): string {
754
760
 
755
761
  if (currentLine === expectedLine) {
756
762
  if (currentLine !== undefined) {
757
- lines.push(` ${currentLine}`);
763
+ lines.push(` ${currentLine}`);
758
764
  }
759
765
  continue;
760
766
  }