Suite of AT Protocol TypeScript libraries built on web standards
21
fork

Configure Feed

Select the types of activity you want to include in your feed.

at lex 210 lines 5.2 kB view raw
1import { z } from "zod"; 2 3// Parsing atproto data 4// -------- 5 6export const isValidDidDoc = (doc: unknown): doc is DidDocument => { 7 return didDocument.safeParse(doc).success; 8}; 9 10export const getDid = (doc: DidDocument): string => { 11 const id = doc.id; 12 if (typeof id !== "string") { 13 throw new Error("No `id` on document"); 14 } 15 return id; 16}; 17 18export const getHandle = (doc: DidDocument): string | undefined => { 19 const aka = doc.alsoKnownAs; 20 if (aka) { 21 for (let i = 0; i < aka.length; i++) { 22 const alias = aka[i]; 23 if (alias.startsWith("at://")) { 24 // strip off "at://" prefix 25 return alias.slice(5); 26 } 27 } 28 } 29 return undefined; 30}; 31 32// @NOTE we parse to type/publicKeyMultibase to avoid the dependency on @atproto/crypto 33export const getSigningKey = ( 34 doc: DidDocument, 35): { type: string; publicKeyMultibase: string } | undefined => { 36 return getVerificationMaterial(doc, "atproto"); 37}; 38 39export const getVerificationMaterial = ( 40 doc: DidDocument, 41 keyId: string, 42): { type: string; publicKeyMultibase: string } | undefined => { 43 // /!\ Hot path 44 45 const key = findItemById(doc, "verificationMethod", `#${keyId}`); 46 if (!key) { 47 return undefined; 48 } 49 50 if (!key.publicKeyMultibase) { 51 return undefined; 52 } 53 54 return { 55 type: key.type, 56 publicKeyMultibase: key.publicKeyMultibase, 57 }; 58}; 59 60export const getSigningDidKey = (doc: DidDocument): string | undefined => { 61 const parsed = getSigningKey(doc); 62 if (!parsed) return; 63 return `did:key:${parsed.publicKeyMultibase}`; 64}; 65 66export const getPdsEndpoint = (doc: DidDocument): string | undefined => { 67 return getServiceEndpoint(doc, { 68 id: "#atproto_pds", 69 type: "AtprotoPersonalDataServer", 70 }); 71}; 72 73export const getFeedGenEndpoint = (doc: DidDocument): string | undefined => { 74 return getServiceEndpoint(doc, { 75 id: "#bsky_fg", 76 type: "BskyFeedGenerator", 77 }); 78}; 79 80export const getNotifEndpoint = (doc: DidDocument): string | undefined => { 81 return getServiceEndpoint(doc, { 82 id: "#bsky_notif", 83 type: "BskyNotificationService", 84 }); 85}; 86 87export const getServiceEndpoint = ( 88 doc: DidDocument, 89 opts: { id: string; type?: string }, 90): string | undefined => { 91 // /!\ Hot path 92 93 const service = findItemById(doc, "service", opts.id); 94 if (!service) { 95 return undefined; 96 } 97 98 if (opts.type && service.type !== opts.type) { 99 return undefined; 100 } 101 102 if (typeof service.serviceEndpoint !== "string") { 103 return undefined; 104 } 105 106 return validateUrl(service.serviceEndpoint); 107}; 108 109function findItemById< 110 D extends DidDocument, 111 T extends "verificationMethod" | "service", 112>(doc: D, type: T, id: string): NonNullable<D[T]>[number] | undefined; 113function findItemById( 114 doc: DidDocument, 115 type: "verificationMethod" | "service", 116 id: string, 117) { 118 // /!\ Hot path 119 120 const items = doc[type]; 121 if (items) { 122 for (let i = 0; i < items.length; i++) { 123 const item = items[i]; 124 const itemId = item.id; 125 126 if ( 127 itemId[0] === "#" 128 ? itemId === id 129 // Optimized version of: itemId === `${doc.id}${id}` 130 : itemId.length === doc.id.length + id.length && 131 itemId[doc.id.length] === "#" && 132 itemId.endsWith(id) && 133 itemId.startsWith(doc.id) // <== We could probably skip this check 134 ) { 135 return item; 136 } 137 } 138 } 139 return undefined; 140} 141 142// Check protocol and hostname to prevent potential SSRF 143const validateUrl = (urlStr: string): string | undefined => { 144 if (!urlStr.startsWith("http://") && !urlStr.startsWith("https://")) { 145 return undefined; 146 } 147 148 if (!canParseUrl(urlStr)) { 149 return undefined; 150 } 151 152 return urlStr; 153}; 154 155const canParseUrl = URL.canParse ?? 156 // URL.canParse is not available in Node.js < 18.17.0 157 ((urlStr: string): boolean => { 158 try { 159 new URL(urlStr); 160 return true; 161 } catch { 162 return false; 163 } 164 }); 165 166// Types 167// -------- 168 169const verificationMethod: VerificationMethod = z.object({ 170 id: z.string(), 171 type: z.string(), 172 controller: z.string(), 173 publicKeyMultibase: z.string().optional(), 174}); 175 176type VerificationMethod = z.ZodObject<{ 177 id: z.ZodString; 178 type: z.ZodString; 179 controller: z.ZodString; 180 publicKeyMultibase: z.ZodOptional<z.ZodString>; 181}, z.core.$strip>; 182 183const service: Service = z.object({ 184 id: z.string(), 185 type: z.string(), 186 serviceEndpoint: z.union([z.string(), z.record(z.string(), z.unknown())]), 187}); 188 189type Service = z.ZodObject<{ 190 id: z.ZodString; 191 type: z.ZodString; 192 serviceEndpoint: z.ZodUnion< 193 readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>] 194 >; 195}, z.core.$strip>; 196 197export const didDocument: DidDocumentType = z.object({ 198 id: z.string(), 199 alsoKnownAs: z.array(z.string()).optional(), 200 verificationMethod: z.array(verificationMethod).optional(), 201 service: z.array(service).optional(), 202}); 203type DidDocumentType = z.ZodObject<{ 204 id: z.ZodString; 205 alsoKnownAs: z.ZodOptional<z.ZodArray<z.ZodString>>; 206 verificationMethod: z.ZodOptional<z.ZodArray<VerificationMethod>>; 207 service: z.ZodOptional<z.ZodArray<Service>>; 208}, z.core.$strip>; 209 210export type DidDocument = z.infer<DidDocumentType>;