@sdxc/doh 2026.10.2 → 2026.10.9

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/README.md CHANGED
@@ -8,8 +8,9 @@ Typed DNS over HTTPS lookups.
8
8
  npm add @sdxc/doh
9
9
  ```
10
10
 
11
- Every lookup returns a [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) value, which
12
- installs alongside this package.
11
+ Every lookup returns a [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) value, and
12
+ records are read by [`@sdxc/zone-file`](https://www.npmjs.com/package/@sdxc/zone-file)'s record data
13
+ codec; both install alongside this package.
13
14
 
14
15
  A runtime with no DNS socket, such as a Cloudflare Worker, resolves names through DNS over HTTPS:
15
16
  a `fetch` to a public resolver. This package speaks the DoH JSON API (`application/dns-json`)
@@ -48,15 +49,6 @@ let verified = await verifyTxtRecord("_verify.example.com", "token_abc123"); //
48
49
  let pointed = await checkCname("shop.example.com", "custom.hosting.example"); // Result<boolean>
49
50
  ```
50
51
 
51
- ### Read Record Data From Anywhere
52
-
53
- ```typescript
54
- import { parseRecordData } from "@sdxc/doh";
55
-
56
- let data = parseRecordData("MX", "10 mail.example.com.");
57
- // success({ type: "MX", preference: 10, exchange: "mail.example.com" })
58
- ```
59
-
60
52
  ## API
61
53
 
62
54
  ### `@sdxc/doh`
@@ -66,7 +58,9 @@ let data = parseRecordData("MX", "10 mail.example.com.");
66
58
  Looks up `name`'s records of `type`. The name is sent as written, so an internationalized name must
67
59
  already be in its ASCII (punycode) form. The answer holds:
68
60
 
69
- - `records` - the records of the asked type, typed by `DoH.RecordFor<Type>`
61
+ - `records` - the records of the asked type, typed by `DoH.RecordFor<Type>`: the
62
+ `ZoneFile.RecordData<Type>` fields plus `name` and `ttl`, so a resolved record and one read from a
63
+ zone file share every type-specific field
70
64
  - `unparsed` - records of the asked type whose data did not parse, as `{ name, ttl, type, data }`,
71
65
  so one odd record never fails the whole answer
72
66
  - `chain` - the CNAME records followed before reaching `records`
@@ -86,21 +80,12 @@ back lowercased without the trailing dot; an AAAA address comes back in RFC 5952
86
80
  - `signal`: aborts the request
87
81
  - `timeoutMs`: abandons the request as a `TransportError`, default `5000`
88
82
 
89
- Typed record types: `A` (`address`), `AAAA` (`address`), `CNAME` (`target`), `NS` (`host`), `MX`
90
- (`preference`, `exchange`), `TXT` (`text`, `strings`), `CAA` (`critical`, `tag`, `value`), `SRV`
91
- (`priority`, `weight`, `port`, `target`), `SOA` (`primary`, `mailbox`, `serial`, `refresh`,
92
- `retry`, `expire`, `minimum`). Any other type returns records with a raw `data` string.
93
-
94
- #### `parseRecordData(type, data): Result<DoH.RecordData<Type>, RecordDataError>`
95
-
96
- The presentation-format reader `resolve` uses, for RDATA from anywhere else, such as a zone file.
97
- Reads RFC 3597 generic data (`\# 4 0A000001`) for the typed types too, which is how Cloudflare
98
- answers CAA. TXT bare words are separate character-strings, per RFC 1035.
99
-
100
- ```typescript
101
- parseRecordData("TXT", '"v=DKIM1; p=AAA" "BBB"');
102
- // success({ type: "TXT", text: "v=DKIM1; p=AAABBB", strings: ["v=DKIM1; p=AAA", "BBB"] })
103
- ```
83
+ Typed record types: `A` (`address`), `AAAA` (`address`), `CNAME`, `PTR` and `DNAME` (`target`), `NS`
84
+ (`host`), `MX` (`preference`, `exchange`), `TXT` (`text`, `strings`), `CAA` (`flags`, `critical`,
85
+ `tag`, `value`), `SRV` (`priority`, `weight`, `port`, `target`), `SOA` (`primary`, `mailbox`,
86
+ `serial`, `refresh`, `retry`, `expire`, `minimum`). Any other type returns records with a raw `data`
87
+ string. To read or print RDATA outside a lookup, use `parseRecordData` and `formatRecordData` from
88
+ `@sdxc/zone-file`.
104
89
 
