@sdxc/doh 2026.10.10 → 2026.10.16

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 (2) hide show
  1. package/README.md +216 -175
  2. package/package.json +7 -5
package/README.md CHANGED
@@ -2,33 +2,24 @@
2
2
 
3
3
  Typed DNS over HTTPS lookups.
4
4
 
5
+ A runtime with no DNS socket, such as a Cloudflare Worker, resolves names with a `fetch` to a
6
+ public resolver. This package speaks the DoH JSON API (`application/dns-json`) that Cloudflare
7
+ and Google answer, returns records typed per query type, and gives each kind of failure its own
8
+ error class. The `./caa` entry point reads CAA records the way a certificate authority does.
9
+
5
10
  ## Installation
6
11
 
7
- ```bash
12
+ ```sh
8
13
  npm add @sdxc/doh
9
14
  ```
10
15
 
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.
14
-
15
- A runtime with no DNS socket, such as a Cloudflare Worker, resolves names through DNS over HTTPS:
16
- a `fetch` to a public resolver. This package speaks the DoH JSON API (`application/dns-json`)
17
- that Cloudflare and Google answer, validates the envelope, and returns a `Result` whose records
18
- are typed per query type: an `MX` query gives `preference` and `exchange`, a `TXT` query gives
19
- the joined `text` with the resolver's quoting and escapes removed.
20
-
21
- Failures are classes, so a caller tells "the name is gone" from "the resolver is down" with
22
- `instanceof`: NXDOMAIN is `NameNotFoundError`, SERVFAIL is `ServerFailureError`, any other RCODE
23
- is `ResponseCodeError`, and a request that produced no DNS answer is `TransportError`. A name
24
- that exists with no records of the asked type (NODATA) is a success with no records.
25
-
26
- Each call is one request through the global `fetch` and keeps no state: every answer carries its
27
- TTL for a caller that caches, and retry policy stays with the caller.
16
+ Every lookup returns a `Result` from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result),
17
+ and record data is read by [`@sdxc/zone-file`](https://www.npmjs.com/package/@sdxc/zone-file); both
18
+ install alongside this package.
28
19
 
29
20
  ## Usage
30
21
 
31
- ### Look Up Records
22
+ ### Look up records
32
23
 
33
24
  ```typescript
34
25
  import { resolve } from "@sdxc/doh";
@@ -36,202 +27,241 @@ import { isSuccess } from "@sdxc/result";
36
27
 
37
28
  let answer = await resolve("example.com", "MX");
38
29
  if (isSuccess(answer)) {
39
- for (let record of answer.data.records) console.log(record.preference, record.exchange);
30
+ for (let record of answer.data.records) {
31
+ record.preference; // 10
32
+ record.exchange; // "mail.example.com"
33
+ }
34
+ answer.data.ttl; // the smallest TTL across the records
35
+ }
36
+ ```
37
+
38
+ Each record carries the fields of its type plus `name` and `ttl`. A name that exists with no
39
+ records of the type (NODATA) succeeds with an empty `records`, and `chain` lists any CNAMEs
40
+ followed to reach them.
41
+
42
+ ### Tell failures apart
43
+
44
+ ```typescript
45
+ import { NameNotFoundError, resolve, ServerFailureError, TransportError } from "@sdxc/doh";
46
+ import { isFailure } from "@sdxc/result";
47
+
48
+ let answer = await resolve("app.example.com", "A", { timeoutMs: 2000 });
49
+ if (isFailure(answer)) {
50
+ if (answer.error instanceof NameNotFoundError) return "the name does not exist";
51
+ if (answer.error instanceof ServerFailureError) return "the resolver failed (SERVFAIL)";
52
+ if (answer.error instanceof TransportError) return "no DNS answer";
53
+ return answer.error.message;
40
54
  }
41
55
  ```
42
56
 
43
- ### Check Domain Ownership
57
+ ### Verify domain ownership
44
58
 
45
59
  ```typescript
46
60
  import { checkCname, verifyTxtRecord } from "@sdxc/doh";
47
61
 
48
62
  let verified = await verifyTxtRecord("_verify.example.com", "token_abc123"); // Result<boolean>
