diff --git a/src/lib/api.ts b/src/lib/api.ts index f7917c9..a706ebc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,8 @@ import "@atcute/atproto"; +import { getPublicKeyFromDidController, P256PublicKey, Secp256k1PublicKey } from "@atcute/crypto"; import { type DidDocument, + getAtprotoVerificationMaterial, getLabelerEndpoint, getPdsEndpoint, isAtprotoDid, @@ -16,6 +18,7 @@ import { import { DohJsonLexiconAuthorityResolver, LexiconSchemaResolver } from "@atcute/lexicon-resolver"; import { Did, Handle } from "@atcute/lexicons"; import { AtprotoDid, isHandle, Nsid } from "@atcute/lexicons/syntax"; +import { verifyRecord } from "@atcute/repo"; import { createMemo } from "solid-js"; import { createStore } from "solid-js/store"; @@ -180,6 +183,61 @@ const resolveLexiconSchema = async (authority: AtprotoDid, nsid: Nsid) => { return await schemaResolver().resolve(authority, nsid); }; +const lexiconSchemaCollection = "com.atproto.lexicon.schema"; + +const resolveRawLexiconSchema = async (authority: AtprotoDid, nsid: Nsid) => { + const didDocument = await didDocumentResolver().resolve(authority); + const pds = getPdsEndpoint(didDocument); + if (!pds) throw new Error(`No PDS found for Lexicon authority ${authority}`); + + const url = new URL("/xrpc/com.atproto.sync.getRecord", pds); + url.searchParams.set("did", authority); + url.searchParams.set("collection", lexiconSchemaCollection); + url.searchParams.set("rkey", nsid); + + const response = await fetch(url, { + headers: { accept: "application/vnd.ipld.car" }, + }); + if (!response.ok) { + throw new Error(`Could not fetch the Lexicon type declaration (${response.status})`); + } + + const controller = getAtprotoVerificationMaterial(didDocument); + if (!controller) throw new Error("Lexicon authority has no verification key"); + + const found = getPublicKeyFromDidController(controller); + const publicKey = + found.type === "p256" + ? await P256PublicKey.importRaw(found.publicKeyBytes) + : await Secp256k1PublicKey.importRaw(found.publicKeyBytes); + const verified = await verifyRecord({ + did: authority, + collection: lexiconSchemaCollection, + rkey: nsid, + publicKey, + carBytes: new Uint8Array(await response.arrayBuffer()), + }); + + const rawSchema = verified.record; + if ( + typeof rawSchema !== "object" || + rawSchema === null || + Array.isArray(rawSchema) || + !("$type" in rawSchema) || + rawSchema.$type !== lexiconSchemaCollection || + !("id" in rawSchema) || + rawSchema.id !== nsid + ) { + throw new Error("The resolved record is not the requested Lexicon type declaration"); + } + + return { + uri: `at://${authority}/${lexiconSchemaCollection}/${nsid}`, + cid: verified.cid, + rawSchema, + }; +}; + interface LinkData { links: { [key: string]: { @@ -274,6 +332,7 @@ export { resolveHandle, resolveLexiconAuthority, resolveLexiconAuthorityDirect, + resolveRawLexiconSchema, resolveLexiconSchema, validateHandle, type LinksWithRecords, diff --git a/src/lib/lexicon.ts b/src/lib/lexicon.ts index 8b2adca..3d83087 100644 --- a/src/lib/lexicon.ts +++ b/src/lib/lexicon.ts @@ -2,7 +2,7 @@ import type { ResolvedSchema } from "@atcute/lexicon-resolver"; import type { Nsid } from "@atcute/lexicons"; import type { AtprotoDid } from "@atcute/lexicons/syntax"; -import { resolveLexiconAuthority, resolveLexiconSchema } from "./api.js"; +import { resolveLexiconAuthority, resolveLexiconSchema, resolveRawLexiconSchema } from "./api.js"; interface CachedLexicon { authority: AtprotoDid; @@ -10,6 +10,7 @@ interface CachedLexicon { } const cache = new Map>(); +const rawCache = new Map>>>(); export const parseLexiconRef = (lexicon: string) => { const [nsid, defName] = lexicon.split("#"); @@ -37,3 +38,17 @@ export const resolveLexicon = (nsid: Nsid): Promise => { promise.catch(() => cache.delete(nsid)); return promise; }; + +export const resolveRawLexicon = (nsid: Nsid) => { + let cached = rawCache.get(nsid); + if (cached) return cached; + + const promise = (async () => { + const authority = await resolveLexiconAuthority(nsid); + return await resolveRawLexiconSchema(authority, nsid); + })(); + + rawCache.set(nsid, promise); + promise.catch(() => rawCache.delete(nsid)); + return promise; +}; diff --git a/src/views/spaces/nav.tsx b/src/views/spaces/nav.tsx index 45d4815..fb80a4a 100644 --- a/src/views/spaces/nav.tsx +++ b/src/views/spaces/nav.tsx @@ -3,6 +3,7 @@ import { createResource, createSignal, type JSX, Show } from "solid-js"; import { Favicon } from "../../components/favicon.jsx"; import { resolveDidInfo } from "../../components/hover-card/did.jsx"; +import { schemaHref } from "../../lib/lexicon.js"; import { makeSpaceCollectionPath, makeSpacePath, makeSpaceRepoPath } from "./context.jsx"; const resolveDidHandle = async (did: string) => { @@ -184,7 +185,18 @@ export const SpacesNav = (props: { action?: JSX.Element }) => { } - label={params.spaceType} + label={ + params.spaceRepo ? ( + params.spaceType + ) : ( + + {params.spaceType} + + ) + } detailPrefix={`${params.skey} ·`} detail={authorityHandle() ?? params.spaceAuthority} detailHref={authorityHandle() ? `/at://${params.spaceAuthority}` : undefined} diff --git a/src/views/spaces/simple-space-members.tsx b/src/views/spaces/simple-space-members.tsx index 45e6aa2..39d033a 100644 --- a/src/views/spaces/simple-space-members.tsx +++ b/src/views/spaces/simple-space-members.tsx @@ -23,7 +23,7 @@ import { ErrorNotice, LoadingState } from "./shared.jsx"; type MemberDialog = { kind: "add" } | { kind: "remove"; did: string }; const actionButtonClass = - "flex items-center gap-1 rounded-md border border-neutral-300 px-1.5 py-0.5 text-xs transition-colors hover:bg-neutral-200/50 active:bg-neutral-200 sm:px-2 sm:py-0.75 dark:border-neutral-700 dark:hover:bg-neutral-800 dark:active:bg-neutral-700"; + "flex self-center items-center gap-1 rounded-md border border-neutral-300 px-1.5 py-0.5 text-xs transition-colors hover:bg-neutral-200/50 active:bg-neutral-200 sm:px-2 sm:py-0.75 dark:border-neutral-700 dark:hover:bg-neutral-800 dark:active:bg-neutral-700"; const notify = (message: string, type: "success" | "error", duration = 3000) => { const notification = addNotification({ message, type }); @@ -148,8 +148,8 @@ export const SimpleSpaceMembers = (props: { space: string; authority: string }) return (
-
-

Members

+
+

Members

{members().length.toLocaleString()} {cursor() ? "loaded" : ""} diff --git a/src/views/spaces/simple-space.tsx b/src/views/spaces/simple-space.tsx index 44c6e9b..0f1ea19 100644 --- a/src/views/spaces/simple-space.tsx +++ b/src/views/spaces/simple-space.tsx @@ -33,40 +33,43 @@ export const SimpleSpaceDetails = (props: { authority: string; }) => { return ( -
-
-
User access
-
{policyLabel(props.info)}
+ <> +
+

Access

+
+
User access
+
{policyLabel(props.info)}
- -
Managing app
-
- {props.info.policy.kind === "managing-app" && props.info.policy.managingApp} -
-
+ +
Managing app
+
+ {props.info.policy.kind === "managing-app" && props.info.policy.managingApp} +
+
-
Application access
-
{appAccessLabel(props.info)}
+
Application access
+
{appAccessLabel(props.info)}
- -
Allowed applications
-
-
    - - {(clientId) =>
  • {clientId}
  • } -
    -
-
-
-
+ +
Allowed applications
+
+
    + + {(clientId) =>
  • {clientId}
  • } +
    +
+
+
+
+
- + ); }; diff --git a/src/views/spaces/space-type-details.tsx b/src/views/spaces/space-type-details.tsx new file mode 100644 index 0000000..dca0440 --- /dev/null +++ b/src/views/spaces/space-type-details.tsx @@ -0,0 +1,106 @@ +import { A } from "@solidjs/router"; +import { For, Show } from "solid-js"; + +import { schemaHref } from "../../lib/lexicon.js"; + +export interface SpaceTypeInfo { + name?: string; + description?: string; + key?: string; + collections?: string[]; +} + +const isObject = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const localizedName = (fallback: string | undefined, value: unknown) => { + if (!isObject(value)) return fallback; + + for (const language of navigator.languages) { + const exact = value[language]; + if (typeof exact === "string") return exact; + + const base = value[language.split("-")[0]]; + if (typeof base === "string") return base; + } + + return fallback; +}; + +export const parseSpaceTypeInfo = (schema: unknown): SpaceTypeInfo => { + if (!isObject(schema) || !isObject(schema.defs) || !isObject(schema.defs.main)) { + throw new Error("The resolved Lexicon is not a Space type declaration"); + } + + const main = schema.defs.main; + if (main.type !== "space") { + throw new Error("The resolved Lexicon is not a Space type declaration"); + } + + return { + name: localizedName(typeof main.name === "string" ? main.name : undefined, main["name:lang"]), + description: typeof main.description === "string" ? main.description : undefined, + key: typeof main.key === "string" ? main.key : undefined, + collections: Array.isArray(main.collections) + ? main.collections.filter( + (collection): collection is string => typeof collection === "string", + ) + : undefined, + }; +}; + +export const SpaceTypeDetails = (props: { + info?: SpaceTypeInfo; + loading: boolean; + error?: string; +}) => ( +
+
+ +
Name
+
{props.info!.name}
+
+ + +
Description
+
{props.info!.description}
+
+ + +
Key
+
{props.info!.key}
+
+ + + {(collections) => ( + <> +
Collections
+
+ +
+ + )} +
+
+ + +

Resolving type declaration…

+
+ + {(message) =>

{message()}

} +
+
+); diff --git a/src/views/spaces/space.tsx b/src/views/spaces/space.tsx index 0646320..f9bb170 100644 --- a/src/views/spaces/space.tsx +++ b/src/views/spaces/space.tsx @@ -1,9 +1,11 @@ +import type { Nsid } from "@atcute/lexicons"; import { A, type RouteSectionProps, useLocation, useNavigate, useParams } from "@solidjs/router"; import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { Button } from "../../components/button.jsx"; import DidHoverCard from "../../components/hover-card/did.jsx"; import { NestedLayout } from "../../components/nested-layout.jsx"; +import { resolveRawLexicon } from "../../lib/lexicon.js"; import { getSimpleSpace, listSpaceRepos, @@ -21,6 +23,7 @@ import { SpaceRecordEditor } from "./create-record.jsx"; import { ManageSpaceDialog } from "./manage-space.jsx"; import { EmptyState, ErrorNotice, LoadingState } from "./shared.jsx"; import { SimpleSpaceDetails } from "./simple-space.jsx"; +import { parseSpaceTypeInfo, SpaceTypeDetails, type SpaceTypeInfo } from "./space-type-details.jsx"; const SpaceView = () => { const auth = useSpacesAuth(); @@ -36,14 +39,19 @@ const SpaceView = () => { const [error, setError] = createSignal(); const [simpleSpaceInfo, setSimpleSpaceInfo] = createSignal(); const [simpleSpaceResolved, setSimpleSpaceResolved] = createSignal(false); + const [spaceTypeInfo, setSpaceTypeInfo] = createSignal(); + const [spaceTypeLoading, setSpaceTypeLoading] = createSignal(false); + const [spaceTypeError, setSpaceTypeError] = createSignal(); let activeKey = ""; let requestVersion = 0; let simpleSpaceKey = ""; let simpleSpaceVersion = 0; + let spaceTypeKey = ""; + let spaceTypeVersion = 0; const space = () => makeSpaceRef(params.spaceAuthority!, params.spaceType!, params.skey!); const spacePath = () => makeSpacePath(params.spaceAuthority!, params.spaceType!, params.skey!); - const showingDetails = () => location.hash === "#details" && !!simpleSpaceInfo(); + const showingDetails = () => location.hash === "#details"; const Tab = (props: { details?: boolean; label: string }) => { const active = () => (props.details ? showingDetails() : !showingDetails()); @@ -136,15 +144,51 @@ const SpaceView = () => { }); }); + createEffect(() => { + if (hidden()) return; + + const nsid = params.spaceType; + if (!nsid || nsid === spaceTypeKey) return; + + spaceTypeKey = nsid; + spaceTypeVersion += 1; + setSpaceTypeInfo(undefined); + setSpaceTypeError(undefined); + setSpaceTypeLoading(true); + + const version = spaceTypeVersion; + void resolveRawLexicon(nsid as Nsid).then( + ({ rawSchema }) => { + if (version !== spaceTypeVersion) return; + + try { + setSpaceTypeInfo(parseSpaceTypeInfo(rawSchema)); + } catch (err) { + setSpaceTypeError( + err instanceof Error ? err.message : "Could not read the Space type declaration", + ); + } finally { + setSpaceTypeLoading(false); + } + }, + (err) => { + if (version === spaceTypeVersion) { + setSpaceTypeError( + err instanceof Error ? err.message : "Could not resolve the Space type declaration", + ); + setSpaceTypeLoading(false); + } + }, + ); + }); + return (
- - - +
@@ -240,10 +284,27 @@ const SpaceView = () => { - - {(info) => ( - - )} + +
+ + + + + + + {(info) => ( + + )} + +