@bkazemi/verily 0.2.0 → 0.4.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.
@@ -2,9 +2,16 @@ export type Visibility = 'public' | 'unlisted';
2
2
  /**
3
3
  * `unconfirmed` is a published proof that has not been read lately. It is not disproved,
4
4
  * and turns back to `verified` the moment the proof reads again. `expired` is an approval
5
- * that ran out, which only the holder renewing it undoes.
5
+ * that ran out, which only the holder renewing it undoes. `retired` is an account its
6
+ * holder said is finished: the record is kept as it stood at its last proof and never
7
+ * reads as verified again, unless that proof is made afresh.
6
8
  */
7
- export type Status = 'verified' | 'unconfirmed' | 'expired' | 'revoked';
9
+ export type Status = 'verified' | 'unconfirmed' | 'expired' | 'retired' | 'revoked';
10
+ /**
11
+ * How the holder would have an account listed: the one to lead with, or one they still
12
+ * control and no longer use. The holder's own statement, and no part of what was verified.
13
+ */
14
+ export type Mark = 'preferred' | 'unused';
8
15
  /** What a site links: one of its accounts, a single page, or the site itself. */
9
16
  export type LocalKind = 'account' | 'page' | 'site';
10
17
  /** id is private and must never be reassigned. reference is durable and public-safe. */