49
- let pointed = await checkCname("shop.example.com", "custom.hosting.example"); // Result<boolean>
63
+ let pointed = await checkCname("shop.example.com", "custom.hosting.com"); // Result<boolean>
50
64
  ```
51
65
 
52
- ## API
66
+ Both read NXDOMAIN as `success(false)`, since an unpublished record is the ordinary state of a
67
+ verification in progress.
53
68
 
54
- ### `@sdxc/doh`
69
+ ### Check whether a CA may issue
55
70
 
56
- #### `resolve(name, type, options?): Promise<Result<DoH.Answer<Type>, DoHError>>`
71
+ ```typescript
72
+ import { mayIssue } from "@sdxc/doh/caa";
57
73
 
58
- Looks up `name`'s records of `type`. The name is sent as written, so an internationalized name must
59
- already be in its ASCII (punycode) form. The answer holds:
74
+ let verdict = await mayIssue({ domain: "*.example.com", issuer: "letsencrypt.org" });
75
+ if (!verdict.allowed) verdict.reason; // "not-authorized", "forbidden", "lookup-failed", …
76
+ ```
60
77
 
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
64
- - `unparsed` - records of the asked type whose data did not parse, as `{ name, ttl, type, data }`,
65
- so one odd record never fails the whole answer
66
- - `chain` - the CNAME records followed before reaching `records`
67
- - `ttl` - the smallest TTL across `records` and `unparsed`, `null` when both are empty
68
- - `authenticated` - the AD flag (the resolver validated DNSSEC); requiring it is the caller's policy
69
- - `truncated` - the TC flag
70
- - `name` (lowercased, no trailing dot), `type`, and `durationMs`
78
+ ## API
71
79
 
72
- Records of other types (RRSIG with `dnssec: true`, say) are dropped. Owner names and targets come
73
- back lowercased without the trailing dot; an AAAA address comes back in RFC 5952 form.
80
+ ### `resolve(name, type, options?): Promise<Result<DoH.Answer<Type>, DoHError>>`
74
81
 
75
- **Options:**
82
+ Looks up `name`'s records of `type` with one `fetch` and no state. Send an internationalized name
83
+ in its ASCII (punycode) form. Options:
76
84
 
77
- - `resolver`: `CLOUDFLARE` (default), `GOOGLE`, or `{ url, format: "json" }`
85
+ - `resolver`: `CLOUDFLARE` (default), `GOOGLE`, or any `{ url, format: "json" }`
78
86
  - `dnssec`: sets `do=1`
79
87
  - `checkingDisabled`: sets `cd=1`
80
88
  - `signal`: aborts the request
81
89
  - `timeoutMs`: abandons the request as a `TransportError`, default `5000`
82
90
 
83
- Typed record types: `A` (`address`), `AAAA` (`address`), `CNAME`, `PTR` and `DNAME` (`target`), `NS`
91
+ The answer holds `records`, `unparsed` (records of the type whose data did not parse, with their
92
+ raw `data`), `chain` (the CNAMEs followed to reach `records`), `ttl` (smallest across `records`
93
+ and `unparsed`, `null` when both are empty), `authenticated` (the AD flag), `truncated` (the TC
94
+ flag), `name`, `type` and `durationMs`. Names come back lowercased without the trailing dot, and
95
+ AAAA addresses in RFC 5952 form.
96
+
97
+ Typed record fields: `A` and `AAAA` (`address`), `CNAME`, `PTR` and `DNAME` (`target`), `NS`
84
98
  (`host`), `MX` (`preference`, `exchange`), `TXT` (`text`, `strings`), `CAA` (`flags`, `critical`,
85
99
  `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`.
89
-
90
- #### `verifyTxtRecord(name, expected, options?): Promise<Result<boolean, DoHError>>`
91
-
92
- Whether `name` publishes a TXT record whose joined text is exactly `expected`. A token split into
93
- several character-strings or escaped still matches. NXDOMAIN is `success(false)`, since a record not
94
- yet published is the ordinary state of a verification; every other failure stays a failure.
95
-
96
- #### `checkCname(name, target, options?): Promise<Result<boolean, DoHError>>`
100
+ `serial`, `refresh`, `retry`, `expire`, `minimum`). Any other type gives a raw `data` string.
97
101
 
98
- Whether `name` aliases `target`, directly or through further CNAMEs, compared case-insensitively and
99
- ignoring the trailing dot. It follows the chain one `CNAME` query per hop (up to eight, stopping on
100
- a loop), so it holds even when the target resolves to nothing. NXDOMAIN is `success(false)`.
102
+ ### `verifyTxtRecord(name, expected, options?): Promise<Result<boolean, DoHError>>`
101
103
 
102
- #### `CLOUDFLARE` / `GOOGLE`
104
+ Whether `name` publishes a TXT record whose joined text equals `expected`, so a token split into
105
+ several character-strings still matches. NXDOMAIN is `success(false)`.
103
106
 
104
- `{ url: "https://cloudflare-dns.com/dns-query", format: "json" }` and
105
- `{ url: "https://dns.google/resolve", format: "json" }`.
107
+ ### `checkCname(name, target, options?): Promise<Result<boolean, DoHError>>`
106
108
 
107
- #### Errors
109
+ Whether `name` aliases `target`, directly or through up to eight CNAME hops, compared
110
+ case-insensitively without the trailing dot. It holds even when the target resolves to nothing.
111
+ NXDOMAIN is `success(false)`.
108
112
 
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 |
113
+ ### `CLOUDFLARE` / `GOOGLE`
116
114
 
117
- #### `DoH` namespace
115
+ The resolvers `https://cloudflare-dns.com/dns-query` and `https://dns.google/resolve`, ready to
116
+ pass as `resolver`.
118
117
 
