@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.
Files changed (44) hide show
  1. package/AUTHORING.md +70 -6
  2. package/CHANGELOG.md +12 -0
  3. package/dist/define.js +9 -0
  4. package/dist/errors.d.ts +13 -0
  5. package/dist/errors.js +25 -0
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +2 -2
  8. package/dist/native-egress-policy.d.ts +27 -0
  9. package/dist/native-egress-policy.js +225 -0
  10. package/dist/provider.d.ts +3 -3
  11. package/dist/provider.js +2 -2
  12. package/dist/runtime/executor.js +17 -2
  13. package/dist/runtime/http.js +189 -9
  14. package/dist/runtime/native-network.d.ts +39 -4
  15. package/dist/runtime/native-network.js +365 -20
  16. package/dist/runtime/redirects.d.ts +29 -0
  17. package/dist/runtime/redirects.js +36 -0
  18. package/dist/runtime/stealth.js +16 -44
  19. package/dist/server/index.d.ts +1 -1
  20. package/dist/server/index.js +1 -1
  21. package/dist/server/serve.d.ts +9 -0
  22. package/dist/server/serve.js +190 -51
  23. package/dist/server/types.d.ts +3 -0
  24. package/dist/server/types.js +1 -0
  25. package/dist/stateful/stateful-provider-owner-forwarder.js +9 -1
  26. package/dist/testing/run.js +32 -13
  27. package/dist/types.d.ts +23 -2
  28. package/package.json +1 -1
  29. package/src/define.ts +11 -0
  30. package/src/errors.ts +37 -0
  31. package/src/index.ts +12 -1
  32. package/src/native-egress-policy.ts +285 -0
  33. package/src/provider.ts +7 -0
  34. package/src/runtime/executor.ts +22 -2
  35. package/src/runtime/http.ts +217 -9
  36. package/src/runtime/native-network.ts +474 -22
  37. package/src/runtime/redirects.ts +66 -0
  38. package/src/runtime/stealth.ts +20 -47
  39. package/src/server/index.ts +2 -0
  40. package/src/server/serve.ts +226 -68
  41. package/src/server/types.ts +1 -0
  42. package/src/stateful/stateful-provider-owner-forwarder.ts +9 -1
  43. package/src/testing/run.ts +39 -14
  44. package/src/types.ts +32 -2
@@ -6,15 +6,24 @@ import { SocksClient } from "socks";
6
6
 
7
7
  import { ProxyResolutionError } from "../config/loader.js";
8
8
  import { TransportError } from "../errors.js";
9
+ import {
10
+ NativeEgressPolicyValidationError,
11
+ parseNativeEgressPolicy,
12
+ type DynamicEgressRuleSnapshot,
13
+ type StaticEgressRuleSnapshot,
14
+ } from "../native-egress-policy.js";
9
15
  import type {
10
16
  NativeNetworkClient,
11
17
  NativeNetworkConnection,
12
18
  NativeNetworkConnectInput,
13
19
  NativeNetworkDynamicGrantOptions,
14
20
  NativeNetworkEgressGrant,
21
+ NativeProviderConfig,
15
22
  NativeProxyDrainHandler,
16
23
  NativeProxyEgressInfo,
17
24
  NativeProxyExpiringEvent,
25
+ NativeTcpPortRange,
26
+ NativeTcpTlsMode,
18
27
  ProviderProxyPolicy,
19
28
  ProviderProxyProvider,
20
29
  } from "../types.js";
@@ -30,13 +39,26 @@ export type NativeNetworkErrorCode =
30
39
  | "native_connection_failed"
31
40
  | "native_connection_idle_timeout"
32
41
  | "native_connection_timeout"
42
+ | "native_egress_authorization_failed"
43
+ | "native_egress_grant_expired"
44
+ | "native_egress_grant_invalid"
45
+ | "native_egress_grant_limit_exceeded"
46
+ | "native_egress_input_invalid"
47
+ | "native_egress_not_declared"
48
+ | "native_egress_policy_invalid"
33
49
  | "native_dynamic_egress_unsupported"
