@drawbridge/drawbridge-utils 0.0.96 → 0.0.98

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/dist/ai.cjs CHANGED
@@ -177,6 +177,17 @@ var cost = {
177
177
  cached: 3,
178
178
  input: 30,
179
179
  output: 3e3
180
+ },
181
+ // gemini-3-pro-image-preview — verified against Google's pricing page
182
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
183
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
184
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
185
+ // that reproduces the per-image price from the tokens usageMetadata
186
+ // reports. Growth's hero generation runs this model today.
187
+ "gemini-3-pro-image-preview": {
188
+ cached: 20,
189
+ input: 200,
190
+ output: 12e3
180
191
  }
181
192
  };
182
193
  var toolCost = {
package/dist/ai.js CHANGED
@@ -143,6 +143,17 @@ var cost = {
143
143
  cached: 3,
144
144
  input: 30,
145
145
  output: 3e3
146
+ },
147
+ // gemini-3-pro-image-preview — verified against Google's pricing page
148
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
149
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
150
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
151
+ // that reproduces the per-image price from the tokens usageMetadata
152
+ // reports. Growth's hero generation runs this model today.
153
+ "gemini-3-pro-image-preview": {
154
+ cached: 20,
155
+ input: 200,
156
+ output: 12e3
146
157
  }
147
158
  };
148
159
  var toolCost = {
package/dist/billing.cjs CHANGED
@@ -177,6 +177,17 @@ var cost = {
177
177
  cached: 3,
178
178
  input: 30,
179
179
  output: 3e3
180
+ },
181
+ // gemini-3-pro-image-preview — verified against Google's pricing page
182
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
183
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
184
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
185
+ // that reproduces the per-image price from the tokens usageMetadata
186
+ // reports. Growth's hero generation runs this model today.
187
+ "gemini-3-pro-image-preview": {
188
+ cached: 20,
189
+ input: 200,
190
+ output: 12e3
180
191
  }
181
192
  };
182
193
  var toolCost = {
@@ -48,6 +48,17 @@ const cost = {
48
48
  cached : 3,
49
49
  input : 30,
50
50
  output : 3000
51
+ },
52
+ // gemini-3-pro-image-preview — verified against Google's pricing page
53
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
54
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
55
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
56
+ // that reproduces the per-image price from the tokens usageMetadata
57
+ // reports. Growth's hero generation runs this model today.
58
+ 'gemini-3-pro-image-preview' : {
59
+ cached : 20,
60
+ input : 200,
61
+ output : 12000
51
62
  }
52
63
  };
53
64
 
package/dist/billing.d.ts CHANGED
@@ -48,6 +48,17 @@ const cost = {
48
48
  cached : 3,
49
49
  input : 30,
50
50
  output : 3000
51
+ },
52
+ // gemini-3-pro-image-preview — verified against Google's pricing page
53
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
54
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
55
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
56
+ // that reproduces the per-image price from the tokens usageMetadata
57
+ // reports. Growth's hero generation runs this model today.
58
+ 'gemini-3-pro-image-preview' : {
59
+ cached : 20,
60
+ input : 200,
61
+ output : 12000
51
62
  }
52
63
  };
53
64
 
package/dist/billing.js CHANGED
@@ -147,6 +147,17 @@ var cost = {
147
147
  cached: 3,
148
148
  input: 30,
149
149
  output: 3e3
150
+ },
151
+ // gemini-3-pro-image-preview — verified against Google's pricing page
152
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
153
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
154
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
155
+ // that reproduces the per-image price from the tokens usageMetadata
156
+ // reports. Growth's hero generation runs this model today.
157
+ "gemini-3-pro-image-preview": {
158
+ cached: 20,
159
+ input: 200,
160
+ output: 12e3
150
161
  }
151
162
  };
