chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileStack, KeyRound, Pencil, Trash2, UserPlus, X } from 'lucide-react';
import { useState } from 'react';
import { AppTile } from '#/components/atoms/app-tile.jsx';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Card } from '#/components/atoms/card.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { ActorAvatar } from '#/components/molecules/actor-avatar.jsx';
import { AtUri } from '#/components/molecules/at-uri.jsx';
import { Copyable } from '#/components/molecules/copyable.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { Identity } from '#/components/molecules/identity.jsx';
import { RepoScope } from '#/components/molecules/repo-scope.jsx';
import { SourceDialog } from '#/components/molecules/source-dialog.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import {
accessLabel,
ago,
appAccessLabel,
bytesToHex,
collectionsOf,
policyLabel,
spaceParts,
} from '#/lib/format.js';
import { href, useNavigate } from '#/lib/navigation.jsx';
import {
ACCESS_KINDS,
ACCESS_LABELS,
accessKind,
allowedOf,
isPublic,
managingAppOf,
POLICY_KINDS,
POLICY_LABELS,
policyKind,
policyUnion,
publishes,
} from '#/lib/policy.js';
import { useSession } from '#/lib/session.jsx';
import {
deleteSpace,
getLatestCommit,
getSpace,
listAllRecords,
listMembers,
listRepos,
putMember,
removeMember,
updateSpace,
} from '#/lib/spaces.js';
/** A labelled line in a summary card. */
function Fact({ label, children }) {
return (
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 py-1.5">
<span className="text-muted-foreground text-xs">{label}</span>
<span className="min-w-0 text-[13px] text-foreground">{children}</span>
</div>
);
}
/** One labelled `select`, in the shape of a TextField. */
function Choice({ label, value, onChange, options, labels }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-[13px] text-muted-foreground">{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-11 rounded-xl border border-input bg-transparent px-3 text-[14px] text-foreground outline-none focus-visible:border-ring"
>
{options.map((option) => (
<option key={option} value={option}>
{labels[option]}
</option>
))}
</select>
</label>
);
}
/** A labelled checkbox, for one flag of a policy. */
function Flag({ label, checked, onChange }) {
return (
<label className="flex items-center gap-2 text-[13px] text-foreground">
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
/**
* Change a space's policy. The endpoint replaces the policy whole, so a save
* sends every field this form shows. A policy of another kind discards the
* member list.
*/
function ConfigurationEditor({ space, config, onDone }) {
const { session } = useSession();
const [kind, setKind] = useState(() => policyKind(config) ?? 'memberspace');
const [publicRead, setPublicRead] = useState(() => isPublic(config, 'read'));
const [publicWrite, setPublicWrite] = useState(() =>
isPublic(config, 'write'),
);
const [managingApp, setManagingApp] = useState(
() => managingAppOf(config) ?? '',
);
const [access, setAccess] = useState(() => accessKind(config));
const [allowed, setAllowed] = useState(() => allowedOf(config).join('\n'));
const [publishPolicy, setPublishPolicy] = useState(() =>
publishes(config, 'publishPolicy'),
);
const [publishMembers, setPublishMembers] = useState(() =>
publishes(config, 'publishMembers'),
);
const clients = allowed
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean);
const save = useMutation({
mutationFn: () =>
updateSpace(session, {
space,
policy: policyUnion(kind, {
publicRead,
publicWrite,
allowedApps: access === 'allowList' ? clients : null,
managingApp: managingApp.trim() || null,
publishPolicy,
publishMembers,
}),
}),
onSuccess: onDone,
});
return (
<Card className="flex flex-col gap-4 p-4">
<Choice
label="Policy"
value={kind}
onChange={setKind}
options={POLICY_KINDS}
labels={POLICY_LABELS}
/>
{kind !== policyKind(config) && kind !== 'memberspace' && (
<Notice>
Changing the policy discards the member list. Changing back does not
restore it.
</Notice>
)}
{kind === 'memberspace' && (
<>
<Flag
label="Anyone may read"
checked={publicRead}
onChange={setPublicRead}
/>
<Flag
label="Anyone's writes count"
checked={publicWrite}
onChange={setPublicWrite}
/>
<Choice
label="Which clients"
value={access}
onChange={setAccess}
options={ACCESS_KINDS}
labels={ACCESS_LABELS}
/>
{access === 'allowList' && (
<label className="flex flex-col gap-1.5">
<span className="text-[13px] text-muted-foreground">
Allowed client IDs
</span>
<textarea
value={allowed}
onChange={(event) => setAllowed(event.target.value)}
rows={4}
spellCheck="false"
placeholder="https://app.example/client-metadata.json"
className="rounded-xl border border-input bg-transparent p-3 font-mono text-[12.5px] text-foreground outline-none placeholder:text-faint focus-visible:border-ring"
/>
<span className="text-faint text-xs">
One per line. A client proves its ID with an attestation signed
by a key it publishes, and the authority checks that signature.
</span>
</label>
)}
</>
)}
{kind === 'managedspace' && (
<TextField
name="managingApp"
label="Managing app"
placeholder="did:web:app.example#space_host"
hint="The authority asks this service about each reader and writer. Anything short of an explicit yes is a refusal, so an app that is down denies access."
value={managingApp}
onChange={(event) => setManagingApp(event.target.value)}
/>
)}
{kind !== 'personalspace' && (
<Flag
label="Publish the policy to readers"
checked={publishPolicy}
onChange={setPublishPolicy}
/>
)}
{kind === 'memberspace' && (
<Flag
label="Publish the member list to readers"
checked={publishMembers}
onChange={setPublishMembers}
/>
)}
{save.error && <Notice variant="error">{save.error.message}</Notice>}
<div className="flex gap-2">
<Button
onClick={() => save.mutate()}
disabled={
save.isPending ||
(kind === 'managedspace' && !managingApp.trim()) ||
(kind === 'memberspace' &&
access === 'allowList' &&
clients.length === 0)
}
>
{save.isPending ? 'Saving…' : 'Save'}
</Button>
<Button variant="ghost" onClick={() => onDone()}>
Cancel
</Button>
</div>
</Card>
);
}
/** What the authority says about the space, and what the authority may change. */
function Configuration({ space, editable }) {
const { session } = useSession();
const client = useQueryClient();
const [editing, setEditing] = useState(false);
const { data, error, isPending } = useQuery({
queryKey: ['space-config', space],
queryFn: () => getSpace(session, space),
retry: false,
});
if (isPending) return <Skeleton className="h-28" />;
if (error) {
return (
<Notice>
The authority did not describe this space. {error.message}
</Notice>
);
}
const config = data.value;
if (editing) {
return (
<ConfigurationEditor
space={space}
config={config}
onDone={() => {
setEditing(false);
client.invalidateQueries({ queryKey: ['space-config', space] });
}}
/>
);
}
const kind = policyKind(config);
return (
<Card className="px-4 py-2">
<Fact label="Policy">{policyLabel(config)}</Fact>
<Fact label="Who may read">{accessLabel(config, 'read')}</Fact>
<Fact label="Whose writes count">{accessLabel(config, 'write')}</Fact>
{kind === 'memberspace' && (
<Fact label="Which clients">{appAccessLabel(config)}</Fact>
)}
{managingAppOf(config) && (
<Fact label="Managing app">
<span className="font-mono text-xs">{managingAppOf(config)}</span>
</Fact>
)}
{(publishes(config, 'publishPolicy') ||
publishes(config, 'publishMembers')) && (
<Fact label="Published to readers">
{publishes(config, 'publishMembers')
? 'The policy and the member list'
: 'The policy'}
</Fact>
)}
<div className="flex items-center gap-1 pt-1 pb-2">
{editable && (
<Button variant="outline" size="xs" onClick={() => setEditing(true)}>
<Pencil />
Edit
</Button>
)}
<SourceDialog
source={data.source}
value={config}
note="A space is governed by its authority, which is the account named in the space URI. These are that authority's own answers about it, not anything this repo holds."
fields={{
Policy: 'policy.$type',
'Anyone may read': 'policy.publicRead',
"Anyone's writes count": 'policy.publicWrite',
'Allowed client IDs': 'policy.allowedApps',
'Managing app': 'policy.managingApp',
}}
/>
</div>
</Card>
);
}
/** The commit over this repo's slice of the space. */
function Commit({ space, repo }) {
const { session } = useSession();
const { data, isPending } = useQuery({
queryKey: ['commit', space, repo],
queryFn: () => getLatestCommit(session, { space, repo }),
retry: false,
});
if (isPending) return <Skeleton className="h-28" />;
if (!data) {
return <Notice>This repo has written nothing to the space yet.</Notice>;
}
return (
<Card className="px-4 py-2">
<Fact label="Revision">
<span className="font-mono text-xs">{data.rev}</span>
</Fact>
<Fact label="State hash">
<Copyable value={bytesToHex(data.data)} className="max-w-[22ch]">
{bytesToHex(data.data).slice(0, 16)}…
</Copyable>
</Fact>
</Card>
);
}
/** The collections this repo holds in the space, counted from its own records. */
function Collections({ space, repo }) {
const { session } = useSession();
const { data, error, isPending } = useQuery({
queryKey: ['index', space, repo],
queryFn: () => listAllRecords(session, { space, repo }),
retry: false,
});
if (isPending) return <Skeleton className="h-24" />;
if (error) return <Notice variant="error">{error.message}</Notice>;
const collections = collectionsOf(data.records);
if (collections.length === 0) {
return (
<EmptyState
icon={<FileStack />}
title="No records"
description="This repo holds nothing in this space."
/>
);
}
return (
<div className="flex flex-col gap-2">
{!data.complete && (
<Notice>
Counted the first {data.records.length} records. This repo holds more.
</Notice>
)}
{collections.map((entry) => (
<Link
key={entry.collection}
href={href('/records', {
uri: space,
collection: entry.collection,
repo,
})}
className="no-underline hover:no-underline"
>
<Card className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 p-3.5 hover:border-input">
<span className="font-mono text-[13px] text-foreground">
{entry.collection}
</span>
<span className="text-faint text-xs">
{entry.count} record{entry.count === 1 ? '' : 's'}
{entry.lastWriteAt && ` · ${ago(entry.lastWriteAt)}`}
</span>
</Card>
</Link>
))}
</div>
);
}
/** Every repo the authority has heard from, which is the sync boundary. */
function Repos({ space, repo }) {
const { session } = useSession();
const active = repo || session.did;
const { data, error, isPending } = useQuery({
queryKey: ['repos', space],
queryFn: () => listRepos(session, space),
retry: false,
});
if (isPending) return <Skeleton className="h-20" />;
if (error) return <Notice>{error.message}</Notice>;
const repos = data.repos || [];
return (
<div className="flex flex-col gap-2">
<p className="text-faint text-xs leading-relaxed">
Each of these repos is served by its own account's PDS. Opening one asks
your server for a delegation token and the authority for a credential,
then reads that server directly.
</p>
{repos.map((entry) => (
<Link
key={entry.did}
href={href('/space', {
uri: space,
repo: entry.did === session.did ? null : entry.did,
})}
className="no-underline hover:no-underline"
>
<Card
className={`flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 p-3.5 ${
entry.did === active ? 'border-key/40' : 'hover:border-input'
}`}
>
<span className="flex min-w-0 items-center gap-2">
<ActorAvatar did={entry.did} size={20} />
<Identity did={entry.did} className="text-[13px]" />
{entry.did === session.did && (
<span className="text-faint text-xs">you</span>
)}
</span>
<span className="font-mono text-faint text-xs">{entry.rev}</span>
</Card>
</Link>
))}
</div>
);
}
/** The member list, which only the space's own authority may read or change. */
function Members({ space }) {
const { session, writable } = useSession();
const client = useQueryClient();
const [candidate, setCandidate] = useState('');
const { data, error, isPending } = useQuery({
queryKey: ['members', space],
queryFn: () => listMembers(session, space),
retry: false,
});
const invalidate = () =>
client.invalidateQueries({ queryKey: ['members', space] });
const put = useMutation({
mutationFn: (member) => putMember(session, { space, ...member }),
onSuccess: invalidate,
});
const add = useMutation({
mutationFn: () =>
putMember(session, {
space,
did: candidate.trim(),
read: true,
write: true,
}),
onSuccess: () => {
setCandidate('');
invalidate();
},
});
const remove = useMutation({
mutationFn: (did) => removeMember(session, { space, did }),
onSuccess: invalidate,
});
if (isPending) return <Skeleton className="h-20" />;
if (error) return <Notice>{error.message}</Notice>;
return (
<div className="flex flex-col gap-2">
{(data.members || []).map((member) => (
<Card
key={member.did}
className="flex items-center justify-between gap-3 p-3.5"
>
<span className="flex min-w-0 items-center gap-2">
<ActorAvatar did={member.did} size={20} />
<Identity did={member.did} className="text-[13px]" />
</span>
<span className="flex shrink-0 items-center gap-1">
<Right
label="Read"
on={member.read}
editable={writable && member.did !== session.did}
onToggle={() => put.mutate({ ...member, read: !member.read })}
/>
<Right
label="Write"
on={member.write}
editable={writable && member.did !== session.did}
onToggle={() => put.mutate({ ...member, write: !member.write })}
/>
{writable && member.did !== session.did && (
<Button
variant="ghost"
size="icon-sm"
title="Remove from the space"
onClick={() => remove.mutate(member.did)}
>
<X />
</Button>
)}
</span>
</Card>
))}
{put.error && <Notice variant="error">{put.error.message}</Notice>}
{writable && (
<Card className="flex flex-col gap-3 p-3.5">
<TextField
name="member"
label="Add a member"
placeholder="did:plc:…"
hint="A new member reads and writes. Removing a member does not revoke a credential already issued. That one lasts until it expires."
value={candidate}
onChange={(event) => setCandidate(event.target.value)}
/>
{add.error && <Notice variant="error">{add.error.message}</Notice>}
<Button
size="sm"
className="self-start"
disabled={!candidate.startsWith('did:') || add.isPending}
onClick={() => add.mutate()}
>
<UserPlus />
Add
</Button>
</Card>
)}
</div>
);
}
/**
* One of a member's two rights. Read admits them to a credential; write has the
* authority track their repo and forward its notices.
*/
function Right({ label, on, editable, onToggle }) {
const badge = (
<Badge variant={on ? 'default' : 'outline'}>
{on ? label : `No ${label.toLowerCase()}`}
</Badge>
);
if (!editable) return badge;
return (
<button
type="button"
onClick={onToggle}
title={
on ? `Take away ${label.toLowerCase()}` : `Grant ${label.toLowerCase()}`
}
>
{badge}
</button>
);
}
/**
* Delete the space at the authority that governs it.
*
* The records survive it. Each member's repo holds its own, and the proposal
* has a repo host keep them, so what a delete ends is the authority: it stops
* issuing credentials and tells every syncer the space is gone.
*/
function DeleteSpace({ space }) {
const { session } = useSession();
const navigate = useNavigate();
const [confirming, setConfirming] = useState(false);
const remove = useMutation({
mutationFn: () => deleteSpace(session, { space }),
onSuccess: () => navigate('/'),
});
if (!confirming) {
return (
<Button
variant="ghost"
size="sm"
className="self-start text-destructive"
onClick={() => setConfirming(true)}
>
<Trash2 />
Delete this space
</Button>
);
}
return (
<Card className="flex flex-col gap-3 border-destructive/40 p-4">
<p className="text-muted-foreground text-sm leading-relaxed">
Deleting stops this authority issuing credentials for the space and
tells every registered syncer it is gone. The records stay where they
are. Each member's repo keeps its own, and this account's copy still
lists the space.
</p>
{remove.error && <Notice variant="error">{remove.error.message}</Notice>}
<div className="flex gap-2">
<Button
variant="destructive"
onClick={() => remove.mutate()}
disabled={remove.isPending}
>
{remove.isPending ? 'Deleting…' : 'Delete'}
</Button>
<Button variant="ghost" onClick={() => setConfirming(false)}>
Cancel
</Button>
</div>
</Card>
);
}
const TABS = ['records', 'repos', 'members'];
export function SpacePage({ space, repo }) {
const { session, writable } = useSession();
const { spaceDid, spaceType, skey } = spaceParts(space);
const hosted = spaceDid === session.did;
const [tab, setTab] = useState('records');
return (
<div className="flex flex-col gap-5">
<div className="flex gap-4">
<AppTile nsid={spaceType} size={44} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="font-semibold font-mono text-[17px] text-foreground">
{spaceType}
</h1>
{hosted && <Badge variant="scope">you host this</Badge>}
</div>
<div className="mt-1 text-muted-foreground text-sm">{skey}</div>
{/* The authority governs the space and is named in its URI, where a
DID reads as nothing. Naming it here says whose space this is. */}
<div className="mt-2 flex min-w-0 items-center gap-1.5 text-faint text-xs">
{hosted ? (
'Under your own authority'
) : (
<>
Under
<ActorAvatar did={spaceDid} size={16} />
<Identity did={spaceDid} inline />
</>
)}
</div>
<AtUri uri={space} className="mt-2 text-[11px]" />
</div>
</div>
{hosted ? (
<Configuration space={space} editable={writable} />
) : (
<Notice>
Only the space's authority reads its policy. It can publish the policy
to readers as a record in its own repo here.
</Notice>
)}
<RepoScope space={space} repo={repo} />
<div>
<h2 className="pb-2 font-semibold text-[13px] text-muted-foreground">
Repo state
</h2>
<Commit space={space} repo={repo} />
</div>
<div className="flex flex-wrap items-center gap-4 text-[13px]">
<Link href={href('/ops', { uri: space, repo })}>Write log</Link>
<Link href={href('/blobs', { uri: space, repo })}>Blobs</Link>
<Link
href={href('/credential', { uri: space })}
className="inline-flex items-center gap-1.5"
>
<KeyRound className="size-3.5" />
Credential
</Link>
</div>
<div>
<div className="flex gap-1 border-border border-b pb-2">
{TABS.filter((name) => name !== 'members' || hosted).map((name) => (
<button
key={name}
type="button"
onClick={() => setTab(name)}
className={`cursor-pointer rounded-lg px-3 py-1.5 font-medium text-[13px] capitalize ${
tab === name
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{name}
</button>
))}
</div>
<div className="pt-4">
{tab === 'records' && <Collections space={space} repo={repo} />}
{tab === 'repos' && <Repos space={space} repo={repo} />}
{tab === 'members' && hosted && <Members space={space} />}
</div>
</div>
{hosted && writable && (
<div className="flex flex-col border-border border-t pt-5">
<DeleteSpace space={space} />
</div>
)}
</div>
);
}