@sonordev/contracts 2.9.0 → 2.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.11.0
4
+
5
+ - Add `@sonordev/contracts/public-url-host`: the pure `isBlockedPublicUrlHost` predicate shared by Sonor and Signal, including private IPv4 and IPv6 ranges, mapped/compatible IPv4, and local hostname suffixes. It uses the standard URL parser without Node dependencies. Scoped IPv6 addresses are refused without throwing. Callers retain URL validation, DNS resolution/address pinning, and redirect protections.
6
+
7
+ ## 2.10.0
8
+
9
+ A minor release: two new functions in `@sonordev/contracts/page-titles`. Every entry is otherwise unchanged.
10
+
11
+ - **`page-titles`**: the rule for telling a page's name from its address, once, for everything that prints or stores a title.
12
+ - `isPageAddress(value, path?)` says whether a value is a page's address and not its name, so a path saved where a title belonged never prints as one. It's true for a path or anything starting with a slash, an absolute URL, a host and path with no scheme ("example.com/services"), a route placeholder, and, given the page's `path`, the path itself without its slashes ("services/ac-repair" for "/services/ac-repair"). Ordinary text with a slash in it ("Heating/Cooling") is a name. Ask it about `titleHead`'s result, not the whole title.
13
+ - `isPageName(title, path?)` says whether a stored title is a real name for the page, judging every piece between its pipes. A title that's printed whole prints its pipes too, so "Floor Plans | /floor-plans/studio", "Floor Plans | :slug", ":slug | Example Apartments" and the unspaced "Floor Plans|/floor-plans/studio" aren't names, while "1 Bedroom | 1BR 1BA" is. Empty pieces from a stray pipe are left out, a title that's only pipes isn't a name, and neither are empty, nullish or non-string values. A writer asks it about `titleHead`'s result, so the code that stores a name and the code that prints it can't disagree.
14
+
3
15
  ## 2.9.0
4
16
 
5
17
  A minor release: three new entries, `@sonordev/contracts/page-titles`, `@sonordev/contracts/broadcast-layouts` and `@sonordev/contracts/publication-branding`. Every other entry is unchanged.
package/README.md CHANGED
@@ -34,9 +34,25 @@ pnpm add @sonordev/contracts
34
34
  | `@sonordev/contracts/knowledge` | Which Sonor records Signal reads into a project's knowledge, the address each one's knowledge is stored under (`portfolio://<id>`), and the limits of the call that tells Signal a site's pages changed | the Sonor API sends those calls and Signal answers them |
35
35
  | `@sonordev/contracts/business-claims` | What an outbound email or text may say about a business (`checkOutboundCopy`), the stored review of what it claimed and when that review is settled, and whether a sent email arrived intact (`readBackVerdict`) | the Sonor API checks again on the bytes it sends, Signal checks what its writers and agents file |
36
36
  | `@sonordev/contracts/outreach` | What a segment of outreach is (pitch, sending mailbox, prospect industry), who may turn one on, what a turned-on segment covers, and what a drip's ledger row means for its lead | Signal writes and reads segment grants, the Sonor API checks the same grant before it sends |
37
- | `@sonordev/contracts/page-titles` | A page's own name: its title without the brand (`titleHead`), a readable label from a slug (`slugLabel`), and whether a value is a route placeholder like `:slug` (`isRoutePlaceholder`) | Signal's site-architecture writer, the agency site kit's portfolio components |
37
+ | `@sonordev/contracts/page-titles` | A page's own name: its title without the brand (`titleHead`), a readable label from a slug (`slugLabel`), whether a value is a route placeholder like `:slug` (`isRoutePlaceholder`), whether a value is a page's address and not its name (`isPageAddress`), and whether a stored title is a name when every piece between its pipes is judged (`isPageName`) | Signal's site-architecture writer, the agency site kit's portfolio components |
38
38
  | `@sonordev/contracts/broadcast-layouts` | Which Broadcast card layouts are drawn (centered `headline`, low `anchor`), which retired IDs still read and what each draws as (`normalizeArticleCoverLayout`), the Broadcast styles, and the review compositions with the frozen draw that gives each review its own (`reviewCardVariationFromDigest`) | the Sonor API's card renderers and settings, Signal's agent tools, the dashboard's pickers |
