host-consensus 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tamer Kalla
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # host-consensus
2
+
3
+ **`http://example.com\@evil.com` is `example.com` to JavaScript and `evil.com` to Python.**
4
+
5
+ [![build](https://github.com/tamerkalla/host-consensus/actions/workflows/release.yml/badge.svg)](https://github.com/tamerkalla/host-consensus/actions/workflows/release.yml)
6
+ [![npm](https://img.shields.io/npm/v/host-consensus.svg)](https://www.npmjs.com/package/host-consensus)
7
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+ [![provenance](https://img.shields.io/badge/provenance-attested-brightgreen.svg)](https://www.npmjs.com/package/host-consensus)
9
+
10
+ A runtime-path library — you import it into request-validation code and ship it.
11
+ Zero runtime dependencies. TypeScript, ES2022, dual ESM/CJS, with a small CLI.
12
+
13
+ ```bash
14
+ npm install host-consensus
15
+ ```
16
+
17
+ | | reads the host as |
18
+ |---|---|
19
+ | `new URL()` — WHATWG, what every browser and Node run | **example.com** |
20
+ | `urllib.parse` — RFC 3986, what most of the non-browser world runs | **evil.com** |
21
+
22
+ Validate that URL in Node against an allowlist and it passes as `example.com`.
23
+ Hand the same *string* to a Python service, a Go proxy, or anything else built
24
+ on RFC 3986, and the request goes to `evil.com`.
25
+
26
+ Neither parser is broken. WHATWG URL says a backslash is a path separator in a
27
+ special scheme; RFC 3986 says it is an ordinary character, so the authority runs
28
+ to `@` and the userinfo is `example.com\`. Both are behaving exactly as
29
+ specified.
30
+
31
+ ## What it does
32
+
33
+ ```js
34
+ import { inspect, agrees, assertConsensus } from 'host-consensus';
35
+
36
+ agrees('https://example.com/a'); // true
37
+ agrees('http://example.com\\@evil.com'); // false
38
+
39
+ assertConsensus('http://example.com\\@evil.com');
40
+ // throws HostDisagreementError:
41
+ // "http://example.com\@evil.com" is read as different hosts
42
+ // (whatwg=example.com, rfc3986=evil.com)
43
+ ```
44
+
45
+ `inspect` returns both readings so you can log what actually differed:
46
+
47
+ ```js
48
+ inspect('http://example.com\\@evil.com');
49
+ ```
50
+
51
+ ```json
52
+ {
53
+ "input": "http://example.com\\@evil.com",
54
+ "verdict": "host-differs",
55
+ "host": null,
56
+ "readings": [
57
+ { "convention": "whatwg", "host": "example.com", "port": "", "origin": "http://example.com" },
58
+ { "convention": "rfc3986", "host": "evil.com", "port": "", "origin": "http://evil.com" }
59
+ ]
60
+ }
61
+ ```
62
+
63
+ From the command line:
64
+
65
+ ```console
66
+ $ npx host-consensus 'http://example.com\@evil.com'
67
+ http://example.com\@evil.com
68
+ whatwg example.com
69
+ rfc3986 evil.com
70
+ verdict host-differs
71
+ $ echo $?
72
+ 1
73
+ ```
74
+
75
+ ## The seven constructions
76
+
77
+ Over a deterministic enumeration of 312 URLs — 2 schemes x 26 authority
78
+ constructions x 6 path suffixes — **84 rows** are read as a different host by
79
+ the two conventions. Seven distinct authority constructions sit behind them, and
80
+ each is a different mechanism:
81
+
82
+ | authority | WHATWG reads | RFC 3986 reads | why |
83
+ |---|---|---|---|
84
+ | `example.com\@evil.com` | `example.com` | `evil.com` | backslash is a slash in a special scheme |
85
+ | `0x7f.1` | `127.0.0.1` | `0x7f.1` | hexadecimal IPv4 canonicalisation |
86
+ | `017700000001` | `127.0.0.1` | `017700000001` | octal IPv4 canonicalisation |
87
+ | `2130706433` | `127.0.0.1` | `2130706433` | decimal IPv4 canonicalisation |
88
+ | `①.com` | `1.com` | `①.com` | UTS-46 mapping |
89
+ | `exаmple.com` (Cyrillic а) | `xn--exmple-4nf.com` | `exаmple.com` | IDNA to ASCII |
90
+ | `example。com` (U+3002) | `example.com` | `example。com` | ideographic full stop separates labels |
91
+
92
+ The IPv4 rows run the other way from the backslash row: `http://2130706433` is
93
+ `127.0.0.1` to a private-range filter written against `new URL()`, and an opaque
94
+ hostname to anything RFC-derived. Whichever side does the blocking, the other
95
+ side disagrees about what it is blocking.
96
+
97
+ A further **48 rows** across four constructions are an *acceptance* difference —
98
+ one convention refuses the URL outright and the other returns a host:
99
+ `exa mple.com`, `example.com:65536`, `example.com:+80`, `example.com: 80`.
100
+
101
+ The remaining **180 rows** are reported as agreeing. That matters as much as the
102
+ rest: differences in spelling — IPv6 brackets, host case — are normalised away
103
+ and never reported, so ordinary URLs come back clean.
104
+
105
+ ## What this does not do
106
+
107
+ It does not resolve DNS, classify addresses as private, or make requests. Those
108
+ are a different job, and [`request-filtering-agent`](https://www.npmjs.com/package/request-filtering-agent)
109
+ does them well — it blocks requests to private ranges at the agent level. Use it
110
+ for that; use this to check that the URL string you validated is the one your
111
+ next hop will read.
112
+
113
+ The two are complementary: a private-range filter answers "is this address
114
+ safe", and `host-consensus` answers "will the next parser agree this is even the
115
+ same address".
116
+
117
+ ## API
118
+
119
+ ```ts
120
+ function inspect(url: string): Consensus;
121
+ function agrees(url: string): boolean;
122
+ function assertConsensus(url: string): string; // the agreed host, or throws
123
+ class HostDisagreementError extends Error { readonly consensus: Consensus }
124
+ ```
125
+
126
+ `verdict` is one of:
127
+
128
+ - `'agreed'` — every convention names the same host.
129
+ - `'host-differs'` — all parsed, and they name different hosts.
130
+ - `'acceptance-differs'` — at least one refused and at least one accepted.
131
+
132
+ `inspect` throws `TypeError` on a non-string and nothing else. `agrees` never
133
+ throws on a malformed URL: a predicate that throws gets read as `false` by the
134
+ next person to use it, and "this URL is dangerous" must not be reachable by
135
+ accident. `assertConsensus` also throws when both conventions refuse, because
136
+ there is no host to return.
137
+
138
+ `inspect` performs no I/O — no network, no filesystem, no clock — and is a pure
139
+ function of its argument.
140
+
141
+ ## How the RFC 3986 reading is checked
142
+
143
+ It is not checked against anybody's reading of the specification. `src/corpus.ts`
144
+ records the host and port that **CPython 3.11.15's `urllib.parse.urlsplit`**
145
+ returned for all 312 URLs, and the test suite asserts this package matches real
146
+ CPython on every one of them. See [VERIFY.md](VERIFY.md) to reproduce the
147
+ headline claim from a clean install in under a minute.
148
+
149
+ ## Also from this author
150
+
151
+ [`yaml-drift`](https://www.npmjs.com/package/yaml-drift) reports every value
152
+ whose meaning changed converting YAML to JSON; `host-consensus` reports hosts
153
+ that two URL conventions read differently. Same shape — a boundary that silently
154
+ changes meaning — at a different boundary.
155
+
156
+ ## License
157
+
158
+ MIT
package/VERIFY.md ADDED
@@ -0,0 +1,97 @@
1
+ # Verify the headline claim
2
+
3
+ The claim on the front of this package is that one URL string names two
4
+ different hosts depending on which convention reads it, and that
5
+ `host-consensus` catches that.
6
+
7
+ Reproduce it from a clean directory. Nothing here needs a checkout of this
8
+ repository — it installs the published package from the registry.
9
+
10
+ ## 1. Install
11
+
12
+ ```bash
13
+ mkdir -p /tmp/host-consensus-verify && cd /tmp/host-consensus-verify
14
+ npm init -y >/dev/null
15
+ npm install host-consensus@latest
16
+ ```
17
+
18
+ ## 2. Run the check
19
+
20
+ ```bash
21
+ npx host-consensus 'http://example.com\@evil.com'
22
+ echo "exit=$?"
23
+ ```
24
+
25
+ Expected output, exactly:
26
+
27
+ ```
28
+ http://example.com\@evil.com
29
+ whatwg example.com
30
+ rfc3986 evil.com
31
+ verdict host-differs
32
+ exit=1
33
+ ```
34
+
35
+ ## 3. Confirm an ordinary URL is not flagged
36
+
37
+ A tool that flags everything would pass step 2 and still be useless.
38
+
39
+ ```bash
40
+ npx host-consensus 'https://example.com/a'
41
+ echo "exit=$?"
42
+ ```
43
+
44
+ Expected output, exactly:
45
+
46
+ ```
47
+ https://example.com/a
48
+ whatwg example.com
49
+ rfc3986 example.com
50
+ verdict agreed
51
+ exit=0
52
+ ```
53
+
54
+ ## 4. Confirm the library agrees with the CLI, in both module formats
55
+
56
+ ```bash
57
+ node --input-type=module -e "
58
+ import { agrees } from 'host-consensus';
59
+ console.log('esm', agrees('https://example.com/a'), agrees('http://example.com\\\\@evil.com'));
60
+ "
61
+ node --input-type=commonjs -e "
62
+ const { agrees } = require('host-consensus');
63
+ console.log('cjs', agrees('https://example.com/a'), agrees('http://example.com\\\\@evil.com'));
64
+ "
65
+ ```
66
+
67
+ Expected output, exactly:
68
+
69
+ ```
70
+ esm true false
71
+ cjs true false
72
+ ```
73
+
74
+ ## 5. Check it against a second implementation, if you have Python
75
+
76
+ This is the same comparison the package's own test suite makes against
77
+ CPython 3.11.15, and it needs no extra packages.
78
+
79
+ ```bash
80
+ python3 -c "
81
+ from urllib.parse import urlsplit
82
+ print('python sees:', urlsplit('http://example.com\\\\@evil.com').hostname)
83
+ "
84
+ node -e "
85
+ console.log('node sees: ', new URL('http://example.com\\\\@evil.com').hostname);
86
+ "
87
+ ```
88
+
89
+ Expected output, exactly:
90
+
91
+ ```
92
+ python sees: evil.com
93
+ node sees: example.com
94
+ ```
95
+
96
+ If those two lines differ from each other, the package's reason for existing is
97
+ reproduced on your machine.
package/dist/cli.cjs ADDED
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
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 __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/cli.ts
22
+ var cli_exports = {};
23
+ __export(cli_exports, {
24
+ main: () => main,
25
+ run: () => run
26
+ });
27
+ module.exports = __toCommonJS(cli_exports);
28
+
29
+ // src/compare.ts
30
+ function normaliseHost(host) {
31
+ const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
32
+ return unbracketed.toLowerCase();
33
+ }
34
+ function verdictOf(readings) {
35
+ const accepted = readings.filter((r) => r.host !== null);
36
+ if (accepted.length !== 0 && accepted.length !== readings.length) {
37
+ return "acceptance-differs";
38
+ }
39
+ const keys = new Set(
40
+ readings.map((r) => r.host === null ? "\0none" : normaliseHost(r.host))
41
+ );
42
+ return keys.size === 1 ? "agreed" : "host-differs";
43
+ }
44
+ function consensusOf(input, readings) {
45
+ const verdict = verdictOf(readings);
46
+ const host = verdict === "agreed" ? readings[0].host : null;
47
+ return { input, verdict, host, readings };
48
+ }
49
+
50
+ // src/rfc3986.ts
51
+ var SCHEME_CHARS = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
52
+ function stripUnsafe(url) {
53
+ return url.replace(/[\t\r\n]/g, "");
54
+ }
55
+ function trimC0(url) {
56
+ let start = 0;
57
+ let end = url.length;
58
+ while (start < end && url.charCodeAt(start) <= 32) start++;
59
+ while (end > start && url.charCodeAt(end - 1) <= 32) end--;
60
+ return url.slice(start, end);
61
+ }
62
+ function splitScheme(url) {
63
+ const i = url.indexOf(":");
64
+ if (i > 0 && SCHEME_CHARS.test(url.slice(0, i))) {
65
+ return { scheme: url.slice(0, i).toLowerCase(), rest: url.slice(i + 1) };
66
+ }
67
+ return { scheme: "", rest: url };
68
+ }
69
+ function netlocOf(rest) {
70
+ if (!rest.startsWith("//")) return null;
71
+ const after = rest.slice(2);
72
+ let end = after.length;
73
+ for (const c of ["/", "?", "#"]) {
74
+ const i = after.indexOf(c);
75
+ if (i !== -1 && i < end) end = i;
76
+ }
77
+ return after.slice(0, end);
78
+ }
79
+ function hostInfo(netloc) {
80
+ const at = netloc.lastIndexOf("@");
81
+ const hostinfo = at === -1 ? netloc : netloc.slice(at + 1);
82
+ const open = hostinfo.indexOf("[");
83
+ if (open !== -1) {
84
+ const bracketed = hostinfo.slice(open + 1);
85
+ const close = bracketed.indexOf("]");
86
+ const host = close === -1 ? bracketed : bracketed.slice(0, close);
87
+ const afterBracket = close === -1 ? "" : bracketed.slice(close + 1);
88
+ const colon2 = afterBracket.indexOf(":");
89
+ return { host, port: colon2 === -1 ? "" : afterBracket.slice(colon2 + 1) };
90
+ }
91
+ const colon = hostinfo.indexOf(":");
92
+ if (colon === -1) return { host: hostinfo, port: "" };
93
+ return { host: hostinfo.slice(0, colon), port: hostinfo.slice(colon + 1) };
94
+ }
95
+ function lowerHost(host) {
96
+ const pct = host.indexOf("%");
97
+ if (pct === -1) return host.toLowerCase();
98
+ return host.slice(0, pct).toLowerCase() + host.slice(pct);
99
+ }
100
+ function normalisePort(port) {
101
+ if (port === "") return "";
102
+ if (!/^[0-9]+$/.test(port)) return null;
103
+ const n = Number(port);
104
+ if (n < 0 || n > 65535) return null;
105
+ return String(n);
106
+ }
107
+ function readRfc3986(url) {
108
+ const cleaned = trimC0(stripUnsafe(url));
109
+ const { scheme, rest } = splitScheme(cleaned);
110
+ const netloc = netlocOf(rest);
111
+ if (netloc === null) {
112
+ return {
113
+ convention: "rfc3986",
114
+ host: null,
115
+ port: null,
116
+ origin: null,
117
+ rejected: "no authority component"
118
+ };
119
+ }
120
+ const { host, port } = hostInfo(netloc);
121
+ if (host === "") {
122
+ return {
123
+ convention: "rfc3986",
124
+ host: null,
125
+ port: null,
126
+ origin: null,
127
+ rejected: "empty host"
128
+ };
129
+ }
130
+ const lowered = lowerHost(host);
131
+ const normalisedPort = normalisePort(port);
132
+ const origin = scheme === "" ? null : `${scheme}://${lowered}${normalisedPort ? `:${normalisedPort}` : ""}`;
133
+ return { convention: "rfc3986", host: lowered, port: normalisedPort, origin };
134
+ }
135
+
136
+ // src/whatwg.ts
137
+ function readWhatwg(url) {
138
+ let parsed;
139
+ try {
140
+ parsed = new URL(url);
141
+ } catch (error) {
142
+ return {
143
+ convention: "whatwg",
144
+ host: null,
145
+ port: null,
146
+ origin: null,
147
+ rejected: error instanceof Error ? error.message : String(error)
148
+ };
149
+ }
150
+ if (parsed.hostname === "") {
151
+ return {
152
+ convention: "whatwg",
153
+ host: null,
154
+ port: null,
155
+ origin: null,
156
+ rejected: "empty host"
157
+ };
158
+ }
159
+ const origin = parsed.origin === "null" ? null : parsed.origin;
160
+ return {
161
+ convention: "whatwg",
162
+ host: parsed.hostname,
163
+ port: parsed.port,
164
+ origin
165
+ };
166
+ }
167
+
168
+ // src/index.ts
169
+ function requireString(url, fn) {
170
+ if (typeof url !== "string") {
171
+ throw new TypeError(`${fn}(url) requires a string, received ${typeof url}`);
172
+ }
173
+ }
174
+ function inspect(url) {
175
+ requireString(url, "inspect");
176
+ return consensusOf(url, [readWhatwg(url), readRfc3986(url)]);
177
+ }
178
+
179
+ // src/cli.ts
180
+ var USAGE = `Usage: host-consensus <url>
181
+
182
+ Reads <url> by both URL-parsing conventions and prints the host each one names.
183
+
184
+ Exit codes:
185
+ 0 every convention agrees on the host
186
+ 1 the conventions disagree
187
+ 2 usage error`;
188
+ function main(argv, out) {
189
+ const args = argv.filter((a) => a !== "");
190
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
191
+ out(USAGE);
192
+ return args.length === 0 ? 2 : 0;
193
+ }
194
+ if (args.length > 1) {
195
+ out(`host-consensus: expected one URL, received ${args.length}
196
+
197
+ ${USAGE}`);
198
+ return 2;
199
+ }
200
+ const result = inspect(args[0]);
201
+ const width = Math.max(...result.readings.map((r) => r.convention.length));
202
+ out(result.input);
203
+ for (const reading of result.readings) {
204
+ const host = reading.host === null ? `<rejected: ${reading.rejected}>` : reading.host;
205
+ const port = reading.port ? `:${reading.port}` : "";
206
+ out(` ${reading.convention.padEnd(width)} ${host}${port}`);
207
+ }
208
+ out(` ${"verdict".padEnd(width)} ${result.verdict}`);
209
+ return result.verdict === "agreed" ? 0 : 1;
210
+ }
211
+ function run() {
212
+ process.exitCode = main(process.argv.slice(2), (s) => console.log(s));
213
+ }
214
+ run();
215
+ // Annotate the CommonJS export names for ESM import in node:
216
+ 0 && (module.exports = {
217
+ main,
218
+ run
219
+ });
package/dist/cli.d.cts ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ declare function main(argv: readonly string[], out: (s: string) => void): number;
3
+ declare function run(): void;
4
+
5
+ export { main, run };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ declare function main(argv: readonly string[], out: (s: string) => void): number;
3
+ declare function run(): void;
4
+
5
+ export { main, run };
package/dist/cli.js ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/compare.ts
4
+ function normaliseHost(host) {
5
+ const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
6
+ return unbracketed.toLowerCase();
7
+ }
8
+ function verdictOf(readings) {
9
+ const accepted = readings.filter((r) => r.host !== null);
10
+ if (accepted.length !== 0 && accepted.length !== readings.length) {
11
+ return "acceptance-differs";
12
+ }
13
+ const keys = new Set(
14
+ readings.map((r) => r.host === null ? "\0none" : normaliseHost(r.host))
15
+ );
16
+ return keys.size === 1 ? "agreed" : "host-differs";
17
+ }
18
+ function consensusOf(input, readings) {
19
+ const verdict = verdictOf(readings);
20
+ const host = verdict === "agreed" ? readings[0].host : null;
21
+ return { input, verdict, host, readings };
22
+ }
23
+
24
+ // src/rfc3986.ts
25
+ var SCHEME_CHARS = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
26
+ function stripUnsafe(url) {
27
+ return url.replace(/[\t\r\n]/g, "");
28
+ }
29
+ function trimC0(url) {
30
+ let start = 0;
31
+ let end = url.length;
32
+ while (start < end && url.charCodeAt(start) <= 32) start++;
33
+ while (end > start && url.charCodeAt(end - 1) <= 32) end--;
34
+ return url.slice(start, end);
35
+ }
36
+ function splitScheme(url) {
37
+ const i = url.indexOf(":");
38
+ if (i > 0 && SCHEME_CHARS.test(url.slice(0, i))) {
39
+ return { scheme: url.slice(0, i).toLowerCase(), rest: url.slice(i + 1) };
40
+ }
41
+ return { scheme: "", rest: url };
42
+ }
43
+ function netlocOf(rest) {
44
+ if (!rest.startsWith("//")) return null;
45
+ const after = rest.slice(2);
46
+ let end = after.length;
47
+ for (const c of ["/", "?", "#"]) {
48
+ const i = after.indexOf(c);
49
+ if (i !== -1 && i < end) end = i;
50
+ }
51
+ return after.slice(0, end);
52
+ }
53
+ function hostInfo(netloc) {
54
+ const at = netloc.lastIndexOf("@");
55
+ const hostinfo = at === -1 ? netloc : netloc.slice(at + 1);
56
+ const open = hostinfo.indexOf("[");
57
+ if (open !== -1) {
58
+ const bracketed = hostinfo.slice(open + 1);
59
+ const close = bracketed.indexOf("]");
60
+ const host = close === -1 ? bracketed : bracketed.slice(0, close);
61
+ const afterBracket = close === -1 ? "" : bracketed.slice(close + 1);
62
+ const colon2 = afterBracket.indexOf(":");
63
+ return { host, port: colon2 === -1 ? "" : afterBracket.slice(colon2 + 1) };
64
+ }
65
+ const colon = hostinfo.indexOf(":");
66
+ if (colon === -1) return { host: hostinfo, port: "" };
67
+ return { host: hostinfo.slice(0, colon), port: hostinfo.slice(colon + 1) };
68
+ }
69
+ function lowerHost(host) {
70
+ const pct = host.indexOf("%");
71
+ if (pct === -1) return host.toLowerCase();
72
+ return host.slice(0, pct).toLowerCase() + host.slice(pct);
73
+ }
74
+ function normalisePort(port) {
75
+ if (port === "") return "";
76
+ if (!/^[0-9]+$/.test(port)) return null;
77
+ const n = Number(port);
78
+ if (n < 0 || n > 65535) return null;
79
+ return String(n);
80
+ }
81
+ function readRfc3986(url) {
82
+ const cleaned = trimC0(stripUnsafe(url));
83
+ const { scheme, rest } = splitScheme(cleaned);
84
+ const netloc = netlocOf(rest);
85
+ if (netloc === null) {
86
+ return {
87
+ convention: "rfc3986",
88
+ host: null,
89
+ port: null,
90
+ origin: null,
91
+ rejected: "no authority component"
92
+ };
93
+ }
94
+ const { host, port } = hostInfo(netloc);
95
+ if (host === "") {
96
+ return {
97
+ convention: "rfc3986",
98
+ host: null,
99
+ port: null,
100
+ origin: null,
101
+ rejected: "empty host"
102
+ };
103
+ }
104
+ const lowered = lowerHost(host);
105
+ const normalisedPort = normalisePort(port);
106
+ const origin = scheme === "" ? null : `${scheme}://${lowered}${normalisedPort ? `:${normalisedPort}` : ""}`;
107
+ return { convention: "rfc3986", host: lowered, port: normalisedPort, origin };
108
+ }
109
+
110
+ // src/whatwg.ts
111
+ function readWhatwg(url) {
112
+ let parsed;
113
+ try {
114
+ parsed = new URL(url);
115
+ } catch (error) {
116
+ return {
117
+ convention: "whatwg",
118
+ host: null,
119
+ port: null,
120
+ origin: null,
121
+ rejected: error instanceof Error ? error.message : String(error)
122
+ };
123
+ }
124
+ if (parsed.hostname === "") {
125
+ return {
126
+ convention: "whatwg",
127
+ host: null,
128
+ port: null,
129
+ origin: null,
130
+ rejected: "empty host"
131
+ };
132
+ }
133
+ const origin = parsed.origin === "null" ? null : parsed.origin;
134
+ return {
135
+ convention: "whatwg",
136
+ host: parsed.hostname,
137
+ port: parsed.port,
138
+ origin
139
+ };
140
+ }
141
+
142
+ // src/index.ts
143
+ function requireString(url, fn) {
144
+ if (typeof url !== "string") {
145
+ throw new TypeError(`${fn}(url) requires a string, received ${typeof url}`);
146
+ }
147
+ }
148
+ function inspect(url) {
149
+ requireString(url, "inspect");
150
+ return consensusOf(url, [readWhatwg(url), readRfc3986(url)]);
151
+ }
152
+
153
+ // src/cli.ts
154
+ var USAGE = `Usage: host-consensus <url>
155
+
156
+ Reads <url> by both URL-parsing conventions and prints the host each one names.
157
+
158
+ Exit codes:
159
+ 0 every convention agrees on the host
160
+ 1 the conventions disagree
161
+ 2 usage error`;
162
+ function main(argv, out) {
163
+ const args = argv.filter((a) => a !== "");
164
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
165
+ out(USAGE);
166
+ return args.length === 0 ? 2 : 0;
167
+ }
168
+ if (args.length > 1) {
169
+ out(`host-consensus: expected one URL, received ${args.length}
170
+
171
+ ${USAGE}`);
172
+ return 2;
173
+ }
174
+ const result = inspect(args[0]);
175
+ const width = Math.max(...result.readings.map((r) => r.convention.length));
176
+ out(result.input);
177
+ for (const reading of result.readings) {
178
+ const host = reading.host === null ? `<rejected: ${reading.rejected}>` : reading.host;
179
+ const port = reading.port ? `:${reading.port}` : "";
180
+ out(` ${reading.convention.padEnd(width)} ${host}${port}`);
181
+ }
182
+ out(` ${"verdict".padEnd(width)} ${result.verdict}`);
183
+ return result.verdict === "agreed" ? 0 : 1;
184
+ }
185
+ function run() {
186
+ process.exitCode = main(process.argv.slice(2), (s) => console.log(s));
187
+ }
188
+ run();
189
+ export {
190
+ main,
191
+ run
192
+ };
package/dist/index.cjs ADDED
@@ -0,0 +1,214 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ HostDisagreementError: () => HostDisagreementError,
24
+ agrees: () => agrees,
25
+ assertConsensus: () => assertConsensus,
26
+ inspect: () => inspect,
27
+ normaliseHost: () => normaliseHost
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/compare.ts
32
+ function normaliseHost(host) {
33
+ const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
34
+ return unbracketed.toLowerCase();
35
+ }
36
+ function verdictOf(readings) {
37
+ const accepted = readings.filter((r) => r.host !== null);
38
+ if (accepted.length !== 0 && accepted.length !== readings.length) {
39
+ return "acceptance-differs";
40
+ }
41
+ const keys = new Set(
42
+ readings.map((r) => r.host === null ? "\0none" : normaliseHost(r.host))
43
+ );
44
+ return keys.size === 1 ? "agreed" : "host-differs";
45
+ }
46
+ function consensusOf(input, readings) {
47
+ const verdict = verdictOf(readings);
48
+ const host = verdict === "agreed" ? readings[0].host : null;
49
+ return { input, verdict, host, readings };
50
+ }
51
+
52
+ // src/rfc3986.ts
53
+ var SCHEME_CHARS = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
54
+ function stripUnsafe(url) {
55
+ return url.replace(/[\t\r\n]/g, "");
56
+ }
57
+ function trimC0(url) {
58
+ let start = 0;
59
+ let end = url.length;
60
+ while (start < end && url.charCodeAt(start) <= 32) start++;
61
+ while (end > start && url.charCodeAt(end - 1) <= 32) end--;
62
+ return url.slice(start, end);
63
+ }
64
+ function splitScheme(url) {
65
+ const i = url.indexOf(":");
66
+ if (i > 0 && SCHEME_CHARS.test(url.slice(0, i))) {
67
+ return { scheme: url.slice(0, i).toLowerCase(), rest: url.slice(i + 1) };
68
+ }
69
+ return { scheme: "", rest: url };
70
+ }
71
+ function netlocOf(rest) {
72
+ if (!rest.startsWith("//")) return null;
73
+ const after = rest.slice(2);
74
+ let end = after.length;
75
+ for (const c of ["/", "?", "#"]) {
76
+ const i = after.indexOf(c);
77
+ if (i !== -1 && i < end) end = i;
78
+ }
79
+ return after.slice(0, end);
80
+ }
81
+ function hostInfo(netloc) {
82
+ const at = netloc.lastIndexOf("@");
83
+ const hostinfo = at === -1 ? netloc : netloc.slice(at + 1);
84
+ const open = hostinfo.indexOf("[");
85
+ if (open !== -1) {
86
+ const bracketed = hostinfo.slice(open + 1);
87
+ const close = bracketed.indexOf("]");
88
+ const host = close === -1 ? bracketed : bracketed.slice(0, close);
89
+ const afterBracket = close === -1 ? "" : bracketed.slice(close + 1);
90
+ const colon2 = afterBracket.indexOf(":");
91
+ return { host, port: colon2 === -1 ? "" : afterBracket.slice(colon2 + 1) };
92
+ }
93
+ const colon = hostinfo.indexOf(":");
94
+ if (colon === -1) return { host: hostinfo, port: "" };
95
+ return { host: hostinfo.slice(0, colon), port: hostinfo.slice(colon + 1) };
96
+ }
97
+ function lowerHost(host) {
98
+ const pct = host.indexOf("%");
99
+ if (pct === -1) return host.toLowerCase();
100
+ return host.slice(0, pct).toLowerCase() + host.slice(pct);
101
+ }
102
+ function normalisePort(port) {
103
+ if (port === "") return "";
104
+ if (!/^[0-9]+$/.test(port)) return null;
105
+ const n = Number(port);
106
+ if (n < 0 || n > 65535) return null;
107
+ return String(n);
108
+ }
109
+ function readRfc3986(url) {
110
+ const cleaned = trimC0(stripUnsafe(url));
111
+ const { scheme, rest } = splitScheme(cleaned);
112
+ const netloc = netlocOf(rest);
113
+ if (netloc === null) {
114
+ return {
115
+ convention: "rfc3986",
116
+ host: null,
117
+ port: null,
118
+ origin: null,
119
+ rejected: "no authority component"
120
+ };
121
+ }
122
+ const { host, port } = hostInfo(netloc);
123
+ if (host === "") {
124
+ return {
125
+ convention: "rfc3986",
126
+ host: null,
127
+ port: null,
128
+ origin: null,
129
+ rejected: "empty host"
130
+ };
131
+ }
132
+ const lowered = lowerHost(host);
133
+ const normalisedPort = normalisePort(port);
134
+ const origin = scheme === "" ? null : `${scheme}://${lowered}${normalisedPort ? `:${normalisedPort}` : ""}`;
135
+ return { convention: "rfc3986", host: lowered, port: normalisedPort, origin };
136
+ }
137
+
138
+ // src/whatwg.ts
139
+ function readWhatwg(url) {
140
+ let parsed;
141
+ try {
142
+ parsed = new URL(url);
143
+ } catch (error) {
144
+ return {
145
+ convention: "whatwg",
146
+ host: null,
147
+ port: null,
148
+ origin: null,
149
+ rejected: error instanceof Error ? error.message : String(error)
150
+ };
151
+ }
152
+ if (parsed.hostname === "") {
153
+ return {
154
+ convention: "whatwg",
155
+ host: null,
156
+ port: null,
157
+ origin: null,
158
+ rejected: "empty host"
159
+ };
160
+ }
161
+ const origin = parsed.origin === "null" ? null : parsed.origin;
162
+ return {
163
+ convention: "whatwg",
164
+ host: parsed.hostname,
165
+ port: parsed.port,
166
+ origin
167
+ };
168
+ }
169
+
170
+ // src/index.ts
171
+ function requireString(url, fn) {
172
+ if (typeof url !== "string") {
173
+ throw new TypeError(`${fn}(url) requires a string, received ${typeof url}`);
174
+ }
175
+ }
176
+ function inspect(url) {
177
+ requireString(url, "inspect");
178
+ return consensusOf(url, [readWhatwg(url), readRfc3986(url)]);
179
+ }
180
+ function agrees(url) {
181
+ requireString(url, "agrees");
182
+ return inspect(url).verdict === "agreed";
183
+ }
184
+ var HostDisagreementError = class _HostDisagreementError extends Error {
185
+ name = "HostDisagreementError";
186
+ consensus;
187
+ constructor(consensus) {
188
+ super(_HostDisagreementError.describe(consensus));
189
+ this.consensus = consensus;
190
+ }
191
+ static describe(consensus) {
192
+ const readings = consensus.readings.map((r) => `${r.convention}=${r.host === null ? `<rejected: ${r.rejected}>` : r.host}`).join(", ");
193
+ if (consensus.verdict === "agreed") {
194
+ return `${JSON.stringify(consensus.input)} names no host (${readings})`;
195
+ }
196
+ return `${JSON.stringify(consensus.input)} is read as different hosts (${readings})`;
197
+ }
198
+ };
199
+ function assertConsensus(url) {
200
+ requireString(url, "assertConsensus");
201
+ const consensus = inspect(url);
202
+ if (consensus.verdict !== "agreed" || consensus.host === null) {
203
+ throw new HostDisagreementError(consensus);
204
+ }
205
+ return consensus.host;
206
+ }
207
+ // Annotate the CommonJS export names for ESM import in node:
208
+ 0 && (module.exports = {
209
+ HostDisagreementError,
210
+ agrees,
211
+ assertConsensus,
212
+ inspect,
213
+ normaliseHost
214
+ });
@@ -0,0 +1,70 @@
1
+ /** The two URL-parsing conventions in live use. */
2
+ type Convention = 'whatwg' | 'rfc3986';
3
+ interface Reading {
4
+ convention: Convention;
5
+ /** The host this convention says the URL names. `null` when it names none. */
6
+ host: string | null;
7
+ /**
8
+ * The port as a string; `''` when the URL carries no port. `null` when this
9
+ * convention produced no usable port — either because it refused the URL, or
10
+ * because the port is present but not an integer in 0-65535.
11
+ */
12
+ port: string | null;
13
+ /** `scheme://host[:port]`, or `null` where this convention defines none. */
14
+ origin: string | null;
15
+ /** Present only when `host` is `null`: why this convention produced no host. */
16
+ rejected?: string;
17
+ }
18
+ type Verdict =
19
+ /** Every convention names the same host. */
20
+ 'agreed'
21
+ /** All conventions parsed, but they name different hosts. */
22
+ | 'host-differs'
23
+ /** At least one convention refused the URL and at least one accepted it. */
24
+ | 'acceptance-differs';
25
+ interface Consensus {
26
+ input: string;
27
+ verdict: Verdict;
28
+ /** The agreed host. `null` unless `verdict` is `'agreed'` with a host. */
29
+ host: string | null;
30
+ readings: Reading[];
31
+ }
32
+
33
+ /**
34
+ * Normalise a host for comparison only. Surrounding IPv6 brackets are removed
35
+ * and ASCII case is folded; nothing else. A difference that survives this is a
36
+ * disagreement about which host is named. A difference that does not survive it
37
+ * is a difference in spelling, and reporting those would make the tool
38
+ * unusable — it would flag every IPv6 URL in the world.
39
+ */
40
+ declare function normaliseHost(host: string): string;
41
+
42
+ /**
43
+ * Read `url` by both conventions and report whether they name the same host.
44
+ *
45
+ * Performs no I/O: no network, no filesystem, no clock. Pure in its argument.
46
+ */
47
+ declare function inspect(url: string): Consensus;
48
+ /**
49
+ * `true` when every convention names the same host.
50
+ *
51
+ * Never throws on a malformed URL — a predicate that throws gets read as
52
+ * `false` by the next person to use it, and "this URL is dangerous" must not
53
+ * be reachable by accident.
54
+ */
55
+ declare function agrees(url: string): boolean;
56
+ declare class HostDisagreementError extends Error {
57
+ readonly name: "HostDisagreementError";
58
+ readonly consensus: Consensus;
59
+ constructor(consensus: Consensus);
60
+ private static describe;
61
+ }
62
+ /**
63
+ * Return the host every convention agrees on, or throw `HostDisagreementError`.
64
+ *
65
+ * This is the guard: put it in front of anything that will hand the URL
66
+ * *string* to another system, which will parse it again by its own rules.
67
+ */
68
+ declare function assertConsensus(url: string): string;
69
+
70
+ export { type Consensus, type Convention, HostDisagreementError, type Reading, type Verdict, agrees, assertConsensus, inspect, normaliseHost };
@@ -0,0 +1,70 @@
1
+ /** The two URL-parsing conventions in live use. */
2
+ type Convention = 'whatwg' | 'rfc3986';
3
+ interface Reading {
4
+ convention: Convention;
5
+ /** The host this convention says the URL names. `null` when it names none. */
6
+ host: string | null;
7
+ /**
8
+ * The port as a string; `''` when the URL carries no port. `null` when this
9
+ * convention produced no usable port — either because it refused the URL, or
10
+ * because the port is present but not an integer in 0-65535.
11
+ */
12
+ port: string | null;
13
+ /** `scheme://host[:port]`, or `null` where this convention defines none. */
14
+ origin: string | null;
15
+ /** Present only when `host` is `null`: why this convention produced no host. */
16
+ rejected?: string;
17
+ }
18
+ type Verdict =
19
+ /** Every convention names the same host. */
20
+ 'agreed'
21
+ /** All conventions parsed, but they name different hosts. */
22
+ | 'host-differs'
23
+ /** At least one convention refused the URL and at least one accepted it. */
24
+ | 'acceptance-differs';
25
+ interface Consensus {
26
+ input: string;
27
+ verdict: Verdict;
28
+ /** The agreed host. `null` unless `verdict` is `'agreed'` with a host. */
29
+ host: string | null;
30
+ readings: Reading[];
31
+ }
32
+
33
+ /**
34
+ * Normalise a host for comparison only. Surrounding IPv6 brackets are removed
35
+ * and ASCII case is folded; nothing else. A difference that survives this is a
36
+ * disagreement about which host is named. A difference that does not survive it
37
+ * is a difference in spelling, and reporting those would make the tool
38
+ * unusable — it would flag every IPv6 URL in the world.
39
+ */
40
+ declare function normaliseHost(host: string): string;
41
+
42
+ /**
43
+ * Read `url` by both conventions and report whether they name the same host.
44
+ *
45
+ * Performs no I/O: no network, no filesystem, no clock. Pure in its argument.
46
+ */
47
+ declare function inspect(url: string): Consensus;
48
+ /**
49
+ * `true` when every convention names the same host.
50
+ *
51
+ * Never throws on a malformed URL — a predicate that throws gets read as
52
+ * `false` by the next person to use it, and "this URL is dangerous" must not
53
+ * be reachable by accident.
54
+ */
55
+ declare function agrees(url: string): boolean;
56
+ declare class HostDisagreementError extends Error {
57
+ readonly name: "HostDisagreementError";
58
+ readonly consensus: Consensus;
59
+ constructor(consensus: Consensus);
60
+ private static describe;
61
+ }
62
+ /**
63
+ * Return the host every convention agrees on, or throw `HostDisagreementError`.
64
+ *
65
+ * This is the guard: put it in front of anything that will hand the URL
66
+ * *string* to another system, which will parse it again by its own rules.
67
+ */
68
+ declare function assertConsensus(url: string): string;
69
+
70
+ export { type Consensus, type Convention, HostDisagreementError, type Reading, type Verdict, agrees, assertConsensus, inspect, normaliseHost };
package/dist/index.js ADDED
@@ -0,0 +1,183 @@
1
+ // src/compare.ts
2
+ function normaliseHost(host) {
3
+ const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
4
+ return unbracketed.toLowerCase();
5
+ }
6
+ function verdictOf(readings) {
7
+ const accepted = readings.filter((r) => r.host !== null);
8
+ if (accepted.length !== 0 && accepted.length !== readings.length) {
9
+ return "acceptance-differs";
10
+ }
11
+ const keys = new Set(
12
+ readings.map((r) => r.host === null ? "\0none" : normaliseHost(r.host))
13
+ );
14
+ return keys.size === 1 ? "agreed" : "host-differs";
15
+ }
16
+ function consensusOf(input, readings) {
17
+ const verdict = verdictOf(readings);
18
+ const host = verdict === "agreed" ? readings[0].host : null;
19
+ return { input, verdict, host, readings };
20
+ }
21
+
22
+ // src/rfc3986.ts
23
+ var SCHEME_CHARS = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
24
+ function stripUnsafe(url) {
25
+ return url.replace(/[\t\r\n]/g, "");
26
+ }
27
+ function trimC0(url) {
28
+ let start = 0;
29
+ let end = url.length;
30
+ while (start < end && url.charCodeAt(start) <= 32) start++;
31
+ while (end > start && url.charCodeAt(end - 1) <= 32) end--;
32
+ return url.slice(start, end);
33
+ }
34
+ function splitScheme(url) {
35
+ const i = url.indexOf(":");
36
+ if (i > 0 && SCHEME_CHARS.test(url.slice(0, i))) {
37
+ return { scheme: url.slice(0, i).toLowerCase(), rest: url.slice(i + 1) };
38
+ }
39
+ return { scheme: "", rest: url };
40
+ }
41
+ function netlocOf(rest) {
42
+ if (!rest.startsWith("//")) return null;
43
+ const after = rest.slice(2);
44
+ let end = after.length;
45
+ for (const c of ["/", "?", "#"]) {
46
+ const i = after.indexOf(c);
47
+ if (i !== -1 && i < end) end = i;
48
+ }
49
+ return after.slice(0, end);
50
+ }
51
+ function hostInfo(netloc) {
52
+ const at = netloc.lastIndexOf("@");
53
+ const hostinfo = at === -1 ? netloc : netloc.slice(at + 1);
54
+ const open = hostinfo.indexOf("[");
55
+ if (open !== -1) {
56
+ const bracketed = hostinfo.slice(open + 1);
57
+ const close = bracketed.indexOf("]");
58
+ const host = close === -1 ? bracketed : bracketed.slice(0, close);
59
+ const afterBracket = close === -1 ? "" : bracketed.slice(close + 1);
60
+ const colon2 = afterBracket.indexOf(":");
61
+ return { host, port: colon2 === -1 ? "" : afterBracket.slice(colon2 + 1) };
62
+ }
63
+ const colon = hostinfo.indexOf(":");
64
+ if (colon === -1) return { host: hostinfo, port: "" };
65
+ return { host: hostinfo.slice(0, colon), port: hostinfo.slice(colon + 1) };
66
+ }
67
+ function lowerHost(host) {
68
+ const pct = host.indexOf("%");
69
+ if (pct === -1) return host.toLowerCase();
70
+ return host.slice(0, pct).toLowerCase() + host.slice(pct);
71
+ }
72
+ function normalisePort(port) {
73
+ if (port === "") return "";
74
+ if (!/^[0-9]+$/.test(port)) return null;
75
+ const n = Number(port);
76
+ if (n < 0 || n > 65535) return null;
77
+ return String(n);
78
+ }
79
+ function readRfc3986(url) {
80
+ const cleaned = trimC0(stripUnsafe(url));
81
+ const { scheme, rest } = splitScheme(cleaned);
82
+ const netloc = netlocOf(rest);
83
+ if (netloc === null) {
84
+ return {
85
+ convention: "rfc3986",
86
+ host: null,
87
+ port: null,
88
+ origin: null,
89
+ rejected: "no authority component"
90
+ };
91
+ }
92
+ const { host, port } = hostInfo(netloc);
93
+ if (host === "") {
94
+ return {
95
+ convention: "rfc3986",
96
+ host: null,
97
+ port: null,
98
+ origin: null,
99
+ rejected: "empty host"
100
+ };
101
+ }
102
+ const lowered = lowerHost(host);
103
+ const normalisedPort = normalisePort(port);
104
+ const origin = scheme === "" ? null : `${scheme}://${lowered}${normalisedPort ? `:${normalisedPort}` : ""}`;
105
+ return { convention: "rfc3986", host: lowered, port: normalisedPort, origin };
106
+ }
107
+
108
+ // src/whatwg.ts
109
+ function readWhatwg(url) {
110
+ let parsed;
111
+ try {
112
+ parsed = new URL(url);
113
+ } catch (error) {
114
+ return {
115
+ convention: "whatwg",
116
+ host: null,
117
+ port: null,
118
+ origin: null,
119
+ rejected: error instanceof Error ? error.message : String(error)
120
+ };
121
+ }
122
+ if (parsed.hostname === "") {
123
+ return {
124
+ convention: "whatwg",
125
+ host: null,
126
+ port: null,
127
+ origin: null,
128
+ rejected: "empty host"
129
+ };
130
+ }
131
+ const origin = parsed.origin === "null" ? null : parsed.origin;
132
+ return {
133
+ convention: "whatwg",
134
+ host: parsed.hostname,
135
+ port: parsed.port,
136
+ origin
137
+ };
138
+ }
139
+
140
+ // src/index.ts
141
+ function requireString(url, fn) {
142
+ if (typeof url !== "string") {
143
+ throw new TypeError(`${fn}(url) requires a string, received ${typeof url}`);
144
+ }
145
+ }
146
+ function inspect(url) {
147
+ requireString(url, "inspect");
148
+ return consensusOf(url, [readWhatwg(url), readRfc3986(url)]);
149
+ }
150
+ function agrees(url) {
151
+ requireString(url, "agrees");
152
+ return inspect(url).verdict === "agreed";
153
+ }
154
+ var HostDisagreementError = class _HostDisagreementError extends Error {
155
+ name = "HostDisagreementError";
156
+ consensus;
157
+ constructor(consensus) {
158
+ super(_HostDisagreementError.describe(consensus));
159
+ this.consensus = consensus;
160
+ }
161
+ static describe(consensus) {
162
+ const readings = consensus.readings.map((r) => `${r.convention}=${r.host === null ? `<rejected: ${r.rejected}>` : r.host}`).join(", ");
163
+ if (consensus.verdict === "agreed") {
164
+ return `${JSON.stringify(consensus.input)} names no host (${readings})`;
165
+ }
166
+ return `${JSON.stringify(consensus.input)} is read as different hosts (${readings})`;
167
+ }
168
+ };
169
+ function assertConsensus(url) {
170
+ requireString(url, "assertConsensus");
171
+ const consensus = inspect(url);
172
+ if (consensus.verdict !== "agreed" || consensus.host === null) {
173
+ throw new HostDisagreementError(consensus);
174
+ }
175
+ return consensus.host;
176
+ }
177
+ export {
178
+ HostDisagreementError,
179
+ agrees,
180
+ assertConsensus,
181
+ inspect,
182
+ normaliseHost
183
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "host-consensus",
3
+ "version": "0.1.0",
4
+ "description": "Reports whether every URL-parsing convention agrees on the host a URL names.",
5
+ "keywords": [
6
+ "url",
7
+ "ssrf",
8
+ "hostname",
9
+ "whatwg-url",
10
+ "rfc3986",
11
+ "security",
12
+ "validation",
13
+ "parser"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Tamer Kalla <tamerkalla@gmail.com>",
17
+ "type": "module",
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/tamerkalla/host-consensus.git"
24
+ },
25
+ "homepage": "https://github.com/tamerkalla/host-consensus#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/tamerkalla/host-consensus/issues"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js",
33
+ "require": "./dist/index.cjs"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "main": "./dist/index.cjs",
38
+ "module": "./dist/index.js",
39
+ "types": "./dist/index.d.ts",
40
+ "bin": {
41
+ "host-consensus": "./dist/cli.js"
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "VERIFY.md"
46
+ ],
47
+ "sideEffects": false,
48
+ "scripts": {
49
+ "typecheck": "tsc --noEmit",
50
+ "test": "vitest run",
51
+ "build": "tsup"
52
+ },
53
+ "devDependencies": {
54
+ "@types/js-yaml": "4.0.9",
55
+ "@types/node": "22.18.12",
56
+ "js-yaml": "4.3.2",
57
+ "tsup": "8.5.0",
58
+ "typescript": "5.9.3",
59
+ "vitest": "3.2.7"
60
+ }
61
+ }