@@ -30,9 +37,11 @@ export interface ExternalAccount {
30
37
  * A page is an address that was read and nothing more: a method that only fetches a
31
38
  * document learns what it says, never whose account the address is. A mailbox is an
32
39
  * account in all but how it is written: its domain issued it and can hand it to somebody
33
- * else, but its address is already its whole name and it has no profile to link to.
40
+ * else, but its address is already its whole name and it has no profile to link to. A
41
+ * domain is likewise issued and can be handed over, and is named by itself: what was
42
+ * shown is control of the name, whatever is or is not served at it.
34
43
  */
35
- kind?: 'account' | 'key' | 'page' | 'mailbox';
44
+ kind?: 'account' | 'key' | 'page' | 'mailbox' | 'domain';
36
45
  handle: string;
37
46
  /** Where a reader goes to see the subject. For a mailbox, its `mailto:` address. */
38
47
  profileUrl: string;
@@ -47,7 +56,7 @@ export interface ExternalAccount {
47
56
  * source could improve on it. A sign-in and a mailed code happen once, between the holder
48
57
  * and this backend. The rest publish an artifact a reader can fetch.
49
58
  */
50
- export type Method = 'declared' | 'oauth' | 'gist' | 'backlink' | 'signature' | 'code';
59
+ export type Method = 'declared' | 'oauth' | 'gist' | 'backlink' | 'dns' | 'wellknown' | 'signature' | 'code';
51
60
  /** Who vouches for one side: this backend from its own records, or the account's provider. */
52
61
  export type Attester = 'backend' | 'provider';
53
62
  /**
@@ -106,6 +115,13 @@ export interface Connection {
106
115
  attestations?: Attestations;
107
116
  /** The proof itself, for a method whose artifact this backend publishes rather than reads. */
108
117
  proof?: string;
118
+ /** Set and cleared by the holder. Absent is an ordinary current account. */
119
+ mark?: Mark;
120
+ /**
121
+ * When the holder said the account is finished. From then the record is frozen at its
122
+ * last proof: never renewed by another method, never reread, never verified.
123
+ */
124
+ retiredAt?: number;
109
125
  }
110
126
  export interface Flow {
111
127
  id: string;
@@ -426,10 +442,17 @@ export interface Evidence {
426
442
  visibilityApprovedAt: number;
427
443
  expiresAt: number;
428
444
  revokedAt?: number;
445
+ /**
446
+ * What the holder says of the account, and when they said it was finished. Both are the
447
+ * holder's statements, beside the attestations and never among them: nothing checked them.
448
+ */
449
+ mark?: Mark;
450
+ retiredAt?: number;
429
451
  evidenceUrl: string;
430
452
  /**
431
453
  * Where this record can be had signed, on a verifier that signs. Only a public record
432
- * that stands has a signed form: once saved, a signed record cannot be taken back.
454
+ * that stands, or one retired, has a signed form: once saved, a signed record cannot be
455
+ * taken back.
433
456
  */
434
457
  signedUrl?: string;
435
458
  }
@@ -452,6 +475,11 @@ export declare function status(connection: Connection, now: number, freshness?:
452
475
  * reverses the moment it reads again.
453
476
  */
454
477
  export declare function fresh(attestation: Attestation, now: number, freshness?: number): boolean;
478
+ /**
479
+ * The date a retired record stands on: when its account was last proved. A sign-in was
480
+ * proved when it happened, and a published proof when it was last read.
481
+ */
482
+ export declare function lastProved(evidence: Pick<Evidence, 'authenticatedAt' | 'attestations'>): number;
455
483
  /**
456
484
  * The word for a record's state. Evidence can be read after it was issued, so an approval
457
485
  * that has run out since reads as expired whatever status it was issued with.
@@ -488,6 +516,11 @@ export declare function attestationLabel(method: string, names: {
488
516
  site: string;
489
517
  provider: string;
490
518
  }): string | undefined;
519
+ /**
520
+ * A subject's second identifier, shown beneath its name, or nothing where the name is
521
+ * already all of it: a mailbox's address and a domain are each their own id.
522
+ */
523
+ export declare function externalId(external: ExternalAccount): string | undefined;
491
524
  /**
492
525
  * Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
493
526
  * address is `mailto:`, which opens a message to it and shows a reader nothing.
@@ -35,6 +35,9 @@ export const freshnessMs = 7 * 86400000;
35
35
  export function status(connection, now, freshness = freshnessMs) {
36
36
  if (connection.revokedAt !== undefined)
37
37
  return 'revoked';
38
+ // Frozen at its last proof, so neither its expiry nor its freshness is read again.
39
+ if (connection.retiredAt !== undefined)
40
+ return 'retired';
38
41
  if (now >= connection.expiresAt)
39
42
  return 'expired';
40
43
  const main = connection.attestations?.external[0];
@@ -53,6 +56,14 @@ export function fresh(attestation, now, freshness = freshnessMs) {
53
56
  freshness === Infinity ||
54
57
  now < attestation.confirmedAt + freshness);
55
58
  }
59
+ /**
60
+ * The date a retired record stands on: when its account was last proved. A sign-in was
61
+ * proved when it happened, and a published proof when it was last read.
62
+ */
63
+ export function lastProved(evidence) {
64
+ const main = evidence.attestations.external[0];
65
+ return main.artifactUrl ? main.confirmedAt : evidence.authenticatedAt;
66
+ }
56
67
  /**
57
68
  * The word for a record's state. Evidence can be read after it was issued, so an approval
58
69
  * that has run out since reads as expired whatever status it was issued with.
@@ -60,6 +71,9 @@ export function fresh(attestation, now, freshness = freshnessMs) {
60
71
  export function statusLabel(evidence, now) {
61
72
  if (evidence.status === 'revoked')
62
73
  return 'Revoked';
74
+ // Before expiry: a retired record's approval has long run out, and that is not its state.
75
+ if (evidence.status === 'retired')
76
+ return 'Retired';
63
77
  if (evidence.status === 'expired' || evidence.expiresAt <= now)
64
78
  return 'Expired';
65
79
  return evidence.status === 'unconfirmed' ? 'Unconfirmed' : 'Verified';
@@ -107,10 +121,19 @@ export function attestationLabel(method, names) {
107
121
  oauth: `Signed in with ${names.provider}`,
108
122
  gist: `Published a proof on ${names.provider}`,
109
123
  backlink: `Linked back to ${names.site}`,
124
+ dns: `Named ${names.site} in a DNS record`,
125
+ wellknown: `Named ${names.site} in a file it serves`,
110
126
  signature: 'Proved with a signature',
111
127
  code: 'Entered a code sent to this address',
112
128
  }[method];
113
129
  }
130
+ /**
131
+ * A subject's second identifier, shown beneath its name, or nothing where the name is
132
+ * already all of it: a mailbox's address and a domain are each their own id.
133
+ */
134
+ export function externalId(external) {
135
+ return external.kind === 'mailbox' || external.kind === 'domain' ? undefined : external.id;
136
+ }
114
137
  /**
115
138
  * Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
116
139
  * address is `mailto:`, which opens a message to it and shows a reader nothing.
@@ -11,15 +11,29 @@ export interface VerifierKey {
11
11
  publicKey: string;
12
12
  }
13
13
  /**
14
- * What a signature covers: the record as it stood at `issuedAt`. Whether it still stands
15
- * is a live fact no signature holds, so its status is left out and `evidenceUrl` is where
16
- * to ask.
14
+ * What either version says of a record. The holder's mark is left out: it is about how
15
+ * their accounts are listed, and no part of what was verified.
17
16
  */
18
- export type SignedDocument = Omit<Evidence, 'status' | 'revokedAt' | 'signedUrl'> & {
17
+ type Signable = Omit<Evidence, 'status' | 'revokedAt' | 'signedUrl' | 'mark' | 'retiredAt'> & {
19
18
  type: 'verily-evidence';
20
- version: 1;
21
19
  issuedAt: number;
22
20
  };
21
+ /**
22
+ * What a signature covers: the record as it stood at `issuedAt`. Whether it still stands
23
+ * is a live fact no signature holds, so version 1 leaves its status out, means the record
24
+ * stood when it was signed, and gives `evidenceUrl` as where to ask.
25
+ *
26
+ * Version 2 is a retired record, which did not stand when it was signed and says so. It is
27
+ * a version of its own because a reader of version 1 ignores fields it does not know, and
28
+ * would read a retired record signed as version 1 as one that stood.
29
+ */
30
+ export type SignedDocument = (Signable & {
31
+ version: 1;
32
+ }) | (Signable & {
33
+ version: 2;
34
+ status: 'retired';
35
+ retiredAt: number;
36
+ });
23
37
  /**
24
38
  * A record that can be checked away from the verifier that served it: the document as
25
39
  * text under an OpenPGP cleartext signature, which `gpg --verify` reads as it is.
@@ -47,3 +61,4 @@ export declare function signedBy(signed: unknown): Promise<string | undefined>;
47
61
  * of it, so the fingerprint a record names is the one a list holds.
48
62
  */
49
63
  export declare function verifySigned(signed: unknown, keys: VerifierKey[]): Promise<SignedDocument | undefined>;
64
+ export {};
@@ -100,8 +100,11 @@ export async function verifySigned(signed, keys) {
100
100
  await signatures[0].verified;
101
101
  const document = JSON.parse(data);
102
102
  return document?.type === 'verily-evidence' &&
103
- document.version === 1 &&
104
- typeof document.issuedAt === 'number'
103
+ typeof document.issuedAt === 'number' &&
104
+ (document.version === 1 ||
105
+ (document.version === 2 &&
106
+ document.status === 'retired' &&
107
+ typeof document.retiredAt === 'number'))
105
108
  ? document
106
109
  : undefined;
107
110
  }
@@ -0,0 +1,51 @@
1
+ import { type ArtifactProvider } from '../core/index.js';
2
+ export interface DomainProviderOptions {
3
+ /** Shown as "Verify with …". */
4
+ name?: string;
5
+ /** Replaces the transport. For `dnsProvider()` that is only ever the resolver. */
6
+ fetch?: typeof fetch;
7
+ timeoutMs?: number;
8
+ }
9
+ export interface WellKnownProviderOptions extends DomainProviderOptions {
10
+ /**
11
+ * Domains this instance will read, lowercase. Omitted means any public host, which is
12
+ * the point of the method and needs Node: see `linkProvider()`.
13
+ */
14
+ hosts?: string[];
15
+ /**
16
+ * Replaces the transport, and with it the check that keeps an instance reading any host
17
+ * out of the network it runs in. Pass one only where it enforces that itself, such as
18
+ * through an egress proxy, or where `hosts` already confines it.
19
+ */
20
+ fetch?: typeof fetch;
21
+ /** How much of the file is read before giving up on finding the line. */
22
+ maxBytes?: number;
23
+ }
24
+ /**
25
+ * Proves a domain by a record in its DNS. The holder publishes the local subject's address
26
+ * in a TXT record at `_verily.<domain>` and names the domain; this looks the record up and
27
+ * checks the address is there. It reaches a domain that serves no page of its own, or only
28
+ * forwards somewhere else, which a link back never can.
29
+ *
30
+ * What it establishes is that whoever wrote the record could write that domain's DNS. That
31
+ * is the domain's holder or somebody they delegated to, and it is the exact name proved:
32
+ * nothing is said about a parent or a subdomain. A domain changes hands like an account,
33
+ * and takes the record with it or drops it.
34
+ *
35
+ * Like a link back, the proof is a standing statement and not a per-flow token, so
36
+ * freshness comes from `recheck()` looking it up again. A lookup that could not be made is
37
+ * unread, never absent.
38
+ */
39
+ export declare function dnsProvider(options?: DomainProviderOptions): ArtifactProvider;
40
+ /**
41
+ * Proves a domain by a file it serves. The holder puts the local subject's address on a
42
+ * line of `/.well-known/verily.txt` and names the domain; this reads the file and checks
43
+ * the line is there. It is for a holder who can add a file to a site and cannot reach its
44
+ * DNS or its pages' markup.
45
+ *
46
+ * The domain names the host that is fetched, so the read is the web key directory's: HTTPS
47
+ * on the default port, a public address, no redirect followed, a deadline and a bounded
48
+ * read. Only `text/plain` is read, because a line in anything else may be an example of a
49
+ * line and not one.
50
+ */
51
+ export declare function wellKnownProvider(options?: WellKnownProviderOptions): ArtifactProvider;
@@ -0,0 +1,285 @@
1
+ import { MIMEType } from 'node:util';
2
+ import { Refused, } from '../core/index.js';
3
+ import { discard, readBounded } from './body.js';
4
+ import { transport } from './public-fetch.js';
5
+ /**
6
+ * Domain suffixes that must never be fetched. The holder names the domain, so it names the
7
+ * host, and these are the names that can resolve to something inside the network running
8
+ * this. Reserved suffixes that resolve nowhere are left alone: asking about them is
9
+ * pointless rather than dangerous, and they are what tests use.
10
+ */
11
+ const reserved = ['local', 'internal', 'localhost', 'home', 'lan', 'corp', 'intranet'];
12
+ const name = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
13
+ /**
14
+ * The one resolver every lookup goes to. Fixed, because the holder chooses the name and
15
+ * nothing else: a resolver they could choose would answer whatever they liked. It is asked
16
+ * over HTTPS because a Worker has no resolver of its own, and at an address that answers a
17
+ * plain GET with JSON, so the lookup that was run is also a link a reader can open.
18
+ */
19
+ const resolver = 'https://dns.google/resolve';
20
+ /** The label the record is published under, so it sits apart from the domain's own records. */
21
+ const label = '_verily';
22
+ /** Where on a domain the file is served. */
23
+ const path = '/.well-known/verily.txt';
24
+ /** The TXT record type, as the resolver numbers it. */
25
+ const txt = 16;
26
+ /**
27
+ * Proves a domain by a record in its DNS. The holder publishes the local subject's address
28
+ * in a TXT record at `_verily.<domain>` and names the domain; this looks the record up and
29
+ * checks the address is there. It reaches a domain that serves no page of its own, or only
30
+ * forwards somewhere else, which a link back never can.
31
+ *
32
+ * What it establishes is that whoever wrote the record could write that domain's DNS. That
33
+ * is the domain's holder or somebody they delegated to, and it is the exact name proved:
34
+ * nothing is said about a parent or a subdomain. A domain changes hands like an account,
35
+ * and takes the record with it or drops it.
36
+ *
37
+ * Like a link back, the proof is a standing statement and not a per-flow token, so
38
+ * freshness comes from `recheck()` looking it up again. A lookup that could not be made is
39
+ * unread, never absent.
40
+ */
41
+ export function dnsProvider(options = {}) {
42
+ const request = options.fetch ?? fetch;
43
+ return {
44
+ ...shared(options),
45
+ method: 'dns',
46
+ instructions: (expect) => [
47
+ 'Add a TXT record to your domain’s DNS with this name and value, then enter the domain below.',
48
+ { code: label },
49
+ { code: expect },
50
+ 'A new record can take a few minutes to be seen.',
51
+ ],
52
+ resolve(artifact) {
53
+ const domain = named(artifact);
54
+ return domain ? lookup(domain) : artifact;
55
+ },
56
+ async verify({ artifact, expect }) {
57
+ const domain = looked(artifact);
58
+ // Why the resolver could not be asked is about the network this runs in.
59
+ const response = await request(lookup(domain), {
60
+ redirect: 'manual',
61
+ headers: { Accept: 'application/dns-json', 'User-Agent': 'Verily-V0' },
62
+ signal: AbortSignal.timeout(options.timeoutMs ?? 15000),
63
+ }).catch(() => {
64
+ throw new Refused('DNS could not be read');
65
+ });
66
+ let answer;
67
+ try {
68
+ if (!response.ok)
69
+ throw new Refused('DNS could not be read');
70
+ const { bytes, truncated } = await readBounded(response, 64 * 1024);
71
+ if (truncated)
72
+ throw new Refused('DNS could not be read');
73
+ answer = parsed(new TextDecoder().decode(bytes));
74
+ }
75
+ finally {
76
+ await discard(response);
77
+ }
78
+ // A cut-off answer may have left out the record that would have matched.
79
+ if (!isRecord(answer) || answer.TC === true)
80
+ throw new Refused('DNS could not be read');
81
+ // The name not existing is an answer; anything else that is not success is no answer.
82
+ if (answer.Status !== 0 && answer.Status !== 3)
83
+ throw new Refused('DNS could not be read');
84
+ const records = Array.isArray(answer.Answer) ? answer.Answer : [];
85
+ if (!records.some((record) => isRecord(record) &&
86
+ record.type === txt &&
87
+ typeof record.data === 'string' &&
88
+ text(record.data) === expect))
89
+ throw new Refused(`No TXT record at ${label}.${domain} names this subject`);
90
+ return account(domain);
91
+ },
92
+ };
93
+ }
94
+ /**
95
+ * Proves a domain by a file it serves. The holder puts the local subject's address on a
96
+ * line of `/.well-known/verily.txt` and names the domain; this reads the file and checks
97
+ * the line is there. It is for a holder who can add a file to a site and cannot reach its
98
+ * DNS or its pages' markup.
99
+ *
100
+ * The domain names the host that is fetched, so the read is the web key directory's: HTTPS
101
+ * on the default port, a public address, no redirect followed, a deadline and a bounded
102
+ * read. Only `text/plain` is read, because a line in anything else may be an example of a
103
+ * line and not one.
104
+ */
105
+ export function wellKnownProvider(options = {}) {
106
+ const request = transport(options);
107
+ const hosts = options.hosts?.map((host) => host.toLowerCase());
108
+ const maxBytes = options.maxBytes ?? 64 * 1024;
109
+ return {
110
+ ...shared(options),
111
+ method: 'wellknown',
112
+ instructions: (expect) => [
113
+ 'Serve a plain text file at this path on your domain, with this address on a line of its own, then enter the domain below.',
114
+ { code: path },
115
+ { code: expect },
116
+ ],
117
+ resolve(artifact) {
118
+ const domain = named(artifact);
119
+ return domain ? `https://${domain}${path}` : artifact;
120
+ },
121
+ async verify({ artifact, expect }) {
122
+ const domain = served(artifact);
123
+ if (hosts && !hosts.includes(domain))
124
+ throw new Refused('Not a host this instance reads');
125
+ const response = await request(`https://${domain}${path}`, {
126
+ // Workers refuse 'error', so a redirect comes back as a response and is refused below.
127
+ redirect: 'manual',
128
+ headers: { Accept: 'text/plain', 'User-Agent': 'Verily-V0' },
129
+ signal: AbortSignal.timeout(options.timeoutMs ?? 15000),
130
+ }).catch(() => {
131
+ throw new Refused('File could not be reached');
132
+ });
133
+ try {
134
+ // The holder named this domain, so a redirect would take the check somewhere else.
135
+ if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400))
136
+ throw new Refused('File redirects, and a redirect is not followed');
137
+ if (!response.ok)
138
+ throw new Refused('File unavailable');
139
+ if (mediaType(response.headers.get('content-type')) !== 'text/plain')
140
+ throw new Refused('File is not plain text');
141
+ const { bytes, truncated } = await readBounded(response, maxBytes);
142
+ const lines = new TextDecoder().decode(bytes).split(/\r?\n/);
143
+ // The last line of a file cut short may be the start of some longer address.
144
+ if (truncated)
145
+ lines.pop();
146
+ if (!lines.some((line) => line.trim() === expect))
147
+ throw new Refused(truncated ? 'File is too large to read' : 'File has no line naming this subject');
148
+ }
149
+ finally {
150
+ await discard(response);
151
+ }
152
+ return account(domain);
153
+ },
154
+ };
155
+ }
156
+ /** What both methods say of themselves: they name the same thing, and differ in how. */
157
+ function shared(options) {
158
+ return {
159
+ id: 'domain',
160
+ name: options.name ?? 'Domain',
161
+ artifact: 'location',
162
+ field: 'Your domain',
163
+ input: 'text',
164
+ // The subject's own address is the entire claim, so there is nothing per-flow to mint.
165
+ expect(local) {
166
+ if (!local.profileUrl)
167
+ throw new Error('A domain proof needs a local subject with a profileUrl to name');
168
+ return local.profileUrl;
169
+ },
170
+ // A domain is where it says it is, so a record of one already says where to look.
171
+ known: (account) => (account.kind === 'domain' ? account.id : undefined),
172
+ };
173
+ }
174
+ /**
175
+ * A domain, named by itself. The id is the name as DNS compares it: lowercase, and in
176
+ * punycode where it was typed in another script.
177
+ */
178
+ function account(domain) {
179
+ return { id: domain, kind: 'domain', handle: domain, profileUrl: `https://${domain}/` };
180
+ }
181
+ /**
182
+ * The domain a holder typed, or nothing where it is not one. Taken bare or as the address
183
+ * of its front page, since both are how a domain is written, and never with a path: a path
184
+ * is somebody's page, and this proves no page.
185
+ */
186
+ function named(typed) {
187
+ const trimmed = typed.trim();
188
+ if (trimmed.length > 300)
189
+ return undefined;
190
+ const url = URL.parse(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
191
+ if (!url || url.username || url.password || url.port || url.search || url.hash)
192
+ return undefined;
193
+ if (url.pathname !== '/')
194
+ return undefined;
195
+ const domain = url.hostname.toLowerCase().replace(/\.$/, '');
196
+ return valid(domain) ? domain : undefined;
197
+ }
198
+ /** A dotted public-looking name, as DNS limits one, and not an address literal. */
199
+ function valid(domain) {
200
+ return (domain.length <= 253 - label.length - 1 &&
201
+ name.test(domain) &&
202
+ domain.split('.').every((part) => part.length <= 63) &&
203
+ !/^[\d.]+$/.test(domain) &&
204
+ !reserved.includes(domain.split('.').pop()));
205
+ }
206
+ /** The lookup for a domain's record: the address that is fetched, and kept as the proof's. */
207
+ function lookup(domain) {
208
+ return `${resolver}?name=${label}.${domain}&type=TXT`;
209
+ }
210
+ /**
211
+ * The domain a lookup address asks about, once it is exactly a lookup this would make. The
212
+ * address comes back from the holder or from a record, and anything else is somewhere this
213
+ * has no business fetching.
214
+ */
215
+ function looked(artifact) {
216
+ const url = artifact.length <= 600 ? URL.parse(artifact) : null;
217
+ const asked = url?.searchParams.get('name') ?? '';
218
+ const domain = asked.startsWith(`${label}.`) ? asked.slice(label.length + 1) : '';
219
+ if (!url || !valid(domain) || url.href !== lookup(domain))
220
+ throw new Refused('Not a domain this can look up');
221
+ return domain;
222
+ }
223
+ /** The domain a file address is on, once it is exactly where this would read one. */
224
+ function served(artifact) {
225
+ const url = artifact.length <= 600 ? URL.parse(artifact) : null;
226
+ const domain = url?.hostname.toLowerCase() ?? '';
227
+ if (!url || !valid(domain) || url.href !== `https://${domain}${path}`)
228
+ throw new Refused('Not a domain this can read');
229
+ return domain;
230
+ }
231
+ /**
232
+ * A TXT record's text. A record is one or more strings joined end to end, and a resolver
233
+ * writes them either already joined or each in quotes with a backslash before anything
234
+ * awkward. One this cannot read is no text at all, since a guess could read as a match.
235
+ */
236
+ function text(data) {
237
+ if (!data.startsWith('"'))
238
+ return data.trim();
239
+ let out = '';
240
+ let at = 0;
241
+ while (at < data.length) {
242
+ if (data[at] === ' ') {
243
+ at++;
244
+ continue;
245
+ }
246
+ if (data[at] !== '"')
247
+ return undefined;
248
+ at++;
249
+ while (at < data.length && data[at] !== '"') {
250
+ if (data[at] === '\\') {
251
+ const code = /^\d{3}/.exec(data.slice(at + 1))?.[0];
252
+ out += code ? String.fromCharCode(Number(code)) : (data[at + 1] ?? '');
253
+ at += code ? 4 : 2;
254
+ }
255
+ else
256
+ out += data[at++];
257
+ }
258
+ if (data[at] !== '"')
259
+ return undefined;
260
+ at++;
261
+ }
262
+ return out.trim();
263
+ }
264
+ function parsed(body) {
265
+ try {
266
+ return JSON.parse(body);
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ }
272
+ /** A `Content-Type`'s essence, or nothing where it is missing or malformed. */
273
+ function mediaType(header) {
274
+ if (header === null)
275
+ return undefined;
276
+ try {
277
+ return new MIMEType(header).essence;
278
+ }
279
+ catch {
280
+ return undefined;
281
+ }
282
+ }
283
+ function isRecord(value) {
284
+ return value !== null && typeof value === 'object';
285
+ }
@@ -7,6 +7,7 @@ export { discordProvider } from './discord.js';
7
7
  export { youtubeProvider } from './youtube.js';
8
8
  export { githubGistProvider } from './github-gist.js';
9
9
  export { linkProvider, githubLinkProvider, type LinkProviderOptions } from './link.js';
10
+ export { dnsProvider, wellKnownProvider, type DomainProviderOptions, type WellKnownProviderOptions, } from './domain.js';
10
11
  export { pgpProvider } from './pgp.js';
11
12
  export { generateSigningKey, signer } from './signing.js';
12
13
  export { emailProvider, type EmailImage, type EmailMessage, type EmailProviderOptions, } from './email.js';