Suite of AT Protocol TypeScript libraries built on web standards
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>;