105
90
  #### `verifyTxtRecord(name, expected, options?): Promise<Result<boolean, DoHError>>`
106
91
 
@@ -121,20 +106,101 @@ a loop), so it holds even when the target resolves to nothing. NXDOMAIN is `succ
121
106
 
122
107
  #### Errors
123
108
 
124
- | Class | When | Extra field |
125
- | -------------------- | -------------------------------------------------------------------------- | --------------------------------------------------- |
126
- | `DoHError` | Base of every `resolve` failure | |
127
- | `NameNotFoundError` | RCODE 3, NXDOMAIN | `ttl`: the SOA's negative-caching TTL, or `null` |
128
- | `ServerFailureError` | RCODE 2, SERVFAIL, DNSSEC validation failures included | |
129
- | `ResponseCodeError` | Any other non-zero RCODE (FORMERR, NOTIMP, REFUSED) | `rcode` |
130
- | `TransportError` | Network error, timeout, abort, non-2xx, or a body that is not the envelope | `status`: the HTTP status, `null` when none arrived |
131
- | `RecordDataError` | `parseRecordData` on data that does not fit the type | |
109
+ | Class | When | Extra field |
110
+ | -------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
111
+ | `DoHError` | Base of every `resolve` failure | |
112
+ | `NameNotFoundError` | RCODE 3, NXDOMAIN | `ttl`: the SOA's negative-caching TTL, or `null`; `authenticated`: the AD flag, a DNSSEC-validated denial |
113
+ | `ServerFailureError` | RCODE 2, SERVFAIL, DNSSEC validation failures included | |
114
+ | `ResponseCodeError` | Any other non-zero RCODE (FORMERR, NOTIMP, REFUSED) | `rcode` |
115
+ | `TransportError` | Network error, timeout, abort, non-2xx, or a body that is not the envelope | `status`: the HTTP status, `null` when none arrived |
132
116
 
133
117
  #### `DoH` namespace
134
118
 
135
119
  Types only: `RecordType`, `Resolver`, `ResolveOptions`, `Answer<Type>`, `RecordFor<Type>`,
136
- `RecordData<Type>`, `RecordBase`, the nine record interfaces (`ARecord` through `SOARecord`), and
137
- `UnknownRecord`.
120
+ `RecordBase`, one alias per typed record (`ARecord` through `SOARecord`, `PTRRecord` and
121
+ `DNAMERecord` included), and `UnknownRecord`.
122
+
123
+ ### `@sdxc/doh/caa`
124
+
125
+ The WebPKI reading of CAA records (RFC 8659, RFC 8657): what a record asks of a certificate
126
+ authority, which RRset applies to a name, and whether a CA may issue for it.
127
+
128
+ #### `parseCaaProperty(record): CAA.Property`
129
+
130
+ Reads one CAA record's meaning:
131
+
132
+ - `issue` / `issuewild` - `{ kind, critical, issuer, malformed, parameters }`. The value follows RFC
133
+ 8659's grammar, whitespace around `;` and `=` included. `issuer` is lowercased without a trailing
134
+ dot, and `null` when the value names none (`";"`), which forbids issuance. A value that fails the
135
+ grammar reads as `issuer: null, malformed: true`, forbidding issuance the same way. Parameters keep
136
+ their published order and spelling.
137
+ - `iodef` - `{ kind, critical, url }`, `url` being `null` unless the value is a `mailto:`, `http:` or
138
+ `https:` URL.
139
+ - Any other tag - `{ kind: "unknown", critical, tag, value }`; critical, it forbids issuance.
140
+
141
+ ```typescript
142
+ import { parseCaaProperty } from "@sdxc/doh/caa";
143
+
144
+ parseCaaProperty({
145
+ type: "CAA",
146
+ flags: 0,
147
+ critical: false,
148
+ tag: "issue",
149
+ value: "digicert.com; cansignhttpexchanges=yes",
150
+ });
151
+ // { kind: "issue", critical: false, issuer: "digicert.com", malformed: false,
152
+ // parameters: [{ key: "cansignhttpexchanges", value: "yes" }] }
153
+ ```
154
+
155
+ #### `findRelevantCaa(domain, options?): Promise<Result<CAA.RelevantSet, DoHError>>`
156
+
157
+ RFC 8659's climb: one `CAA` query for the domain, then for each ancestor up to the TLD, stopping at
158
+ the first answer with records. A leading `*.` and a trailing dot are dropped and the name is
159
+ lowercased. NODATA and NXDOMAIN move the climb on; any other failure ends it as a failure. Records
160
+ reached through a CNAME or DNAME are the target's, with the aliases in `chain`, and when the target
161
+ publishes none the climb continues at the alias's parent.
162
+
163
+ The set holds `name` (where the records were found, `null` when nowhere), `records`, `unparsed`,
164
+ `chain`, `queried` (every name asked, nearest first) and `authenticated`: whether every answer in
165
+ the climb, denials of existence included, carried the AD flag.
166
+
167
+ #### `evaluateCaa(records, request): CAA.Decision`
168
+
169
+ Decides a request against one RRset with no I/O, so records from a zone file decide as records from
170
+ DNS do. `request` is `{ domain, issuer, accountUri?, validationMethod? }`: `domain` starting with
171
+ `*.` asks about a wildcard certificate, and `issuer` is the CA's CAA identifier or a list of them.
172
+ In order:
173
+
174
+ | Reason | `allowed` | When |
175
+ | ------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------- |
176
+ | `no-policy` | `true` | No records |
177
+ | `critical-tag` | `false` | A critical property of a tag this package does not interpret |
178
+ | `unrestricted` | `true` | No property applies: only `iodef`, non-critical unknown tags, or `issuewild` on a plain name |
179
+ | `authorized` | `true` | An applicable property names one of the issuers and its parameters are satisfied |
180
+ | `account-mismatch` / `validation-method-mismatch` | `false` | Properties name the issuer, and RFC 8657's `accounturi` or `validationmethods` exclude the request |
181
+ | `not-authorized` | `false` | Applicable properties name other issuers, listed in `issuers` |
182
+ | `forbidden` | `false` | Every applicable property names no issuer |
183
+
184
+ For a wildcard domain, any `issuewild` property replaces every `issue`. Authorizations add up, so
185
+ `issue ";"` beside `issue "ca.example"` still authorizes `ca.example`. Issuers match exactly and
186
+ case-insensitively. Two `accounturi` parameters, or a repeated, empty or malformed
187
+ `validationmethods`, are never satisfied; an omitted `accountUri` or `validationMethod` passes that
188
+ check, and the `authorized` decision carries the property whose parameters show what went unchecked.
189
+
190
+ #### `mayIssue(request, options?): Promise<CAA.Verdict>`
191
+
192
+ `findRelevantCaa`, then `evaluateCaa`, with the RRset in `relevant`. Two more refusals:
193
+ `unreadable` when the set holds a record that does not parse, and `lookup-failed` when a query in
194
+ the climb fails, with the failing `name`, `queried` and the `DoHError`. `options` reach every
195
+ query, so `resolver: GOOGLE` confirms a verdict through a second resolver.
196
+
197
+ The verdict predicts what a CA's own lookup finds when the zone serves it what the resolver saw.
198
+
199
+ #### `CAA` namespace
200
+
201
+ Types only: `Record`, `Parameter`, `Property` (`IssueProperty`, `IodefProperty`, `UnknownProperty`),
202
+ `RelevantSet`, `Request`, `Decision`, `Verdict` and one interface per reason. Every one but
203
+ `LookupFailed` is plain data, ready for a log line or storage.
138
204
 
139
205
  ## Pattern: Tell A Vanished Name From A Failing Resolver
140
206
 
@@ -154,6 +220,44 @@ if (isFailure(answer)) {
154
220
  let viaAlias = answer.data.chain.length > 0;
155
221
  ```