119
- Types only: `RecordType`, `Resolver`, `ResolveOptions`, `Answer<Type>`, `RecordFor<Type>`,
120
- `RecordBase`, one alias per typed record (`ARecord` through `SOARecord`, `PTRRecord` and
121
- `DNAMERecord` included), and `UnknownRecord`.
118
+ ### Errors
122
119
 
123
- ### `@sdxc/doh/caa`
120
+ | Class | When | Extra fields |
121
+ | -------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------- |
122
+ | `DoHError` | Base of every failure | |
123
+ | `NameNotFoundError` | NXDOMAIN (RCODE 3) | `ttl`: the SOA's negative-caching TTL or `null`; `authenticated` |
124
+ | `ServerFailureError` | SERVFAIL (RCODE 2), DNSSEC validation failures included | |
125
+ | `ResponseCodeError` | Any other non-zero RCODE | `rcode` |
126
+ | `TransportError` | Network error, timeout, abort, non-2xx, or a body that is not a DNS answer | `status`: the HTTP status, `null` when no response arrived |
124
127
 
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.
128
+ ### `parseCaaProperty(record): CAA.Property` from `./caa`
127
129
 
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.
130
+ Reads what one CAA record asks of a CA. `issue` and `issuewild` give `{ issuer, malformed,
131
+ parameters }`, with `issuer: null` when the value names none or fails RFC 8659's grammar (both
132
+ forbid issuance). `iodef` gives `{ url }`. Any other tag gives `{ kind: "unknown", tag, value }`.
140
133
 
141
134
  ```typescript
142
- import { parseCaaProperty } from "@sdxc/doh/caa";
143
-
144
135
  parseCaaProperty({
145
136
  type: "CAA",
146
137
  flags: 0,
147
138
  critical: false,
148
139
  tag: "issue",
149
- value: "digicert.com; cansignhttpexchanges=yes",
140
+ value: "ca.com; accounturi=x",
150
141
  });
151
- // { kind: "issue", critical: false, issuer: "digicert.com", malformed: false,
152
- // parameters: [{ key: "cansignhttpexchanges", value: "yes" }] }
142
+ // { kind: "issue", critical: false, issuer: "ca.com", malformed: false,
143
+ // parameters: [{ key: "accounturi", value: "x" }] }
153
144
  ```
154
145
 
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.
146
+ ### `findRelevantCaa(domain, options?): Promise<Result<CAA.RelevantSet, DoHError>>` from `./caa`
162
147
 
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.
148
+ Finds the CAA RRset RFC 8659 applies to `domain`: its own, or the nearest ancestor's, one query
149
+ per label. A leading `*.` is dropped. The set holds `name` (where the records live, `null` when
150
+ nowhere), `records`, `unparsed`, `chain`, `queried` and `authenticated` (every answer in the climb
151
+ carried the AD flag).
166
152
 