39
39
  | `@sonordev/contracts/publication-branding` | What a publication's branding may hold and what a change may set (`createPublicationBrandingValidator`), how a change merges, the editorial charter, and safe font stacks | the Sonor API stores and resolves it, Signal checks an agent's change before filing it, the dashboard edits it |
40
+ | `@sonordev/contracts/public-url-host` | Which literal hosts are refused as public fetch destinations (`isBlockedPublicUrlHost`) | the Sonor API and Signal's URL validators and network fetchers |
41
+
42
+ ## Public URL hosts
43
+
44
+ `isBlockedPublicUrlHost(host)` shares the APIs' loopback, private IPv4,
45
+ link-local, carrier-grade NAT, multicast/reserved IPv4, private IPv6 and local
46
+ hostname checks. It checks IPv4-mapped and IPv4-compatible IPv6 destinations
47
+ too, with or without brackets. It uses the standard `URL` parser, with no
48
+ Node, DNS or network dependencies. Scoped IPv6 addresses are refused.
49
+
50
+ Pass the **parsed `URL.hostname`**, so alternate IPv4 spellings such as
51
+ `127.1` and `0x7f000001` are normalized first, or pass a DNS result's address.
52
+ This predicate doesn't validate a URL or trim a raw string. A `false` result
53
+ isn't proof a destination is public: the fetcher still validates the protocol,
54
+ credentials and port, checks the DNS addresses it connects to, and applies its
55
+ redirect policy on each hop. Those network protections stay in the APIs.
40
56
 
41
57
  ## Schema placeholders
42
58
 
@@ -551,15 +567,20 @@ HVAC". A list of pages, a sitemap or a case study wants the name alone, and
551
567
  the usual shortcuts get it wrong. Capitalizing each word of a slug turns
552
568
  "ac-replacement" into "Ac Replacement", "faq" into "Faq" and
553
569
  "smith-and-smith" into "Smith And Smith". A title with a stray second pipe
554
- ships as "Loans | | Brand". `titleHead`, `slugLabel` and `isRoutePlaceholder`
555
- are the one set of rules, so every list reads the same.
570
+ ships as "Loans | | Brand". A title that is really the page's path ships as a
571
+ page name. `titleHead`, `slugLabel`, `isRoutePlaceholder`, `isPageAddress`
572
+ and `isPageName` are the one set of rules, so every list reads the same.
556
573
 
557
574
  ```ts
558
- import { isRoutePlaceholder, slugLabel, titleHead } from '@sonordev/contracts/page-titles'
559
-
560
- titleHead('FHA Home Loans | | Example Mortgage') // 'FHA Home Loans'
561
- slugLabel('/services/hvac-repair/') // 'HVAC Repair'
562
- isRoutePlaceholder('/floor-plans/:slug') // true
575
+ import { isPageAddress, isPageName, isRoutePlaceholder, slugLabel, titleHead } from '@sonordev/contracts/page-titles'
576
+
577
+ titleHead('FHA Home Loans | | Example Mortgage') // 'FHA Home Loans'
578
+ slugLabel('/services/hvac-repair/') // 'HVAC Repair'
579
+ isRoutePlaceholder('/floor-plans/:slug') // true
580
+ isPageAddress('services/ac-repair', '/services/ac-repair') // true
581
+ isPageAddress('AC Repair', '/services/ac-repair') // false
582
+ isPageName('1 Bedroom | 1BR 1BA', '/floor-plans/one-bed') // true
583
+ isPageName('Studio | /floor-plans/studio', '/floor-plans/studio') // false
563
584
  ```
564
585
 
565
586
  - **`titleHead(title)`** is the title without its trailing " | Brand". Only
@@ -612,6 +633,50 @@ isRoutePlaceholder('/floor-plans/:slug') // true
612
633
  single token with no spaces, so ordinary text that contains a colon or a
613
634
  bracket isn't one ("Update: new hours", "FAQ / [draft] notes"). Nullish and
614
635
  empty values are false.
