@growth-labs/seo 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/utils/validation.d.ts.map +1 -1
  2. package/dist/utils/validation.js +22 -0
  3. package/dist/utils/validation.js.map +1 -1
  4. package/package.json +9 -5
  5. package/src/_internal/state.ts +26 -0
  6. package/src/bindings.ts +146 -0
  7. package/src/cron/prune-aeo-r2.ts +140 -0
  8. package/src/durable-objects/aeo-revalidation-coord.ts +246 -0
  9. package/src/index.ts +380 -0
  10. package/src/middleware/seo.ts +350 -0
  11. package/src/options.ts +456 -0
  12. package/src/routes/aeo-twin.ts +130 -0
  13. package/src/routes/apple-news.ts +36 -0
  14. package/src/routes/llms-full.ts +36 -0
  15. package/src/routes/llms.ts +15 -0
  16. package/src/routes/podcast-narration.ts +45 -0
  17. package/src/routes/podcast.ts +27 -0
  18. package/src/routes/revalidate.ts +298 -0
  19. package/src/routes/robots.ts +21 -0
  20. package/src/routes/rss.ts +29 -0
  21. package/src/routes/sitemap-articles.ts +25 -0
  22. package/src/routes/sitemap-index.ts +89 -0
  23. package/src/routes/sitemap-markdown.ts +39 -0
  24. package/src/routes/sitemap-pages.ts +24 -0
  25. package/src/routes/sitemap-products.ts +24 -0
  26. package/src/routes/sitemap-videos.ts +24 -0
  27. package/src/runtime.ts +17 -0
  28. package/src/site-url-core.ts +71 -0
  29. package/src/site-url.ts +21 -0
  30. package/src/types.ts +166 -0
  31. package/src/utils/aeo-summary.ts +176 -0
  32. package/src/utils/aeo-twin-emitter.ts +173 -0
  33. package/src/utils/aeo.ts +223 -0
  34. package/src/utils/apple-news-anf.ts +163 -0
  35. package/src/utils/apple-news-rss.ts +136 -0
  36. package/src/utils/content-filter.ts +87 -0
  37. package/src/utils/crawler-class.ts +155 -0
  38. package/src/utils/define-content-provider.ts +65 -0
  39. package/src/utils/effective-auth.ts +44 -0
  40. package/src/utils/fcrdns.ts +269 -0
  41. package/src/utils/fresh-layer.ts +175 -0
  42. package/src/utils/hreflang.ts +26 -0
  43. package/src/utils/index.ts +91 -0
  44. package/src/utils/json-ld/article.ts +120 -0
  45. package/src/utils/json-ld/audio.ts +32 -0
  46. package/src/utils/json-ld/breadcrumb.ts +28 -0
  47. package/src/utils/json-ld/faq.ts +18 -0
  48. package/src/utils/json-ld/howto.ts +23 -0
  49. package/src/utils/json-ld/index.ts +12 -0
  50. package/src/utils/json-ld/item-list.ts +26 -0
  51. package/src/utils/json-ld/organization.ts +42 -0
  52. package/src/utils/json-ld/person.ts +25 -0
  53. package/src/utils/json-ld/product.ts +155 -0
  54. package/src/utils/json-ld/video.ts +20 -0
  55. package/src/utils/json-ld/website.ts +27 -0
  56. package/src/utils/llms-full.ts +90 -0
  57. package/src/utils/llms.ts +45 -0
  58. package/src/utils/meta.ts +184 -0
  59. package/src/utils/podcast.ts +112 -0
  60. package/src/utils/robots.ts +47 -0
  61. package/src/utils/rss.ts +64 -0
  62. package/src/utils/seo-head.ts +81 -0
  63. package/src/utils/sitemap-markdown.ts +80 -0
  64. package/src/utils/sitemap.ts +169 -0
  65. package/src/utils/staleness.ts +61 -0
  66. package/src/utils/validation.ts +308 -0
  67. package/src/virtual.d.ts +8 -0
  68. package/src/vite-plugin.ts +66 -0
