Skip to content

Commit 1e559c9

Browse files
committed
feat(identity-resolver): handle resolvers
1 parent 12b217f commit 1e559c9

File tree

9 files changed

+397
-6
lines changed

9 files changed

+397
-6
lines changed

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ of atcute.
4040
| **Identity packages** _(work in progress)_ |
4141
| [`did-plc`](./packages/identity/did-plc): validations, type definitions and schemas for did:plc operations |
4242
| [`identity`](./packages/identity/identity): syntax, type definitions and schemas for handles, DIDs and DID documents |
43-
| [`identity-resolver`]('./packages/identity/identity-resolver): DID document resolution |
44-
| `identity-resolver-node`: node.js dns handle resolution |
43+
| [`identity-resolver`]('./packages/identity/identity-resolver): handle and DID document resolution |
44+
| `identity-resolver-node`: `node:dns`-powered handle resolution |
4545
| **Lexicon definitions** |
4646
| [`bluemoji`](./packages/definitions/bluemoji): adds `blue.moji.*` lexicons |
4747
| [`bluesky`](./packages/definitions/bluesky): adds `app.bsky.*` and `chat.bsky.*` lexicons |

packages/identity/identity-resolver/README.md

+16-3
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,26 @@
66
atproto handle and DID document resolution
77

88
```ts
9-
const resolver = new CompositeDidResolver({
9+
// handle resolution
10+
const handleResolver = new CompositeHandleResolver({
11+
strategy: 'race',
12+
methods: {
13+
dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }),
14+
http: new WellKnownHandleResolver(),
15+
},
16+
});
17+
18+
const handle = await didResolver.resolve('bsky.app');
19+
// ^? 'did:plc:z72i7hdynmk6r22z27h6tvur'
20+
21+
// did doc resolution
22+
const didResolver = new CompositeDidResolver({
1023
methods: {
1124
plc: new PlcDidResolver(),
1225
web: new WebDidResolver(),
1326
},
1427
});
1528

16-
const doc = await resolver.resolve('did:plc:ia76kvnndjutgedggx2ibrem');
17-
// ^? { '@context': [...], id: 'did:plc:ia76kvnndjutgedggx2ibrem', ... }
29+
const doc = await didResolver.resolve('did:plc:z72i7hdynmk6r22z27h6tvur');
30+
// ^? { '@context': [...], id: 'did:plc:z72i7hdynmk6r22z27h6tvur', ... }
1831
```

packages/identity/identity-resolver/lib/errors.ts

+42
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,45 @@ export class ImproperDidError extends DidResolutionError {
1919
super(`improper did; did=${did}`);
2020
}
2121
}
22+
23+
export class HandleResolutionError extends Error {
24+
override name = 'HandleResolutionError';
25+
}
26+
27+
export class MissingDidError extends HandleResolutionError {
28+
override name = 'MissingDidError';
29+
30+
constructor(public handle: string) {
31+
super(`handle returned no did; handle=${handle}`);
32+
}
33+
}
34+
35+
export class FailedDidResolutionError extends HandleResolutionError {
36+
override name = 'FailedDidResolutionError';
37+
38+
constructor(
39+
public handle: string,
40+
options?: ErrorOptions,
41+
) {
42+
super(`failed to resolve handle; handle=${handle}`, options);
43+
}
44+
}
45+
46+
export class InvalidResolvedDidError extends HandleResolutionError {
47+
override name = 'InvalidResolvedDidError';
48+
49+
constructor(
50+
public handle: string,
51+
public did: string,
52+
) {
53+
super(`handle returned invalid did; handle=${handle}; did=${did}`);
54+
}
55+
}
56+
57+
export class DuplicateResolvedDidError extends HandleResolutionError {
58+
override name = 'DuplicateResolvedDidError';
59+
60+
constructor(handle: string) {
61+
super(`handle returned multiple did values; handle=${handle}`);
62+
}
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Did, Handle } from '@atcute/identity';
2+
3+
import type { HandleResolver, ResolveHandleOptions } from '../types.js';
4+
5+
export type CompositeStrategy = 'http-first' | 'dns-first' | 'race';
6+
7+
export interface CompositeHandleResolverOptions {
8+
/** controls how the resolution is done, defaults to 'race' */
9+
strategy?: CompositeStrategy;
10+
/** the methods to use for resolving the handle. */
11+
methods: Record<'http' | 'dns', HandleResolver>;
12+
}
13+
14+
export class CompositeHandleResolver implements HandleResolver {
15+
#methods: Record<string, HandleResolver>;
16+
strategy: CompositeStrategy;
17+
18+
constructor({ methods, strategy = 'race' }: CompositeHandleResolverOptions) {
19+
this.#methods = methods;
20+
this.strategy = strategy;
21+
}
22+
23+
async resolve(handle: Handle, options?: ResolveHandleOptions): Promise<Did> {
24+
const { http, dns } = this.#methods;
25+
26+
const parentSignal = options?.signal;
27+
const controller = new AbortController();
28+
if (parentSignal) {
29+
parentSignal.addEventListener('abort', () => controller.abort(), { signal: controller.signal });
30+
}
31+
32+
const dnsPromise = dns.resolve(handle, { ...options, signal: controller.signal });
33+
const httpPromise = http.resolve(handle, { ...options, signal: controller.signal });
34+
35+
switch (this.strategy) {
36+
case 'race': {
37+
return new Promise((resolve) => {
38+
dnsPromise.then(
39+
(did) => {
40+
controller.abort();
41+
resolve(did);
42+
},
43+
() => resolve(httpPromise),
44+
);
45+
46+
httpPromise.then(
47+
(did) => {
48+
controller.abort();
49+
resolve(did);
50+
},
51+
() => resolve(dnsPromise),
52+
);
53+
});
54+
}
55+
case 'dns-first': {
56+
const resolved = await dnsPromise.catch(noopPromise);
57+
if (resolved) {
58+
controller.abort();
59+
return resolved;
60+
}
61+
62+
return httpPromise;
63+
}
64+
case 'http-first': {
65+
const resolved = await httpPromise.catch(noopPromise);
66+
if (resolved) {
67+
controller.abort();
68+
return resolved;
69+
}
70+
71+
return dnsPromise;
72+
}
73+
}
74+
}
75+
}
76+
77+
const noop = () => {};
78+
const noopPromise = () => new Promise<never>(noop);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import * as v from '@badrap/valita';
2+
3+
import { isAtprotoDid, type Did, type Handle } from '@atcute/identity';
4+
import { isResponseOk, parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch';
5+
6+
import * as err from '../../errors.js';
7+
import type { HandleResolver, ResolveHandleOptions } from '../../types.js';
8+
9+
const uint32 = v.number().assert((input) => Number.isInteger(input) && input >= 0 && input <= 2 ** 32 - 1);
10+
11+
const question = v.object({
12+
name: v.string(),
13+
type: v.literal(16), // TXT
14+
});
15+
16+
const answer = v.object({
17+
name: v.string(),
18+
type: v.literal(16), // TXT
19+
TTL: uint32,
20+
data: v.string(),
21+
});
22+
23+
const result = v.object({
24+
/** DNS response code */
25+
Status: uint32,
26+
/** Whether response is truncated */
27+
TC: v.boolean(),
28+
/** Whether recursive desired bit is set, always true for Google and Cloudflare DoH */
29+
RD: v.boolean(),
30+
/** Whether recursive available bit is set, always true for Google and Cloudflare DoH */
31+
RA: v.boolean(),
32+
/** Whether response data was validated with DNSSEC */
33+
AD: v.boolean(),
34+
/** Whether client asked to disable DNSSEC validation */
35+
CD: v.boolean(),
36+
/** Requested records */
37+
Question: v.tuple([question]),
38+
/** Answers */
39+
Answer: v.array(answer),
40+
});
41+
42+
const extractTxtData = (input: string) => {
43+
return input.replace(/^"|"$/g, '').replace(/\\"/g, '"');
44+
};
45+
46+
const SUBDOMAIN = '_atproto';
47+
const PREFIX = 'did=';
48+
49+
const fetchDohJsonHandler = pipe(
50+
isResponseOk,
51+
parseResponseAsJson(/^application\/(dns-)?json$/, 16 * 1024),
52+
validateJsonWith(result),
53+
);
54+
55+
export interface DohJsonHandleResolverOptions {
56+
dohUrl: string;
57+
fetch?: typeof fetch;
58+
}
59+
60+
export class DohJsonHandleResolver implements HandleResolver {
61+
readonly dohUrl: string;
62+
#fetch: typeof fetch;
63+
64+
constructor({ dohUrl, fetch: fetchThis = fetch }: DohJsonHandleResolverOptions) {
65+
this.dohUrl = dohUrl;
66+
this.#fetch = fetchThis;
67+
}
68+
69+
async resolve(handle: Handle, options?: ResolveHandleOptions): Promise<Did> {
70+
let json: v.Infer<typeof result>;
71+
72+
try {
73+
const url = new URL(this.dohUrl);
74+
url.searchParams.set('name', `${SUBDOMAIN}.${handle}`);
75+
url.searchParams.set('type', 'TXT');
76+
77+
const response = await (0, this.#fetch)(url, {
78+
signal: options?.signal,
79+
cache: options?.noCache ? 'no-cache' : 'default',
80+
headers: { accept: 'application/dns-json' },
81+
});
82+
83+
const handled = await fetchDohJsonHandler(response);
84+
85+
json = handled.json;
86+
} catch (cause) {
87+
throw new err.FailedDidResolutionError(handle, { cause });
88+
}
89+
90+
const status = json.Status;
91+
const answers = json.Answer;
92+
93+
if (status !== 0 /* NOERROR */) {
94+
if (status === 3 /* NXDOMAIN */) {
95+
throw new err.MissingDidError(handle);
96+
}
97+
98+
throw new err.FailedDidResolutionError(handle, {
99+
cause: new TypeError(`dns returned ${status}`),
100+
});
101+
}
102+
103+
for (let i = 0, il = answers.length; i < il; i++) {
104+
const answer = answers[i];
105+
const data = extractTxtData(answer.data);
106+
107+
if (!data.startsWith(PREFIX)) {
108+
continue;
109+
}
110+
111+
for (let j = i + 1; j < il; j++) {
112+
const data = extractTxtData(answers[j].data);
113+
if (data.startsWith(PREFIX)) {
114+
throw new err.DuplicateResolvedDidError(handle);
115+
}
116+
}
117+
118+
const did = data.slice(PREFIX.length);
119+
if (!isAtprotoDid(did)) {
120+
throw new err.InvalidResolvedDidError(handle, did);
121+
}
122+
123+
return did;
124+
}
125+
126+
// theoretically this shouldn't happen, it should've returned NXDOMAIN
127+
throw new err.MissingDidError(handle);
128+
}
129+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { isAtprotoDid, type Did, type Handle } from '@atcute/identity';
2+
import { FailedResponseError, isResponseOk, pipe, readResponseAsText } from '@atcute/util-fetch';
3+
4+
import * as err from '../../errors.js';
5+
import type { HandleResolver, ResolveHandleOptions } from '../../types.js';
6+
7+
export interface WellKnownHandleResolverOptions {
8+
fetch?: typeof fetch;
9+
}
10+
11+
const fetchWellKnownHandler = pipe(isResponseOk, readResponseAsText(2048 + 16));
12+
13+
export class WellKnownHandleResolver implements HandleResolver {
14+
#fetch: typeof fetch;
15+
16+
constructor({ fetch: fetchThis = fetch }: WellKnownHandleResolverOptions = {}) {
17+
this.#fetch = fetchThis;
18+
}
19+
20+
async resolve(handle: Handle, options?: ResolveHandleOptions): Promise<Did> {
21+
let text: string;
22+
23+
try {
24+
const url = new URL('/.well-known/atproto-did', `https://${handle}`);
25+
26+
const response = await (0, this.#fetch)(url, {
27+
signal: options?.signal,
28+
cache: options?.noCache ? 'no-cache' : 'default',
29+
redirect: 'error',
30+
});
31+
32+
const handled = await fetchWellKnownHandler(response);
33+
34+
text = handled.text;
35+
} catch (cause) {
36+
if (cause instanceof FailedResponseError && cause.status === 404) {
37+
throw new err.MissingDidError(handle);
38+
}
39+
40+
throw new err.FailedDidResolutionError(handle, { cause });
41+
}
42+
43+
const did = text.split('\n')[0]!.trim();
44+
if (!isAtprotoDid(did)) {
45+
throw new err.InvalidResolvedDidError(handle, did);
46+
}
47+
48+
return did;
49+
}
50+
}

0 commit comments

Comments
 (0)