@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.
@@ -3,6 +3,90 @@ import { ProviderError } from "../errors.js";
3
3
  import { createProviderRedisClient, ensureRedisReady, withRedisTimeout, } from "./redis.js";
4
4
  const DEFAULT_REDIS_TIMEOUT_MS = 250;
5
5
  const REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
6
+ const LEGACY_INDEX_SCAN_COUNT = 256;
7
+ const LEGACY_INDEX_SCAN_MAX_PAGES = 8;
8
+ const SET_WITH_QUOTA_SCRIPT = `
9
+ local now = tonumber(ARGV[1])
10
+ local max_entries = tonumber(ARGV[2])
11
+ local expires_at = tonumber(ARGV[3])
12
+ local index_ttl = tonumber(ARGV[4])
13
+ local envelope = ARGV[5]
14
+
15
+ redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
16
+ local exists = redis.call("EXISTS", KEYS[1])
17
+ local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
18
+ if exists == 0 and not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
19
+ return {0, false}
20
+ end
21
+
22
+ redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
23
+ redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
24
+ redis.call("PEXPIRE", KEYS[2], index_ttl)
25
+ return {1, envelope}
26
+ `;
27
+ const COMPARE_AND_SET_WITH_QUOTA_SCRIPT = `
28
+ local current = redis.call("GET", KEYS[1])
29
+ local current_version = 0
30
+ if current then
31
+ local ok, decoded = pcall(cjson.decode, current)
32
+ if not ok or type(decoded) ~= "table" or type(decoded.version) ~= "number" then
33
+ return {-2, current}
34
+ end
35
+ current_version = decoded.version
36
+ end
37
+ if current_version ~= tonumber(ARGV[1]) then
38
+ return {-1, current or false}
39
+ end
40
+
41
+ local now = tonumber(ARGV[2])
42
+ local max_entries = tonumber(ARGV[3])
43
+ local expires_at = tonumber(ARGV[4])
44
+ local index_ttl = tonumber(ARGV[5])
45
+ local envelope = ARGV[6]
46
+ redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
47
+ local exists = current and 1 or 0
48
+ local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
49
+ if exists == 0 and not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
50
+ return {0, false}
51
+ end
52
+
53
+ redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
54
+ redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
55
+ redis.call("PEXPIRE", KEYS[2], index_ttl)
56
+ return {1, envelope}
57
+ `;
58
+ const DELETE_WITH_INDEX_SCRIPT = `
59
+ redis.call("DEL", KEYS[1])
60
+ redis.call("ZREM", KEYS[2], KEYS[1])
61
+ return 1
62
+ `;
63
+ // Older SDKs wrote only the value key. Every operation that depends on the
64
+ // namespace index advances a bounded SCAN cursor and lazily imports active
65
+ // legacy envelopes into the new ZSET. The cursor is
66
+ // deliberately cyclic rather than permanently "complete": an old pod may
67
+ // still write an unindexed key during a rolling deploy. Each list/write call
68
+ // does a fixed amount of migration work; Redis KEYS and unbounded scans remain
69
+ // forbidden.
70
+ const BACKFILL_LEGACY_INDEX_SCRIPT = `
71
+ local now = tonumber(ARGV[1])
72
+ local index_ttl = tonumber(ARGV[2])
73
+ local next_cursor = ARGV[3]
74
+
75
+ redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", now)
76
+ for i = 4, #ARGV, 3 do
77
+ local key = ARGV[i]
78
+ local expected = ARGV[i + 1]
79
+ local expires_at = tonumber(ARGV[i + 2])
80
+ if redis.call("GET", key) == expected then
81
+ redis.call("ZADD", KEYS[1], "NX", expires_at, key)
82
+ end
83
+ end
84
+ if redis.call("EXISTS", KEYS[1]) == 1 then
85
+ redis.call("PEXPIRE", KEYS[1], index_ttl)
86
+ end
87
+ redis.call("SET", KEYS[2], next_cursor, "PX", index_ttl)
88
+ return redis.call("ZCARD", KEYS[1])
89
+ `;
6
90
  const redisBackends = new Map();
