atproto explorer pdsls.dev
atproto tool
at v1.1.1 21 kB view raw
1import { Client, CredentialManager } from "@atcute/client"; 2import { DidDocument, getPdsEndpoint } from "@atcute/identity"; 3import { lexiconDoc } from "@atcute/lexicon-doc"; 4import { RecordValidator } from "@atcute/lexicon-doc/validations"; 5import { FailedLexiconResolutionError, ResolvedSchema } from "@atcute/lexicon-resolver"; 6import { ActorIdentifier, is, Nsid } from "@atcute/lexicons"; 7import { AtprotoDid, Did, isNsid } from "@atcute/lexicons/syntax"; 8import { verifyRecord } from "@atcute/repo"; 9import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 10import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 11import { Backlinks } from "../components/backlinks.jsx"; 12import { Button } from "../components/button.jsx"; 13import { RecordEditor, setPlaceholder } from "../components/create.jsx"; 14import { 15 CopyMenu, 16 DropdownMenu, 17 MenuProvider, 18 MenuSeparator, 19 NavMenu, 20} from "../components/dropdown.jsx"; 21import { JSONValue } from "../components/json.jsx"; 22import { LexiconSchemaView } from "../components/lexicon-schema.jsx"; 23import { agent } from "../components/login.jsx"; 24import { Modal } from "../components/modal.jsx"; 25import { pds } from "../components/navbar.jsx"; 26import { addNotification, removeNotification } from "../components/notification.jsx"; 27import Tooltip from "../components/tooltip.jsx"; 28import { 29 didDocumentResolver, 30 resolveLexiconAuthority, 31 resolveLexiconSchema, 32 resolvePDS, 33} from "../utils/api.js"; 34import { AtUri, uriTemplates } from "../utils/templates.js"; 35import { lexicons } from "../utils/types/lexicons.js"; 36 37const authorityCache = new Map<string, Promise<AtprotoDid>>(); 38const documentCache = new Map<string, Promise<DidDocument>>(); 39const schemaCache = new Map<string, Promise<unknown>>(); 40 41const getAuthoritySegment = (nsid: string): string => { 42 const segments = nsid.split("."); 43 return segments.slice(0, -1).join("."); 44}; 45 46const resolveSchema = async (authority: AtprotoDid, nsid: Nsid): Promise<unknown> => { 47 const cacheKey = `${authority}:${nsid}`; 48 49 let cachedSchema = schemaCache.get(cacheKey); 50 if (cachedSchema) { 51 return cachedSchema; 52 } 53 54 const schemaPromise = (async () => { 55 let didDocPromise = documentCache.get(authority); 56 if (!didDocPromise) { 57 didDocPromise = didDocumentResolver.resolve(authority); 58 documentCache.set(authority, didDocPromise); 59 } 60 61 const didDocument = await didDocPromise; 62 const pdsEndpoint = getPdsEndpoint(didDocument); 63 64 if (!pdsEndpoint) { 65 throw new FailedLexiconResolutionError(nsid, { 66 cause: new TypeError(`no pds service in did document; did=${authority}`), 67 }); 68 } 69 70 const rpc = new Client({ handler: new CredentialManager({ service: pdsEndpoint }) }); 71 const response = await rpc.get("com.atproto.repo.getRecord", { 72 params: { 73 repo: authority, 74 collection: "com.atproto.lexicon.schema", 75 rkey: nsid, 76 }, 77 }); 78 79 if (!response.ok) { 80 throw new Error(`got http ${response.status}`); 81 } 82 83 return response.data.value; 84 })(); 85 86 schemaCache.set(cacheKey, schemaPromise); 87 88 try { 89 return await schemaPromise; 90 } catch (err) { 91 schemaCache.delete(cacheKey); 92 throw err; 93 } 94}; 95 96const extractRefs = (obj: any): Nsid[] => { 97 const refs: Set<string> = new Set(); 98 99 const traverse = (value: any) => { 100 if (!value || typeof value !== "object") return; 101 102 if (value.type === "ref" && value.ref) { 103 const ref = value.ref; 104 if (!ref.startsWith("#")) { 105 const nsid = ref.split("#")[0]; 106 if (isNsid(nsid)) refs.add(nsid); 107 } 108 } 109 110 if (value.type === "union" && Array.isArray(value.refs)) { 111 for (const ref of value.refs) { 112 if (!ref.startsWith("#")) { 113 const nsid = ref.split("#")[0]; 114 if (isNsid(nsid)) refs.add(nsid); 115 } 116 } 117 } 118 119 if (Array.isArray(value)) value.forEach(traverse); 120 else Object.values(value).forEach(traverse); 121 }; 122 123 traverse(obj); 124 return Array.from(refs) as Nsid[]; 125}; 126 127const resolveAllLexicons = async ( 128 nsid: Nsid, 129 depth: number = 0, 130 resolved: Map<string, any> = new Map(), 131 failed: Set<string> = new Set(), 132 inFlight: Map<string, Promise<void>> = new Map(), 133): Promise<{ resolved: Map<string, any>; failed: Set<string> }> => { 134 if (depth >= 10) { 135 console.warn(`Maximum recursion depth reached for ${nsid}`); 136 return { resolved, failed }; 137 } 138 139 if (resolved.has(nsid) || failed.has(nsid)) return { resolved, failed }; 140 141 if (inFlight.has(nsid)) { 142 await inFlight.get(nsid); 143 return { resolved, failed }; 144 } 145 146 const fetchPromise = (async () => { 147 let authority: AtprotoDid | undefined; 148 const authoritySegment = getAuthoritySegment(nsid); 149 try { 150 let authorityPromise = authorityCache.get(authoritySegment); 151 if (!authorityPromise) { 152 authorityPromise = resolveLexiconAuthority(nsid); 153 authorityCache.set(authoritySegment, authorityPromise); 154 } 155 156 authority = await authorityPromise; 157 const schema = await resolveSchema(authority, nsid); 158 159 resolved.set(nsid, schema); 160 161 const refs = extractRefs(schema); 162 163 if (refs.length > 0) { 164 await Promise.all( 165 refs.map((ref) => resolveAllLexicons(ref, depth + 1, resolved, failed, inFlight)), 166 ); 167 } 168 } catch (err) { 169 console.error(`Failed to resolve lexicon ${nsid}:`, err); 170 failed.add(nsid); 171 authorityCache.delete(authoritySegment); 172 if (authority) { 173 documentCache.delete(authority); 174 } 175 } finally { 176 inFlight.delete(nsid); 177 } 178 })(); 179 180 inFlight.set(nsid, fetchPromise); 181 await fetchPromise; 182 183 return { resolved, failed }; 184}; 185 186export const RecordView = () => { 187 const location = useLocation(); 188 const navigate = useNavigate(); 189 const params = useParams(); 190 const [openDelete, setOpenDelete] = createSignal(false); 191 const [verifyError, setVerifyError] = createSignal(""); 192 const [validationError, setValidationError] = createSignal(""); 193 const [externalLink, setExternalLink] = createSignal< 194 { label: string; link: string; icon?: string } | undefined 195 >(); 196 const [lexiconUri, setLexiconUri] = createSignal<string>(); 197 const [validRecord, setValidRecord] = createSignal<boolean | undefined>(undefined); 198 const [validSchema, setValidSchema] = createSignal<boolean | undefined>(undefined); 199 const [schema, setSchema] = createSignal<ResolvedSchema>(); 200 const [lexiconNotFound, setLexiconNotFound] = createSignal<boolean>(); 201 const [remoteValidation, setRemoteValidation] = createSignal<boolean>(); 202 const did = params.repo; 203 let rpc: Client; 204 205 const fetchRecord = async () => { 206 setValidRecord(undefined); 207 setValidSchema(undefined); 208 setLexiconUri(undefined); 209 const pds = await resolvePDS(did!); 210 rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 211 const res = await rpc.get("com.atproto.repo.getRecord", { 212 params: { 213 repo: did as ActorIdentifier, 214 collection: params.collection as `${string}.${string}.${string}`, 215 rkey: params.rkey!, 216 }, 217 }); 218 if (!res.ok) { 219 setValidRecord(false); 220 setVerifyError(res.data.error); 221 throw new Error(res.data.error); 222 } 223 setPlaceholder(res.data.value); 224 setExternalLink(checkUri(res.data.uri, res.data.value)); 225 resolveLexicon(params.collection as Nsid); 226 verifyRecordIntegrity(); 227 validateLocalSchema(res.data.value); 228 229 return res.data; 230 }; 231 232 const [record, { refetch }] = createResource(fetchRecord); 233 234 const validateLocalSchema = async (record: Record<string, unknown>) => { 235 try { 236 if (params.collection === "com.atproto.lexicon.schema") { 237 setLexiconNotFound(false); 238 lexiconDoc.parse(record, { mode: "passthrough" }); 239 setValidSchema(true); 240 } else if (params.collection && params.collection in lexicons) { 241 if (is(lexicons[params.collection], record)) setValidSchema(true); 242 else setValidSchema(false); 243 } 244 } catch (err: any) { 245 console.error("Schema validation error:", err); 246 setValidSchema(false); 247 setValidationError(err.message || String(err)); 248 } 249 }; 250 251 const validateRemoteSchema = async (record: Record<string, unknown>) => { 252 try { 253 setRemoteValidation(true); 254 const { resolved, failed } = await resolveAllLexicons(params.collection as Nsid); 255 256 if (failed.size > 0) { 257 console.error(`Failed to resolve ${failed.size} documents:`, Array.from(failed)); 258 setValidSchema(false); 259 setValidationError(`Unable to resolve lexicon documents: ${Array.from(failed).join(", ")}`); 260 return; 261 } 262 263 const lexiconDocs = Object.fromEntries(resolved); 264 console.log(lexiconDocs); 265 266 const validator = new RecordValidator(lexiconDocs, params.collection as Nsid); 267 validator.parse({ 268 key: params.rkey ?? null, 269 object: record, 270 }); 271 272 setValidSchema(true); 273 } catch (err: any) { 274 console.error("Schema validation error:", err); 275 setValidSchema(false); 276 setValidationError(err.message || String(err)); 277 } 278 setRemoteValidation(false); 279 }; 280 281 const verifyRecordIntegrity = async () => { 282 try { 283 const { ok, data } = await rpc.get("com.atproto.sync.getRecord", { 284 params: { 285 did: did as Did, 286 collection: params.collection as Nsid, 287 rkey: params.rkey!, 288 }, 289 as: "bytes", 290 }); 291 if (!ok) throw data.error; 292 293 await verifyRecord({ 294 did: did as AtprotoDid, 295 collection: params.collection!, 296 rkey: params.rkey!, 297 carBytes: data as Uint8Array<ArrayBufferLike>, 298 }); 299 300 setValidRecord(true); 301 } catch (err: any) { 302 console.error("Record verification error:", err); 303 setVerifyError(err.message); 304 setValidRecord(false); 305 } 306 }; 307 308 const resolveLexicon = async (nsid: Nsid) => { 309 try { 310 const authority = await resolveLexiconAuthority(nsid); 311 setLexiconUri(`at://${authority}/com.atproto.lexicon.schema/${nsid}`); 312 if (params.collection !== "com.atproto.lexicon.schema") { 313 const schema = await resolveLexiconSchema(authority, nsid); 314 setSchema(schema); 315 setLexiconNotFound(false); 316 } 317 } catch { 318 setLexiconNotFound(true); 319 } 320 }; 321 322 const deleteRecord = async () => { 323 rpc = new Client({ handler: agent()! }); 324 await rpc.post("com.atproto.repo.deleteRecord", { 325 input: { 326 repo: params.repo as ActorIdentifier, 327 collection: params.collection as `${string}.${string}.${string}`, 328 rkey: params.rkey!, 329 }, 330 }); 331 const id = addNotification({ 332 message: "Record deleted", 333 type: "success", 334 }); 335 setTimeout(() => removeNotification(id), 3000); 336 navigate(`/at://${params.repo}/${params.collection}`); 337 }; 338 339 const checkUri = (uri: string, record: any) => { 340 const uriParts = uri.split("/"); // expected: ["at:", "", "repo", "collection", "rkey"] 341 if (uriParts.length != 5) return undefined; 342 if (uriParts[0] !== "at:" || uriParts[1] !== "") return undefined; 343 const parsedUri: AtUri = { repo: uriParts[2], collection: uriParts[3], rkey: uriParts[4] }; 344 const template = uriTemplates[parsedUri.collection]; 345 if (!template) return undefined; 346 return template(parsedUri, record); 347 }; 348 349 const RecordTab = (props: { 350 tab: "record" | "backlinks" | "info" | "schema"; 351 label: string; 352 error?: boolean; 353 }) => { 354 const isActive = () => { 355 if (!location.hash && props.tab === "record") return true; 356 if (location.hash === `#${props.tab}`) return true; 357 if (props.tab === "schema" && location.hash.startsWith("#schema:")) return true; 358 return false; 359 }; 360 361 return ( 362 <div class="flex items-center gap-0.5"> 363 <A 364 classList={{ 365 "border-b-2": true, 366 "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": 367 !isActive(), 368 }} 369 href={`/at://${did}/${params.collection}/${params.rkey}#${props.tab}`} 370 > 371 {props.label} 372 </A> 373 <Show when={props.error && (validRecord() === false || validSchema() === false)}> 374 <span class="iconify lucide--x text-red-500 dark:text-red-400"></span> 375 </Show> 376 </div> 377 ); 378 }; 379 380 return ( 381 <Show when={record()} keyed> 382 <div class="flex w-full flex-col items-center"> 383 <div class="dark:shadow-dark-700 dark:bg-dark-300 mb-3 flex w-full justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-sm shadow-xs dark:border-neutral-700"> 384 <div class="ml-1 flex items-center gap-3"> 385 <RecordTab tab="record" label="Record" /> 386 <RecordTab tab="schema" label="Schema" /> 387 <RecordTab tab="backlinks" label="Backlinks" /> 388 <RecordTab tab="info" label="Info" error /> 389 </div> 390 <div class="flex gap-0.5"> 391 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}> 392 <RecordEditor create={false} record={record()?.value} refetch={refetch} /> 393 <Tooltip text="Delete"> 394 <button 395 class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 396 onclick={() => setOpenDelete(true)} 397 > 398 <span class="iconify lucide--trash-2"></span> 399 </button> 400 </Tooltip> 401 <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 402 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 403 <h2 class="mb-2 font-semibold">Delete this record?</h2> 404 <div class="flex justify-end gap-2"> 405 <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 406 <Button 407 onClick={deleteRecord} 408 class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400" 409 > 410 Delete 411 </Button> 412 </div> 413 </div> 414 </Modal> 415 </Show> 416 <MenuProvider> 417 <DropdownMenu 418 icon="lucide--ellipsis-vertical" 419 buttonClass="rounded-sm p-1.5" 420 menuClass="top-9 p-2 text-sm" 421 > 422 <CopyMenu 423 content={JSON.stringify(record()?.value, null, 2)} 424 label="Copy record" 425 icon="lucide--copy" 426 /> 427 <CopyMenu 428 content={`at://${params.repo}/${params.collection}/${params.rkey}`} 429 label="Copy AT URI" 430 icon="lucide--copy" 431 /> 432 <Show when={record()?.cid}> 433 {(cid) => <CopyMenu content={cid()} label="Copy CID" icon="lucide--copy" />} 434 </Show> 435 <MenuSeparator /> 436 <Show when={externalLink()}> 437 {(externalLink) => ( 438 <NavMenu 439 href={externalLink()?.link} 440 icon={`${externalLink().icon ?? "lucide--app-window"}`} 441 label={`Open on ${externalLink().label}`} 442 newTab 443 /> 444 )} 445 </Show> 446 <NavMenu 447 href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`} 448 icon="lucide--external-link" 449 label="Record on PDS" 450 newTab 451 /> 452 </DropdownMenu> 453 </MenuProvider> 454 </div> 455 </div> 456 <Show when={!location.hash || location.hash === "#record"}> 457 <div class="w-max max-w-screen min-w-full px-4 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:px-2 sm:text-sm md:max-w-3xl"> 458 <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} /> 459 </div> 460 </Show> 461 <Show when={location.hash === "#schema" || location.hash.startsWith("#schema:")}> 462 <Show when={lexiconNotFound() === true}> 463 <span class="w-full px-2 text-sm">Lexicon schema could not be resolved.</span> 464 </Show> 465 <Show when={lexiconNotFound() === undefined}> 466 <span class="w-full px-2 text-sm">Resolving lexicon schema...</span> 467 </Show> 468 <Show when={schema() || params.collection === "com.atproto.lexicon.schema"}> 469 <ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}> 470 <LexiconSchemaView schema={schema()?.rawSchema ?? (record()?.value as any)} /> 471 </ErrorBoundary> 472 </Show> 473 </Show> 474 <Show when={location.hash === "#backlinks"}> 475 <ErrorBoundary 476 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 477 > 478 <Suspense 479 fallback={ 480 <div class="iconify lucide--loader-circle animate-spin self-center text-xl" /> 481 } 482 > 483 <div class="w-full px-2"> 484 <Backlinks target={`at://${did}/${params.collection}/${params.rkey}`} /> 485 </div> 486 </Suspense> 487 </ErrorBoundary> 488 </Show> 489 <Show when={location.hash === "#info"}> 490 <div class="flex w-full flex-col gap-2 px-2 text-sm"> 491 <div> 492 <div class="flex items-center gap-1"> 493 <span class="iconify lucide--at-sign"></span> 494 <p class="font-semibold">AT URI</p> 495 </div> 496 <div class="truncate text-xs">{record()?.uri}</div> 497 </div> 498 <Show when={record()?.cid}> 499 <div> 500 <div class="flex items-center gap-1"> 501 <span class="iconify lucide--box"></span> 502 <p class="font-semibold">CID</p> 503 </div> 504 <div class="truncate text-left text-xs" dir="rtl"> 505 {record()?.cid} 506 </div> 507 </div> 508 </Show> 509 <div> 510 <div class="flex items-center gap-1"> 511 <span class="iconify lucide--lock-keyhole"></span> 512 <p class="font-semibold">Record verification</p> 513 <span 514 classList={{ 515 "iconify lucide--check text-green-500 dark:text-green-400": 516 validRecord() === true, 517 "iconify lucide--x text-red-500 dark:text-red-400": validRecord() === false, 518 "iconify lucide--loader-circle animate-spin": validRecord() === undefined, 519 }} 520 ></span> 521 </div> 522 <Show when={validRecord() === false}> 523 <div class="text-xs wrap-break-word">{verifyError()}</div> 524 </Show> 525 </div> 526 <div> 527 <div class="flex items-center gap-1"> 528 <span class="iconify lucide--file-check"></span> 529 <p class="font-semibold">Schema validation</p> 530 <span 531 classList={{ 532 "iconify lucide--check text-green-500 dark:text-green-400": 533 validSchema() === true, 534 "iconify lucide--x text-red-500 dark:text-red-400": validSchema() === false, 535 "iconify lucide--loader-circle animate-spin": 536 validSchema() === undefined && remoteValidation(), 537 }} 538 ></span> 539 </div> 540 <Show when={validSchema() === false}> 541 <div class="text-xs wrap-break-word">{validationError()}</div> 542 </Show> 543 <Show 544 when={ 545 !remoteValidation() && 546 validSchema() === undefined && 547 params.collection && 548 !(params.collection in lexicons) 549 } 550 > 551 <Button onClick={() => validateRemoteSchema(record()!.value)}> 552 Validate via resolution 553 </Button> 554 </Show> 555 </div> 556 <Show when={lexiconUri()}> 557 <div> 558 <div class="flex items-center gap-1"> 559 <span class="iconify lucide--scroll-text"></span> 560 <p class="font-semibold">Lexicon schema</p> 561 </div> 562 <div class="truncate text-xs"> 563 <A 564 href={`/${lexiconUri()}`} 565 class="text-blue-400 hover:underline active:underline" 566 > 567 {lexiconUri()} 568 </A> 569 </div> 570 </div> 571 </Show> 572 </div> 573 </Show> 574 </div> 575 </Show> 576 ); 577};