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