156
222
 
223
+ ## Pattern: Compare A Zone File With DNS
224
+
225
+ A zone file and a resolver can spell one record differently: `0 ISSUE "ca.example"` in one,
226
+ RFC 3597 generic data (`\# 15 00 05 69 73 73 75 65 …`) in the other. Parse both and print them, and
227
+ equal records give equal strings.
228
+
229
+ ```typescript
230
+ import { resolve } from "@sdxc/doh";
231
+ import { isSuccess } from "@sdxc/result";
232
+ import { formatRecordData, parseRecordData } from "@sdxc/zone-file";
233
+
234
+ let published = parseRecordData("CAA", zoneLine.data);
235
+ let answer = await resolve("example.com", "CAA");
236
+ if (isSuccess(published) && isSuccess(answer)) {
237
+ let live = new Set(answer.data.records.map(formatRecordData));
238
+ let missing = !live.has(formatRecordData(published.data));
239
+ }
240
+ ```
241
+
242
+ ## Pattern: A Lookup Failure Is A Refusal
243
+
244
+ A CA refuses to issue when its CAA lookup fails, so `mayIssue` answers `allowed: false` for a
245
+ failing lookup too. Checking `allowed` alone treats a SERVFAIL the way a CA does; narrow on
246
+ `reason` to retry first.
247
+
248
+ ```typescript
249
+ import { GOOGLE } from "@sdxc/doh";
250
+ import { mayIssue } from "@sdxc/doh/caa";
251
+
252
+ let request = { domain: "*.example.com", issuer: "letsencrypt.org" };
253
+ let verdict = await mayIssue(request);
254
+ if (verdict.reason === "lookup-failed") verdict = await mayIssue(request, { resolver: GOOGLE });
255
+
256
+ if (!verdict.allowed && verdict.reason !== "lookup-failed") {
257
+ verdict.relevant.name; // "example.com": where the blocking policy lives
258
+ }
259
+ ```
260
+
157
261
  ## Pattern: Cache Through The Answer's TTL
