@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.
- package/CHANGELOG.md +13 -0
- package/dist/ceremonies/index.d.ts +8 -0
- package/dist/ceremonies/index.js +57 -0
- package/dist/define.js +103 -1
- package/dist/error-resolution.d.ts +2 -0
- package/dist/error-resolution.js +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/lint.d.ts +6 -1
- package/dist/lint.js +277 -0
- package/dist/provider.d.ts +1 -1
- package/dist/runtime/auth-flow.d.ts +1 -0
- package/dist/runtime/auth-flow.js +1 -0
- package/dist/runtime/state.js +223 -22
- package/dist/server/serve.d.ts +9 -8
- package/dist/server/serve.js +15 -30
- package/dist/server/types.d.ts +4 -0
- package/dist/server/types.js +7 -1
- package/dist/testing/run.js +8 -2
- package/dist/types.d.ts +28 -12
- package/package.json +1 -1
- package/src/ceremonies/index.ts +79 -0
- package/src/define.ts +138 -1
- package/src/error-resolution.ts +31 -0
- package/src/index.ts +1 -0
- package/src/lint.ts +310 -1
- package/src/provider.ts +1 -0
- package/src/runtime/auth-flow.ts +2 -0
- package/src/runtime/state.ts +317 -28
- package/src/server/serve.ts +28 -42
- package/src/server/types.ts +7 -1
- package/src/testing/run.ts +8 -2
- package/src/types.ts +48 -26
package/dist/runtime/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
222
|
+
async backfillLegacyIndex() {
|
|
126
223
|
await requireRedisReady(this.backend.redis);
|
|
127
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
297
|
-
|
|
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);
|
package/dist/server/serve.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
*/
|
package/dist/server/serve.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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;
|
package/dist/server/types.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/server/types.js
CHANGED
|
@@ -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([
|
|
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,
|
package/dist/testing/run.js
CHANGED
|
@@ -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 = [
|
|
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(`
|
|
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
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
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
|
-
*
|
|
667
|
+
* credentials; geo/session encoded in the username, no allocation API.
|
|
666
668
|
* - `decodo` — **decodo.com**, the *gateway* proxy that was named "Smartproxy"
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
720
|
+
* the existing provider policy stack.
|
|
719
721
|
* - `early_access`: hidden from public discovery and callable only when the
|
|
720
|
-
*
|
|
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