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 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};