167
- #### `evaluateCaa(records, request): CAA.Decision`
153
+ ### `evaluateCaa(records, request): CAA.Decision` from `./caa`
168
154
 
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:
155
+ Decides a request `{ domain, issuer, accountUri?, validationMethod? }` against one RRset with no
156
+ I/O. `issuer` is one CAA identifier or a list; a `*.` domain reads `issuewild` when the set has
157
+ any. Checked in this order:
173
158
 
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 |
159
+ | `reason` | `allowed` | When |
160
+ | ------------------------------------------------- | --------- | ---------------------------------------------------------------- |
161
+ | `no-policy` | `true` | No records |
162
+ | `critical-tag` | `false` | A critical property with a tag this package does not interpret |
163
+ | `unrestricted` | `true` | No property applies, such as only `iodef` |
164
+ | `authorized` | `true` | A property names the issuer and its parameters are satisfied |
165
+ | `account-mismatch` / `validation-method-mismatch` | `false` | RFC 8657 `accounturi` or `validationmethods` exclude the request |
166
+ | `not-authorized` | `false` | Properties name other issuers, listed in `issuers` |
167
+ | `forbidden` | `false` | Every applicable property names no issuer |
183
168
 
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.
169
+ An omitted `accountUri` or `validationMethod` passes that check.
189
170
 
190
- #### `mayIssue(request, options?): Promise<CAA.Verdict>`
171
+ ### `mayIssue(request, options?): Promise<CAA.Verdict>` from `./caa`
191
172
 
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.
173
+ `findRelevantCaa` then `evaluateCaa`, with the RRset in `relevant`. It adds two refusals, as a CA
174
+ does: `unreadable` when the set holds a record that does not parse, and `lookup-failed` (with
175
+ `name`, `queried` and `error`) when a query in the climb fails.
196
176
 
197
- The verdict predicts what a CA's own lookup finds when the zone serves it what the resolver saw.
177
+ ### Types
198
178
 
199
- #### `CAA` namespace
179
+ - `DoH`: `RecordType`, `Resolver`, `ResolveOptions`, `Answer<Type>`, `RecordFor<Type>`,
180
+ `RecordBase`, `UnknownRecord`, and one alias per typed record (`ARecord` through `SOARecord`).
181
+ - `CAA` (from `./caa`): `Record`, `Parameter`, `Property` and its three variants, `RelevantSet`,
182
+ `Request`, `Decision`, `Verdict`, and one interface per reason. Every one but `LookupFailed` is
183
+ plain data, ready to log or store.
200
184
 
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.
185
+ ## Pattern: Cache Answers And Absences By TTL
204
186
 
205
- ## Pattern: Tell A Vanished Name From A Failing Resolver
206
-
207
- Keep NXDOMAIN and NODATA apart, and treat `TransportError` and `ServerFailureError` as unknown
208
- rather than as "no records".
187
+ Each answer carries the TTL to cache it for, and an NXDOMAIN carries the negative-caching TTL from
188
+ the zone's SOA.
209
189
 
210
190
  ```typescript
191
+ import { env } from "cloudflare:workers";
211
192
  import { NameNotFoundError, resolve } from "@sdxc/doh";
212
193
  import { isFailure } from "@sdxc/result";
213
194
 
214
- let answer = await resolve("app.example.com", "A");
215
- if (isFailure(answer)) {
216
- if (answer.error instanceof NameNotFoundError) return { records: [] };
217
- return { error: answer.error.message }; // the resolver is having a bad minute
195
+ async function cachedAddresses(hostname: string): Promise<string[] | null> {
196
+ let key = `doh:A:${hostname}`;
197
+ let cached = await env.DNS_CACHE.get<string[]>(key, "json");
198
+ if (cached) return cached;
199
+
200
+ let answer = await resolve(hostname, "A");
201
+ if (isFailure(answer)) {
202
+ if (!(answer.error instanceof NameNotFoundError)) return null;
203
+ let expirationTtl = Math.max(answer.error.ttl ?? 60, 60);
204
+ await env.DNS_CACHE.put(key, "[]", { expirationTtl });
205
+ return [];
206
+ }
207
+
208
+ let addresses = answer.data.records.map((record) => record.address);
209
+ let expirationTtl = Math.max(answer.data.ttl ?? 60, 60);
210
+ await env.DNS_CACHE.put(key, JSON.stringify(addresses), { expirationTtl });
211
+ return addresses;
218
212
  }
213
+ ```
214
+
215
+ ## Pattern: Confirm A Failure With A Second Resolver
219
216
 