158
262
 
159
263
  ```typescript
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Decides a certificate request against one CAA RRset under RFC 8659 and RFC 8657, with no
3
+ * I/O, so records read from a zone file decide exactly as records from a resolver do: the
4
+ * `issuewild` override, additive authorizations, critical tags and the account and method bindings.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { CAA } from "./types.js";
10
+ /**
11
+ * Decides a request against one RRset. A critical unknown tag refuses before anything
12
+ * else; for a wildcard domain any `issuewild` replaces every `issue`; issuers match
13
+ * exactly, case-insensitively and without the trailing dot. Reserved flag bits are ignored.
14
+ *
15
+ * @param records - The relevant RRset, such as `findRelevantCaa`'s records or a zone file's.
16
+ * @param request - The domain, the CA's identifiers and, optionally, the RFC 8657 account and method.
17
+ * @returns Whether the RRset allows the request, and which property or issuers decided it.
18
+ * @example evaluateCaa([{ type: "CAA", flags: 0, critical: false, tag: "issue", value: "pki.goog" }], { domain: "example.com", issuer: "letsencrypt.org" }) // { allowed: false, reason: "not-authorized", issuers: ["pki.goog"] }
19
+ */
20
+ export declare function evaluateCaa(records: readonly CAA.Record[], request: CAA.Request): CAA.Decision;
@@ -0,0 +1,87 @@
1
+ import { parseCaaProperty } from "./property.js";
2
+ /** RFC 8657 §4's `validationmethods` value: comma-separated method labels. */
3
+ const METHOD_LIST = /^[A-Za-z0-9](?:-*[A-Za-z0-9])*(?:,[A-Za-z0-9](?:-*[A-Za-z0-9])*)*$/;
4
+ /**
5
+ * Decides a request against one RRset. A critical unknown tag refuses before anything
6
+ * else; for a wildcard domain any `issuewild` replaces every `issue`; issuers match
7
+ * exactly, case-insensitively and without the trailing dot. Reserved flag bits are ignored.
8
+ *
9
+ * @param records - The relevant RRset, such as `findRelevantCaa`'s records or a zone file's.
10
+ * @param request - The domain, the CA's identifiers and, optionally, the RFC 8657 account and method.
11
+ * @returns Whether the RRset allows the request, and which property or issuers decided it.
12
+ * @example evaluateCaa([{ type: "CAA", flags: 0, critical: false, tag: "issue", value: "pki.goog" }], { domain: "example.com", issuer: "letsencrypt.org" }) // { allowed: false, reason: "not-authorized", issuers: ["pki.goog"] }
13
+ */
14
+ export function evaluateCaa(records, request) {
15
+ if (records.length === 0)
16
+ return { allowed: true, reason: "no-policy" };
17
+ let properties = records.map(parseCaaProperty);
18
+ let critical = properties.find((property) => property.kind === "unknown" && property.critical);
19
+ if (critical)
20
+ return { allowed: false, reason: "critical-tag", property: critical };
21
+ let applicable = applicableProperties(properties, request.domain);
22
+ if (applicable.length === 0)
23
+ return { allowed: true, reason: "unrestricted" };
24
+ let issuers = new Set([request.issuer].flat().map(normalizeIssuer));
25
+ let matching = applicable.filter((property) => property.issuer !== null && issuers.has(property.issuer));
26
+ let authorized = matching.find((property) => accountSatisfied(property, request) && methodSatisfied(property, request));
27
+ if (authorized)
28
+ return { allowed: true, reason: "authorized", property: authorized };
29
+ let [first] = matching;
30
+ if (first) {
31
+ let reason = accountSatisfied(first, request)
32
+ ? "validation-method-mismatch"
33
+ : "account-mismatch";
34
+ return { allowed: false, reason, property: first };
35
+ }
36
+ let named = applicable.flatMap((property) => (property.issuer === null ? [] : [property.issuer]));
37
+ if (named.length > 0)
38
+ return { allowed: false, reason: "not-authorized", issuers: [...new Set(named)] };
39
+ return { allowed: false, reason: "forbidden" };
40
+ }
41
+ /**
42
+ * The properties that decide `domain` (RFC 8659 §4.3): `issuewild` for a wildcard name
43
+ * that has any, otherwise `issue`; a non-wildcard name never reads `issuewild`.
44
+ */
45
+ function applicableProperties(properties, domain) {
46
+ let issue = properties.filter((property) => property.kind === "issue");
47
+ if (!domain.trim().startsWith("*."))
48
+ return issue;
49
+ let wild = properties.filter((property) => property.kind === "issuewild");
50
+ return wild.length > 0 ? wild : issue;
51
+ }
52
+ /** Folds a CA identifier the way `parseCaaProperty` folds an issuer. */
53
+ function normalizeIssuer(issuer) {
54
+ let lower = issuer.trim().toLowerCase();
55
+ return lower.endsWith(".") ? lower.slice(0, -1) : lower;
56
+ }
57
+ /** The values of every parameter named `key`, in published order. */
58
+ function parameterValues(property, key) {
59
+ return property.parameters
60
+ .filter((parameter) => parameter.key === key)
61
+ .map((parameter) => parameter.value);
62
+ }
63
+ /**
64
+ * RFC 8657 §3: one `accounturi` binds the property to that account, and two or more
65
+ * make it unsatisfiable. A request with no `accountUri` passes a single binding.
66
+ */
67
+ function accountSatisfied(property, request) {
68
+ let accounts = parameterValues(property, "accounturi");
69
+ if (accounts.length === 0)
70
+ return true;
71
+ if (accounts.length > 1)
72
+ return false;
73
+ return request.accountUri === undefined || accounts[0] === request.accountUri;
74
+ }
75
+ /**
76
+ * RFC 8657 §4: `validationmethods` binds the property to the listed methods. An empty,
77
+ * malformed or repeated list is unsatisfiable; a request with no method passes a valid list.
78
+ */
79
+ function methodSatisfied(property, request) {
80
+ let lists = parameterValues(property, "validationmethods");
81
+ if (lists.length === 0)
82
+ return true;
83
+ let [list] = lists;
84
+ if (lists.length > 1 || list === undefined || !METHOD_LIST.test(list))
85
+ return false;
86
+ return (request.validationMethod === undefined || list.split(",").includes(request.validationMethod));
87
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Predicts whether a CA may issue for a name: the relevant RRset found over DoH, then
3
+ * decided with no further I/O. Every failure, a broken lookup included, reads as
4
+ * `allowed: false`, because that is what a CA does with it (RFC 8659 §6).
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { DoH } from "../types.js";
10
+ import type { CAA } from "./types.js";
11
+ /**
12
+ * Whether a CA may issue for `request.domain`. A failing query answers `lookup-failed`
13
+ * with the name that failed, and an unparsable record in the set answers `unreadable`,
14
+ * both refused. The verdict predicts what the CA's own lookup finds if the zone serves the same.
15
+ *
16
+ * @param request - The domain, the CA's identifiers and, optionally, the RFC 8657 account and method.
17
+ * @param options - Passed to every lookup, so `resolver: GOOGLE` confirms through a second resolver.
18
+ * @returns The verdict and, unless the lookup failed, the RRset that decided it.
19
+ * @example let verdict = await mayIssue({ domain: "*.example.com", issuer: "letsencrypt.org" });
20
+ */
21
+ export declare function mayIssue(request: CAA.Request, options?: DoH.ResolveOptions): Promise<CAA.Verdict>;
@@ -0,0 +1,21 @@
1
+ import { evaluateCaa } from "./evaluate.js";
2
+ import { climbCaa } from "./relevant-set.js";
3
+ /**
4
+ * Whether a CA may issue for `request.domain`. A failing query answers `lookup-failed`
5
+ * with the name that failed, and an unparsable record in the set answers `unreadable`,
6
+ * both refused. The verdict predicts what the CA's own lookup finds if the zone serves the same.
7
+ *
8
+ * @param request - The domain, the CA's identifiers and, optionally, the RFC 8657 account and method.
9
+ * @param options - Passed to every lookup, so `resolver: GOOGLE` confirms through a second resolver.
10
+ * @returns The verdict and, unless the lookup failed, the RRset that decided it.
11
+ * @example let verdict = await mayIssue({ domain: "*.example.com", issuer: "letsencrypt.org" });
12
+ */
13
+ export async function mayIssue(request, options) {
14
+ let relevant = await climbCaa(request.domain, options);
15
+ if ("error" in relevant)
16
+ return { allowed: false, reason: "lookup-failed", ...relevant };
17
+ let [unreadable] = relevant.unparsed;
18
+ if (unreadable)
19
+ return { allowed: false, reason: "unreadable", record: unreadable, relevant };
20
+ return { ...evaluateCaa(relevant.records, request), relevant };
21
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Reads what one CAA record asks of a CA: the issuer and parameters of an `issue` or
3
+ * `issuewild` value under RFC 8659 §4.2's grammar, the report URL of an `iodef`, and any
4
+ * other tag kept as is, so a policy decision works on meanings instead of strings.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { CAA } from "./types.js";
10
+ /**
11
+ * Reads what one CAA record asks of a CA. An `issue` value that fails the grammar reads
12
+ * as `issuer: null, malformed: true`, which forbids issuance as RFC 8659 says it does;
13
+ * issuers are lowercased without a trailing dot, parameters are kept as written.
14
+ *
15
+ * @param record - The record's data, from `resolve` or `parseRecordData`.
16
+ * @returns The property, with `kind: "unknown"` for any tag other than `issue`, `issuewild` and `iodef`.
17
+ * @example parseCaaProperty({ type: "CAA", flags: 0, critical: false, tag: "issue", value: ";" }) // { kind: "issue", critical: false, issuer: null, malformed: false, parameters: [] }
18
+ */
19
+ export declare function parseCaaProperty(record: CAA.Record): CAA.Property;
@@ -0,0 +1,54 @@
1
+ /** RFC 8659 §4.2's `label`, which is also the grammar of a parameter `tag`. */
2
+ const LABEL = "[A-Za-z0-9](?:-*[A-Za-z0-9])*";
3
+ /** One `tag = value` parameter; a value is any printable ASCII but space and `;`. */
4
+ const PARAMETER = `${LABEL}[ \\t]*=[ \\t]*[\\x21-\\x3A\\x3C-\\x7E]*`;
5
+ /**
6
+ * The whole `issue-value`: an optional issuer domain (a trailing dot tolerated), then an
7
+ * optional `;` with an optional parameter list. Group 1 is the domain, group 2 the list.
8
+ */
9
+ const ISSUE_VALUE = new RegExp(`^[ \\t]*(?:(${LABEL}(?:\\.${LABEL})*)\\.?[ \\t]*)?` +
10
+ `(?:;[ \\t]*(?:(${PARAMETER}(?:[ \\t]*;[ \\t]*${PARAMETER})*)[ \\t]*)?)?$`);
11
+ /** The URL schemes RFC 8659 §4.4 allows an `iodef` to report to. */
12
+ const IODEF_SCHEMES = new Set(["mailto:", "http:", "https:"]);
13
+ /**
14
+ * Reads what one CAA record asks of a CA. An `issue` value that fails the grammar reads
15
+ * as `issuer: null, malformed: true`, which forbids issuance as RFC 8659 says it does;
16
+ * issuers are lowercased without a trailing dot, parameters are kept as written.
17
+ *
18
+ * @param record - The record's data, from `resolve` or `parseRecordData`.
19
+ * @returns The property, with `kind: "unknown"` for any tag other than `issue`, `issuewild` and `iodef`.
20
+ * @example parseCaaProperty({ type: "CAA", flags: 0, critical: false, tag: "issue", value: ";" }) // { kind: "issue", critical: false, issuer: null, malformed: false, parameters: [] }
21
+ */
22
+ export function parseCaaProperty(record) {
23
+ let tag = record.tag.toLowerCase();
24
+ let critical = record.critical;
25
+ if (tag === "issue" || tag === "issuewild") {
26
+ return { kind: tag, critical, ...readIssueValue(record.value) };
27
+ }
28
+ if (tag === "iodef")
29
+ return { kind: "iodef", critical, url: readIodefUrl(record.value) };
30
+ return { kind: "unknown", critical, tag, value: record.value };
31
+ }
32
+ /** The issuer and parameters of an `issue` value, or the malformed reading when the grammar fails. */
33
+ function readIssueValue(value) {
34
+ let match = ISSUE_VALUE.exec(value);
35
+ if (!match)
36
+ return { issuer: null, malformed: true, parameters: [] };
37
+ let parameters = (match[2] ?? "")
38
+ .split(";")
39
+ .filter((parameter) => parameter.trim().length > 0)
40
+ .map((parameter) => {
41
+ let separator = parameter.indexOf("=");
42
+ return {
43
+ key: parameter.slice(0, separator).trim(),
44
+ value: parameter.slice(separator + 1).trim(),
45
+ };
46
+ });
47
+ return { issuer: match[1]?.toLowerCase() ?? null, malformed: false, parameters };
48
+ }
49
+ /** The `iodef` value when it is a URL in one of RFC 8659's schemes, `null` otherwise. */
50
+ function readIodefUrl(value) {
51
+ let trimmed = value.trim();
52
+ let url = URL.parse(trimmed);
53
+ return url && IODEF_SCHEMES.has(url.protocol) ? trimmed : null;
54
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Finds the CAA RRset RFC 8659 §3 applies to a name: the domain's own, or the nearest
3
+ * ancestor's below the root, one query per label and stopping at the first non-empty
4
+ * answer, so a domain publishing at its apex costs one or two lookups.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { DoHError } from "../errors.js";
11
+ import type { DoH } from "../types.js";
12
+ import type { CAA } from "./types.js";
13
+ /** A climb that ended on a failing query, with the name that failed and the names asked so far. */
14
+ export interface ClimbFailure {
15
+ name: string;
16
+ queried: string[];
17
+ error: DoHError;
18
+ }
19
+ /**
20
+ * Finds the RRset a CA reads for `domain`. NODATA and NXDOMAIN move the climb to the
21
+ * parent; any other failure ends it, since a CA would not issue past it. Aliases are the
22
+ * resolver's: records reached through a CNAME are the target's, and `chain` says so.
23
+ *
24
+ * @param domain - The name, a leading `*.` allowed; an internationalized name must already be punycode.
25
+ * @param options - Passed to every `resolve` in the climb.
26
+ * @returns The relevant RRset, empty with `name: null` when no ancestor publishes one.
27
+ * @example await findRelevantCaa("shop.example.com") // success({ name: "example.com", records: [...], queried: ["shop.example.com", "example.com"], ... })
28
+ */
29
+ export declare function findRelevantCaa(domain: string, options?: DoH.ResolveOptions): Promise<Result<CAA.RelevantSet, DoHError>>;
30
+ /**
31
+ * The climb behind `findRelevantCaa`, reporting a failure with the name that failed so a
32
+ * verdict can say where DNS broke. `authenticated` is the AD flag of every answer ANDed,
33
+ * NXDOMAIN included, because an unvalidated empty step is where a suppressed record hides.
34
+ */
35
+ export declare function climbCaa(domain: string, options?: DoH.ResolveOptions): Promise<CAA.RelevantSet | ClimbFailure>;
@@ -0,0 +1,61 @@
1
+ import { failure, isFailure, success } from "@sdxc/result";
2
+ import { NameNotFoundError } from "../errors.js";
3
+ import { resolve } from "../resolve.js";
4
+ /**
5
+ * Finds the RRset a CA reads for `domain`. NODATA and NXDOMAIN move the climb to the
6
+ * parent; any other failure ends it, since a CA would not issue past it. Aliases are the
7
+ * resolver's: records reached through a CNAME are the target's, and `chain` says so.
8
+ *
9
+ * @param domain - The name, a leading `*.` allowed; an internationalized name must already be punycode.
10
+ * @param options - Passed to every `resolve` in the climb.
11
+ * @returns The relevant RRset, empty with `name: null` when no ancestor publishes one.
12
+ * @example await findRelevantCaa("shop.example.com") // success({ name: "example.com", records: [...], queried: ["shop.example.com", "example.com"], ... })
13
+ */
14
+ export async function findRelevantCaa(domain, options) {
15
+ let climbed = await climbCaa(domain, options);
16
+ return "error" in climbed ? failure(climbed.error) : success(climbed);
17
+ }
18
+ /**
19
+ * The climb behind `findRelevantCaa`, reporting a failure with the name that failed so a
20
+ * verdict can say where DNS broke. `authenticated` is the AD flag of every answer ANDed,
21
+ * NXDOMAIN included, because an unvalidated empty step is where a suppressed record hides.
22
+ */
23
+ export async function climbCaa(domain, options) {
24
+ let queried = [];
25
+ let authenticated = true;
26
+ for (let name of candidateNames(domain)) {
27
+ queried.push(name);
28
+ let answer = await resolve(name, "CAA", options);
29
+ if (isFailure(answer)) {
30
+ if (!(answer.error instanceof NameNotFoundError))
31
+ return { name, queried, error: answer.error };
32
+ authenticated &&= answer.error.authenticated;
33
+ continue;
34
+ }
35
+ authenticated &&= answer.data.authenticated;
36
+ let { records, unparsed, chain } = answer.data;
37
+ if (records.length > 0 || unparsed.length > 0)
38
+ return { name, records, unparsed, chain, queried, authenticated };
39
+ }
40
+ return {
41
+ name: null,
42
+ records: [],
43
+ unparsed: [],
44
+ chain: [],
45
+ queried,
46
+ authenticated: authenticated && queried.length > 0,
47
+ };
48
+ }
49
+ /**
50
+ * The domain and each ancestor below the root, nearest first. The domain is lowercased,
51
+ * its trailing dot and a leading `*.` dropped, since RFC 8659 computes `*.X`'s set at `X`.
52
+ */
53
+ function candidateNames(domain) {
54
+ let name = domain.trim().toLowerCase();
55
+ if (name.endsWith("."))
56
+ name = name.slice(0, -1);
57
+ if (name.startsWith("*."))
58
+ name = name.slice(2);
59
+ let labels = name.split(".").filter((label) => label.length > 0);
60
+ return labels.map((_, index) => labels.slice(index).join("."));
61
+ }