152
163
  var toolCost = {
@@ -0,0 +1,109 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // lib/domains.js
20
+ var domains_exports = {};
21
+ __export(domains_exports, {
22
+ companyDomain: () => companyDomain,
23
+ freeEmailDomains: () => freeEmailDomains,
24
+ isFreeEmailDomain: () => isFreeEmailDomain,
25
+ normalizeDomain: () => normalizeDomain,
26
+ normalizeEmail: () => normalizeEmail
27
+ });
28
+ module.exports = __toCommonJS(domains_exports);
29
+ var freeEmailDomains = /* @__PURE__ */ new Set([
30
+ "aol.com",
31
+ "comcast.net",
32
+ "gmail.com",
33
+ "googlemail.com",
34
+ "gmx.com",
35
+ "gmx.de",
36
+ "hey.com",
37
+ "hotmail.co.uk",
38
+ "hotmail.com",
39
+ "hotmail.fr",
40
+ "icloud.com",
41
+ "inbox.com",
42
+ "live.com",
43
+ "mac.com",
44
+ "mail.com",
45
+ "mail.ru",
46
+ "me.com",
47
+ "msn.com",
48
+ "outlook.com",
49
+ "pm.me",
50
+ "proton.me",
51
+ "protonmail.com",
52
+ "qq.com",
53
+ "rocketmail.com",
54
+ "sbcglobal.net",
55
+ "verizon.net",
56
+ "yahoo.co.uk",
57
+ "yahoo.com",
58
+ "yahoo.fr",
59
+ "yandex.com",
60
+ "yandex.ru",
61
+ "ymail.com",
62
+ "zoho.com"
63
+ ]);
64
+ var isFreeEmailDomain = (domain) => {
65
+ if (typeof domain !== "string") return false;
66
+ return freeEmailDomains.has(domain.trim().toLowerCase());
67
+ };
68
+ var normalizeEmail = (value) => {
69
+ if (typeof value !== "string") return null;
70
+ const email = value.trim().toLowerCase().normalize("NFC");
71
+ if (!email.includes("@")) return null;
72
+ const [local, domain] = email.split("@");
73
+ if (!local || !domain || !domain.includes(".")) return null;
74
+ return email;
75
+ };
76
+ var normalizeDomain = (value) => {
77
+ if (typeof value !== "string") return null;
78
+ let host = value.trim().toLowerCase();
79
+ if (!host) return null;
80
+ if (host.includes("://")) {
81
+ try {
82
+ host = new URL(host).hostname;
83
+ } catch {
84
+ return null;
85
+ }
86
+ } else {
87
+ host = host.split("/")[0].split("?")[0];
88
+ }
89
+ host = host.replace(/^www\./, "");
90
+ if (!host.includes(".")) return null;
91
+ return host;
92
+ };
93
+ var companyDomain = ({ email, website } = {}) => {
94
+ const fromWebsite = normalizeDomain(website);
95
+ if (fromWebsite) return fromWebsite;
96
+ const normalized = normalizeEmail(email);
97
+ if (!normalized) return null;
98
+ const domain = normalized.split("@")[1];
99
+ if (isFreeEmailDomain(domain)) return null;
100
+ return domain;
101
+ };
102
+ // Annotate the CommonJS export names for ESM import in node:
103
+ 0 && (module.exports = {
104
+ companyDomain,
105
+ freeEmailDomains,
106
+ isFreeEmailDomain,
107
+ normalizeDomain,
108
+ normalizeEmail
109
+ });
@@ -0,0 +1,133 @@
1
+ // Email-domain classification. Separate from ./email (which canonicalizes an
2
+ // address for identity matching) and from ./sanitize's disposable-domain check
3
+ // (which decides whether an address is acceptable at all) — this answers a
4
+ // third, orthogonal question: does this address belong to a company?
5
+ //
6
+ // A free-provider address is not disposable and a disposable address is not a
7
+ // free provider, so a caller that cares about both has to ask both.
8
+
9
+ // Consumer email providers. An email domain on this list must never stand in for
10
+ // a company — otherwise every gmail.com address would resolve to one giant
11
+ // "company" (and, for signup autogeneration, we would scrape gmail.com).
12
+ const freeEmailDomains = new Set([
13
+ 'aol.com',
14
+ 'comcast.net',
15
+ 'gmail.com',
16
+ 'googlemail.com',
17
+ 'gmx.com',
18
+ 'gmx.de',
19
+ 'hey.com',
20
+ 'hotmail.co.uk',
21
+ 'hotmail.com',
22
+ 'hotmail.fr',
23
+ 'icloud.com',
24
+ 'inbox.com',
25
+ 'live.com',
26
+ 'mac.com',
27
+ 'mail.com',
28
+ 'mail.ru',
29
+ 'me.com',
30
+ 'msn.com',
31
+ 'outlook.com',
32
+ 'pm.me',
33
+ 'proton.me',
34
+ 'protonmail.com',
35
+ 'qq.com',
36
+ 'rocketmail.com',
37
+ 'sbcglobal.net',
38
+ 'verizon.net',
39
+ 'yahoo.co.uk',
40
+ 'yahoo.com',
41
+ 'yahoo.fr',
42
+ 'yandex.com',
43
+ 'yandex.ru',
44
+ 'ymail.com',
45
+ 'zoho.com'
46
+ ]);
47
+
48
+ // Takes a BARE domain, not a full address.
49
+ const isFreeEmailDomain = ( domain ) => {
50
+
51
+ if( typeof domain !== 'string' ) return false;
52
+
53
+ return freeEmailDomains.has( domain.trim().toLowerCase() );
54
+
55
+ };
56
+
57
+ // Trim, lowercase, NFC-normalize. Unicode normalization matters because a
58
+ // composed and a decomposed é are different bytes but the same address.
59
+ const normalizeEmail = ( value ) => {
60
+
61
+ if( typeof value !== 'string' ) return null;
62
+
63
+ const email = value.trim().toLowerCase().normalize( 'NFC' );
64
+
65
+ if( ! email.includes( '@' ) ) return null;
66
+
67
+ // Reject obvious garbage rather than treating it as an address.
68
+ const [ local, domain ] = email.split( '@' );
69
+
70
+ if( ! local || ! domain || ! domain.includes( '.' ) ) return null;
71
+
72
+ return email;
73
+
74
+ };
75
+
76
+ // Host part of a URL or a bare hostname, lowercased with www. removed.
77
+ const normalizeDomain = ( value ) => {
78
+
79
+ if( typeof value !== 'string' ) return null;
80
+
81
+ let host = value.trim().toLowerCase();
82
+
83
+ if( ! host ) return null;
84
+
85
+ if( host.includes( '://' ) ){
86
+
87
+ try {
88
+
89
+ host = new URL( host ).hostname;
90
+
91
+ } catch {
92
+
93
+ return null;
94
+
95
+ }
96
+
97
+ } else {
98
+
99
+ // Strip a path or query someone pasted in without a scheme.
100
+ host = host.split( '/' )[ 0 ].split( '?' )[ 0 ];
101
+
102
+ }
103
+
104
+ host = host.replace( /^www\./, '' );
105
+
106
+ if( ! host.includes( '.' ) ) return null;
107
+
108
+ return host;
109
+
110
+ };
111
+
112
+ // The company domain behind an address, or null when there isn't one. An email
113
+ // domain only counts when it is not a consumer provider; an explicit website
114
+ // always counts and wins.
115
+ const companyDomain = ({ email, website } = {}) => {
116
+
117
+ const fromWebsite = normalizeDomain( website );
118
+
119
+ if( fromWebsite ) return fromWebsite;
120
+
121
+ const normalized = normalizeEmail( email );
122
+
123
+ if( ! normalized ) return null;
124
+
125
+ const domain = normalized.split( '@' )[ 1 ];
126
+
127
+ if( isFreeEmailDomain( domain ) ) return null;
128
+
129
+ return domain;
130
+
131
+ };
132
+
133
+ export { companyDomain, freeEmailDomains, isFreeEmailDomain, normalizeDomain, normalizeEmail };
@@ -0,0 +1,133 @@
1
+ // Email-domain classification. Separate from ./email (which canonicalizes an
2
+ // address for identity matching) and from ./sanitize's disposable-domain check
3
+ // (which decides whether an address is acceptable at all) — this answers a
4
+ // third, orthogonal question: does this address belong to a company?
5
+ //
6
+ // A free-provider address is not disposable and a disposable address is not a
7
+ // free provider, so a caller that cares about both has to ask both.
8
+
9
+ // Consumer email providers. An email domain on this list must never stand in for
10
+ // a company — otherwise every gmail.com address would resolve to one giant
11
+ // "company" (and, for signup autogeneration, we would scrape gmail.com).
12
+ const freeEmailDomains = new Set([
13
+ 'aol.com',
14
+ 'comcast.net',
15
+ 'gmail.com',
16
+ 'googlemail.com',
17
+ 'gmx.com',
18
+ 'gmx.de',
19
+ 'hey.com',
20
+ 'hotmail.co.uk',
21
+ 'hotmail.com',
22
+ 'hotmail.fr',
23
+ 'icloud.com',
24
+ 'inbox.com',
25
+ 'live.com',
26
+ 'mac.com',
27
+ 'mail.com',
28
+ 'mail.ru',
29
+ 'me.com',
30
+ 'msn.com',
31
+ 'outlook.com',
32
+ 'pm.me',
33
+ 'proton.me',
34
+ 'protonmail.com',
35
+ 'qq.com',
36
+ 'rocketmail.com',
37
+ 'sbcglobal.net',
38
+ 'verizon.net',
39
+ 'yahoo.co.uk',
40
+ 'yahoo.com',
41
+ 'yahoo.fr',
42
+ 'yandex.com',
43
+ 'yandex.ru',
44
+ 'ymail.com',
45
+ 'zoho.com'
46
+ ]);
47
+
48
+ // Takes a BARE domain, not a full address.
49
+ const isFreeEmailDomain = ( domain ) => {
50
+
51
+ if( typeof domain !== 'string' ) return false;
52
+
53
+ return freeEmailDomains.has( domain.trim().toLowerCase() );
54
+
55
+ };
56
+
57
+ // Trim, lowercase, NFC-normalize. Unicode normalization matters because a
58
+ // composed and a decomposed é are different bytes but the same address.
59
+ const normalizeEmail = ( value ) => {
60
+
61
+ if( typeof value !== 'string' ) return null;
62
+
63
+ const email = value.trim().toLowerCase().normalize( 'NFC' );
64
+
65
+ if( ! email.includes( '@' ) ) return null;
66
+
67
+ // Reject obvious garbage rather than treating it as an address.
68
+ const [ local, domain ] = email.split( '@' );
69
+
70
+ if( ! local || ! domain || ! domain.includes( '.' ) ) return null;
71
+
72
+ return email;
73
+
74
+ };
75
+
76
+ // Host part of a URL or a bare hostname, lowercased with www. removed.
77
+ const normalizeDomain = ( value ) => {
78
+
79
+ if( typeof value !== 'string' ) return null;
80
+
81
+ let host = value.trim().toLowerCase();
82
+
83
+ if( ! host ) return null;
84
+
85
+ if( host.includes( '://' ) ){
86
+
87
+ try {
88
+
89
+ host = new URL( host ).hostname;
90
+
91
+ } catch {
92
+
93
+ return null;
94
+
95
+ }
96
+
97
+ } else {
98
+
99
+ // Strip a path or query someone pasted in without a scheme.
100
+ host = host.split( '/' )[ 0 ].split( '?' )[ 0 ];
101
+
102
+ }
103
+
104
+ host = host.replace( /^www\./, '' );
105
+
106
+ if( ! host.includes( '.' ) ) return null;
107
+
108
+ return host;
109
+
110
+ };
111
+
112
+ // The company domain behind an address, or null when there isn't one. An email
113
+ // domain only counts when it is not a consumer provider; an explicit website
114
+ // always counts and wins.
115
+ const companyDomain = ({ email, website } = {}) => {
116
+
117
+ const fromWebsite = normalizeDomain( website );
118
+
119
+ if( fromWebsite ) return fromWebsite;
120
+
121
+ const normalized = normalizeEmail( email );
122
+
123
+ if( ! normalized ) return null;
124
+
125
+ const domain = normalized.split( '@' )[ 1 ];
126
+
127
+ if( isFreeEmailDomain( domain ) ) return null;
128
+
129
+ return domain;
130
+
131
+ };
132
+
133
+ export { companyDomain, freeEmailDomains, isFreeEmailDomain, normalizeDomain, normalizeEmail };
@@ -0,0 +1,81 @@
1
+ // lib/domains.js
2
+ var freeEmailDomains = /* @__PURE__ */ new Set([
3
+ "aol.com",
4
+ "comcast.net",
5
+ "gmail.com",
6
+ "googlemail.com",
7
+ "gmx.com",
8
+ "gmx.de",
9
+ "hey.com",
10
+ "hotmail.co.uk",
11
+ "hotmail.com",
12
+ "hotmail.fr",
13
+ "icloud.com",
14
+ "inbox.com",
15
+ "live.com",
16
+ "mac.com",
17
+ "mail.com",
18
+ "mail.ru",
19
+ "me.com",
20
+ "msn.com",
21
+ "outlook.com",
22
+ "pm.me",
23
+ "proton.me",
24
+ "protonmail.com",
25
+ "qq.com",
26
+ "rocketmail.com",
27
+ "sbcglobal.net",
28
+ "verizon.net",
29
+ "yahoo.co.uk",
30
+ "yahoo.com",
31
+ "yahoo.fr",
32
+ "yandex.com",
33
+ "yandex.ru",
34
+ "ymail.com",
35
+ "zoho.com"
36
+ ]);
37
+ var isFreeEmailDomain = (domain) => {
38
+ if (typeof domain !== "string") return false;
39
+ return freeEmailDomains.has(domain.trim().toLowerCase());
40
+ };
41
+ var normalizeEmail = (value) => {
42
+ if (typeof value !== "string") return null;
43
+ const email = value.trim().toLowerCase().normalize("NFC");
44
+ if (!email.includes("@")) return null;
45
+ const [local, domain] = email.split("@");
46
+ if (!local || !domain || !domain.includes(".")) return null;
47
+ return email;
48
+ };
49
+ var normalizeDomain = (value) => {
50
+ if (typeof value !== "string") return null;
51
+ let host = value.trim().toLowerCase();
52
+ if (!host) return null;
53
+ if (host.includes("://")) {
54
+ try {
55
+ host = new URL(host).hostname;
56
+ } catch {
57
+ return null;
58
+ }
59
+ } else {
60
+ host = host.split("/")[0].split("?")[0];
61
+ }
62
+ host = host.replace(/^www\./, "");
63
+ if (!host.includes(".")) return null;
64
+ return host;
65
+ };
66
+ var companyDomain = ({ email, website } = {}) => {
67
+ const fromWebsite = normalizeDomain(website);
68
+ if (fromWebsite) return fromWebsite;
69
+ const normalized = normalizeEmail(email);
70
+ if (!normalized) return null;
71
+ const domain = normalized.split("@")[1];
72
+ if (isFreeEmailDomain(domain)) return null;
73
+ return domain;
74
+ };
75
+ export {
76
+ companyDomain,
77
+ freeEmailDomains,
78
+ isFreeEmailDomain,
79
+ normalizeDomain,
80
+ normalizeEmail
81
+ };
@@ -0,0 +1,180 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+
29
+ // lib/safe-http.js
30
+ var safe_http_exports = {};
31
+ __export(safe_http_exports, {
32
+ assertSafeHost: () => assertSafeHost,
33
+ pinnedAgent: () => pinnedAgent,
34
+ resolveSafeHost: () => resolveSafeHost,
35
+ safeRequest: () => safeRequest
36
+ });
37
+ module.exports = __toCommonJS(safe_http_exports);
38
+ var import_dns2 = __toESM(require("dns"), 1);
39
+ var http2 = __toESM(require("http"), 1);
40
+ var https2 = __toESM(require("https"), 1);
41
+
42
+ // lib/axios.js
43
+ var import_axios = __toESM(require("axios"), 1);
44
+ var import_dns = __toESM(require("dns"), 1);
45
+ var http = __toESM(require("http"), 1);
46
+ var https = __toESM(require("https"), 1);
47
+ var import_net = __toESM(require("net"), 1);
48
+ var dnsLookup = import_dns.default.promises.lookup;
49
+ var isBlockedIPv4 = (ip) => {
50
+ const parts = ip.split(".").map(Number);
51
+ if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
52
+ const [a, b] = parts;
53
+ if (a === 0) return true;
54
+ if (a === 10) return true;
55
+ if (a === 127) return true;
56
+ if (a === 169 && b === 254) return true;
57
+ if (a === 172 && b >= 16 && b <= 31) return true;
58
+ if (a === 192 && b === 168) return true;
59
+ if (a === 100 && b >= 64 && b <= 127) return true;
60
+ if (a === 192 && b === 0) return true;
61
+ if (a === 198 && (b === 18 || b === 19)) return true;
62
+ if (a === 198 && b === 51) return true;
63
+ if (a === 203 && b === 0) return true;
64
+ if (a >= 224) return true;
65
+ return false;
66
+ };
67
+ var isBlockedIPv6 = (ip) => {
68
+ const lower = ip.toLowerCase();
69
+ if (lower === "::1" || lower === "::") return true;
70
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
71
+ if (/^fe[89ab]/.test(lower)) return true;
72
+ if (lower.startsWith("ff")) return true;
73
+ if (lower.startsWith("::ffff:")) {
74
+ const v4 = lower.slice(7);
75
+ return isBlockedIPv4(v4);
76
+ }
77
+ ;
78
+ return false;
79
+ };
80
+ var isBlockedIP = (ip) => {
81
+ const version = import_net.default.isIP(ip);
82
+ if (version === 4) return isBlockedIPv4(ip);
83
+ if (version === 6) return isBlockedIPv6(ip);
84
+ return true;
85
+ };
86
+ var axios = import_axios.default.create({
87
+ timeout: 3e4,
88
+ httpAgent: new http.Agent({ keepAlive: true, maxSockets: 128 }),
89
+ httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 128 })
90
+ });
91
+
92
+ // lib/safe-http.js
93
+ var DEFAULT_TIMEOUT_MS = 15e3;
94
+ var resolveSafeHost = async (url) => {
95
+ const parsed = new URL(url);
96
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
97
+ throw new Error("Only http(s) URLs are allowed");
98
+ }
99
+ const records = await import_dns2.default.promises.lookup(parsed.hostname, { all: true });
100
+ if (!(records == null ? void 0 : records.length)) {
101
+ throw new Error("Host could not be resolved");
102
+ }
103
+ for (const record of records) {
104
+ if (isBlockedIP(record.address)) {
105
+ throw new Error("Host resolves to a blocked IP range");
106
+ }
107
+ }
108
+ return { parsed, records };
109
+ };
110
+ var assertSafeHost = async (url) => {
111
+ await resolveSafeHost(url);
112
+ };
113
+ var pinnedAgent = async (url) => {
114
+ const { parsed, records } = await resolveSafeHost(url);
115
+ const pinned = records[0];
116
+ const lookup = (hostname, options, callback) => {
117
+ if (options == null ? void 0 : options.all) {
118
+ callback(null, [{ address: pinned.address, family: pinned.family }]);
119
+ } else {
120
+ callback(null, pinned.address, pinned.family);
121
+ }
122
+ };
123
+ return {
124
+ protocol: parsed.protocol,
125
+ agent: parsed.protocol === "https:" ? new https2.Agent({ lookup }) : new http2.Agent({ lookup })
126
+ };
127
+ };
128
+ var safeRequest = async ({
129
+ body,
130
+ headers = {},
131
+ method = "GET",
132
+ query,
133
+ timeout = DEFAULT_TIMEOUT_MS,
134
+ type = "json",
135
+ url
136
+ }) => {
137
+ const full = new URL(url);
138
+ if (query) {
139
+ Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
140
+ }
141
+ const { protocol, agent } = await pinnedAgent(full.toString());
142
+ const isForm = type === "form";
143
+ try {
144
+ const response = await axios({
145
+ method,
146
+ url: full.toString(),
147
+ headers: {
148
+ "Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
149
+ ...headers
150
+ },
151
+ ...body !== void 0 && {
152
+ data: isForm ? new URLSearchParams(body).toString() : body
153
+ },
154
+ timeout,
155
+ maxRedirects: 0,
156
+ httpAgent: protocol === "http:" ? agent : void 0,
157
+ httpsAgent: protocol === "https:" ? agent : void 0,
158
+ responseType: "json",
159
+ validateStatus: (status) => status >= 200 && status < 300
160
+ });
161
+ return response.data || null;
162
+ } catch (error) {
163
+ if (error == null ? void 0 : error.response) {
164
+ const normalized = new Error(
165
+ typeof error.response.data === "string" ? error.response.data : error.message || "Request failed"
166
+ );
167
+ normalized.status = error.response.status;
168
+ normalized.response = error.response.data;
169
+ throw normalized;
170
+ }
171
+ throw error;
172
+ }
173
+ };
174
+ // Annotate the CommonJS export names for ESM import in node:
175
+ 0 && (module.exports = {
176
+ assertSafeHost,
177
+ pinnedAgent,
178
+ resolveSafeHost,
179
+ safeRequest
180
+ });
@@ -0,0 +1,174 @@
1
+ import dns from 'dns';
2
+ import * as http from 'node:http';
3
+ import * as https from 'node:https';
4
+ import { isBlockedIP, axios } from './axios.cjs';
5
+ import 'axios';
6
+ import 'net';
7
+
8
+ // SSRF-safe outbound HTTP for user-supplied URLs (scrape targets, video assets,
9
+ // outgoing webhooks, signup domain pre-flights). The sibling `safe.get` in
10
+ // ./axios is https-only, GET-only, and hard-caps the body at 5MB — too small for
11
+ // video and no help for POST/redirect control — so this offers the same
12
+ // protection with the transport under the caller's control:
13
+ //
14
+ // 1. resolve the host and reject if ANY record is a private/reserved IP
15
+ // (blocks direct navigation to 10.x / 127.x / 169.254.169.254 / etc.)
16
+ // 2. pin the vetted IP on the connection agent so a second lookup can't be
17
+ // re-pointed at an internal address between the check and the connect
18
+ // (DNS rebinding), and
19
+ // 3. callers pass maxRedirects:0 — a redirect to an internal host would
20
+ // otherwise re-resolve past the pin.
21
+ //
22
+ // isBlockedIP is the shared allow/deny logic in ./axios, so this and safe.get
23
+ // stay in lockstep on what counts as a blocked range.
24
+
25
+ const DEFAULT_TIMEOUT_MS = 15000;
26
+
27
+ // Resolve + validate a URL's host. Throws if the URL is not http(s), the host is
28
+ // unresolvable, or any resolved record is a blocked IP. Returns the parsed URL
29
+ // and its DNS records so callers can pin.
30
+ const resolveSafeHost = async ( url ) => {
31
+
32
+ const parsed = new URL( url );
33
+
34
+ if( parsed.protocol !== 'https:' && parsed.protocol !== 'http:' ){
35
+
36
+ throw new Error( 'Only http(s) URLs are allowed' );
37
+
38
+ }
39
+
40
+ const records = await dns.promises.lookup( parsed.hostname, { all : true } );
41
+
42
+ if( ! records?.length ){
43
+
44
+ throw new Error( 'Host could not be resolved' );
45
+
46
+ }
47
+
48
+ for( const record of records ){
49
+
50
+ if( isBlockedIP( record.address ) ){
51
+
52
+ throw new Error( 'Host resolves to a blocked IP range' );
53
+
54
+ }
55
+
56
+ }
57
+
58
+ return { parsed, records };
59
+
60
+ };
61
+
62
+ // Throwing guard for callers that manage their own transport (e.g. Puppeteer,
63
+ // which does its own DNS/redirects and can't take our agent). Rejects the URL if
64
+ // it resolves to a blocked range.
65
+ const assertSafeHost = async ( url ) => {
66
+
67
+ await resolveSafeHost( url );
68
+
69
+ };
70
+
71
+ // Build an http(s).Agent pinned to the vetted IP for `url`'s host. Pair with
72
+ // maxRedirects:0 on the request. Throws (via resolveSafeHost) if the host is blocked.
73
+ const pinnedAgent = async ( url ) => {
74
+
75
+ const { parsed, records } = await resolveSafeHost( url );
76
+ const pinned = records[ 0 ];
77
+
78
+ const lookup = ( hostname, options, callback ) => {
79
+
80
+ if( options?.all ){
81
+
82
+ callback( null, [ { address : pinned.address, family : pinned.family } ] );
83
+
84
+ } else {
85
+
86
+ callback( null, pinned.address, pinned.family );
87
+
88
+ }
89
+
90
+ };
91
+
92
+ return {
93
+ protocol : parsed.protocol,
94
+ agent : parsed.protocol === 'https:'
95
+ ? new https.Agent({ lookup })
96
+ : new http.Agent({ lookup })
97
+ };
98
+
99
+ };
100
+
101
+ // SSRF-safe drop-in for ./http `request`: identical call / return / throw
102
+ // contract (parsed JSON body on success, throws Error with .status on non-2xx),
103
+ // but pins the vetted IP and refuses redirects. A webhook endpoint that
104
+ // 3xx-redirects is treated as a failure — an intentional trade for closing the
105
+ // redirect-to-internal vector.
106
+ const safeRequest = async ({
107
+ body,
108
+ headers = {},
109
+ method = 'GET',
110
+ query,
111
+ timeout = DEFAULT_TIMEOUT_MS,
112
+ type = 'json',
113
+ url
114
+ }) => {
115
+
116
+ const full = new URL( url );
117
+
118
+ if( query ){
119
+
120
+ Object.entries( query ).forEach( ([ key, value ]) => full.searchParams.set( key, value ) );
121
+
122
+ }
123
+
124
+ const { protocol, agent } = await pinnedAgent( full.toString() );
125
+ const isForm = type === 'form';
126
+
127
+ try {
128
+
129
+ const response = await axios({
130
+ method,
131
+ url : full.toString(),
132
+ headers : {
133
+ 'Content-Type' : isForm ? 'application/x-www-form-urlencoded' : 'application/json',
134
+ ...headers
135
+ },
136
+ ...( body !== undefined && {
137
+ data : isForm ? new URLSearchParams( body ).toString() : body
138
+ }),
139
+ timeout,
140
+ maxRedirects : 0,
141
+ httpAgent : protocol === 'http:' ? agent : undefined,
142
+ httpsAgent : protocol === 'https:' ? agent : undefined,
143
+ responseType : 'json',
144
+ validateStatus : ( status ) => status >= 200 && status < 300
145
+ });
146
+
147
+ return response.data || null;
148
+
149
+ } catch ( error ) {
150
+
151
+ // Normalize to the ./http `request` error shape so existing callers
152
+ // (which read error.status / error.message / error.response) behave the same.
153
+ if( error?.response ){
154
+
155
+ const normalized = new Error(
156
+ typeof error.response.data === 'string'
157
+ ? error.response.data
158
+ : ( error.message || 'Request failed' )
159
+ );
160
+
161
+ normalized.status = error.response.status;
162
+ normalized.response = error.response.data;
163
+
164
+ throw normalized;
165
+
166
+ }
167
+
168
+ throw error;
169
+
170
+ }
171
+
172
+ };
173
+
174
+ export { assertSafeHost, pinnedAgent, resolveSafeHost, safeRequest };
@@ -0,0 +1,174 @@
1
+ import dns from 'dns';
2
+ import * as http from 'node:http';
3
+ import * as https from 'node:https';
4
+ import { isBlockedIP, axios } from './axios.js';
5
+ import 'axios';
6
+ import 'net';
7
+
8
+ // SSRF-safe outbound HTTP for user-supplied URLs (scrape targets, video assets,
9
+ // outgoing webhooks, signup domain pre-flights). The sibling `safe.get` in
10
+ // ./axios is https-only, GET-only, and hard-caps the body at 5MB — too small for
11
+ // video and no help for POST/redirect control — so this offers the same
12
+ // protection with the transport under the caller's control:
13
+ //
14
+ // 1. resolve the host and reject if ANY record is a private/reserved IP
15
+ // (blocks direct navigation to 10.x / 127.x / 169.254.169.254 / etc.)
16
+ // 2. pin the vetted IP on the connection agent so a second lookup can't be
17
+ // re-pointed at an internal address between the check and the connect
18
+ // (DNS rebinding), and
19
+ // 3. callers pass maxRedirects:0 — a redirect to an internal host would
20
+ // otherwise re-resolve past the pin.
21
+ //
22
+ // isBlockedIP is the shared allow/deny logic in ./axios, so this and safe.get
23
+ // stay in lockstep on what counts as a blocked range.
24
+
25
+ const DEFAULT_TIMEOUT_MS = 15000;
26
+
27
+ // Resolve + validate a URL's host. Throws if the URL is not http(s), the host is
28
+ // unresolvable, or any resolved record is a blocked IP. Returns the parsed URL
29
+ // and its DNS records so callers can pin.
30
+ const resolveSafeHost = async ( url ) => {
31
+
32
+ const parsed = new URL( url );
33
+
34
+ if( parsed.protocol !== 'https:' && parsed.protocol !== 'http:' ){
35
+
36
+ throw new Error( 'Only http(s) URLs are allowed' );
37
+
38
+ }
39
+
40
+ const records = await dns.promises.lookup( parsed.hostname, { all : true } );
41
+
42
+ if( ! records?.length ){
43
+
44
+ throw new Error( 'Host could not be resolved' );
45
+
46
+ }
47
+
48
+ for( const record of records ){
49
+
50
+ if( isBlockedIP( record.address ) ){
51
+
52
+ throw new Error( 'Host resolves to a blocked IP range' );
53
+
54
+ }
55
+
56
+ }
57
+
58
+ return { parsed, records };
59
+
60
+ };
61
+
62
+ // Throwing guard for callers that manage their own transport (e.g. Puppeteer,
63
+ // which does its own DNS/redirects and can't take our agent). Rejects the URL if
64
+ // it resolves to a blocked range.
65
+ const assertSafeHost = async ( url ) => {
66
+
67
+ await resolveSafeHost( url );
68
+
69
+ };
70
+
71
+ // Build an http(s).Agent pinned to the vetted IP for `url`'s host. Pair with
72
+ // maxRedirects:0 on the request. Throws (via resolveSafeHost) if the host is blocked.
73
+ const pinnedAgent = async ( url ) => {
74
+
75
+ const { parsed, records } = await resolveSafeHost( url );
76
+ const pinned = records[ 0 ];
77
+
78
+ const lookup = ( hostname, options, callback ) => {
79
+
80
+ if( options?.all ){
81
+
82
+ callback( null, [ { address : pinned.address, family : pinned.family } ] );
83
+
84
+ } else {
85
+
86
+ callback( null, pinned.address, pinned.family );
87
+
88
+ }
89
+
90
+ };
91
+
92
+ return {
93
+ protocol : parsed.protocol,
94
+ agent : parsed.protocol === 'https:'
95
+ ? new https.Agent({ lookup })
96
+ : new http.Agent({ lookup })
97
+ };
98
+
99
+ };
100
+
101
+ // SSRF-safe drop-in for ./http `request`: identical call / return / throw
102
+ // contract (parsed JSON body on success, throws Error with .status on non-2xx),
103
+ // but pins the vetted IP and refuses redirects. A webhook endpoint that
104
+ // 3xx-redirects is treated as a failure — an intentional trade for closing the
105
+ // redirect-to-internal vector.
106
+ const safeRequest = async ({
107
+ body,
108
+ headers = {},
109
+ method = 'GET',
110
+ query,
111
+ timeout = DEFAULT_TIMEOUT_MS,
112
+ type = 'json',
113
+ url
114
+ }) => {
115
+
116
+ const full = new URL( url );
117
+
118
+ if( query ){
119
+
120
+ Object.entries( query ).forEach( ([ key, value ]) => full.searchParams.set( key, value ) );
121
+
122
+ }
123
+
124
+ const { protocol, agent } = await pinnedAgent( full.toString() );
125
+ const isForm = type === 'form';
126
+
127
+ try {
128
+
129
+ const response = await axios({
130
+ method,
131
+ url : full.toString(),
132
+ headers : {
133
+ 'Content-Type' : isForm ? 'application/x-www-form-urlencoded' : 'application/json',
134
+ ...headers
135
+ },
136
+ ...( body !== undefined && {
137
+ data : isForm ? new URLSearchParams( body ).toString() : body
138
+ }),
139
+ timeout,
140
+ maxRedirects : 0,
141
+ httpAgent : protocol === 'http:' ? agent : undefined,
142
+ httpsAgent : protocol === 'https:' ? agent : undefined,
143
+ responseType : 'json',
144
+ validateStatus : ( status ) => status >= 200 && status < 300
145
+ });
146
+
147
+ return response.data || null;
148
+
149
+ } catch ( error ) {
150
+
151
+ // Normalize to the ./http `request` error shape so existing callers
152
+ // (which read error.status / error.message / error.response) behave the same.
153
+ if( error?.response ){
154
+
155
+ const normalized = new Error(
156
+ typeof error.response.data === 'string'
157
+ ? error.response.data
158
+ : ( error.message || 'Request failed' )
159
+ );
160
+
161
+ normalized.status = error.response.status;
162
+ normalized.response = error.response.data;
163
+
164
+ throw normalized;
165
+
166
+ }
167
+
168
+ throw error;
169
+
170
+ }
171
+
172
+ };
173
+
174
+ export { assertSafeHost, pinnedAgent, resolveSafeHost, safeRequest };
@@ -0,0 +1,143 @@
1
+ // lib/safe-http.js
2
+ import dns2 from "dns";
3
+ import * as http2 from "http";
4
+ import * as https2 from "https";
5
+
6
+ // lib/axios.js
7
+ import axiosLib from "axios";
8
+ import dns from "dns";
9
+ import * as http from "http";
10
+ import * as https from "https";
11
+ import net from "net";
12
+ var dnsLookup = dns.promises.lookup;
13
+ var isBlockedIPv4 = (ip) => {
14
+ const parts = ip.split(".").map(Number);
15
+ if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
16
+ const [a, b] = parts;
17
+ if (a === 0) return true;
18
+ if (a === 10) return true;
19
+ if (a === 127) return true;
20
+ if (a === 169 && b === 254) return true;
21
+ if (a === 172 && b >= 16 && b <= 31) return true;
22
+ if (a === 192 && b === 168) return true;
23
+ if (a === 100 && b >= 64 && b <= 127) return true;
24
+ if (a === 192 && b === 0) return true;
25
+ if (a === 198 && (b === 18 || b === 19)) return true;
26
+ if (a === 198 && b === 51) return true;
27
+ if (a === 203 && b === 0) return true;
28
+ if (a >= 224) return true;
29
+ return false;
30
+ };
31
+ var isBlockedIPv6 = (ip) => {
32
+ const lower = ip.toLowerCase();
33
+ if (lower === "::1" || lower === "::") return true;
34
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
35
+ if (/^fe[89ab]/.test(lower)) return true;
36
+ if (lower.startsWith("ff")) return true;
37
+ if (lower.startsWith("::ffff:")) {
38
+ const v4 = lower.slice(7);
39
+ return isBlockedIPv4(v4);
40
+ }
41
+ ;
42
+ return false;
43
+ };
44
+ var isBlockedIP = (ip) => {
45
+ const version = net.isIP(ip);
46
+ if (version === 4) return isBlockedIPv4(ip);
47
+ if (version === 6) return isBlockedIPv6(ip);
48
+ return true;
49
+ };
50
+ var axios = axiosLib.create({
51
+ timeout: 3e4,
52
+ httpAgent: new http.Agent({ keepAlive: true, maxSockets: 128 }),
53
+ httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 128 })
54
+ });
55
+
56
+ // lib/safe-http.js
57
+ var DEFAULT_TIMEOUT_MS = 15e3;
58
+ var resolveSafeHost = async (url) => {
59
+ const parsed = new URL(url);
60
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
61
+ throw new Error("Only http(s) URLs are allowed");
62
+ }
63
+ const records = await dns2.promises.lookup(parsed.hostname, { all: true });
64
+ if (!(records == null ? void 0 : records.length)) {
65
+ throw new Error("Host could not be resolved");
66
+ }
67
+ for (const record of records) {
68
+ if (isBlockedIP(record.address)) {
69
+ throw new Error("Host resolves to a blocked IP range");
70
+ }
71
+ }
72
+ return { parsed, records };
73
+ };
74
+ var assertSafeHost = async (url) => {
75
+ await resolveSafeHost(url);
76
+ };
77
+ var pinnedAgent = async (url) => {
78
+ const { parsed, records } = await resolveSafeHost(url);
79
+ const pinned = records[0];
80
+ const lookup = (hostname, options, callback) => {
81
+ if (options == null ? void 0 : options.all) {
82
+ callback(null, [{ address: pinned.address, family: pinned.family }]);
83
+ } else {
84
+ callback(null, pinned.address, pinned.family);
85
+ }
86
+ };
87
+ return {
88
+ protocol: parsed.protocol,
89
+ agent: parsed.protocol === "https:" ? new https2.Agent({ lookup }) : new http2.Agent({ lookup })
90
+ };
91
+ };
92
+ var safeRequest = async ({
93
+ body,
94
+ headers = {},
95
+ method = "GET",
96
+ query,
97
+ timeout = DEFAULT_TIMEOUT_MS,
98
+ type = "json",
99
+ url
100
+ }) => {
101
+ const full = new URL(url);
102
+ if (query) {
103
+ Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
104
+ }
105
+ const { protocol, agent } = await pinnedAgent(full.toString());
106
+ const isForm = type === "form";
107
+ try {
108
+ const response = await axios({
109
+ method,
110
+ url: full.toString(),
111
+ headers: {
112
+ "Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
113
+ ...headers
114
+ },
115
+ ...body !== void 0 && {
116
+ data: isForm ? new URLSearchParams(body).toString() : body
117
+ },
118
+ timeout,
119
+ maxRedirects: 0,
120
+ httpAgent: protocol === "http:" ? agent : void 0,
121
+ httpsAgent: protocol === "https:" ? agent : void 0,
122
+ responseType: "json",
123
+ validateStatus: (status) => status >= 200 && status < 300
124
+ });
125
+ return response.data || null;
126
+ } catch (error) {
127
+ if (error == null ? void 0 : error.response) {
128
+ const normalized = new Error(
129
+ typeof error.response.data === "string" ? error.response.data : error.message || "Request failed"
130
+ );
131
+ normalized.status = error.response.status;
132
+ normalized.response = error.response.data;
133
+ throw normalized;
134
+ }
135
+ throw error;
136
+ }
137
+ };
138
+ export {
139
+ assertSafeHost,
140
+ pinnedAgent,
141
+ resolveSafeHost,
142
+ safeRequest
143
+ };
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "tinycolor2": "1.6.0"
14
14
  },
