forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {type ComAtprotoIdentityDefs, isDid} from '@atproto/api'
2import {
3 type IdentityInfo,
4 type IdentityResolver,
5} from '@atproto-labs/identity-resolver'
6
7import {getDidDocumentUrl} from '#/lib/atproto/did'
8import {DOH_ENDPOINT} from '#/lib/constants'
9import {readPlcDirectory} from '#/state/preferences/plc-directory'
10import {createPublicAgent} from './agent'
11
12type AtprotoDid = `did:plc:${string}` | `did:web:${string}`
13type DidDocument = {
14 id?: string
15 alsoKnownAs?: string[]
16 service?: Service[]
17}
18
19type Service = {
20 id?: string
21 type?: string
22 serviceEndpoint?: string
23}
24
25const HANDLE_INVALID = 'handle.invalid'
26
27function asNormalizedHandle(input: string) {
28 const handle = input.toLowerCase()
29 return /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/.test(
30 handle,
31 )
32 ? handle
33 : undefined
34}
35
36function extractNormalizedHandle(document: DidDocument) {
37 if (!Array.isArray(document.alsoKnownAs)) return
38
39 for (const value of document.alsoKnownAs) {
40 if (value.startsWith('at://')) {
41 return asNormalizedHandle(value.slice(5))
42 }
43 }
44}
45
46function findService(doc: DidDocument, id: string, type?: string) {
47 if (!Array.isArray(doc?.service)) return
48 return doc.service.find(
49 service =>
50 service?.serviceEndpoint &&
51 service?.id === id &&
52 (!type || service?.type === type),
53 )
54}
55
56async function resolveHandleUsingAppView(
57 handle: string,
58 signal?: AbortSignal,
59): Promise<AtprotoDid> {
60 const agent = createPublicAgent()
61
62 try {
63 const res = await agent.resolveHandle({handle}, {signal})
64 return res.data.did as AtprotoDid
65 } finally {
66 agent.dispose()
67 }
68}
69
70async function resolveHandleUsingDoh(
71 handle: string,
72 signal?: AbortSignal,
73): Promise<AtprotoDid | null> {
74 const url = new URL(DOH_ENDPOINT)
75 url.searchParams.set('type', 'TXT')
76 url.searchParams.set('name', `_atproto.${handle}`)
77
78 const response = await fetch(url, {
79 headers: {
80 accept: 'application/dns-json',
81 },
82 redirect: 'follow',
83 signal,
84 })
85
86 if (!response.ok) {
87 return null
88 }
89
90 const result = (await response.json()) as {
91 Answer?: Array<{type?: number; data?: string}>
92 }
93 const txtRecords =
94 result.Answer?.filter(
95 answer => answer.type === 16 && typeof answer.data === 'string',
96 ).map(answer => answer.data!.replace(/^"|"$/g, '').replace(/\\"/g, '"')) ??
97 []
98
99 let did: AtprotoDid | null = null
100 for (const record of txtRecords) {
101 if (!record.startsWith('did=')) continue
102
103 const nextDid = record.slice(4)
104 if (!isDid(nextDid)) {
105 return null
106 }
107
108 if (did && did !== nextDid) {
109 return null
110 }
111
112 did = nextDid as AtprotoDid
113 }
114
115 return did
116}
117
118async function resolveHandleUsingWellKnown(
119 handle: string,
120 signal?: AbortSignal,
121): Promise<AtprotoDid | null> {
122 try {
123 const response = await fetch(`https://${handle}/.well-known/atproto-did`, {
124 redirect: 'error',
125 signal,
126 })
127 const text = await response.text()
128 const firstLine = text.split('\n')[0]?.trim()
129 return firstLine && isDid(firstLine) ? (firstLine as AtprotoDid) : null
130 } catch {
131 signal?.throwIfAborted()
132 return null
133 }
134}
135
136async function resolveHandleClientSide(
137 handle: string,
138 signal?: AbortSignal,
139): Promise<AtprotoDid | null> {
140 try {
141 const did = await resolveHandleUsingDoh(handle, signal)
142 if (did) return did
143 } catch {
144 signal?.throwIfAborted()
145 }
146
147 return resolveHandleUsingWellKnown(handle, signal)
148}
149
150async function resolveHandle(
151 handle: string,
152 signal?: AbortSignal,
153): Promise<AtprotoDid> {
154 try {
155 return await resolveHandleUsingAppView(handle, signal)
156 } catch (appViewError) {
157 const fallbackDid = await resolveHandleClientSide(handle, signal)
158 if (fallbackDid) {
159 return fallbackDid
160 }
161
162 throw appViewError
163 }
164}
165
166async function resolveDidDocument(
167 did: AtprotoDid,
168 signal?: AbortSignal,
169): Promise<DidDocument> {
170 const docUrl = getDidDocumentUrl(did, readPlcDirectory())
171 if (!docUrl) {
172 throw new Error(`Unsupported DID method for ${did}`)
173 }
174
175 try {
176 const res = await fetch(docUrl, {
177 headers: {
178 accept: 'application/did+ld+json, application/json',
179 },
180 signal,
181 })
182
183 if (!res.ok) {
184 throw new Error(`Failed to resolve DID document for ${did}`)
185 }
186
187 return (await res.json()) as DidDocument
188 } catch (err) {
189 if (!did.startsWith('did:web:')) {
190 throw err
191 }
192
193 const agent = createPublicAgent()
194 try {
195 const res = await agent.com.atproto.identity.resolveDid({did}, {signal})
196 return res.data.didDoc as DidDocument
197 } finally {
198 agent.dispose()
199 }
200 }
201}
202
203async function getValidatedHandleFromDidDocument(
204 did: AtprotoDid,
205 didDoc: DidDocument,
206 signal?: AbortSignal,
207) {
208 const handle = extractNormalizedHandle(didDoc)
209 if (!handle) return HANDLE_INVALID
210
211 try {
212 const resolvedDid = await resolveHandle(handle, signal)
213 return resolvedDid === did ? handle : HANDLE_INVALID
214 } catch {
215 return HANDLE_INVALID
216 }
217}
218
219export async function resolveIdentityUsingAppView(
220 identifier: string,
221 signal?: AbortSignal,
222): Promise<ComAtprotoIdentityDefs.IdentityInfo> {
223 if (isDid(identifier)) {
224 const did = identifier as AtprotoDid
225 const didDoc = await resolveDidDocument(did, signal)
226 const handle = await getValidatedHandleFromDidDocument(did, didDoc, signal)
227
228 return {
229 did,
230 didDoc,
231 handle,
232 }
233 }
234
235 const handle = asNormalizedHandle(identifier)
236 if (!handle) {
237 throw new Error(`Invalid handle "${identifier}" provided.`)
238 }
239
240 const did = await resolveHandle(handle, signal)
241 const didDoc = await resolveDidDocument(did, signal)
242
243 return {
244 did,
245 didDoc,
246 handle: extractNormalizedHandle(didDoc) ?? HANDLE_INVALID,
247 }
248}
249
250export function createIdentityResolver(): IdentityResolver {
251 return {
252 async resolve(
253 input: string,
254 options?: {signal?: AbortSignal},
255 ): Promise<IdentityInfo> {
256 const identity = await resolveIdentityUsingAppView(input, options?.signal)
257
258 return {
259 did: identity.did as AtprotoDid,
260 didDoc: identity.didDoc as IdentityInfo['didDoc'],
261 handle: identity.handle,
262 }
263 },
264 }
265}
266
267export function getPdsServiceUrlFromIdentityInfo(
268 identity: Pick<ComAtprotoIdentityDefs.IdentityInfo, 'didDoc'>,
269) {
270 return findService(
271 identity.didDoc as DidDocument,
272 '#atproto_pds',
273 'AtprotoPersonalDataServer',
274 )?.serviceEndpoint
275}