34
50
  | "native_proxy_expired"
35
51
  | "native_proxy_invalid";
36
52
 
37
53
  export class NativeNetworkError extends TransportError {
38
54
  constructor(message: string, code: NativeNetworkErrorCode) {
39
- super(message, { code, status: 0 });
55
+ const isEgressPolicyFailure =
56
+ code.startsWith("native_egress_") || code === "native_dynamic_egress_unsupported";
57
+ super(message, {
58
+ code,
59
+ status: 0,
60
+ ...(isEgressPolicyFailure ? { category: "provider_error" as const, retryable: false } : {}),
61
+ });
40
62
  this.name = "NativeNetworkError";
41
63
  }
42
64
 
@@ -52,6 +74,40 @@ export class NativeProxyExpiredError extends NativeNetworkError {
52
74
  }
53
75
  }
54
76
 
77
+ /** Raised before transport setup when a native destination is not authorized. */
78
+ export class NativeEgressNotDeclaredError extends NativeNetworkError {
79
+ constructor(
80
+ readonly host: string,
81
+ readonly port: number,
82
+ readonly tls: "required" | "disabled",
83
+ ) {
84
+ super(
85
+ `Native ${tls === "required" ? "TLS" : "TCP"} egress is not declared for ${host}:${port}`,
86
+ "native_egress_not_declared",
87
+ );
88
+ this.name = "NativeEgressNotDeclaredError";
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Raised when the destination was authorized by a grant whose TTL elapsed and
94
+ * its expiry remains in the client's bounded recent-expiry evidence window.
95
+ */
96
+ export class NativeEgressGrantExpiredError extends NativeNetworkError {
97
+ constructor(
98
+ readonly host: string,
99
+ readonly port: number,
100
+ readonly tls: "required" | "disabled",
101
+ readonly expiresAt: string,
102
+ ) {
103
+ super(
104
+ `Native ${tls === "required" ? "TLS" : "TCP"} egress grant expired for ${host}:${port}`,
105
+ "native_egress_grant_expired",
106
+ );
107
+ this.name = "NativeEgressGrantExpiredError";
108
+ }
109
+ }
110
+
55
111
  /** Raised when an established connection exceeds its opt-in read-idle window. */
56
112
  export class NativeIdleTimeoutError extends NativeNetworkError {
57
113
  constructor() {
@@ -95,7 +151,12 @@ export type NativeNetworkClientOptions = {
95
151
  readonly gatewaySynthesizers?: readonly NativeGatewayProxySynthesizer[];
96
152
  /** Warning-level lifecycle diagnostic sink. */
97
153
  readonly warn?: (message: string) => void;
98
- /** Delegate to the deployment's native egress authorization layer. */
154
+ /**
155
+ * Provider-declared native egress. Undefined preserves legacy unrestricted
156
+ * behavior; any provided declaration, including an empty object, is enforced.
157
+ */
158
+ readonly egress?: NonNullable<NativeProviderConfig["network"]>;
159
+ /** Additional deployment authorization layered on top of SDK enforcement. */
99
160
  readonly grantTcpEgress?: (input: NativeNetworkDynamicGrantOptions) => NativeNetworkEgressGrant;
100
161
  };
101
162
 
@@ -340,6 +401,7 @@ async function connectSocksTunnel(
340
401
  proxy: NativeGatewayProxy,
341
402
  input: NativeNetworkConnectInput,
342
403
  deadline: Deadline,
404
+ beforeDestinationConnect: () => void,
343
405
  ): Promise<Socket> {
344
406
  const parsed = parseSocks5Proxy(proxy.url);
345
407
  const proxySocket = await connectPlainSocket(parsed.host, parsed.port, input.signal, deadline);
@@ -348,6 +410,12 @@ async function connectSocksTunnel(
348
410
  // an unhandled late network error between library cleanup and our wrapper.
349
411
  proxySocket.on("error", () => undefined);
350
412
  const remaining = remainingMs(deadline);
413
+ try {
414
+ beforeDestinationConnect();
415
+ } catch (error) {
416
+ proxySocket.destroy();
417
+ throw error;
418
+ }
351
419
  const handshake = SocksClient.createConnection({
352
420
  command: "connect",
353
421
  destination: { host: input.host, port: input.port },
@@ -567,34 +635,418 @@ async function resolveConnectionProxy(
567
635
  return resolved;
568
636
  }
569
637
 
570
- /** Create the SDK byte-stream runtime; deployment egress authorization stays delegated. */
638
+ type NativeConnectTls = "required" | "disabled";
639
+
640
+ type StoredEgressGrant = {
641
+ readonly ruleIndex: number;
642
+ readonly host: string;
643
+ readonly port: number;
644
+ readonly tls: NativeTcpTlsMode;
645
+ readonly expiresAtMs?: number;
646
+ revoked: boolean;
647
+ };
648
+
649
+ export const NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT = 256;
650
+
651
+ function normalizeEgressHost(host: string): string {
652
+ return host.trim().toLowerCase().replace(/\.$/, "");
653
+ }
654
+
655
+ function invalidPolicy(message: string): NativeNetworkError {
656
+ return new NativeNetworkError(message, "native_egress_policy_invalid");
657
+ }
658
+
659
+ function matchesDnsSuffix(host: string, suffix: string): boolean {
660
+ return host === suffix || host.endsWith(`.${suffix}`);
661
+ }
662
+
663
+ function matchesSourceHost(rule: DynamicEgressRuleSnapshot, host: string): boolean {
664
+ const hasSelector = rule.sourceHost !== undefined || rule.sourceHostSuffixes.length > 0;
665
+ if (!hasSelector) return false;
666
+ return (
667
+ host === rule.sourceHost ||
668
+ rule.sourceHostSuffixes.some((suffix) => matchesDnsSuffix(host, suffix))
669
+ );
670
+ }
671
+
672
+ function matchesPortSelectors(
673
+ port: number,
674
+ ports: readonly number[],
675
+ ranges: readonly NativeTcpPortRange[],
676
+ ): boolean {
677
+ if (ports.length === 0 && ranges.length === 0) return false;
678
+ return ports.includes(port) || ranges.some(({ start, end }) => port >= start && port <= end);
679
+ }
680
+
681
+ function tlsModeAllows(mode: NativeTcpTlsMode, requested: NativeConnectTls): boolean {
682
+ return mode === "allowed" || mode === requested;
683
+ }
684
+
685
+ function grantTlsFitsRule(grant: NativeTcpTlsMode, rule: NativeTcpTlsMode): boolean {
686
+ return (
687
+ rule === "allowed" ||
688
+ (grant === "required" && rule === "required") ||
689
+ (grant === "disabled" && rule === "disabled")
690
+ );
691
+ }
692
+
693
+ function matchesDynamicRuleSelectors(
694
+ rule: DynamicEgressRuleSnapshot,
695
+ input: NativeNetworkDynamicGrantOptions,
696
+ ): boolean {
697
+ const sourceHost = normalizeEgressHost(input.sourceHost);
698
+ const targetHost = normalizeEgressHost(input.host);
699
+ return (
700
+ matchesSourceHost(rule, sourceHost) &&
701
+ matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges) &&
702
+ rule.targetHostSuffixes.some((suffix) => matchesDnsSuffix(targetHost, suffix)) &&
703
+ matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges) &&
704
+ grantTlsFitsRule(input.tls, rule.tls)
705
+ );
706
+ }
707
+
708
+ function invalidGrant(message: string): NativeNetworkError {
709
+ return new NativeNetworkError(message, "native_egress_grant_invalid");
710
+ }
711
+
712
+ function hasControlCharacter(value: string): boolean {
713
+ for (let index = 0; index < value.length; index += 1) {
714
+ const code = value.charCodeAt(index);
715
+ if (code <= 31 || code === 127) return true;
716
+ }
717
+ return false;
718
+ }
719
+
720
+ function assertValidGrantInput(input: NativeNetworkDynamicGrantOptions): void {
721
+ if (
722
+ !normalizeEgressHost(input.sourceHost) ||
723
+ !normalizeEgressHost(input.host) ||
724
+ hasControlCharacter(input.sourceHost) ||
725
+ hasControlCharacter(input.host) ||
726
+ /\s/.test(input.sourceHost) ||
727
+ /\s/.test(input.host) ||
728
+ input.sourceHost.includes("://") ||
729
+ input.host.includes("://") ||
730
+ input.sourceHost.includes("*") ||
731
+ input.host.includes("*")
732
+ )
733
+ throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
734
+ if (
735
+ !Number.isSafeInteger(input.sourcePort) ||
736
+ input.sourcePort < 1 ||
737
+ input.sourcePort > 65_535 ||
738
+ !Number.isSafeInteger(input.port) ||
739
+ input.port < 1 ||
740
+ input.port > 65_535
741
+ )
742
+ throw invalidGrant("Native TCP egress grant ports must be integers from 1 to 65535");
743
+ if (input.tls !== "required" && input.tls !== "allowed" && input.tls !== "disabled")
744
+ throw invalidGrant("Native TCP egress grant tls must be required, allowed, or disabled");
745
+ if (input.ttlMs !== undefined && (!Number.isSafeInteger(input.ttlMs) || input.ttlMs <= 0))
746
+ throw invalidGrant("Native TCP egress grant ttlMs must be a positive integer");
747
+ }
748
+
749
+ /** Internal canonical snapshot shared by production and SDK transport test doubles. */
750
+ export function snapshotNativeConnectInput(
751
+ input: NativeNetworkConnectInput,
752
+ ): NativeNetworkConnectInput {
753
+ try {
754
+ const host = input.host;
755
+ const port = input.port;
756
+ const serverName = input.serverName;
757
+ const rejectUnauthorized = input.rejectUnauthorized;
758
+ const idleTimeoutMs = input.idleTimeoutMs;
759
+ const timeoutMs = input.timeoutMs;
760
+ const signal = input.signal;
761
+ const affinityKey = input.affinityKey;
762
+ const snapshot: NativeNetworkConnectInput = {
763
+ host,
764
+ port,
765
+ ...(serverName === undefined ? {} : { serverName }),
766
+ ...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
767
+ ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
768
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
769
+ ...(signal === undefined ? {} : { signal }),
770
+ ...(affinityKey === undefined ? {} : { affinityKey }),
771
+ };
772
+ if (
773
+ typeof snapshot.host !== "string" ||
774
+ !snapshot.host.trim() ||
775
+ hasControlCharacter(snapshot.host) ||
776
+ !Number.isInteger(snapshot.port) ||
777
+ snapshot.port < 1 ||
778
+ snapshot.port > 65_535
779
+ )
780
+ throw new TypeError("invalid native connection target");
781
+ return snapshot;
782
+ } catch {
783
+ throw new NativeNetworkError(
784
+ "Native connection input could not be inspected safely",
785
+ "native_egress_input_invalid",
786
+ );
787
+ }
788
+ }
789
+
790
+ /** Internal canonical snapshot shared by production and SDK transport test doubles. */
791
+ export function snapshotNativeGrantInput(
792
+ input: NativeNetworkDynamicGrantOptions,
793
+ ): NativeNetworkDynamicGrantOptions {
794
+ try {
795
+ const sourceHost = input.sourceHost;
796
+ const sourcePort = input.sourcePort;
797
+ const host = input.host;
798
+ const port = input.port;
799
+ const tls = input.tls;
800
+ const ttlMs = input.ttlMs;
801
+ return {
802
+ sourceHost,
803
+ sourcePort,
804
+ host,
805
+ port,
806
+ tls,
807
+ ...(ttlMs === undefined ? {} : { ttlMs }),
808
+ };
809
+ } catch {
810
+ throw new NativeNetworkError(
811
+ "Native TCP egress grant input could not be inspected safely",
812
+ "native_egress_input_invalid",
813
+ );
814
+ }
815
+ }
816
+
817
+ /** Internal authorization seam shared by production and SDK transport test doubles. */
818
+ export function createNativeEgressAuthorization(options: NativeNetworkClientOptions): {
819
+ assertConnect(input: NativeNetworkConnectInput, tls: NativeConnectTls): void;
820
+ grant(input: NativeNetworkDynamicGrantOptions): NativeNetworkEgressGrant;
821
+ } {
822
+ let declared: boolean;
823
+ let staticRules: readonly StaticEgressRuleSnapshot[];
824
+ let dynamicRules: readonly DynamicEgressRuleSnapshot[];
825
+ let delegate: NativeNetworkClientOptions["grantTcpEgress"];
826
+ try {
827
+ const policy = options.egress;
828
+ delegate = options.grantTcpEgress;
829
+ if (delegate !== undefined && typeof delegate !== "function")
830
+ throw invalidPolicy("Native egress delegate must be a function");
831
+ declared = policy !== undefined;
832
+ const snapshot = declared
833
+ ? parseNativeEgressPolicy(policy)
834
+ : { staticRules: [], dynamicRules: [] };
835
+ staticRules = snapshot.staticRules;
836
+ dynamicRules = snapshot.dynamicRules;
837
+ } catch (error) {
838
+ if (error instanceof NativeNetworkError) throw error;
839
+ if (error instanceof NativeEgressPolicyValidationError) throw invalidPolicy(error.message);
840
+ throw invalidPolicy("Native egress policy could not be inspected safely");
841
+ }
842
+ const grants: StoredEgressGrant[] = [];
843
+ const expiredEvidence = new Map<string, StoredEgressGrant>();
844
+ const grantKey = (grant: Pick<StoredEgressGrant, "host" | "port" | "tls">): string =>
845
+ `${grant.host}\0${grant.port}\0${grant.tls}`;
846
+ const recordExpired = (grant: StoredEgressGrant): void => {
847
+ const key = grantKey(grant);
848
+ expiredEvidence.delete(key);
849
+ expiredEvidence.set(key, grant);
850
+ while (expiredEvidence.size > NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT) {
851
+ const oldest = expiredEvidence.keys().next().value;
852
+ if (typeof oldest !== "string") break;
853
+ expiredEvidence.delete(oldest);
854
+ }
855
+ };
856
+ const purgeInactive = (now: number): void => {
857
+ const live: StoredEgressGrant[] = [];
858
+ for (const grant of grants) {
859
+ if (grant.revoked) continue;
860
+ if (grant.expiresAtMs !== undefined && now >= grant.expiresAtMs) {
861
+ recordExpired(grant);
862
+ continue;
863
+ }
864
+ live.push(grant);
865
+ }
866
+ grants.length = 0;
867
+ grants.push(...live);
868
+ };
869
+
870
+ const assertConnect = (input: NativeNetworkConnectInput, tls: NativeConnectTls): void => {
871
+ if (!declared) return;
872
+ const host = normalizeEgressHost(input.host);
873
+ const now = Date.now();
874
+ purgeInactive(now);
875
+ if (
876
+ staticRules.some(
877
+ (rule) =>
878
+ rule.host === host && rule.ports.includes(input.port) && tlsModeAllows(rule.tls, tls),
879
+ )
880
+ )
881
+ return;
882
+ const matching = grants.filter(
883
+ (grant) =>
884
+ !grant.revoked &&
885
+ grant.host === host &&
886
+ grant.port === input.port &&
887
+ tlsModeAllows(grant.tls, tls),
888
+ );
889
+ if (matching.length > 0) return;
890
+ const expired = [...expiredEvidence.values()]
891
+ .filter(
892
+ (grant) =>
893
+ grant.host === host &&
894
+ grant.port === input.port &&
895
+ tlsModeAllows(grant.tls, tls),
896
+ )
897
+ .sort((left, right) => (right.expiresAtMs ?? 0) - (left.expiresAtMs ?? 0))[0];
898
+ if (expired?.expiresAtMs !== undefined)
899
+ throw new NativeEgressGrantExpiredError(
900
+ input.host,
901
+ input.port,
902
+ tls,
903
+ new Date(expired.expiresAtMs).toISOString(),
904
+ );
905
+ throw new NativeEgressNotDeclaredError(input.host, input.port, tls);
906
+ };
907
+
908
+ const grantLocal = (input: NativeNetworkDynamicGrantOptions): NativeNetworkEgressGrant => {
909
+ assertValidGrantInput(input);
910
+ const ruleIndex = dynamicRules.findIndex((rule) => matchesDynamicRuleSelectors(rule, input));
911
+ if (ruleIndex < 0)
912
+ throw new NativeNetworkError(
913
+ `Native TCP egress grant is not declared for ${input.host}:${input.port} (${input.tls})`,
914
+ "native_egress_not_declared",
915
+ );
916
+ const rule = dynamicRules[ruleIndex];
917
+ if (!rule) throw invalidGrant("Native TCP egress declaration is missing its matched rule");
918
+ if (input.ttlMs !== undefined && rule.ttlMs !== undefined && input.ttlMs > rule.ttlMs)
919
+ throw invalidGrant(
920
+ `Native TCP egress grant ttlMs ${input.ttlMs} exceeds declared maximum ${rule.ttlMs}`,
921
+ );
922
+ const now = Date.now();
923
+ const targetHost = normalizeEgressHost(input.host);
924
+ purgeInactive(now);
925
+ const activeForRule = grants.filter(
926
+ (grant) =>
927
+ grant.ruleIndex === ruleIndex &&
928
+ !grant.revoked &&
929
+ (grant.expiresAtMs === undefined || now < grant.expiresAtMs),
930
+ ).length;
931
+ if (rule.maxGrants !== undefined && activeForRule >= rule.maxGrants)
932
+ throw new NativeNetworkError(
933
+ `Native TCP egress grant limit exceeded for declaration ${ruleIndex}`,
934
+ "native_egress_grant_limit_exceeded",
935
+ );
936
+ const ttlMs = input.ttlMs ?? rule.ttlMs;
937
+ const expiresAtMs = ttlMs === undefined ? undefined : now + ttlMs;
938
+ if (expiresAtMs !== undefined && (!Number.isFinite(expiresAtMs) || expiresAtMs > 8.64e15))
939
+ throw invalidGrant("Native TCP egress grant expiry exceeds the supported date range");
940
+ const stored: StoredEgressGrant = {
941
+ ruleIndex,
942
+ host: targetHost,
943
+ port: input.port,
944
+ tls: input.tls,
945
+ ...(expiresAtMs === undefined ? {} : { expiresAtMs }),
946
+ revoked: false,
947
+ };
948
+ expiredEvidence.delete(grantKey(stored));
949
+ grants.push(stored);
950
+ return {
951
+ revoke() {
952
+ stored.revoked = true;
953
+ },
954
+ };
955
+ };
956
+
957
+ return {
958
+ assertConnect,
959
+ grant(input) {
960
+ if (!declared) {
961
+ if (delegate) {
962
+ try {
963
+ const delegated = delegate(input);
964
+ if (!delegated || typeof delegated.revoke !== "function")
965
+ throw new TypeError("Native egress delegate returned an invalid grant");
966
+ return delegated;
967
+ } catch (error) {
968
+ if (error instanceof NativeNetworkError) throw error;
969
+ throw new NativeNetworkError(
970
+ "Deployment native egress authorization failed",
971
+ "native_egress_authorization_failed",
972
+ );
973
+ }
974
+ }
975
+ throw new NativeNetworkError(
976
+ "Dynamic native egress authorization is not configured",
977
+ "native_dynamic_egress_unsupported",
978
+ );
979
+ }
980
+ const local = grantLocal(input);
981
+ let delegated: NativeNetworkEgressGrant | undefined;
982
+ try {
983
+ delegated = delegate?.(Object.freeze({ ...input }));
984
+ if (delegated !== undefined && typeof delegated.revoke !== "function")
985
+ throw new TypeError("Native egress delegate returned an invalid grant");
986
+ } catch (error) {
987
+ local.revoke();
988
+ if (error instanceof NativeNetworkError) throw error;
989
+ throw new NativeNetworkError(
990
+ "Deployment native egress authorization failed",
991
+ "native_egress_authorization_failed",
992
+ );
993
+ }
994
+ let revoked = false;
995
+ return {
996
+ revoke() {
997
+ if (revoked) return;
998
+ revoked = true;
999
+ local.revoke();
1000
+ try {
1001
+ delegated?.revoke();
1002
+ } catch (error) {
1003
+ if (error instanceof NativeNetworkError) throw error;
1004
+ throw new NativeNetworkError(
1005
+ "Deployment native egress grant revocation failed",
1006
+ "native_egress_authorization_failed",
1007
+ );
1008
+ }
1009
+ },
1010
+ };
1011
+ },
1012
+ };
1013
+ }
1014
+
1015
+ /** Create the SDK byte-stream runtime with provider-declared egress enforcement. */
571
1016
  export function createNativeNetworkClient(
572
1017
  options: NativeNetworkClientOptions = {},
573
1018
  ): NativeNetworkClient {
1019
+ const egress = createNativeEgressAuthorization(options);
574
1020
  return {
575
1021
  connectTcp: async (input) => {
576
- const deadline = deadlineFrom(input.timeoutMs);
577
- assertCanStart(input.signal, deadline);
578
- const proxy = await resolveConnectionProxy(options, input);
1022
+ const request = snapshotNativeConnectInput(input);
1023
+ egress.assertConnect(request, "disabled");
1024
+ const deadline = deadlineFrom(request.timeoutMs);
1025
+ assertCanStart(request.signal, deadline);
1026
+ const proxy = await resolveConnectionProxy(options, request);
1027
+ egress.assertConnect(request, "disabled");
579
1028
  const socket = proxy
580
- ? await connectSocksTunnel(proxy, input, deadline)
581
- : await connectPlainSocket(input.host, input.port, input.signal, deadline);
582
- return createNativeNetworkConnection(socket, proxy, options, input.idleTimeoutMs);
1029
+ ? await connectSocksTunnel(proxy, request, deadline, () =>
1030
+ egress.assertConnect(request, "disabled"),
1031
+ )
1032
+ : await connectPlainSocket(request.host, request.port, request.signal, deadline);
1033
+ return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
583
1034
  },
584
1035
  connectTls: async (input) => {
585
- const deadline = deadlineFrom(input.timeoutMs);
586
- assertCanStart(input.signal, deadline);
587
- const proxy = await resolveConnectionProxy(options, input);
588
- const tunnel = proxy ? await connectSocksTunnel(proxy, input, deadline) : undefined;
589
- const socket = await upgradeTls(tunnel, input, deadline);
590
- return createNativeNetworkConnection(socket, proxy, options, input.idleTimeoutMs);
591
- },
592
- grantTcpEgress: (input) => {
593
- if (options.grantTcpEgress) return options.grantTcpEgress(input);
594
- throw new NativeNetworkError(
595
- "Dynamic native egress authorization is not configured",
596
- "native_dynamic_egress_unsupported",
597
- );
1036
+ const request = snapshotNativeConnectInput(input);
1037
+ egress.assertConnect(request, "required");
1038
+ const deadline = deadlineFrom(request.timeoutMs);
1039
+ assertCanStart(request.signal, deadline);
1040
+ const proxy = await resolveConnectionProxy(options, request);
1041
+ egress.assertConnect(request, "required");
1042
+ const tunnel = proxy
1043
+ ? await connectSocksTunnel(proxy, request, deadline, () =>
1044
+ egress.assertConnect(request, "required"),
1045
+ )
1046
+ : undefined;
1047
+ const socket = await upgradeTls(tunnel, request, deadline);
1048
+ return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
598
1049
  },
1050
+ grantTcpEgress: (input) => egress.grant(snapshotNativeGrantInput(input)),
599
1051
  };
600
1052
  }
@@ -0,0 +1,66 @@
1
+ import type { HttpRedirectFailureReason } from "../types.js";
2
+
3
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
4
+
5
+ export type RedirectHopDecision<TMethod extends string> =
6
+ | {
7
+ kind: "follow";
8
+ nextMethod: TMethod | "GET";
9
+ nextUrl: string;
10
+ }
11
+ | {
12
+ kind: "stop";
13
+ reason: HttpRedirectFailureReason;
14
+ nextUrl?: string;
15
+ };
16
+
17
+ export function isRedirectStatus(status: number): boolean {
18
+ return REDIRECT_STATUSES.has(status);
19
+ }
20
+
21
+ /** Shared fetch-compatible redirect method rewriting for stealth and ctx.http. */
22
+ export function nextRedirectMethod<TMethod extends string>(
23
+ status: number,
24
+ method: TMethod,
25
+ ): TMethod | "GET" {
26
+ if (status === 303 && method !== "HEAD") return "GET";
27
+ if ((status === 301 || status === 302) && method === "POST") return "GET";
28
+ return method;
29
+ }
30
+
31
+ /** Resolves a Location value against the response URL without issuing a request. */
32
+ export function resolveRedirectUrl(
33
+ location: string | undefined,
34
+ responseUrl: string,
35
+ ): string | undefined {
36
+ return location ? new URL(location, responseUrl).toString() : undefined;
37
+ }
38
+
39
+ /**
40
+ * Shared post-response decision ordering for both redirect walkers. The
41
+ * caller-owned stop hook is checked first, matching stealth's pre-follow
42
+ * contract, then structural termination and loop checks run before follow.
43
+ */
44
+ export function evaluateRedirectHop<TMethod extends string>(input: {
45
+ status: number;
46
+ method: TMethod;
47
+ nextUrl: string | undefined;
48
+ shouldStop: boolean;
49
+ redirectCount: number;
50
+ maxHops: number;
51
+ visitedRequests: ReadonlySet<string>;
52
+ }): RedirectHopDecision<TMethod> {
53
+ if (input.shouldStop) {
54
+ return { kind: "stop", reason: "stopped", ...(input.nextUrl ? { nextUrl: input.nextUrl } : {}) };
55
+ }
56
+ if (!input.nextUrl) return { kind: "stop", reason: "missing_location" };
57
+ if (input.redirectCount > input.maxHops) {
58
+ return { kind: "stop", reason: "max_hops", nextUrl: input.nextUrl };
59
+ }
60
+
61
+ const nextMethod = nextRedirectMethod(input.status, input.method);
62
+ if (input.visitedRequests.has(`${nextMethod} ${input.nextUrl}`)) {
63
+ return { kind: "stop", reason: "loop", nextUrl: input.nextUrl };
64
+ }
65
+ return { kind: "follow", nextMethod, nextUrl: input.nextUrl };
66
+ }