@@ -0,0 +1,155 @@
1
+ import type { CrawlerClass } from '../types.js'
2
+ import type { FcrdnsVerifier } from './fcrdns.js'
3
+
4
+ // ─── User-Agent patterns ───
5
+
6
+ // Known LLM-training crawlers. Blocked at robots.txt AND 403'd in middleware on
7
+ // access: 'members' items. Matched case-insensitively against the UA string.
8
+ const LLM_TRAINING_UAS = [
9
+ 'GPTBot',
10
+ 'ClaudeBot',
11
+ 'CCBot',
12
+ 'Google-Extended',
13
+ 'PerplexityBot',
14
+ 'Applebot-Extended',
15
+ 'Bytespider',
16
+ 'FacebookBot',
17
+ 'OAI-SearchBot',
18
+ 'anthropic-ai',
19
+ 'cohere-ai',
20
+ 'AI2Bot',
21
+ 'Diffbot',
22
+ 'ImagesiftBot',
23
+ 'Omgilibot',
24
+ 'Omgili',
25
+ 'Timpibot',
26
+ ] as const
27
+
28
+ // User-directed LLM agents — fetch on behalf of a logged-in user. Treated as anonymous:
29
+ // these forward response to a third-party model that may retain them, so never serve
30
+ // gated content even if member cookies are present.
31
+ const USER_DIRECTED_UAS = [
32
+ 'ChatGPT-User',
33
+ 'Claude-User',
34
+ 'PerplexityBot-User',
35
+ 'Google-NotebookLM',
36
+ ] as const
37
+
38
+ // FCrDNS suffixes for verified search crawlers. Case-insensitive, dot-boundary match.
39
+ // Deliberately excludes googleusercontent.com (Google Cloud VMs, Apps Script, proxy
40
+ // infrastructure that would let any GCP user impersonate Googlebot).
41
+ export const VERIFIED_SEARCH_SUFFIXES = [
42
+ 'googlebot.com',
43
+ 'google.com',
44
+ 'search.msn.com',
45
+ 'applebot.apple.com',
46
+ 'duckduckbot.com',
47
+ ] as const
48
+
49
+ // ─── Classification ───
50
+
51
+ export interface ClassifyRequestInput {
52
+ request: Request
53
+ // FCrDNS verifier. Injected so tests can mock DNS without hitting the network.
54
+ // In production, wired to the DoH-backed implementation in fcrdns.ts.
55
+ fcrdnsVerify?: FcrdnsVerifier
56
+ // If true, skips the Cloudflare Bot Management fast path. Used in tests.
57
+ skipBotManagementFastPath?: boolean
58
+ }
59
+
60
+ /**
61
+ * Classify a request into one of four crawler classes.
62
+ *
63
+ * Order of checks:
64
+ * 1. Cloudflare Bot Management fast path (zero subrequests) when available.
65
+ * 2. LLM-training UA match.
66
+ * 3. User-directed LLM agent UA match.
67
+ * 4. FCrDNS against verified-search-crawler suffixes (DoH subrequests if uncached).
68
+ * 5. Fallthrough → anonymous.
69
+ *
70
+ * The BM fast path and UA checks always run; FCrDNS only runs when no UA matches
71
+ * (spoofed UAs that claim to be Googlebot without FCrDNS confirmation fail here).
72
+ *
73
+ * @returns the crawler class for cache-key segmentation, body-variant selection,
74
+ * and JSON-LD redaction decisions downstream.
75
+ */
76
+ export async function classifyRequest(input: ClassifyRequestInput): Promise<CrawlerClass> {
77
+ const { request, fcrdnsVerify, skipBotManagementFastPath = false } = input
78
+
79
+ // 1. Cloudflare Bot Management fast path — zero subrequests, Cloudflare-stood-behind.
80
+ // Only present on Enterprise zones with BM enabled. Absent on Free/Pro/Business.
81
+ if (!skipBotManagementFastPath) {
82
+ const cf = (
83
+ request as {
84
+ cf?: { botManagement?: { verifiedBot?: boolean }; verifiedBotCategory?: string }
85
+ }
86
+ ).cf
87
+ if (cf?.botManagement?.verifiedBot && cf.verifiedBotCategory === 'Search Engine Crawler') {
88
+ return 'verifiedSearchCrawler'
89
+ }
90
+ }
91
+
92
+ const ua = request.headers.get('user-agent') ?? ''
93
+
94
+ // 2. User-directed LLM agent — checked BEFORE training-bot UAs, because many
95
+ // user-directed UAs contain training-bot substrings (e.g. `PerplexityBot-User`
96
+ // contains `PerplexityBot`; `ChatGPT-User` contains `ChatGPT`). The more
97
+ // specific rule must match first.
98
+ if (matchesAnyToken(ua, USER_DIRECTED_UAS)) {
99
+ return 'userDirectedLlmAgent'
100
+ }
101
+
102
+ // 3. LLM-training crawler — matches a training-bot UA substring.
103
+ if (matchesAnyToken(ua, LLM_TRAINING_UAS)) {
104
+ return 'llmTrainingCrawler'
105
+ }
106
+
107
+ // 4. FCrDNS for verified search crawlers. Only attempted if a verifier is wired
108
+ // (the classifier is pure when FCrDNS isn't available — production middleware
109
+ // always wires it; pure-function tests can skip it).
110
+ if (fcrdnsVerify) {
111
+ const clientIp = getClientIp(request)
112
+ if (clientIp) {
113
+ const verified = await fcrdnsVerify({
114
+ clientIp,
115
+ trustedSuffixes: VERIFIED_SEARCH_SUFFIXES,
116
+ })
117
+ if (verified) return 'verifiedSearchCrawler'
118
+ }
119
+ }
120
+
121
+ // 5. Fallthrough.
122
+ return 'anonymous'
123
+ }
124
+
125
+ // ─── Internals ───
126
+
127
+ function matchesAnyToken(ua: string, tokens: readonly string[]): boolean {
128
+ if (!ua) return false
129
+ const lowered = ua.toLowerCase()
130
+ return tokens.some((token) => lowered.includes(token.toLowerCase()))
131
+ }
132
+
133
+ /**
134
+ * Extract the effective client IP from a request. Prefers CF-Connecting-IP
135
+ * (Cloudflare-verified); falls back to X-Forwarded-For's first entry.
136
+ */
137
+ function getClientIp(request: Request): string | null {
138
+ const cf = request.headers.get('cf-connecting-ip')
139
+ if (cf) return cf
140
+ const xff = request.headers.get('x-forwarded-for')
141
+ if (xff) {
142
+ const first = xff.split(',')[0]?.trim()
143
+ if (first) return first
144
+ }
145
+ return null
146
+ }
147
+
148
+ // ─── Exports for testing ───
149
+
150
+ export const _internals = {
151
+ LLM_TRAINING_UAS,
152
+ USER_DIRECTED_UAS,
153
+ matchesAnyToken,
154
+ getClientIp,
155
+ }
@@ -0,0 +1,65 @@
1
+ import type { APIContext } from 'astro'
2
+ import type { ContentItem, ContentProviderParams, ContentType } from '../types.js'
3
+
4
+ /**
5
+ * Narrows `ContentItem` per `ContentType` so a consumer-authored provider
6
+ * body gets compile-time feedback when a required field for that type is
7
+ * missing from the returned objects.
8
+ *
9
+ * The base `ContentItem` is intentionally permissive because most fields are
10
+ * optional at the JSON-LD layer. These narrowed variants add the fields that
11
+ * the feed and sitemap generators actually need for each content type —
12
+ * `video` for videos, `product` for products — so an author can't silently
13
+ * ship an item with `type: 'products'` but no `product` payload.
14
+ */
15
+ export interface ContentItemByType {
16
+ articles: ContentItem
17
+ pages: ContentItem
18
+ videos: ContentItem & { video: NonNullable<ContentItem['video']> }
19
+ products: ContentItem & { product: NonNullable<ContentItem['product']> }
20
+ authors: ContentItem
21
+ }
22
+
23
+ export type TypedContentProvider = <T extends ContentType>(
24
+ params: ContentProviderParams & { type: T },
25
+ context: APIContext,
26
+ ) => Promise<ContentItemByType[T][]>
27
+
28
+ /**
29
+ * Identity helper that preserves the narrowest `type` → return-shape binding
30
+ * for a consumer-authored content provider. Use inside your
31
+ * `contentProviderModule` default export so TypeScript catches mismatches
32
+ * between the `type` the caller requested and the shape you returned.
33
+ *
34
+ * ```ts
35
+ * // src/lib/content-provider.mjs
36
+ * import { defineContentProvider } from '@growth-labs/seo/utils'
37
+ *
38
+ * export default defineContentProvider(async ({ type }, ctx) => {
39
+ * if (type === 'products') {
40
+ * return [{
41
+ * url: 'https://example.com/widget',
42
+ * title: 'Widget',
43
+ * // Required for `products` — compile error if omitted.
44
+ * product: {
45
+ * name: 'Widget', description: 'Blue', price: 10, currency: 'USD',
46
+ * availability: 'InStock', images: ['https://example.com/widget.jpg'],
47
+ * },
48
+ * }]
49
+ * }
50
+ * return []
51
+ * })
52
+ * ```
53
+ */
54
+ export function defineContentProvider(provider: TypedContentProvider): TypedContentProvider {
55
+ return provider
56
+ }
57
+
58
+ /**
59
+ * Module-style variant of {@link defineContentProvider}. Semantically identical
60
+ * but communicates intent at the call site: "this is the default export of my
61
+ * content-provider module file." Use whichever reads better.
62
+ */
63
+ export function defineContentProviderModule(provider: TypedContentProvider): TypedContentProvider {
64
+ return provider
65
+ }
@@ -0,0 +1,44 @@
1
+ import type { CrawlerClass, EffectiveAuthSegment } from '../types.js'
2
+
3
+ /**
4
+ * Raw auth segment from the consumer's auth layer. Consumers produce this (typically
5
+ * 'anon' for unauthenticated, 'member' for a logged-in subscriber) and feed it into
6
+ * the package to derive the effective segment.
7
+ */
8
+ export type RawAuthSegment = 'anon' | 'member'
9
+
10
+ /**
11
+ * Compute the effective auth segment given a crawler class and a raw consumer auth segment.
12
+ *
13
+ * This function encodes the policy from spec "Effective auth segment" (lines
14
+ * "crawler class overrides member cookies"):
15
+ *
16
+ * - Verified search crawler → 'search-full' (regardless of cookies). Gets the
17
+ * sanctioned paywall-marked full body under Flexible Sampling.
18
+ * - LLM training crawler → caller should 403 before calling this; if it does reach
19
+ * here, we surface 'anon' defensively.
20
+ * - User-directed LLM agent → 'anon', ALWAYS. Even if member cookies are present.
21
+ * Load-bearing: ChatGPT-User / Claude-User forward responses to a third-party
22
+ * model that may retain them; we cannot verify the cookies belong to a paid
23
+ * subscriber, and leaking gated content to a third-party LLM defeats gating.
24
+ * - Anonymous → raw as-is.
25
+ *
26
+ * Consumers use `effectiveAuthSegment` (NOT raw `authSegment`) in cache keys so
27
+ * that cache segmentation reflects the crawler-class override.
28
+ */
29
+ export function computeEffectiveAuthSegment(
30
+ crawlerClass: CrawlerClass,
31
+ rawAuthSegment: RawAuthSegment,
32
+ ): EffectiveAuthSegment {
33
+ switch (crawlerClass) {
34
+ case 'verifiedSearchCrawler':
35
+ return 'search-full'
36
+ case 'llmTrainingCrawler':
37
+ // Caller should reject with 403 before computing this; defensive fallback.
38
+ return 'anon'
39
+ case 'userDirectedLlmAgent':
40
+ return 'anon' // Override: never 'member', even with cookies.
41
+ case 'anonymous':
42
+ return rawAuthSegment
43
+ }
44
+ }
@@ -0,0 +1,269 @@
1
+ // Forward-confirmed reverse DNS verification. Used to classify incoming requests
2
+ // as verified search crawlers when Cloudflare Bot Management isn't available.
3
+ //
4
+ // Algorithm (spec "FCrDNS algorithm"):
5
+ // 1. PTR lookup on the client IP → hostname
6
+ // 2. Hostname must end with a trusted suffix, on a dot boundary
7
+ // 3. A/AAAA lookup on that hostname → IPs
8
+ // 4. Client IP must be in the result set
9
+ //
10
+ // Caching (spec "FCrDNS cache semantics"):
11
+ // - Positive: keyed by (clientIp, matchedHostname); TTL = min(10min, rDNS TTL, fDNS TTL)
12
+ // - Negative: 60s fixed TTL
13
+ // - Process-local only; do NOT persist across deploys
14
+
15
+ // ─── Resolver interface ───
16
+
17
+ export interface DnsAnswer {
18
+ name: string
19
+ type: number
20
+ TTL: number // seconds
21
+ data: string
22
+ }
23
+
24
+ export interface DnsResolver {
25
+ /**
26
+ * Perform a DoH-JSON-style DNS query.
27
+ *
28
+ * Returns the Answer array or null on failure. `type` is an RFC 1035
29
+ * numeric record type (12 = PTR, 1 = A, 28 = AAAA).
30
+ *
31
+ * Production implementation: {@link createDohResolver}. Tests inject a mock.
32
+ */
33
+ query(name: string, type: 'PTR' | 'A' | 'AAAA'): Promise<DnsAnswer[] | null>
34
+ }
35
+
36
+ // ─── Public API ───
37
+
38
+ export interface FcrdnsVerifyInput {
39
+ clientIp: string
40
+ trustedSuffixes: readonly string[]
41
+ }
42
+
43
+ export type FcrdnsVerifier = (input: FcrdnsVerifyInput) => Promise<boolean>
44
+
45
+ /**
46
+ * Create an FCrDNS verifier. Returns a function that, given an IP and suffix list,
47
+ * returns true iff the IP's forward-confirmed reverse DNS lands on a trusted suffix.
48
+ *
49
+ * The returned verifier carries its own cache; create one per Worker isolate.
50
+ *
51
+ * @param resolver DNS resolver. Default in production: DoH against 1.1.1.1.
52
+ * Tests inject a mock.
53
+ * @param now time source (for testable TTL expiry). Default: Date.now.
54
+ */
55
+ export function createFcrdnsVerifier(
56
+ resolver: DnsResolver,
57
+ now: () => number = Date.now,
58
+ ): FcrdnsVerifier {
59
+ const cache = new FcrdnsCache(now)
60
+
61
+ return async ({ clientIp, trustedSuffixes }: FcrdnsVerifyInput): Promise<boolean> => {
62
+ // Cache check (positive or negative).
63
+ const cached = cache.get(clientIp)
64
+ if (cached !== undefined) return cached
65
+
66
+ try {
67
+ // 1. Reverse DNS lookup.
68
+ const ptrName = reverseIpToArpa(clientIp)
69
+ if (!ptrName) {
70
+ cache.setNegative(clientIp)
71
+ return false
72
+ }
73
+ const ptrAnswers = await resolver.query(ptrName, 'PTR')
74
+ if (!ptrAnswers || ptrAnswers.length === 0) {
75
+ cache.setNegative(clientIp)
76
+ return false
77
+ }
78
+
79
+ // 2. Suffix match on any PTR answer (dot-boundary, case-insensitive).
80
+ let matchedHostname: string | null = null
81
+ let ptrTtl = Number.POSITIVE_INFINITY
82
+ for (const answer of ptrAnswers) {
83
+ const hostname = stripTrailingDot(answer.data).toLowerCase()
84
+ if (matchesAnyTrustedSuffix(hostname, trustedSuffixes)) {
85
+ matchedHostname = hostname
86
+ ptrTtl = Math.min(ptrTtl, answer.TTL)
87
+ break
88
+ }
89
+ }
90
+ if (!matchedHostname) {
91
+ cache.setNegative(clientIp)
92
+ return false
93
+ }
94
+
95
+ // 3. Forward DNS lookup. Try A first; if IPv6, also AAAA.
96
+ const isIpv6 = clientIp.includes(':')
97
+ const fwdType = isIpv6 ? 'AAAA' : 'A'
98
+ const fwdAnswers = await resolver.query(matchedHostname, fwdType)
99
+ if (!fwdAnswers || fwdAnswers.length === 0) {
100
+ cache.setNegative(clientIp)
101
+ return false
102
+ }
103
+
104
+ // 4. Client IP must be in the result set.
105
+ const normalizedClient = normalizeIp(clientIp)
106
+ const matched = fwdAnswers.some((a) => normalizeIp(a.data) === normalizedClient)
107
+ if (!matched) {
108
+ cache.setNegative(clientIp)
109
+ return false
110
+ }
111
+
112
+ // Positive: cache with TTL = min(10min, rDNS TTL, fDNS TTL).
113
+ const fwdTtl = fwdAnswers.reduce((min, a) => Math.min(min, a.TTL), Number.POSITIVE_INFINITY)
114
+ const ttlSeconds = Math.min(600, ptrTtl, fwdTtl)
115
+ cache.setPositive(clientIp, matchedHostname, ttlSeconds * 1000)
116
+ return true
117
+ } catch {
118
+ // Any error → fail closed (anonymous), short negative cache to avoid repeat hits.
119
+ cache.setNegative(clientIp)
120
+ return false
121
+ }
122
+ }
123
+ }
124
+
125
+ // ─── DoH resolver (production default) ───
126
+
127
+ /**
128
+ * Create a DoH-JSON resolver that queries Cloudflare's 1.1.1.1. One subrequest per
129
+ * DNS lookup. Not a singleton — resolver has no persistent state of its own.
130
+ *
131
+ * Docs: https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/
132
+ */
133
+ export function createDohResolver(
134
+ baseUrl = 'https://1.1.1.1/dns-query',
135
+ fetchImpl: typeof fetch = fetch,
136
+ ): DnsResolver {
137
+ return {
138
+ async query(name: string, type: 'PTR' | 'A' | 'AAAA'): Promise<DnsAnswer[] | null> {
139
+ const url = `${baseUrl}?name=${encodeURIComponent(name)}&type=${type}`
140
+ const response = await fetchImpl(url, {
141
+ headers: { accept: 'application/dns-json' },
142
+ })
143
+ if (!response.ok) return null
144
+ const body = (await response.json()) as { Status?: number; Answer?: DnsAnswer[] }
145
+ // Status 0 = NOERROR. Anything else means no usable answer.
146
+ if (body.Status !== 0 || !body.Answer) return null
147
+ return body.Answer
148
+ },
149
+ }
150
+ }
151
+
152
+ // ─── Internals ───
153
+
154
+ /**
155
+ * Convert an IP address to its in-addr.arpa / ip6.arpa PTR query name.
156
+ * Returns null for malformed input.
157
+ */
158
+ function reverseIpToArpa(ip: string): string | null {
159
+ if (ip.includes(':')) {
160
+ // IPv6 — expand and nibble-reverse into ip6.arpa.
161
+ const expanded = expandIpv6(ip)
162
+ if (!expanded) return null
163
+ const nibbles = expanded.replace(/:/g, '').split('').reverse().join('.')
164
+ return `${nibbles}.ip6.arpa`
165
+ }
166
+ // IPv4 — reverse octets into in-addr.arpa.
167
+ const octets = ip.split('.')
168
+ if (octets.length !== 4 || octets.some((o) => !/^\d+$/.test(o))) return null
169
+ return `${octets.reverse().join('.')}.in-addr.arpa`
170
+ }
171
+
172
+ /**
173
+ * Expand an IPv6 address to its full 32-hex-character form (colon-separated groups
174
+ * of 4). Handles "::" compression. Returns null for malformed input.
175
+ */
176
+ function expandIpv6(ip: string): string | null {
177
+ const doubleColon = ip.indexOf('::')
178
+ let parts: string[]
179
+ if (doubleColon === -1) {
180
+ parts = ip.split(':')
181
+ } else {
182
+ const head = ip.slice(0, doubleColon).split(':').filter(Boolean)
183
+ const tail = ip
184
+ .slice(doubleColon + 2)
185
+ .split(':')
186
+ .filter(Boolean)
187
+ const missing = 8 - head.length - tail.length
188
+ if (missing < 0) return null
189
+ parts = [...head, ...Array(missing).fill('0000'), ...tail]
190
+ }
191
+ if (parts.length !== 8) return null
192
+ return parts.map((p) => p.padStart(4, '0')).join(':')
193
+ }
194
+
195
+ function matchesAnyTrustedSuffix(hostname: string, suffixes: readonly string[]): boolean {
196
+ const lowered = hostname.toLowerCase()
197
+ return suffixes.some((suffix) => {
198
+ const lsuffix = suffix.toLowerCase()
199
+ return lowered === lsuffix || lowered.endsWith(`.${lsuffix}`)
200
+ })
201
+ }
202
+
203
+ function stripTrailingDot(s: string): string {
204
+ return s.endsWith('.') ? s.slice(0, -1) : s
205
+ }
206
+
207
+ /**
208
+ * Normalize an IP for comparison. IPv4 addresses pass through; IPv6 are canonicalized
209
+ * by expanding "::" and stripping leading zeros in each group.
210
+ */
211
+ function normalizeIp(ip: string): string {
212
+ if (!ip.includes(':')) return ip // IPv4
213
+ const expanded = expandIpv6(ip)
214
+ if (!expanded) return ip
215
+ // Strip leading zeros in each group for canonical comparison.
216
+ return expanded
217
+ .split(':')
218
+ .map((g) => g.replace(/^0+/, '') || '0')
219
+ .join(':')
220
+ }
221
+
222
+ // ─── Cache ───
223
+
224
+ interface CacheEntry {
225
+ verified: boolean
226
+ expiresAt: number
227
+ hostname?: string
228
+ }
229
+
230
+ class FcrdnsCache {
231
+ private readonly entries = new Map<string, CacheEntry>()
232
+
233
+ constructor(private readonly now: () => number) {}
234
+
235
+ get(clientIp: string): boolean | undefined {
236
+ const entry = this.entries.get(clientIp)
237
+ if (!entry) return undefined
238
+ if (entry.expiresAt <= this.now()) {
239
+ this.entries.delete(clientIp)
240
+ return undefined
241
+ }
242
+ return entry.verified
243
+ }
244
+
245
+ setPositive(clientIp: string, hostname: string, ttlMs: number): void {
246
+ this.entries.set(clientIp, {
247
+ verified: true,
248
+ hostname,
249
+ expiresAt: this.now() + Math.max(1000, ttlMs),
250
+ })
251
+ }
252
+
253
+ setNegative(clientIp: string): void {
254
+ this.entries.set(clientIp, {
255
+ verified: false,
256
+ expiresAt: this.now() + 60_000,
257
+ })
258
+ }
259
+ }
260
+
261
+ // ─── Exports for testing ───
262
+
263
+ export const _internals = {
264
+ reverseIpToArpa,
265
+ expandIpv6,
266
+ matchesAnyTrustedSuffix,
267
+ normalizeIp,
268
+ FcrdnsCache,
269
+ }