636
+ - **`isPageAddress(value, path?)`** is true when the value is a page's address
637
+ and not its name. That's what a title becomes when a writer saved the path
638
+ where the title belonged. It's true for:
639
+ - a path, or anything that starts with a slash ("/services/ac-repair",
640
+ "//example.com/services");
641
+ - an absolute URL ("https://example.com/services");
642
+ - a host and a path with no scheme and no spaces ("example.com/services",
643
+ "www.example.co.uk/floor-plans/", "example.com:8080/x"). A browser shows an
644
+ untitled page's address this way. A bare host ("example.com") isn't an
645
+ address, and neither is text that has a slash in it ("Heating/Cooling",
646
+ "24/7 Emergency Service") or a number that looks like a host ("4.5/5",
647
+ "v2.10/changelog"), because a host ends in a letters-only domain;
648
+ - a route placeholder, the way `isRoutePlaceholder` reads one;
649
+ - the page's own path written without its slashes, when you pass `path`:
650
+ "services/ac-repair" and "services/ac-repair/" for "/services/ac-repair",
651
+ "faq" for "/faq". Leading and trailing slashes come off both sides, and
652
+ the comparison is exact: "ac-repair" isn't the address of
653
+ "/services/ac-repair", and "Pricing" for "/pricing" is a name where
654
+ "pricing" is the path.
655
+
656
+ The value is trimmed first. Empty, whitespace-only, nullish and non-string
657
+ values are false: they're nothing, not an address, and what an empty title
658
+ means is the caller's to say. It judges one string, so cut a brand tail
659
+ first and ask about what's left: `isPageAddress(titleHead(title), path)`.
660
+ - **`isPageName(title, path?)`** is true when a stored title is a real name for
661
+ the page: something is left once the pipes are set aside, and no piece
662
+ between them is a page's address, as `isPageAddress` reads one (given the
663
+ page's `path`). It's the rule for a title that's printed whole. A writer that
664
+ stores a name and a reader that prints it both ask it, so they can't disagree
665
+ about a path, a URL or a placeholder behind a pipe.
666
+ - **Every piece is judged**, the first and the last included. "Floor Plans |
667
+ /floor-plans/studio", "Floor Plans | :slug", ":slug | Example Apartments"
668
+ and "Floor Plans | example.com/floor-plans/studio" aren't names, and neither
669
+ is the unspaced "Floor Plans|/floor-plans/studio", because it prints with
670
+ its path in it. The split is on the bare `|`, and the empty pieces of a
671
+ stray pipe ("| Studio |", "Studio | | Example Apartments") are left out.
672
+ - **A name with a pipe in it stays a name** while every piece is text:
673
+ "1 Bedroom | 1BR 1BA", "Heating/Cooling | Update: new hours".
674
+ - **Nothing left isn't a name.** A title that's only pipes, an empty or
675
+ whitespace-only title, `null`, `undefined` and a non-string are all false.
676
+ - **Cut the brand tail first.** It judges the title it's given, tail
677
+ included. A writer asks about what it stores: `isPageName(titleHead(title),
678
+ path)`. A path that's only in the cut tail isn't stored, so it doesn't
679
+ count against the page.
615
680
 
616
681
  Known limits. A slug carries no capitalization or punctuation, so "DeWalt"
617
682
  reads "Dewalt", "joes-pizza" reads "Joes Pizza", and an `index.html` reads
@@ -691,9 +756,10 @@ valid, so the validator lives here once.
691
756
 