15
15
  "devDependencies": {
16
- "@drawbridge/drawbridge-agents": "0.1.28",
16
+ "@drawbridge/drawbridge-agents": "0.1.31",
17
17
  "@node-oauth/oauth2-server": "5.3.0",
18
18
  "tsup": "8.5.1",
19
19
  "typescript": "5.9.3"
@@ -47,6 +47,11 @@
47
47
  "import": "./dist/color.js",
48
48
  "require": "./dist/color.cjs"
49
49
  },
50
+ "./domains": {
51
+ "types": "./dist/domains.d.ts",
52
+ "import": "./dist/domains.js",
53
+ "require": "./dist/domains.cjs"
54
+ },
50
55
  "./email": {
51
56
  "types": "./dist/email.d.ts",
52
57
  "import": "./dist/email.js",
@@ -107,6 +112,11 @@
107
112
  "import": "./dist/redirect.js",
108
113
  "require": "./dist/redirect.cjs"
109
114
  },
115
+ "./safe-http": {
116
+ "types": "./dist/safe-http.d.ts",
117
+ "import": "./dist/safe-http.js",
118
+ "require": "./dist/safe-http.cjs"
119
+ },
110
120
  "./sanitize": {
111
121
  "types": "./dist/sanitize.d.ts",
112
122
  "import": "./dist/sanitize.js",
@@ -169,5 +179,5 @@
169
179
  "test": ". \"$HOME/.nvm/nvm.sh\" && nvm use && node --test"
170
180
  },
171
181
  "types": "dist/index.d.ts",
172
- "version": "0.0.96"
182
+ "version": "0.0.98"
173
183
  }