7
91
  function getRedisBackend(redisUrl) {
8
92
  const existing = redisBackends.get(redisUrl);
@@ -46,6 +130,9 @@ function publicStateKey(providerId, namespace, redisKey) {
46
130
  const prefix = `${providerStatePrefix(providerId, namespace)}:`;
47
131
  return redisKey.startsWith(prefix) ? redisKey.slice(prefix.length) : redisKey;
48
132
  }
133
+ function redisGlobLiteral(value) {
134
+ return value.replace(/[\\*?\[\]]/g, "\\$&");
135
+ }
49
136
  function parseStateDurationMs(ttl) {
50
137
  const match = /^(\d+)(ms|s|m|h|d)$/.exec(ttl ?? "1h");
51
138
  if (!match)
@@ -119,12 +206,55 @@ class RedisProviderStateNamespace {
119
206
  redisKey(key) {
120
207
  return providerStateKey(this.providerId, this.namespaceName, key);
121
208
  }
122
- prefix() {
209
+ indexKey() {
210
+ // Keep bookkeeping outside the caller-owned keyspace. A provider may use
211
+ // any state key (including "__index"), so a suffix inside the namespace
212
+ // could turn the ZSET into a string and break every subsequent write.
213
+ const namespaceIdentity = Buffer.from(providerStatePrefix(this.providerId, this.namespaceName), "utf8").toString("base64url");
214
+ return `${REDIS_STATE_PREFIX}:index:${namespaceIdentity}`;
215
+ }
216
+ legacyScanCursorKey() {
217
+ return `${this.indexKey()}:legacy-scan-cursor`;
218
+ }
219
+ legacyPrefix() {
123
220
  return `${providerStatePrefix(this.providerId, this.namespaceName)}:`;
124
221
  }
125
- async activeKeys() {
222
+ async backfillLegacyIndex() {
126
223
  await requireRedisReady(this.backend.redis);
127
- return await withRequiredRedis(() => this.backend.redis.keys(`${this.prefix()}*`));
224
+ const cursorKey = this.legacyScanCursorKey();
225
+ let cursor = (await withRequiredRedis(() => this.backend.redis.get(cursorKey))) ?? "0";
226
+ const pattern = `${redisGlobLiteral(this.legacyPrefix())}*`;
227
+ const indexTtlMs = parseStateDurationMs(this.options.maxTtl);
228
+ for (let page = 0; page < LEGACY_INDEX_SCAN_MAX_PAGES; page += 1) {
229
+ const [nextCursor, keys] = await withRequiredRedis(() => this.backend.redis.scan(cursor, "MATCH", pattern, "COUNT", LEGACY_INDEX_SCAN_COUNT));
230
+ const rawValues = keys.length > 0
231
+ ? await withRequiredRedis(() => this.backend.redis.mget(keys))
232
+ : [];
233
+ const now = Date.now();
234
+ const activeLegacyArgs = [];
235
+ for (const [index, raw] of rawValues.entries()) {
236
+ const key = keys[index];
237
+ if (!key || !raw)
238
+ continue;
239
+ const envelope = envelopeFromJson(publicStateKey(this.providerId, this.namespaceName, key), raw);
240
+ const expiresAtMs = envelope ? Date.parse(envelope.expiresAt) : Number.NaN;
241
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now)
242
+ continue;
243
+ activeLegacyArgs.push(key, raw, String(expiresAtMs));
244
+ }
245
+ await withRequiredRedis(() => this.backend.redis.eval(BACKFILL_LEGACY_INDEX_SCRIPT, 2, this.indexKey(), cursorKey, String(now), String(indexTtlMs), nextCursor, ...activeLegacyArgs));
246
+ cursor = nextCursor;
247
+ if (cursor === "0")
248
+ break;
249
+ }
250
+ }
251
+ async indexedKeys(limit) {
252
+ await requireRedisReady(this.backend.redis);
253
+ const now = Date.now();
254
+ return await withRequiredRedis(async () => {
255
+ await this.backend.redis.zremrangebyscore(this.indexKey(), "-inf", now);
256
+ return await this.backend.redis.zrangebyscore(this.indexKey(), now + 1, "+inf", "LIMIT", 0, limit);
257
+ });
128
258
  }
129
259
  enforceValueSize(value) {
130
260
  const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
@@ -132,20 +262,32 @@ class RedisProviderStateNamespace {
132
262
  throw new UnsupportedProviderStateError(`Provider runtime state value exceeds maxValueBytes (${bytes} > ${this.options.maxValueBytes})`);
133
263
  }
134
264
  }
135
- async enforceMaxEntries(key) {
136
- const keys = await this.activeKeys();
137
- const redisKey = this.redisKey(key);
138
- const otherKeys = keys.filter((candidate) => candidate !== redisKey);
139
- if (otherKeys.length >= this.options.maxEntries) {
140
- throw new UnsupportedProviderStateError(`Provider runtime state namespace quota exceeded (${otherKeys.length + 1} > ${this.options.maxEntries})`);
265
+ quotaExceeded() {
266
+ return new UnsupportedProviderStateError(`Provider runtime state namespace quota exceeded (${this.options.maxEntries + 1} > ${this.options.maxEntries})`);
267
+ }
268
+ writeTiming(ttl) {
269
+ const ttlMs = parseStateDurationMs(ttl ?? this.options.defaultTtl);
270
+ const maxTtlMs = parseStateDurationMs(this.options.maxTtl);
271
+ if (ttlMs > maxTtlMs) {
272
+ throw new UnsupportedProviderStateError(`Provider runtime state ttl exceeds maxTtl (${ttlMs} > ${maxTtlMs})`);
141
273
  }
274
+ const expiresAtMs = Date.now() + ttlMs;
275
+ return {
276
+ expiresAt: new Date(expiresAtMs).toISOString(),
277
+ expiresAtMs,
278
+ indexTtlMs: maxTtlMs,
279
+ };
142
280
  }
143
281
  async list(options) {
144
- const keys = (await this.activeKeys()).filter((key) => {
282
+ const requestedLimit = Math.max(0, options?.limit ?? this.options.maxEntries);
283
+ if (requestedLimit === 0)
284
+ return [];
285
+ await this.backfillLegacyIndex();
286
+ const keys = (await this.indexedKeys(this.options.maxEntries)).filter((key) => {
145
287
  const publicKey = publicStateKey(this.providerId, this.namespaceName, key);
146
288
  return options?.prefix ? publicKey.startsWith(options.prefix) : true;
147
289
  });
148
- const limited = keys.slice(0, Math.max(0, options?.limit ?? keys.length));
290
+ const limited = keys.slice(0, requestedLimit);
149
291
  if (limited.length === 0)
150
292
  return [];
151
293
  const values = await withRequiredRedis(() => this.backend.redis.mget(limited));
@@ -164,20 +306,22 @@ class RedisProviderStateNamespace {
164
306
  }
165
307
  async set(key, value, options) {
166
308
  this.enforceValueSize(value);
167
- await this.enforceMaxEntries(key);
309
+ await this.backfillLegacyIndex();
168
310
  const current = await this.get(key);
169
311
  const createdAt = current?.createdAt ?? new Date().toISOString();
170
312
  const version = (current?.version ?? 0) + 1;
171
- const ttl = options?.ttl ?? this.options.defaultTtl;
172
- const ttlMs = parseStateDurationMs(ttl);
173
- const expiresAt = resolveExpiresAt(ttl);
174
- const envelope = redisEnvelope(value, version, createdAt, expiresAt);
175
- await withRequiredRedis(() => this.backend.redis.set(this.redisKey(key), JSON.stringify(envelope), "PX", ttlMs));
313
+ const timing = this.writeTiming(options?.ttl);
314
+ const envelope = redisEnvelope(value, version, createdAt, timing.expiresAt);
315
+ await requireRedisReady(this.backend.redis);
316
+ const result = await withRequiredRedis(() => this.backend.redis.eval(SET_WITH_QUOTA_SCRIPT, 2, this.redisKey(key), this.indexKey(), String(Date.now()), String(this.options.maxEntries), String(timing.expiresAtMs), String(timing.indexTtlMs), JSON.stringify(envelope)));
317
+ if (!Array.isArray(result) || Number(result[0]) !== 1) {
318
+ throw this.quotaExceeded();
319
+ }
176
320
  return {
177
321
  key,
178
322
  value,
179
323
  version,
180
- expiresAt,
324
+ expiresAt: timing.expiresAt,
181
325
  createdAt,
182
326
  updatedAt: envelope.updatedAt,
183
327
  };
@@ -190,15 +334,38 @@ class RedisProviderStateNamespace {
190
334
  }
191
335
  async compareAndSet(key, expectedVersion, value, options) {
192
336
  this.enforceValueSize(value);
337
+ await this.backfillLegacyIndex();
193
338
  const current = await this.get(key);
194
339
  if ((current?.version ?? 0) !== expectedVersion) {
195
340
  return { ok: false, current };
196
341
  }
197
- return { ok: true, value: await this.set(key, value, options) };
342
+ const createdAt = current?.createdAt ?? new Date().toISOString();
343
+ const timing = this.writeTiming(options?.ttl);
344
+ const envelope = redisEnvelope(value, expectedVersion + 1, createdAt, timing.expiresAt);
345
+ await requireRedisReady(this.backend.redis);
346
+ const result = await withRequiredRedis(() => this.backend.redis.eval(COMPARE_AND_SET_WITH_QUOTA_SCRIPT, 2, this.redisKey(key), this.indexKey(), String(expectedVersion), String(Date.now()), String(this.options.maxEntries), String(timing.expiresAtMs), String(timing.indexTtlMs), JSON.stringify(envelope)));
347
+ if (Array.isArray(result) && Number(result[0]) === 0) {
348
+ throw this.quotaExceeded();
349
+ }
350
+ if (!Array.isArray(result) || Number(result[0]) !== 1) {
351
+ const rawCurrent = Array.isArray(result) && typeof result[1] === "string" ? result[1] : null;
352
+ return { ok: false, current: envelopeFromJson(key, rawCurrent) };
353
+ }
354
+ return {
355
+ ok: true,
356
+ value: {
357
+ key,
358
+ value,
359
+ version: envelope.version,
360
+ expiresAt: timing.expiresAt,
361
+ createdAt,
362
+ updatedAt: envelope.updatedAt,
363
+ },
364
+ };
198
365
  }
199
366
  async delete(key) {
200
367
  await requireRedisReady(this.backend.redis);
201
- await withRequiredRedis(() => this.backend.redis.del(this.redisKey(key)));
368
+ await withRequiredRedis(() => this.backend.redis.eval(DELETE_WITH_INDEX_SCRIPT, 2, this.redisKey(key), this.indexKey()));
202
369
  }
203
370
  async increment(key, field, delta = 1, options) {
204
371
  const current = (await this.get(key))?.value ?? {};
@@ -258,6 +425,23 @@ class MemoryProviderStateNamespace {
258
425
  constructor(options) {
259
426
  this.options = options;
260
427
  }
428
+ enforceValueSize(value) {
429
+ const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
430
+ if (bytes > this.options.maxValueBytes) {
431
+ throw new UnsupportedProviderStateError(`Provider runtime state value exceeds maxValueBytes (${bytes} > ${this.options.maxValueBytes})`);
432
+ }
433
+ }
434
+ enforceWritePolicy(key, value, ttl) {
435
+ this.enforceValueSize(value);
436
+ const ttlMs = parseStateDurationMs(ttl ?? this.options.defaultTtl);
437
+ const maxTtlMs = parseStateDurationMs(this.options.maxTtl);
438
+ if (ttlMs > maxTtlMs) {
439
+ throw new UnsupportedProviderStateError(`Provider runtime state ttl exceeds maxTtl (${ttlMs} > ${maxTtlMs})`);
440
+ }
441
+ if (!this.values.has(key) && this.values.size >= this.options.maxEntries) {
442
+ throw new UnsupportedProviderStateError(`Provider runtime state namespace quota exceeded (${this.options.maxEntries + 1} > ${this.options.maxEntries})`);
443
+ }
444
+ }
261
445
  pruneExpired(nowMs = Date.now()) {
262
446
  for (const [key, row] of this.values.entries()) {
263
447
  if (row.expiresAt && Date.parse(row.expiresAt) <= nowMs) {
@@ -276,6 +460,7 @@ class MemoryProviderStateNamespace {
276
460
  }
277
461
  async set(key, value, options) {
278
462
  this.pruneExpired();
463
+ this.enforceWritePolicy(key, value, options?.ttl);
279
464
  const now = new Date().toISOString();
280
465
  const current = this.values.get(key);
281
466
  const expiresAt = resolveMemoryStateExpiresAt(options?.ttl ?? this.options.defaultTtl);
@@ -293,8 +478,24 @@ class MemoryProviderStateNamespace {
293
478
  async patch(_key, _partial, _options) {
294
479
  throw new UnsupportedProviderStateError("In-memory provider runtime state does not support patch");
295
480
  }
296
- async compareAndSet(_key, _expectedVersion, _value, _options) {
297
- throw new UnsupportedProviderStateError("In-memory provider runtime state does not support compareAndSet");
481
+ async compareAndSet(key, expectedVersion, value, options) {
482
+ this.pruneExpired();
483
+ const current = this.values.get(key);
484
+ if ((current?.version ?? 0) !== expectedVersion) {
485
+ return { ok: false, current: current ?? null };
486
+ }
487
+ this.enforceWritePolicy(key, value, options?.ttl);
488
+ const now = new Date().toISOString();
489
+ const stored = {
490
+ key,
491
+ value,
492
+ version: expectedVersion + 1,
493
+ expiresAt: resolveMemoryStateExpiresAt(options?.ttl ?? this.options.defaultTtl),
494
+ createdAt: current?.createdAt ?? now,
495
+ updatedAt: now,
496
+ };
497
+ this.values.set(key, stored);
498
+ return { ok: true, value: stored };
298
499
  }
299
500
  async delete(key) {
300
501
  this.values.delete(key);
@@ -37,6 +37,7 @@ export declare const ProviderServerStatefulForwardEnvelopeSchema: z.ZodObject<{
37
37
  credentials: "credentials";
38
38
  oauth2: "oauth2";
39
39
  "platform-managed": "platform-managed";
40
+ oauth2_proxied: "oauth2_proxied";
40
41
  }>;
41
42
  secrets: z.ZodRecord<z.ZodString, z.ZodString>;
42
43
  scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -142,14 +143,14 @@ export type ProviderServerOptions = {
142
143
  * @example
143
144
  * ```ts
144
145
  * await serve(provider, {
145
- * shutdown: {
146
- * hooks: [
147
- * async () => { await emitter.flush(); },
148
- * async () => { await sessionManager.closeAll("server-shutdown"); },
149
- * async () => { await lease.release(); },
150
- * async () => { await router.close(); },
151
- * ],
152
- * },
146
+ * shutdown: {
147
+ * hooks: [
148
+ * async () => { await emitter.flush(); },
149
+ * async () => { await sessionManager.closeAll("server-shutdown"); },
150
+ * async () => { await lease.release(); },
151
+ * async () => { await router.close(); },
152
+ * ],
153
+ * },
153
154
  * });
154
155
  * ```
155
156
  */
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { Hono } from "hono";
4
4
  import { z } from "zod";
5
5
  import { AuthAbortError, createAuthFlowHelpers } from "../auth.js";
6
- import { SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, } from "../error-resolution.js";
6
+ import { SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "../error-resolution.js";
7
7
  import { AuthError, isProviderError, isSessionExpiredError, isTransportError, isValidationError, ProviderError, } from "../errors.js";
8
8
  import { loadProviderLocaleCatalogs, localizeAuthTurn, } from "../i18n/catalog.js";
9
9
  import { categoryForStatus, sourceForCategory, isRetryableCategory, PROVIDER_OBSERVABILITY_TAXONOMY_VERSION, } from "../observability.js";
@@ -296,6 +296,7 @@ function createAuthFlowContext(provider, request, options = {}, signal) {
296
296
  : undefined;
297
297
  return {
298
298
  context: {
299
+ flowId: request.flowId,
299
300
  connectionId: request.connectionId,
300
301
  externalRef: request.externalRef,
301
302
  tenantId: request.tenantId ?? "",
@@ -318,7 +319,12 @@ function createAuthFlowContext(provider, request, options = {}, signal) {
318
319
  },
319
320
  }
320
321
  : {}),
321
- env: createEnvContext(provider.secrets?.map((secret) => secret.name)),
322
+ env: createEnvContext([
323
+ ...(provider.secrets?.map((secret) => secret.name) ?? []),
324
+ ...(provider.auth?.mode === "oauth2_proxied"
325
+ ? ["APIFUSE__AUTH_PROXY__URL"]
326
+ : []),
327
+ ]),
322
328
  credential,
323
329
  context: flowContextStore.context,
324
330
  stt: options.stt ?? createSttClientFromEnv(provider.stt),
@@ -600,34 +606,13 @@ function toStatusCode(error, declaredErrorCode) {
600
606
  isEmittableErrorStatus(declaredErrorCode?.status)) {
601
607
  return declaredErrorCode.status;
602
608
  }
603
- switch (error.code) {
604
- case "AUTH_REQUIRED":
605
- case "reauth_required":
606
- return 401;
607
- // Unprovisioned declared secret: a deployment/config defect, never an
608
- // upstream failure — explicit 400 (was only reached via fallthrough).
609
- case MISSING_SECRET_CODE:
610
- return 400;
611
- case "NOT_FOUND":
612
- case "not_found":
613
- case "NO_DATA":
614
- return 404;
615
- case "RATE_LIMITED":
616
- case "UPSTREAM_RATE_LIMIT":
617
- case "LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR":
618
- return 429;
619
- // Deterministic upstream business refusal (honest-provider-error-
620
- // contract): the upstream evaluated the request and said no under
621
- // its own rules — a conflict with upstream state, never a 5xx.
622
- case "UPSTREAM_REJECTED":
623
- return 409;
624
- case "UPSTREAM_ERROR":
625
- case "BLOCKED":
626
- return 502;
627
- case "STT_UNAVAILABLE":
628
- case "UNSUPPORTED_STT_BACKEND":
629
- case "STATEFUL_FORWARDING_REPLAY_CACHE_FULL":
630
- return 503;
609
+ // Canonical SDK code → status mapping lives in error-resolution.ts so
610
+ // the authoring lint and this runtime path share one source of truth.
611
+ if (typeof error.code === "string") {
612
+ const mappedStatus = SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.get(error.code);
613
+ if (mappedStatus !== undefined) {
614
+ return mappedStatus;
615
+ }
631
616
  }
632
617
  if (isTransportError(error)) {
633
618
  return error.code === "transport_timeout" ? 504 : 502;
@@ -4,6 +4,7 @@ export declare const ConnectionModeSchema: z.ZodEnum<{
4
4
  credentials: "credentials";
5
5
  oauth2: "oauth2";
6
6
  "platform-managed": "platform-managed";
7
+ oauth2_proxied: "oauth2_proxied";
7
8
  }>;
8
9
  export declare const OperationConnectionSchema: z.ZodObject<{
9
10
  id: z.ZodString;
@@ -12,6 +13,7 @@ export declare const OperationConnectionSchema: z.ZodObject<{
12
13
  credentials: "credentials";
13
14
  oauth2: "oauth2";
14
15
  "platform-managed": "platform-managed";
16
+ oauth2_proxied: "oauth2_proxied";
15
17
  }>;
16
18
  secrets: z.ZodRecord<z.ZodString, z.ZodString>;
17
19
  scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -29,6 +31,7 @@ export declare const OperationRequestSchema: z.ZodObject<{
29
31
  credentials: "credentials";
30
32
  oauth2: "oauth2";
31
33
  "platform-managed": "platform-managed";
34
+ oauth2_proxied: "oauth2_proxied";
32
35
  }>;
33
36
  secrets: z.ZodRecord<z.ZodString, z.ZodString>;
34
37
  scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -119,6 +122,7 @@ export declare const AuthFlowRequestSchema: z.ZodObject<{
119
122
  credentials: "credentials";
120
123
  oauth2: "oauth2";
121
124
  "platform-managed": "platform-managed";
125
+ oauth2_proxied: "oauth2_proxied";
122
126
  }>;
123
127
  secrets: z.ZodRecord<z.ZodString, z.ZodString>;
124
128
  scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -1,7 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import { PROVIDER_ERROR_SOURCES } from "../observability.js";
3
3
  import { HttpRetryPreset } from "../types.js";
4
- export const ConnectionModeSchema = z.enum(["oauth2", "credentials", "platform-managed", "none"]);
4
+ export const ConnectionModeSchema = z.enum([
5
+ "oauth2",
6
+ "oauth2_proxied",
7
+ "credentials",
8
+ "platform-managed",
9
+ "none",
10
+ ]);
5
11
  export const OperationConnectionSchema = z.object({
6
12
  id: z.string(),
7
13
  mode: ConnectionModeSchema,
@@ -11,7 +11,13 @@ import { findStreamCaptureGroup, replayStreamEvidence } from "../stream-evidence
11
11
  // A single lowercase segment (no hyphen) is a valid id, so the trailing group
12
12
  // is optional (`*`), matching providers like `kakaomap`, `kstartup`, `triple`.
13
13
  const CONNECTOR_ID_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
14
- const VALID_AUTH_MODES = ["none", "platform-managed", "credentials", "oauth2"];
14
+ const VALID_AUTH_MODES = [
15
+ "none",
16
+ "platform-managed",
17
+ "credentials",
18
+ "oauth2",
19
+ "oauth2_proxied",
20
+ ];
15
21
  const UPDATE_SNAPSHOT_ARGS = new Set(["-u", "--update-snapshots"]);
16
22
  const snapshotCaptureStates = new WeakMap();
17
23
  function isFixtureEnvelope(value) {
@@ -556,7 +562,7 @@ function formatJsonDiff(current, expected) {
556
562
  const expectedLine = expectedLines[index];
557
563
  if (currentLine === expectedLine) {
558
564
  if (currentLine !== undefined) {
559
- lines.push(` ${currentLine}`);
565
+ lines.push(` ${currentLine}`);
560
566
  }
561
567
  continue;
562
568
  }
package/dist/types.d.ts CHANGED
@@ -47,6 +47,8 @@ export interface OperationToolRouterMetadata {
47
47
  riskClass?: OperationRiskClass;
48
48
  /** OpenAI remote-MCP approval hint. Defaults from riskClass. */
49
49
  approval?: OperationApprovalPolicy;
50
+ /** Canonical operation-level connection requirement consumed by the registry and Gateway. */
51
+ connectionMode?: "none" | "optional" | "required";
50
52
  /** Override connection requirement when provider auth + openWorld inference is insufficient. */
51
53
  requiresConnection?: boolean;
52
54
  /** Public argument used to resolve the tenant-owned connection. Defaults to externalRef. */
@@ -648,7 +650,7 @@ export interface StealthProfile {
648
650
  h2Settings?: Record<string, unknown>;
649
651
  headerOrder?: string[];
650
652
  }
651
- export type AuthMode = "none" | "platform-managed" | "credentials" | "oauth2";
653
+ export type AuthMode = "none" | "platform-managed" | "credentials" | "oauth2" | "oauth2_proxied";
652
654
  export type ConnectionMode = AuthMode;
653
655
  export type ProviderReviewed = "first-party" | "community" | "staging";
654
656
  export type ProviderAccessVisibility = "public" | "early_access";
@@ -658,18 +660,18 @@ export type ProviderProxyMode = "disabled" | "optional" | "required";
658
660
  * (a common mistake because the names collide with a well-known rebrand):
659
661
  *
660
662
  * - `smartproxy` — **api.smartproxy.org**, a residential proxy with an IP
661
- * *extraction/allocation* API (app_key → a pool of raw `ip:port` CONNECT
662
- * endpoints). This is our own vendor. It is NOT the company formerly named
663
- * "Smartproxy". Credentials: `APIFUSE__PROXY__SMARTPROXY_APP_KEY`.
663
+ * *extraction/allocation* API (app_key → a pool of raw `ip:port` CONNECT
664
+ * endpoints). This is our own vendor. It is NOT the company formerly named
665
+ * "Smartproxy". Credentials: `APIFUSE__PROXY__SMARTPROXY_APP_KEY`.
664
666
  * - `nodemaven` — **gate.nodemaven.com**, a *gateway* proxy with static
665
- * credentials; geo/session encoded in the username, no allocation API.
667
+ * credentials; geo/session encoded in the username, no allocation API.
666
668
  * - `decodo` — **decodo.com**, the *gateway* proxy that was named "Smartproxy"
667
- * (smartproxy.com) before its 2025 rebrand to Decodo. Sticky sessions via
668
- * username params. A different company from `smartproxy` above.
669
- * **@deprecated** — unused; no managed adapter. Use `smartproxy`/`nodemaven`,
670
- * or the `APIFUSE__PROXY__URL` bring-your-own escape hatch.
669
+ * (smartproxy.com) before its 2025 rebrand to Decodo. Sticky sessions via
670
+ * username params. A different company from `smartproxy` above.
671
+ * **@deprecated** — unused; no managed adapter. Use `smartproxy`/`nodemaven`,
672
+ * or the `APIFUSE__PROXY__URL` bring-your-own escape hatch.
671
673
  * - `custom` — **@deprecated** bring-your-own static proxy URL marker. The
672
- * `APIFUSE__PROXY__URL` env still works without declaring this value.
674
+ * `APIFUSE__PROXY__URL` env still works without declaring this value.
673
675
  */
674
676
  export type ProviderProxyProvider = "smartproxy" | "nodemaven" | "decodo" | "custom";
675
677
  export type ProviderProxySessionAffinity = "request" | "operation" | "auth-flow" | "connection";
@@ -715,9 +717,9 @@ export interface ProviderAccessConfig {
715
717
  * Provider-level rollout visibility.
716
718
  *
717
719
  * - `public`: visible in public docs/catalog/OpenAPI and callable through
718
- * the existing provider policy stack.
720
+ * the existing provider policy stack.
719
721
  * - `early_access`: hidden from public discovery and callable only when the
720
- * active customer organization has a provider-level access grant.
722
+ * active customer organization has a provider-level access grant.
721
723
  *
722
724
  * This is intentionally provider-level only. It does not alter auth mode,
723
725
  * operation schemas, health-check authoring, `openWorld`, or Connection
@@ -1528,6 +1530,8 @@ export interface AuthFlowTerminalContext {
1528
1530
  }): AuthTurn;
1529
1531
  }
1530
1532
  export interface FlowContext {
1533
+ /** Gateway auth-flow id. Required by flow-scoped auth ceremonies. */
1534
+ flowId?: string;
1531
1535
  connectionId?: string;
1532
1536
  externalRef?: string;
1533
1537
  tenantId: string;
@@ -1633,8 +1637,20 @@ export interface ProviderContext {
1633
1637
  stt: SttContext;
1634
1638
  choice: ProviderChoiceContext;
1635
1639
  }
1640
+ export interface ProxiedOAuthConfig {
1641
+ authorizeUrl: string;
1642
+ tokenUrl: string;
1643
+ customScheme: string;
1644
+ rewriteProfile: string;
1645
+ clientIdEnvKey: string;
1646
+ pkce?: "S256" | "none";
1647
+ authorizeParams?: Record<string, string>;
1648
+ tokenParams?: Record<string, string>;
1649
+ }
1636
1650
  export interface AuthConfig {
1637
1651
  mode: AuthMode;
1652
+ /** Browser reverse-proxy contract for providers with custom-scheme OAuth callbacks. */
1653
+ proxied?: ProxiedOAuthConfig;
1638
1654
  flow?: AuthFlowDefinition;
1639
1655
  }
1640
1656
  export interface ProviderSecretDeclaration {
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.20",
2
+ "version": "2.2.0-beta.22",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",