220
- let viaAlias = answer.data.chain.length > 0;
217
+ A SERVFAIL or timeout from one resolver may be that resolver's problem. Ask another before
218
+ reporting it, and require DNSSEC validation when the answer must be trusted.
219
+
220
+ ```typescript
221
+ import { GOOGLE, resolve, ServerFailureError, TransportError } from "@sdxc/doh";
222
+ import { isFailure, isSuccess } from "@sdxc/result";
223
+
224
+ let options = { dnssec: true, timeoutMs: 3000 };
225
+ let answer = await resolve("example.com", "TXT", options);
226
+ if (
227
+ isFailure(answer) &&
228
+ (answer.error instanceof ServerFailureError || answer.error instanceof TransportError)
229
+ ) {
230
+ answer = await resolve("example.com", "TXT", { ...options, resolver: GOOGLE });
231
+ }
232
+
233
+ let trusted = isSuccess(answer) && answer.data.authenticated;
234
+ ```
235
+
236
+ ## Pattern: A Custom Domain Onboarding Check
237
+
238
+ A customer proves ownership with a TXT token and points their host at yours with a CNAME. Report
239
+ each step separately, and keep a lookup failure apart from "not set up yet".
240
+
241
+ ```typescript
242
+ import { checkCname, verifyTxtRecord } from "@sdxc/doh";
243
+ import { isFailure } from "@sdxc/result";
244
+
245
+ async function checkDomain(hostname: string, token: string) {
246
+ let owned = await verifyTxtRecord(`_verify.${hostname}`, token);
247
+ let pointed = await checkCname(hostname, "custom.hosting.com");
248
+ if (isFailure(owned) || isFailure(pointed)) return { status: "retry-later" as const };
249
+ return { status: "checked" as const, owned: owned.data, pointed: pointed.data };
250
+ }
221
251
  ```
222
252
 
223
253
  ## Pattern: Compare A Zone File With DNS
224
254
 
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.
255
+ A zone file and a resolver can spell one record differently, such as `0 issue "ca.com"` against
256
+ RFC 3597 generic data. Parse both with `@sdxc/zone-file` and print them, and equal records give
257
+ equal strings.
228
258
 
229
259
  ```typescript
230
260
  import { resolve } from "@sdxc/doh";
231
261
  import { isSuccess } from "@sdxc/result";
232
262
  import { formatRecordData, parseRecordData } from "@sdxc/zone-file";
233
263
 
234
- let published = parseRecordData("CAA", zoneLine.data);
264
+ let published = parseRecordData("CAA", '0 issue "letsencrypt.org"');
235
265
  let answer = await resolve("example.com", "CAA");
236
266
  if (isSuccess(published) && isSuccess(answer)) {
237
267
  let live = new Set(answer.data.records.map(formatRecordData));
@@ -239,49 +269,60 @@ if (isSuccess(published) && isSuccess(answer)) {
239
269
  }
240
270
  ```
241
271
 
242
- ## Pattern: A Lookup Failure Is A Refusal
272
+ ## Pattern: Decide A CAA Policy Before Publishing It
243
273
 
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.
274
+ `evaluateCaa` runs on records from anywhere, so a policy can be checked against a request before
275
+ it reaches DNS, and `parseCaaProperty` shows what each record asks.
247
276
 
248
277
  ```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
278
+ import { isSuccess } from "@sdxc/result";
279
+ import { evaluateCaa, parseCaaProperty } from "@sdxc/doh/caa";
280
+ import type { CAA } from "@sdxc/doh/caa";
281
+ import { parseRecordData } from "@sdxc/zone-file";
282
+
283
+ let lines = ['0 issue "letsencrypt.org; validationmethods=dns-01"', '0 issuewild ";"'];
284
+ let records: CAA.Record[] = [];
285
+ for (let line of lines) {
286
+ let parsed = parseRecordData("CAA", line);
287
+ if (isSuccess(parsed)) records.push(parsed.data);
258
288
  }
259
- ```
260
289
 