692
757
  ```ts
693
758
  import { createPublicationBrandingValidator } from '@sonordev/contracts/publication-branding'
759
+ import { isBlockedPublicUrlHost } from '@sonordev/contracts/public-url-host'
694
760
 
695
- // Bind it once, to the server's own private-address guard.
696
- const branding = createPublicationBrandingValidator({ isBlockedHost })
761
+ // Bind it once to the same literal-host policy both APIs use.
762
+ const branding = createPublicationBrandingValidator({ isBlockedHost: isBlockedPublicUrlHost })
697
763
 
698
764
  branding.validatePublicationBrandingPatch({ colors: { primary: '#ABC' } })
699
765
  // { colors: { primary: '#aabbcc' } }
@@ -706,8 +772,10 @@ branding.mergePublicationBrandingPatch(saved, { identity: { logos: { dark: null
706
772
  - **`createPublicationBrandingValidator({ isBlockedHost })`** returns the
707
773
  validator. A URL leaf must be a public HTTPS URL without credentials;
708
774
  whether its host is private (loopback, a LAN range, link-local,
709
- `localhost`) is the caller's guard, because that check needs Node. It gets
710
- `new URL(value).hostname`, so IPv6 keeps its brackets.
775
+ `localhost`) uses the injected guard. Both APIs pass the shared
776
+ `public-url-host` predicate, which doesn't need Node. It gets
777
+ `new URL(value).hostname`, so IPv6 keeps its brackets. DNS and connection
778
+ checks remain the responsibility of the fetcher.
711
779
  - **`validatePublicationBranding`** reads a whole saved profile.
712
780
  **`validatePublicationBrandingPatch`** reads a change: a leaf set to `null`
713
781
  clears that override, and a card layout must be one that's drawn today.
@@ -16,6 +16,15 @@
16
16
  * name or a path, because the page has no title at all.
17
17
  * - `isRoutePlaceholder` says whether a value is a framework route
18
18
  * placeholder (":slug", "[slug]", "[...rest]") and not a page's name.
19
+ * - `isPageAddress` says whether a value is a page's address (a path, a
20
+ * URL, a route pattern, or the page's own path without its slashes) and
21
+ * not its name. It's the one "is this title really a path" rule, so the
22
+ * writer of a page list and the reader that prints it can't disagree.
23
+ * - `isPageName` says whether a stored title is a name: something is left
24
+ * once the pipes are set aside, and no piece between them is an address.
25
+ * It's the one rule for a title that gets printed whole, so the writer
26
+ * that stores a name and the reader that prints it can't disagree about
27
+ * a path, a URL or a placeholder behind a pipe.
19
28
  *
20
29
  * Pure string logic: no I/O, no Node built-ins. The brand split is
21
30
  * seo-meta's `stripTitleSuffix`, imported, never copied: only the LAST
@@ -78,3 +87,54 @@ export declare function slugLabel(input: string): string;
78
87
  * false.
79
88
  */
80
89
  export declare function isRoutePlaceholder(value: string | null | undefined): boolean;
90
+ /**
91
+ * Whether `value` is a page's address and not its name: what a page's title
92
+ * becomes when nothing better was stored (the old sitemap writers saved the
93
+ * path there), or a pattern that leaked out of the router. True for:
94
+ *
95
+ * - a path, or anything starting with a slash ("/services/ac-repair",
96
+ * "//example.com/services");
97
+ * - an absolute URL ("https://example.com/services");
98
+ * - a host and a path with no scheme ("example.com/services");
99
+ * - a route placeholder, as `isRoutePlaceholder` reads it ("/floor-plans/:slug",
100
+ * ":Slug", "[slug]");
101
+ * - the page's own path written without its slashes. Given `path`, the
102
+ * comparison drops leading and trailing slashes from both sides, so for
103
+ * "/services/ac-repair" the values "services/ac-repair" and
104
+ * "services/ac-repair/" are the address, and for "/faq" the value "faq" is.
105
+ * It compares the whole path and it's case-sensitive: "ac-repair" isn't
106
+ * the address of "/services/ac-repair", and "Pricing" for "/pricing" is a
107
+ * name where "pricing" is the path.
108
+ *
109
+ * The value is trimmed first. Empty, whitespace-only, nullish and non-string
110
+ * values give false: they aren't an address, they're nothing, and the caller
111
+ * decides what an empty title means. `path` is optional; without it only the
112
+ * checks that don't need the page's own path run.
113
+ *
114
+ * It judges one string. A title with a " | Brand" tail is `titleHead`'s to
115
+ * cut, and the caller asks about the head: `isPageAddress(titleHead(title), path)`.
116
+ * Pure string logic, linear in the length of the input.
117
+ */
118
+ export declare function isPageAddress(value: string | null | undefined, path?: string | null): boolean;
119
+ /**
120
+ * Whether a stored title is a real name for the page at `path`: something is
121
+ * left once the pipes are set aside, and none of its pieces is a page's
122
+ * address (`isPageAddress`, given the page's path). It's the one rule for a
123
+ * title that gets printed whole, so whoever stores a name and whoever prints
124
+ * it can't disagree.
125
+ *
126
+ * Every piece is judged, the first and the last included, because a title
127
+ * with a pipe in it prints with its pipes: ":slug | Example Apartments" (a
128
+ * placeholder in front of a brand tail) and "Floor Plans | /floor-plans/x" (a
129
+ * path behind the name) are both refused, and so is the unspaced
130
+ * "Floor Plans|/floor-plans/x". A name with a pipe in it stays a name while
131
+ * every piece is text ("1 Bedroom | 1BR 1BA"). A title that's only pipes has
132
+ * no piece and isn't a name. Empty, whitespace-only, nullish and non-string
133
+ * values aren't names either.
134
+ *
135
+ * It judges the title it is given, brand tail and all. A writer cuts the tail
136
+ * first and asks about what it stores: `isPageName(titleHead(title), path)`.
137
+ * `path` is optional, as it is for `isPageAddress`. Pure string logic, linear
138
+ * in the length of the input.
139
+ */
140
+ export declare function isPageName(title: string | null | undefined, path?: string | null): boolean;
@@ -118,7 +118,40 @@ function isRoutePlaceholder(value) {
118
118
  if (typeof value !== "string") return false;
119
119
  return value.split("/").some((segment) => PLACEHOLDER_TOKEN.test(segment.trim()));
120
120
  }
121
+ var ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
122
+ var HOST_NAME = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d{1,5})?$/i;
123
+ function withoutOuterSlashes(value) {
124
+ let start = 0;
125
+ let end = value.length;
126
+ while (start < end && value[start] === "/") start++;
127
+ while (end > start && value[end - 1] === "/") end--;
128
+ return value.slice(start, end);
129
+ }
130
+ function isHostPath(value) {
131
+ const slash = value.indexOf("/");
132
+ if (slash < 0 || /\s/.test(value)) return false;
133
+ return HOST_NAME.test(value.slice(0, slash));
134
+ }
135
+ function isPageAddress(value, path) {
136
+ if (typeof value !== "string") return false;
137
+ const given = value.trim();
138
+ if (!given) return false;
139
+ if (given.startsWith("/") || ABSOLUTE_URL.test(given) || isHostPath(given) || isRoutePlaceholder(given)) return true;
140
+ if (typeof path !== "string") return false;
141
+ const own = withoutOuterSlashes(path.trim());
142
+ return withoutOuterSlashes(given) === own;
143
+ }
144
+ function pipePieces(title) {
145
+ return title.split("|").map((piece) => piece.trim()).filter(Boolean);
146
+ }
147
+ function isPageName(title, path) {
148
+ if (typeof title !== "string") return false;
149
+ const pieces = pipePieces(title);
150
+ return pieces.length > 0 && pieces.every((piece) => !isPageAddress(piece, path));
151
+ }
121
152
 
153
+ exports.isPageAddress = isPageAddress;
154
+ exports.isPageName = isPageName;
122
155
  exports.isRoutePlaceholder = isRoutePlaceholder;
123
156
  exports.slugLabel = slugLabel;
124
157
  exports.titleHead = titleHead;
@@ -116,5 +116,36 @@ function isRoutePlaceholder(value) {
116
116
  if (typeof value !== "string") return false;
117
117
  return value.split("/").some((segment) => PLACEHOLDER_TOKEN.test(segment.trim()));
118
118
  }
119
+ var ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
120
+ var HOST_NAME = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d{1,5})?$/i;
121
+ function withoutOuterSlashes(value) {
122
+ let start = 0;
123
+ let end = value.length;
124
+ while (start < end && value[start] === "/") start++;
125
+ while (end > start && value[end - 1] === "/") end--;
126
+ return value.slice(start, end);
127
+ }
128
+ function isHostPath(value) {
129
+ const slash = value.indexOf("/");
130
+ if (slash < 0 || /\s/.test(value)) return false;
131
+ return HOST_NAME.test(value.slice(0, slash));
132
+ }
133
+ function isPageAddress(value, path) {
134
+ if (typeof value !== "string") return false;
135
+ const given = value.trim();
136
+ if (!given) return false;
137
+ if (given.startsWith("/") || ABSOLUTE_URL.test(given) || isHostPath(given) || isRoutePlaceholder(given)) return true;
138
+ if (typeof path !== "string") return false;
139
+ const own = withoutOuterSlashes(path.trim());
140
+ return withoutOuterSlashes(given) === own;
141
+ }
142
+ function pipePieces(title) {
143
+ return title.split("|").map((piece) => piece.trim()).filter(Boolean);
144
+ }
145
+ function isPageName(title, path) {
146
+ if (typeof title !== "string") return false;
147
+ const pieces = pipePieces(title);
148
+ return pieces.length > 0 && pieces.every((piece) => !isPageAddress(piece, path));
149
+ }
119
150
 
120
- export { isRoutePlaceholder, slugLabel, titleHead };
151
+ export { isPageAddress, isPageName, isRoutePlaceholder, slugLabel, titleHead };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The shared literal-host SSRF policy. Pass URL.hostname (which canonicalizes
3
+ * numeric IPv4 spellings), or an address returned by a DNS lookup. IPv6 may
4
+ * have the brackets URL.hostname retains. This isn't a URL validator and
5
+ * doesn't trim input, resolve DNS, follow redirects or open a connection.
6
+ * Callers still enforce their protocol/port rules and check or pin the DNS
7
+ * addresses used by each connection; a false result alone can't prove a
8
+ * destination is public.
9
+ */
10
+ export declare function isBlockedPublicUrlHost(rawHost: string): boolean;
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/public-url-host.ts
4
+ function isPrivateIpv4(a, b) {
5
+ return a === 127 || a === 10 || a === 0 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127 || a >= 224;
6
+ }
7
+ function ipv6Words(host) {
8
+ if (!host.includes(":") || !/^[a-f\d:.]+$/.test(host)) return null;
9
+ let canonical;
10
+ try {
11
+ canonical = new URL(`http://[${host}]/`).hostname.slice(1, -1);
12
+ } catch {
13
+ return null;
14
+ }
15
+ const sides = canonical.split("::");
16
+ const left = sides[0] ? sides[0].split(":") : [];
17
+ const right = sides.length > 1 && sides[1] ? sides[1].split(":") : [];
18
+ return [...left, ...Array(sides.length > 1 ? 8 - left.length - right.length : 0).fill("0"), ...right].map((word) => parseInt(word, 16));
19
+ }
20
+ function isBlockedPublicUrlHost(rawHost) {
21
+ const host = rawHost.toLowerCase().replace(/^\[|\]$/g, "");
22
+ const blockedName = host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal");
23
+ if (host.includes(":") && host.includes("%")) return true;
24
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
25
+ const blockedIp = !!ipv4 && isPrivateIpv4(Number(ipv4[1]), Number(ipv4[2]));
26
+ const words = ipv6Words(host);
27
+ const blockedIpv6 = !!words && // Unspecified/loopback, unique-local, link-local, deprecated site-local
28
+ // and multicast destinations aren't public logo/webhook endpoints.
29
+ (words.every((word) => word === 0) || words.slice(0, 7).every((word) => word === 0) && words[7] === 1 || (words[0] & 65024) === 64512 || (words[0] & 65472) === 65152 || (words[0] & 65472) === 65216 || (words[0] & 65280) === 65280 || // IPv4-mapped and older IPv4-compatible forms carry an IPv4 destination.
30
+ words.slice(0, 5).every((word) => word === 0) && [0, 65535].includes(words[5]) && isPrivateIpv4(words[6] >> 8, words[6] & 255));
31
+ return blockedName || blockedIp || blockedIpv6;
32
+ }
33
+
34
+ exports.isBlockedPublicUrlHost = isBlockedPublicUrlHost;
@@ -0,0 +1,32 @@
1
+ // src/contracts/public-url-host.ts
2
+ function isPrivateIpv4(a, b) {
3
+ return a === 127 || a === 10 || a === 0 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127 || a >= 224;
4
+ }
5
+ function ipv6Words(host) {
6
+ if (!host.includes(":") || !/^[a-f\d:.]+$/.test(host)) return null;
7
+ let canonical;
8
+ try {
9
+ canonical = new URL(`http://[${host}]/`).hostname.slice(1, -1);
10
+ } catch {
11
+ return null;
12
+ }
13
+ const sides = canonical.split("::");
14
+ const left = sides[0] ? sides[0].split(":") : [];
15
+ const right = sides.length > 1 && sides[1] ? sides[1].split(":") : [];
16
+ return [...left, ...Array(sides.length > 1 ? 8 - left.length - right.length : 0).fill("0"), ...right].map((word) => parseInt(word, 16));
17
+ }
18
+ function isBlockedPublicUrlHost(rawHost) {
19
+ const host = rawHost.toLowerCase().replace(/^\[|\]$/g, "");
20
+ const blockedName = host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal");
21
+ if (host.includes(":") && host.includes("%")) return true;
22
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
23
+ const blockedIp = !!ipv4 && isPrivateIpv4(Number(ipv4[1]), Number(ipv4[2]));
24
+ const words = ipv6Words(host);
25
+ const blockedIpv6 = !!words && // Unspecified/loopback, unique-local, link-local, deprecated site-local
26
+ // and multicast destinations aren't public logo/webhook endpoints.
27
+ (words.every((word) => word === 0) || words.slice(0, 7).every((word) => word === 0) && words[7] === 1 || (words[0] & 65024) === 64512 || (words[0] & 65472) === 65152 || (words[0] & 65472) === 65216 || (words[0] & 65280) === 65280 || // IPv4-mapped and older IPv4-compatible forms carry an IPv4 destination.
28
+ words.slice(0, 5).every((word) => word === 0) && [0, 65535].includes(words[5]) && isPrivateIpv4(words[6] >> 8, words[6] & 255));
29
+ return blockedName || blockedIp || blockedIpv6;
30
+ }
31
+
32
+ export { isBlockedPublicUrlHost };
@@ -107,7 +107,7 @@ export interface PublicationBrandingValidatorOptions {
107
107
  /**
108
108
  * True when a URL's hostname must never be stored as a logo or publisher
109
109
  * URL: loopback, private and link-local ranges, `localhost` and the like.
110
- * Each API passes its own server-side SSRF host guard. It receives
110
+ * Each API passes the shared `public-url-host` policy. It receives
111
111
  * `new URL(value).hostname`, so IPv6 keeps its brackets.
112
112
  */
113
113
  isBlockedHost: (hostname: string) => boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sonordev/contracts",
3
- "version": "2.9.0",
4
- "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, which stored JSON-LD is a template's unfilled placeholder, where prose splits into sentences, which Sonor records Signal reads into a project's knowledge and the address each one is stored under, what outbound email and text may claim about a business and whether a sent email arrived intact, what a segment of outreach is and who may turn one on, a page's own name taken from its title or its slug, which Broadcast card layouts and review compositions are drawn and what a retired one draws as, and what a publication's branding may hold. Pure functions, no dependencies.",
3
+ "version": "2.11.0",
4
+ "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, which stored JSON-LD is a template's unfilled placeholder, where prose splits into sentences, which Sonor records Signal reads into a project's knowledge and the address each one is stored under, what outbound email and text may claim about a business and whether a sent email arrived intact, what a segment of outreach is and who may turn one on, a page's own name taken from its title or its slug, which Broadcast card layouts and review compositions are drawn and what a retired one draws as, what a publication's branding may hold, and which literal hosts are refused as public fetch destinations. Pure functions, no dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -141,6 +141,11 @@
141
141
  "types": "./dist/publication-branding.d.ts",
142
142
  "import": "./dist/publication-branding.mjs",
143
143
  "require": "./dist/publication-branding.js"
144
+ },
145
+ "./public-url-host": {
146
+ "types": "./dist/public-url-host.d.ts",
147
+ "import": "./dist/public-url-host.mjs",
148
+ "require": "./dist/public-url-host.js"
144
149
  }
145
150
  },
146
151
  "typesVersions": {