@apifuse/provider-sdk 2.2.0-beta.13 → 2.2.0-beta.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORING.md +70 -6
- package/CHANGELOG.md +12 -0
- package/dist/define.js +9 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +25 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/native-egress-policy.d.ts +27 -0
- package/dist/native-egress-policy.js +225 -0
- package/dist/provider.d.ts +3 -3
- package/dist/provider.js +2 -2
- package/dist/runtime/executor.js +17 -2
- package/dist/runtime/http.js +189 -9
- package/dist/runtime/native-network.d.ts +39 -4
- package/dist/runtime/native-network.js +365 -20
- package/dist/runtime/redirects.d.ts +29 -0
- package/dist/runtime/redirects.js +36 -0
- package/dist/runtime/stealth.js +16 -44
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/serve.d.ts +9 -0
- package/dist/server/serve.js +190 -51
- package/dist/server/types.d.ts +3 -0
- package/dist/server/types.js +1 -0
- package/dist/stateful/stateful-provider-owner-forwarder.js +9 -1
- package/dist/testing/run.js +32 -13
- package/dist/types.d.ts +23 -2
- package/package.json +1 -1
- package/src/define.ts +11 -0
- package/src/errors.ts +37 -0
- package/src/index.ts +12 -1
- package/src/native-egress-policy.ts +285 -0
- package/src/provider.ts +7 -0
- package/src/runtime/executor.ts +22 -2
- package/src/runtime/http.ts +217 -9
- package/src/runtime/native-network.ts +474 -22
- package/src/runtime/redirects.ts +66 -0
- package/src/runtime/stealth.ts +20 -47
- package/src/server/index.ts +2 -0
- package/src/server/serve.ts +226 -68
- package/src/server/types.ts +1 -0
- package/src/stateful/stateful-provider-owner-forwarder.ts +9 -1
- package/src/testing/run.ts +39 -14
- package/src/types.ts +32 -2
|
@@ -4,10 +4,16 @@ import { connect as connectTlsSocket } from "node:tls";
|
|
|
4
4
|
import { SocksClient } from "socks";
|
|
5
5
|
import { ProxyResolutionError } from "../config/loader.js";
|
|
6
6
|
import { TransportError } from "../errors.js";
|
|
7
|
+
import { NativeEgressPolicyValidationError, parseNativeEgressPolicy, } from "../native-egress-policy.js";
|
|
7
8
|
import { hasNodemavenCredentials, nodemavenSessionWindow, synthesizeNodemavenProxy, } from "./proxy-nodemaven.js";
|
|
8
9
|
export class NativeNetworkError extends TransportError {
|
|
9
10
|
constructor(message, code) {
|
|
10
|
-
|
|
11
|
+
const isEgressPolicyFailure = code.startsWith("native_egress_") || code === "native_dynamic_egress_unsupported";
|
|
12
|
+
super(message, {
|
|
13
|
+
code,
|
|
14
|
+
status: 0,
|
|
15
|
+
...(isEgressPolicyFailure ? { category: "provider_error", retryable: false } : {}),
|
|
16
|
+
});
|
|
11
17
|
this.name = "NativeNetworkError";
|
|
12
18
|
}
|
|
13
19
|
get code() {
|
|
@@ -22,6 +28,37 @@ export class NativeProxyExpiredError extends NativeNetworkError {
|
|
|
22
28
|
this.name = "NativeProxyExpiredError";
|
|
23
29
|
}
|
|
24
30
|
}
|
|
31
|
+
/** Raised before transport setup when a native destination is not authorized. */
|
|
32
|
+
export class NativeEgressNotDeclaredError extends NativeNetworkError {
|
|
33
|
+
host;
|
|
34
|
+
port;
|
|
35
|
+
tls;
|
|
36
|
+
constructor(host, port, tls) {
|
|
37
|
+
super(`Native ${tls === "required" ? "TLS" : "TCP"} egress is not declared for ${host}:${port}`, "native_egress_not_declared");
|
|
38
|
+
this.host = host;
|
|
39
|
+
this.port = port;
|
|
40
|
+
this.tls = tls;
|
|
41
|
+
this.name = "NativeEgressNotDeclaredError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Raised when the destination was authorized by a grant whose TTL elapsed and
|
|
46
|
+
* its expiry remains in the client's bounded recent-expiry evidence window.
|
|
47
|
+
*/
|
|
48
|
+
export class NativeEgressGrantExpiredError extends NativeNetworkError {
|
|
49
|
+
host;
|
|
50
|
+
port;
|
|
51
|
+
tls;
|
|
52
|
+
expiresAt;
|
|
53
|
+
constructor(host, port, tls, expiresAt) {
|
|
54
|
+
super(`Native ${tls === "required" ? "TLS" : "TCP"} egress grant expired for ${host}:${port}`, "native_egress_grant_expired");
|
|
55
|
+
this.host = host;
|
|
56
|
+
this.port = port;
|
|
57
|
+
this.tls = tls;
|
|
58
|
+
this.expiresAt = expiresAt;
|
|
59
|
+
this.name = "NativeEgressGrantExpiredError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
25
62
|
/** Raised when an established connection exceeds its opt-in read-idle window. */
|
|
26
63
|
export class NativeIdleTimeoutError extends NativeNetworkError {
|
|
27
64
|
constructor() {
|
|
@@ -227,7 +264,7 @@ async function waitForSocksHandshake(proxySocket, promise, signal, deadline) {
|
|
|
227
264
|
: failedError()));
|
|
228
265
|
});
|
|
229
266
|
}
|
|
230
|
-
async function connectSocksTunnel(proxy, input, deadline) {
|
|
267
|
+
async function connectSocksTunnel(proxy, input, deadline, beforeDestinationConnect) {
|
|
231
268
|
const parsed = parseSocks5Proxy(proxy.url);
|
|
232
269
|
const proxySocket = await connectPlainSocket(parsed.host, parsed.port, input.signal, deadline);
|
|
233
270
|
// `socks` removes its internal listeners as the handshake settles. Keep a
|
|
@@ -235,6 +272,13 @@ async function connectSocksTunnel(proxy, input, deadline) {
|
|
|
235
272
|
// an unhandled late network error between library cleanup and our wrapper.
|
|
236
273
|
proxySocket.on("error", () => undefined);
|
|
237
274
|
const remaining = remainingMs(deadline);
|
|
275
|
+
try {
|
|
276
|
+
beforeDestinationConnect();
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
proxySocket.destroy();
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
238
282
|
const handshake = SocksClient.createConnection({
|
|
239
283
|
command: "connect",
|
|
240
284
|
destination: { host: input.host, port: input.port },
|
|
@@ -448,30 +492,331 @@ async function resolveConnectionProxy(options, input) {
|
|
|
448
492
|
throw proxyRequiredError(policy);
|
|
449
493
|
return resolved;
|
|
450
494
|
}
|
|
451
|
-
|
|
495
|
+
export const NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT = 256;
|
|
496
|
+
function normalizeEgressHost(host) {
|
|
497
|
+
return host.trim().toLowerCase().replace(/\.$/, "");
|
|
498
|
+
}
|
|
499
|
+
function invalidPolicy(message) {
|
|
500
|
+
return new NativeNetworkError(message, "native_egress_policy_invalid");
|
|
501
|
+
}
|
|
502
|
+
function matchesDnsSuffix(host, suffix) {
|
|
503
|
+
return host === suffix || host.endsWith(`.${suffix}`);
|
|
504
|
+
}
|
|
505
|
+
function matchesSourceHost(rule, host) {
|
|
506
|
+
const hasSelector = rule.sourceHost !== undefined || rule.sourceHostSuffixes.length > 0;
|
|
507
|
+
if (!hasSelector)
|
|
508
|
+
return false;
|
|
509
|
+
return (host === rule.sourceHost ||
|
|
510
|
+
rule.sourceHostSuffixes.some((suffix) => matchesDnsSuffix(host, suffix)));
|
|
511
|
+
}
|
|
512
|
+
function matchesPortSelectors(port, ports, ranges) {
|
|
513
|
+
if (ports.length === 0 && ranges.length === 0)
|
|
514
|
+
return false;
|
|
515
|
+
return ports.includes(port) || ranges.some(({ start, end }) => port >= start && port <= end);
|
|
516
|
+
}
|
|
517
|
+
function tlsModeAllows(mode, requested) {
|
|
518
|
+
return mode === "allowed" || mode === requested;
|
|
519
|
+
}
|
|
520
|
+
function grantTlsFitsRule(grant, rule) {
|
|
521
|
+
return (rule === "allowed" ||
|
|
522
|
+
(grant === "required" && rule === "required") ||
|
|
523
|
+
(grant === "disabled" && rule === "disabled"));
|
|
524
|
+
}
|
|
525
|
+
function matchesDynamicRuleSelectors(rule, input) {
|
|
526
|
+
const sourceHost = normalizeEgressHost(input.sourceHost);
|
|
527
|
+
const targetHost = normalizeEgressHost(input.host);
|
|
528
|
+
return (matchesSourceHost(rule, sourceHost) &&
|
|
529
|
+
matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges) &&
|
|
530
|
+
rule.targetHostSuffixes.some((suffix) => matchesDnsSuffix(targetHost, suffix)) &&
|
|
531
|
+
matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges) &&
|
|
532
|
+
grantTlsFitsRule(input.tls, rule.tls));
|
|
533
|
+
}
|
|
534
|
+
function invalidGrant(message) {
|
|
535
|
+
return new NativeNetworkError(message, "native_egress_grant_invalid");
|
|
536
|
+
}
|
|
537
|
+
function hasControlCharacter(value) {
|
|
538
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
539
|
+
const code = value.charCodeAt(index);
|
|
540
|
+
if (code <= 31 || code === 127)
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
function assertValidGrantInput(input) {
|
|
546
|
+
if (!normalizeEgressHost(input.sourceHost) ||
|
|
547
|
+
!normalizeEgressHost(input.host) ||
|
|
548
|
+
hasControlCharacter(input.sourceHost) ||
|
|
549
|
+
hasControlCharacter(input.host) ||
|
|
550
|
+
/\s/.test(input.sourceHost) ||
|
|
551
|
+
/\s/.test(input.host) ||
|
|
552
|
+
input.sourceHost.includes("://") ||
|
|
553
|
+
input.host.includes("://") ||
|
|
554
|
+
input.sourceHost.includes("*") ||
|
|
555
|
+
input.host.includes("*"))
|
|
556
|
+
throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
|
|
557
|
+
if (!Number.isSafeInteger(input.sourcePort) ||
|
|
558
|
+
input.sourcePort < 1 ||
|
|
559
|
+
input.sourcePort > 65_535 ||
|
|
560
|
+
!Number.isSafeInteger(input.port) ||
|
|
561
|
+
input.port < 1 ||
|
|
562
|
+
input.port > 65_535)
|
|
563
|
+
throw invalidGrant("Native TCP egress grant ports must be integers from 1 to 65535");
|
|
564
|
+
if (input.tls !== "required" && input.tls !== "allowed" && input.tls !== "disabled")
|
|
565
|
+
throw invalidGrant("Native TCP egress grant tls must be required, allowed, or disabled");
|
|
566
|
+
if (input.ttlMs !== undefined && (!Number.isSafeInteger(input.ttlMs) || input.ttlMs <= 0))
|
|
567
|
+
throw invalidGrant("Native TCP egress grant ttlMs must be a positive integer");
|
|
568
|
+
}
|
|
569
|
+
/** Internal canonical snapshot shared by production and SDK transport test doubles. */
|
|
570
|
+
export function snapshotNativeConnectInput(input) {
|
|
571
|
+
try {
|
|
572
|
+
const host = input.host;
|
|
573
|
+
const port = input.port;
|
|
574
|
+
const serverName = input.serverName;
|
|
575
|
+
const rejectUnauthorized = input.rejectUnauthorized;
|
|
576
|
+
const idleTimeoutMs = input.idleTimeoutMs;
|
|
577
|
+
const timeoutMs = input.timeoutMs;
|
|
578
|
+
const signal = input.signal;
|
|
579
|
+
const affinityKey = input.affinityKey;
|
|
580
|
+
const snapshot = {
|
|
581
|
+
host,
|
|
582
|
+
port,
|
|
583
|
+
...(serverName === undefined ? {} : { serverName }),
|
|
584
|
+
...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
|
|
585
|
+
...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
|
|
586
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
587
|
+
...(signal === undefined ? {} : { signal }),
|
|
588
|
+
...(affinityKey === undefined ? {} : { affinityKey }),
|
|
589
|
+
};
|
|
590
|
+
if (typeof snapshot.host !== "string" ||
|
|
591
|
+
!snapshot.host.trim() ||
|
|
592
|
+
hasControlCharacter(snapshot.host) ||
|
|
593
|
+
!Number.isInteger(snapshot.port) ||
|
|
594
|
+
snapshot.port < 1 ||
|
|
595
|
+
snapshot.port > 65_535)
|
|
596
|
+
throw new TypeError("invalid native connection target");
|
|
597
|
+
return snapshot;
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
throw new NativeNetworkError("Native connection input could not be inspected safely", "native_egress_input_invalid");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/** Internal canonical snapshot shared by production and SDK transport test doubles. */
|
|
604
|
+
export function snapshotNativeGrantInput(input) {
|
|
605
|
+
try {
|
|
606
|
+
const sourceHost = input.sourceHost;
|
|
607
|
+
const sourcePort = input.sourcePort;
|
|
608
|
+
const host = input.host;
|
|
609
|
+
const port = input.port;
|
|
610
|
+
const tls = input.tls;
|
|
611
|
+
const ttlMs = input.ttlMs;
|
|
612
|
+
return {
|
|
613
|
+
sourceHost,
|
|
614
|
+
sourcePort,
|
|
615
|
+
host,
|
|
616
|
+
port,
|
|
617
|
+
tls,
|
|
618
|
+
...(ttlMs === undefined ? {} : { ttlMs }),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
throw new NativeNetworkError("Native TCP egress grant input could not be inspected safely", "native_egress_input_invalid");
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/** Internal authorization seam shared by production and SDK transport test doubles. */
|
|
626
|
+
export function createNativeEgressAuthorization(options) {
|
|
627
|
+
let declared;
|
|
628
|
+
let staticRules;
|
|
629
|
+
let dynamicRules;
|
|
630
|
+
let delegate;
|
|
631
|
+
try {
|
|
632
|
+
const policy = options.egress;
|
|
633
|
+
delegate = options.grantTcpEgress;
|
|
634
|
+
if (delegate !== undefined && typeof delegate !== "function")
|
|
635
|
+
throw invalidPolicy("Native egress delegate must be a function");
|
|
636
|
+
declared = policy !== undefined;
|
|
637
|
+
const snapshot = declared
|
|
638
|
+
? parseNativeEgressPolicy(policy)
|
|
639
|
+
: { staticRules: [], dynamicRules: [] };
|
|
640
|
+
staticRules = snapshot.staticRules;
|
|
641
|
+
dynamicRules = snapshot.dynamicRules;
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
if (error instanceof NativeNetworkError)
|
|
645
|
+
throw error;
|
|
646
|
+
if (error instanceof NativeEgressPolicyValidationError)
|
|
647
|
+
throw invalidPolicy(error.message);
|
|
648
|
+
throw invalidPolicy("Native egress policy could not be inspected safely");
|
|
649
|
+
}
|
|
650
|
+
const grants = [];
|
|
651
|
+
const expiredEvidence = new Map();
|
|
652
|
+
const grantKey = (grant) => `${grant.host}\0${grant.port}\0${grant.tls}`;
|
|
653
|
+
const recordExpired = (grant) => {
|
|
654
|
+
const key = grantKey(grant);
|
|
655
|
+
expiredEvidence.delete(key);
|
|
656
|
+
expiredEvidence.set(key, grant);
|
|
657
|
+
while (expiredEvidence.size > NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT) {
|
|
658
|
+
const oldest = expiredEvidence.keys().next().value;
|
|
659
|
+
if (typeof oldest !== "string")
|
|
660
|
+
break;
|
|
661
|
+
expiredEvidence.delete(oldest);
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
const purgeInactive = (now) => {
|
|
665
|
+
const live = [];
|
|
666
|
+
for (const grant of grants) {
|
|
667
|
+
if (grant.revoked)
|
|
668
|
+
continue;
|
|
669
|
+
if (grant.expiresAtMs !== undefined && now >= grant.expiresAtMs) {
|
|
670
|
+
recordExpired(grant);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
live.push(grant);
|
|
674
|
+
}
|
|
675
|
+
grants.length = 0;
|
|
676
|
+
grants.push(...live);
|
|
677
|
+
};
|
|
678
|
+
const assertConnect = (input, tls) => {
|
|
679
|
+
if (!declared)
|
|
680
|
+
return;
|
|
681
|
+
const host = normalizeEgressHost(input.host);
|
|
682
|
+
const now = Date.now();
|
|
683
|
+
purgeInactive(now);
|
|
684
|
+
if (staticRules.some((rule) => rule.host === host && rule.ports.includes(input.port) && tlsModeAllows(rule.tls, tls)))
|
|
685
|
+
return;
|
|
686
|
+
const matching = grants.filter((grant) => !grant.revoked &&
|
|
687
|
+
grant.host === host &&
|
|
688
|
+
grant.port === input.port &&
|
|
689
|
+
tlsModeAllows(grant.tls, tls));
|
|
690
|
+
if (matching.length > 0)
|
|
691
|
+
return;
|
|
692
|
+
const expired = [...expiredEvidence.values()]
|
|
693
|
+
.filter((grant) => grant.host === host &&
|
|
694
|
+
grant.port === input.port &&
|
|
695
|
+
tlsModeAllows(grant.tls, tls))
|
|
696
|
+
.sort((left, right) => (right.expiresAtMs ?? 0) - (left.expiresAtMs ?? 0))[0];
|
|
697
|
+
if (expired?.expiresAtMs !== undefined)
|
|
698
|
+
throw new NativeEgressGrantExpiredError(input.host, input.port, tls, new Date(expired.expiresAtMs).toISOString());
|
|
699
|
+
throw new NativeEgressNotDeclaredError(input.host, input.port, tls);
|
|
700
|
+
};
|
|
701
|
+
const grantLocal = (input) => {
|
|
702
|
+
assertValidGrantInput(input);
|
|
703
|
+
const ruleIndex = dynamicRules.findIndex((rule) => matchesDynamicRuleSelectors(rule, input));
|
|
704
|
+
if (ruleIndex < 0)
|
|
705
|
+
throw new NativeNetworkError(`Native TCP egress grant is not declared for ${input.host}:${input.port} (${input.tls})`, "native_egress_not_declared");
|
|
706
|
+
const rule = dynamicRules[ruleIndex];
|
|
707
|
+
if (!rule)
|
|
708
|
+
throw invalidGrant("Native TCP egress declaration is missing its matched rule");
|
|
709
|
+
if (input.ttlMs !== undefined && rule.ttlMs !== undefined && input.ttlMs > rule.ttlMs)
|
|
710
|
+
throw invalidGrant(`Native TCP egress grant ttlMs ${input.ttlMs} exceeds declared maximum ${rule.ttlMs}`);
|
|
711
|
+
const now = Date.now();
|
|
712
|
+
const targetHost = normalizeEgressHost(input.host);
|
|
713
|
+
purgeInactive(now);
|
|
714
|
+
const activeForRule = grants.filter((grant) => grant.ruleIndex === ruleIndex &&
|
|
715
|
+
!grant.revoked &&
|
|
716
|
+
(grant.expiresAtMs === undefined || now < grant.expiresAtMs)).length;
|
|
717
|
+
if (rule.maxGrants !== undefined && activeForRule >= rule.maxGrants)
|
|
718
|
+
throw new NativeNetworkError(`Native TCP egress grant limit exceeded for declaration ${ruleIndex}`, "native_egress_grant_limit_exceeded");
|
|
719
|
+
const ttlMs = input.ttlMs ?? rule.ttlMs;
|
|
720
|
+
const expiresAtMs = ttlMs === undefined ? undefined : now + ttlMs;
|
|
721
|
+
if (expiresAtMs !== undefined && (!Number.isFinite(expiresAtMs) || expiresAtMs > 8.64e15))
|
|
722
|
+
throw invalidGrant("Native TCP egress grant expiry exceeds the supported date range");
|
|
723
|
+
const stored = {
|
|
724
|
+
ruleIndex,
|
|
725
|
+
host: targetHost,
|
|
726
|
+
port: input.port,
|
|
727
|
+
tls: input.tls,
|
|
728
|
+
...(expiresAtMs === undefined ? {} : { expiresAtMs }),
|
|
729
|
+
revoked: false,
|
|
730
|
+
};
|
|
731
|
+
expiredEvidence.delete(grantKey(stored));
|
|
732
|
+
grants.push(stored);
|
|
733
|
+
return {
|
|
734
|
+
revoke() {
|
|
735
|
+
stored.revoked = true;
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
};
|
|
739
|
+
return {
|
|
740
|
+
assertConnect,
|
|
741
|
+
grant(input) {
|
|
742
|
+
if (!declared) {
|
|
743
|
+
if (delegate) {
|
|
744
|
+
try {
|
|
745
|
+
const delegated = delegate(input);
|
|
746
|
+
if (!delegated || typeof delegated.revoke !== "function")
|
|
747
|
+
throw new TypeError("Native egress delegate returned an invalid grant");
|
|
748
|
+
return delegated;
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
if (error instanceof NativeNetworkError)
|
|
752
|
+
throw error;
|
|
753
|
+
throw new NativeNetworkError("Deployment native egress authorization failed", "native_egress_authorization_failed");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
throw new NativeNetworkError("Dynamic native egress authorization is not configured", "native_dynamic_egress_unsupported");
|
|
757
|
+
}
|
|
758
|
+
const local = grantLocal(input);
|
|
759
|
+
let delegated;
|
|
760
|
+
try {
|
|
761
|
+
delegated = delegate?.(Object.freeze({ ...input }));
|
|
762
|
+
if (delegated !== undefined && typeof delegated.revoke !== "function")
|
|
763
|
+
throw new TypeError("Native egress delegate returned an invalid grant");
|
|
764
|
+
}
|
|
765
|
+
catch (error) {
|
|
766
|
+
local.revoke();
|
|
767
|
+
if (error instanceof NativeNetworkError)
|
|
768
|
+
throw error;
|
|
769
|
+
throw new NativeNetworkError("Deployment native egress authorization failed", "native_egress_authorization_failed");
|
|
770
|
+
}
|
|
771
|
+
let revoked = false;
|
|
772
|
+
return {
|
|
773
|
+
revoke() {
|
|
774
|
+
if (revoked)
|
|
775
|
+
return;
|
|
776
|
+
revoked = true;
|
|
777
|
+
local.revoke();
|
|
778
|
+
try {
|
|
779
|
+
delegated?.revoke();
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
if (error instanceof NativeNetworkError)
|
|
783
|
+
throw error;
|
|
784
|
+
throw new NativeNetworkError("Deployment native egress grant revocation failed", "native_egress_authorization_failed");
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/** Create the SDK byte-stream runtime with provider-declared egress enforcement. */
|
|
452
792
|
export function createNativeNetworkClient(options = {}) {
|
|
793
|
+
const egress = createNativeEgressAuthorization(options);
|
|
453
794
|
return {
|
|
454
795
|
connectTcp: async (input) => {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
const
|
|
796
|
+
const request = snapshotNativeConnectInput(input);
|
|
797
|
+
egress.assertConnect(request, "disabled");
|
|
798
|
+
const deadline = deadlineFrom(request.timeoutMs);
|
|
799
|
+
assertCanStart(request.signal, deadline);
|
|
800
|
+
const proxy = await resolveConnectionProxy(options, request);
|
|
801
|
+
egress.assertConnect(request, "disabled");
|
|
458
802
|
const socket = proxy
|
|
459
|
-
? await connectSocksTunnel(proxy,
|
|
460
|
-
: await connectPlainSocket(
|
|
461
|
-
return createNativeNetworkConnection(socket, proxy, options,
|
|
803
|
+
? await connectSocksTunnel(proxy, request, deadline, () => egress.assertConnect(request, "disabled"))
|
|
804
|
+
: await connectPlainSocket(request.host, request.port, request.signal, deadline);
|
|
805
|
+
return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
|
|
462
806
|
},
|
|
463
807
|
connectTls: async (input) => {
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
808
|
+
const request = snapshotNativeConnectInput(input);
|
|
809
|
+
egress.assertConnect(request, "required");
|
|
810
|
+
const deadline = deadlineFrom(request.timeoutMs);
|
|
811
|
+
assertCanStart(request.signal, deadline);
|
|
812
|
+
const proxy = await resolveConnectionProxy(options, request);
|
|
813
|
+
egress.assertConnect(request, "required");
|
|
814
|
+
const tunnel = proxy
|
|
815
|
+
? await connectSocksTunnel(proxy, request, deadline, () => egress.assertConnect(request, "required"))
|
|
816
|
+
: undefined;
|
|
817
|
+
const socket = await upgradeTls(tunnel, request, deadline);
|
|
818
|
+
return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
|
|
475
819
|
},
|
|
820
|
+
grantTcpEgress: (input) => egress.grant(snapshotNativeGrantInput(input)),
|
|
476
821
|
};
|
|
477
822
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { HttpRedirectFailureReason } from "../types.js";
|
|
2
|
+
export type RedirectHopDecision<TMethod extends string> = {
|
|
3
|
+
kind: "follow";
|
|
4
|
+
nextMethod: TMethod | "GET";
|
|
5
|
+
nextUrl: string;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "stop";
|
|
8
|
+
reason: HttpRedirectFailureReason;
|
|
9
|
+
nextUrl?: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function isRedirectStatus(status: number): boolean;
|
|
12
|
+
/** Shared fetch-compatible redirect method rewriting for stealth and ctx.http. */
|
|
13
|
+
export declare function nextRedirectMethod<TMethod extends string>(status: number, method: TMethod): TMethod | "GET";
|
|
14
|
+
/** Resolves a Location value against the response URL without issuing a request. */
|
|
15
|
+
export declare function resolveRedirectUrl(location: string | undefined, responseUrl: string): string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Shared post-response decision ordering for both redirect walkers. The
|
|
18
|
+
* caller-owned stop hook is checked first, matching stealth's pre-follow
|
|
19
|
+
* contract, then structural termination and loop checks run before follow.
|
|
20
|
+
*/
|
|
21
|
+
export declare function evaluateRedirectHop<TMethod extends string>(input: {
|
|
22
|
+
status: number;
|
|
23
|
+
method: TMethod;
|
|
24
|
+
nextUrl: string | undefined;
|
|
25
|
+
shouldStop: boolean;
|
|
26
|
+
redirectCount: number;
|
|
27
|
+
maxHops: number;
|
|
28
|
+
visitedRequests: ReadonlySet<string>;
|
|
29
|
+
}): RedirectHopDecision<TMethod>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
2
|
+
export function isRedirectStatus(status) {
|
|
3
|
+
return REDIRECT_STATUSES.has(status);
|
|
4
|
+
}
|
|
5
|
+
/** Shared fetch-compatible redirect method rewriting for stealth and ctx.http. */
|
|
6
|
+
export function nextRedirectMethod(status, method) {
|
|
7
|
+
if (status === 303 && method !== "HEAD")
|
|
8
|
+
return "GET";
|
|
9
|
+
if ((status === 301 || status === 302) && method === "POST")
|
|
10
|
+
return "GET";
|
|
11
|
+
return method;
|
|
12
|
+
}
|
|
13
|
+
/** Resolves a Location value against the response URL without issuing a request. */
|
|
14
|
+
export function resolveRedirectUrl(location, responseUrl) {
|
|
15
|
+
return location ? new URL(location, responseUrl).toString() : undefined;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Shared post-response decision ordering for both redirect walkers. The
|
|
19
|
+
* caller-owned stop hook is checked first, matching stealth's pre-follow
|
|
20
|
+
* contract, then structural termination and loop checks run before follow.
|
|
21
|
+
*/
|
|
22
|
+
export function evaluateRedirectHop(input) {
|
|
23
|
+
if (input.shouldStop) {
|
|
24
|
+
return { kind: "stop", reason: "stopped", ...(input.nextUrl ? { nextUrl: input.nextUrl } : {}) };
|
|
25
|
+
}
|
|
26
|
+
if (!input.nextUrl)
|
|
27
|
+
return { kind: "stop", reason: "missing_location" };
|
|
28
|
+
if (input.redirectCount > input.maxHops) {
|
|
29
|
+
return { kind: "stop", reason: "max_hops", nextUrl: input.nextUrl };
|
|
30
|
+
}
|
|
31
|
+
const nextMethod = nextRedirectMethod(input.status, input.method);
|
|
32
|
+
if (input.visitedRequests.has(`${nextMethod} ${input.nextUrl}`)) {
|
|
33
|
+
return { kind: "stop", reason: "loop", nextUrl: input.nextUrl };
|
|
34
|
+
}
|
|
35
|
+
return { kind: "follow", nextMethod, nextUrl: input.nextUrl };
|
|
36
|
+
}
|
package/dist/runtime/stealth.js
CHANGED
|
@@ -6,6 +6,7 @@ import { SDKError, StealthCookieStoreVersionError, TransportError } from "../err
|
|
|
6
6
|
import { getStealthProfile } from "../stealth/profiles.js";
|
|
7
7
|
import { createProxyAuthIpDeniedError, createProxyEdgeAuthRejectedError, createProxyEdgeTlsRejectedError, createProxyPoolExhaustedError, createProxyPoolStaleError, isProxyAuthIpDeniedMessage, isProxyEdgeAuthRejectedMessage, isProxyEdgeTlsRejectedResponse, isProxyPoolRefreshableError, isProxyPoolStaleMessage, isProxyPoolStaleStatus, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_STALE_CODE, } from "./proxy-errors.js";
|
|
8
8
|
import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, normalizeProxyTransportRetryOptions, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
|
|
9
|
+
import { evaluateRedirectHop, isRedirectStatus, resolveRedirectUrl, } from "./redirects.js";
|
|
9
10
|
import { isSensitiveKey, redactSensitiveError, redactSensitiveRequestError, redactSensitiveText, redactUrlQueryParams, normalizeSensitiveParams, serializeRequestUrl, } from "./request-options.js";
|
|
10
11
|
const DEFAULT_PROFILE = "chrome-146";
|
|
11
12
|
const MISSING_PROXY_WARNING = "[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.";
|
|
@@ -558,16 +559,6 @@ function normalizeMethod(method) {
|
|
|
558
559
|
throw new SDKError(`Unsupported stealth method: ${method}`);
|
|
559
560
|
}
|
|
560
561
|
}
|
|
561
|
-
function isRedirectStatus(status) {
|
|
562
|
-
return [301, 302, 303, 307, 308].includes(status);
|
|
563
|
-
}
|
|
564
|
-
function nextRedirectMethod(status, method) {
|
|
565
|
-
if (status === 303 && method !== "HEAD")
|
|
566
|
-
return "GET";
|
|
567
|
-
if ((status === 301 || status === 302) && method === "POST")
|
|
568
|
-
return "GET";
|
|
569
|
-
return method;
|
|
570
|
-
}
|
|
571
562
|
function locationHeader(headers) {
|
|
572
563
|
for (const [name, value] of Object.entries(headers)) {
|
|
573
564
|
if (name.toLowerCase() === "location")
|
|
@@ -972,7 +963,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
|
|
|
972
963
|
const redactedLocation = location ? redactRedirectUrl(location) : undefined;
|
|
973
964
|
let nextUrl;
|
|
974
965
|
try {
|
|
975
|
-
nextUrl =
|
|
966
|
+
nextUrl = resolveRedirectUrl(location, responseUrl);
|
|
976
967
|
}
|
|
977
968
|
catch (error) {
|
|
978
969
|
throw redactSensitiveError(error, [...sensitiveValues], location, redactedLocation);
|
|
@@ -1010,48 +1001,29 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
|
|
|
1010
1001
|
throw sanitizedError;
|
|
1011
1002
|
}
|
|
1012
1003
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
}
|
|
1022
|
-
if (
|
|
1023
|
-
return {
|
|
1024
|
-
final: response,
|
|
1025
|
-
hops,
|
|
1026
|
-
reason: "missing_location",
|
|
1027
|
-
cookies: cookieJar.snapshot(),
|
|
1028
|
-
cookieStore: cookieJar.serialize(),
|
|
1029
|
-
};
|
|
1030
|
-
}
|
|
1031
|
-
if (hops.length > maxHops) {
|
|
1004
|
+
const decision = evaluateRedirectHop({
|
|
1005
|
+
status: response.status,
|
|
1006
|
+
method,
|
|
1007
|
+
nextUrl,
|
|
1008
|
+
shouldStop,
|
|
1009
|
+
redirectCount: hops.length,
|
|
1010
|
+
maxHops,
|
|
1011
|
+
visitedRequests,
|
|
1012
|
+
});
|
|
1013
|
+
if (decision.kind === "stop") {
|
|
1032
1014
|
return {
|
|
1033
1015
|
final: response,
|
|
1034
1016
|
hops,
|
|
1035
|
-
reason:
|
|
1017
|
+
reason: decision.reason,
|
|
1036
1018
|
cookies: cookieJar.snapshot(),
|
|
1037
1019
|
cookieStore: cookieJar.serialize(),
|
|
1038
1020
|
};
|
|
1039
1021
|
}
|
|
1040
|
-
|
|
1041
|
-
if (nextMethod !== method) {
|
|
1022
|
+
if (decision.nextMethod !== method) {
|
|
1042
1023
|
body = undefined;
|
|
1043
1024
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
final: response,
|
|
1047
|
-
hops,
|
|
1048
|
-
reason: "loop",
|
|
1049
|
-
cookies: cookieJar.snapshot(),
|
|
1050
|
-
cookieStore: cookieJar.serialize(),
|
|
1051
|
-
};
|
|
1052
|
-
}
|
|
1053
|
-
method = nextMethod;
|
|
1054
|
-
currentUrl = nextUrl;
|
|
1025
|
+
method = decision.nextMethod;
|
|
1026
|
+
currentUrl = decision.nextUrl;
|
|
1055
1027
|
}
|
|
1056
1028
|
if (!response) {
|
|
1057
1029
|
response = await session.fetch(currentUrl, {
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createServerApp, type ProviderServerCloseOptions, type ProviderServerHandle, type ProviderServerLogEvent, type ProviderServerLogger, type ProviderServerOperationExecutor, type ProviderServerOperationExecutorInput, type ProviderServerOptions, type ProviderServerStatefulForwardEnvelope, type ServeOptions, serve, } from "./serve.js";
|
|
1
|
+
export { createServerApp, ERROR_OBSERVABILITY_HEADER, type ErrorObservabilityDetails, type ProviderServerCloseOptions, type ProviderServerHandle, type ProviderServerLogEvent, type ProviderServerLogger, type ProviderServerOperationExecutor, type ProviderServerOperationExecutorInput, type ProviderServerOptions, type ProviderServerStatefulForwardEnvelope, type ServeOptions, serve, } from "./serve.js";
|
|
2
2
|
export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, type SelfTestAppOptions, type SelfTestAuthFlowInvoke, type SelfTestAuthFlowRoute, type SelfTestCaseResult, type SelfTestCaseStatus, type SelfTestOperationInvoke, type SelfTestRequest, SelfTestRequestSchema, type SelfTestResponse, } from "./self-test.js";
|
|
3
3
|
export { type InputDateTokenCalendar, resolveHealthCheckInputDateTokens, } from "./self-test-input-tokens.js";
|
|
4
4
|
export { collectSelfTestSensitiveValues, redactSelfTestText, SELF_TEST_MAX_TEXT_LENGTH, SELF_TEST_REDACTED_PLACEHOLDER, } from "./self-test-redaction.js";
|
package/dist/server/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createServerApp, serve, } from "./serve.js";
|
|
1
|
+
export { createServerApp, ERROR_OBSERVABILITY_HEADER, serve, } from "./serve.js";
|
|
2
2
|
export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, SelfTestRequestSchema, } from "./self-test.js";
|
|
3
3
|
export { resolveHealthCheckInputDateTokens, } from "./self-test-input-tokens.js";
|
|
4
4
|
export { collectSelfTestSensitiveValues, redactSelfTestText, SELF_TEST_MAX_TEXT_LENGTH, SELF_TEST_REDACTED_PLACEHOLDER, } from "./self-test-redaction.js";
|
package/dist/server/serve.d.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { z } from "zod";
|
|
|
3
3
|
import { type ProviderErrorCategory } from "../observability.js";
|
|
4
4
|
import type { ProviderContext, ProviderDefinition, ProviderRuntimeState, SttContext } from "../types.js";
|
|
5
5
|
import { type OperationRequest } from "./types.js";
|
|
6
|
+
/** Compact SDK-owned error classification emitted separately from the public response body. */
|
|
7
|
+
export declare const ERROR_OBSERVABILITY_HEADER = "X-ApiFuse-Error-Observability";
|
|
8
|
+
export type ErrorObservabilityDetails = {
|
|
9
|
+
category: ProviderErrorCategory;
|
|
10
|
+
taxonomyVersion: string;
|
|
11
|
+
retryable: boolean;
|
|
12
|
+
upstreamStatus?: number;
|
|
13
|
+
};
|
|
6
14
|
export declare const ProviderServerStatefulForwardEnvelopeSchema: z.ZodObject<{
|
|
7
15
|
requestId: z.ZodString;
|
|
8
16
|
providerId: z.ZodString;
|
|
@@ -78,6 +86,7 @@ export type ProviderServerLogEvent = (ProviderServerLogEventBase & {
|
|
|
78
86
|
errorCategory?: ProviderErrorCategory;
|
|
79
87
|
taxonomyVersion?: string;
|
|
80
88
|
retryable?: boolean;
|
|
89
|
+
signal?: "unregistered_provider_error_code";
|
|
81
90
|
issues?: Array<{
|
|
82
91
|
path: string;
|
|
83
92
|
code: string;
|