261
- ## Pattern: Cache Through The Answer's TTL
290
+ records.map(parseCaaProperty); // [{ kind: "issue", issuer: "letsencrypt.org", … }, { kind: "issuewild", issuer: null, … }]
262
291
 
263
- ```typescript
264
- import { resolve } from "@sdxc/doh";
265
- import { isSuccess } from "@sdxc/result";
266
-
267
- let answer = await resolve(hostname, "A");
268
- if (isSuccess(answer)) {
269
- let seconds = Math.max(answer.data.ttl ?? 60, 60);
270
- await cache.put(`doh:A:${hostname}`, JSON.stringify(answer.data.records), {
271
- expirationTtl: seconds,
272
- });
273
- }
292
+ evaluateCaa(records, {
293
+ domain: "example.com",
294
+ issuer: "letsencrypt.org",
295
+ validationMethod: "dns-01",
296
+ });
297
+ // { allowed: true, reason: "authorized", property: … }
298
+ evaluateCaa(records, {
299
+ domain: "example.com",
300
+ issuer: "letsencrypt.org",
301
+ validationMethod: "http-01",
302
+ });
303
+ // { allowed: false, reason: "validation-method-mismatch", property: … }
304
+ evaluateCaa(records, { domain: "*.example.com", issuer: "letsencrypt.org" });
305
+ // { allowed: false, reason: "forbidden" }
274
306
  ```
275
307
 
276
- ## Pattern: Confirm A SERVFAIL With A Second Resolver
308
+ ## Pattern: Explain A CAA Refusal
309
+
310
+ A CA refuses when its CAA lookup fails, so `mayIssue` answers `allowed: false` for a failing
311
+ lookup too. Narrow on `reason` to retry the lookup, or to tell the domain owner where the
312
+ blocking policy lives.
277
313
 
278
314
  ```typescript
279
- import { GOOGLE, resolve, ServerFailureError } from "@sdxc/doh";
280
- import { isFailure } from "@sdxc/result";
315
+ import { GOOGLE } from "@sdxc/doh";
316
+ import { mayIssue } from "@sdxc/doh/caa";
317
+
318
+ let request = { domain: "shop.example.com", issuer: ["letsencrypt.org", "pki.goog"] };
319
+ let verdict = await mayIssue(request);
320
+ if (verdict.reason === "lookup-failed") verdict = await mayIssue(request, { resolver: GOOGLE });
281
321
 
282
- let answer = await resolve(name, "TXT");
283
- if (isFailure(answer) && answer.error instanceof ServerFailureError) {
284
- answer = await resolve(name, "TXT", { resolver: GOOGLE });
322
+ if (verdict.reason === "not-authorized") {
323
+ let where = verdict.relevant.name; // "example.com"
324
+ let allowed = verdict.issuers; // ["digicert.com"]
325
+ let validated = verdict.relevant.authenticated;
285
326
  }
286
327
  ```
287
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/doh",
3
- "version": "2026.10.10",
3
+ "version": "2026.10.16",
4
4
  "description": "Typed DNS over HTTPS lookups",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,11 +9,13 @@
9
9
  "./caa": "./dist/caa.js"
10
10
  },
11
11
  "dependencies": {
12
- "@sdxc/result": "2026.10.6",
13
- "@sdxc/zone-file": "2026.10.10",
12
+ "@sdxc/result": "2026.10.13",
13
+ "@sdxc/zone-file": "2026.10.13"
14
+ },
15
+ "peerDependencies": {
14
16
  "remix": "3.0.0"
15
17
  },
16
- "gitHead": "b61f19c59866708a2d89a5a8f4655ebbc16941b0",
18
+ "gitHead": "1e547d8431568f5ed9bdb079ca3046c936240fde",
17
19
  "publishConfig": {
18
20
  "access": "public"
19
21
  },
@@ -22,7 +24,7 @@
22
24
  "url": "git+https://github.com/sergiodxa/monorepo.git",
23
25
  "directory": "packages/doh"
24
26
  },
25
- "homepage": "https://sdxc.sergiodxa.com/api/doh",
27
+ "homepage": "https://sdxc.dev/api/doh",
26
28
  "author": {
27
29
  "name": "Sergio Xalambrí",
28
30
  "url": "https://sergiodxa.com"