chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Input } from '#/components/atoms/input.jsx';
import { Label } from '#/components/atoms/label.jsx';
import { Textarea } from '#/components/atoms/textarea.jsx';
import { OptionRow } from '#/components/molecules/option-row.jsx';
import { PageNotice } from '#/components/molecules/page-notice.jsx';
import { RefSelect } from '#/components/molecules/ref-select.jsx';
import { Screen } from '#/components/molecules/screen.jsx';
import { SettingsPanel } from '#/components/molecules/settings-panel.jsx';
import {
account,
branchRefs,
defaultRef,
loadRepos,
repoRecord,
shortRef,
} from '#/lib/git.js';
import { resolveIdentity } from '#/lib/identity.js';
import { useNavigate } from '#/lib/navigation.jsx';
import { useSession } from '#/lib/session.jsx';
import {
deleteRepo,
packageKey,
publishedPackages,
renameRepo,
updateSettings,
} from '#/lib/settings.js';
/** How each registry reads beside a package name. */
const REGISTRY_LABELS = { npm: 'npm package', oci: 'container image' };
/** The branch names one repository record holds. */
const branchesOf = (record) =>
branchRefs(record).map((ref) => shortRef(ref.name));
/**
* What the repository is, and which branch a visitor lands on. Both are
* fields of the repository record, which every push rewrites, so the server
* reads the record and patches the two rather than replacing it.
*/
function GeneralPanel({ record, write }) {
const branches = branchesOf(record);
// The branch a visitor lands on today: the one the record names, or main
// where it names none. A repository whose named branch is gone falls back
// to the first it has, so the picker never shows a branch that is not
// there.
const landing = branches.includes(defaultRef(record))
? defaultRef(record)
: (branches[0] ?? '');
const [description, setDescription] = useState(record.description ?? '');
const [defaultBranch, setDefaultBranch] = useState(landing);
const save = () =>
write({
description: description.trim(),
// An empty repository has no branch to name, and sending an empty one
// would clear the branch a first push is about to create.
...(branches.length > 0 ? { defaultBranch } : {}),
});
return (
<SettingsPanel
title="General"
note="What the repository is, and the branch a visitor lands on."
value={{ description: description.trim(), defaultBranch }}
onSave={save}
>
<div>
<Label htmlFor="settings-description">Description</Label>
<Textarea
id="settings-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="What this repository is for"
rows={2}
maxLength={3000}
className="resize-none"
/>
</div>
<div>
<Label htmlFor="settings-default-branch">Default branch</Label>
{branches.length === 0 ? (
<p className="text-[12.5px] text-faint">
Branches appear here after the first push.
</p>
) : (
<RefSelect
repo={record}
value={defaultBranch}
onSelect={setDefaultBranch}
tags={false}
/>
)}
</div>
</SettingsPanel>
);
}
/**
* Which branches the write guard defends. The rules are in the config record
* beside the repository, which no push touches, so a protection outlives
* every push it refuses.
*/
function BranchPanel({ record, write }) {
const branches = branchesOf(record);
const listed = record.protectedBranches ?? [];
// A protected name whose branch is gone stays listed, so saving an
// unrelated change does not drop the protection with it.
const known = [...new Set([...branches, ...listed])].sort();
const [chosen, setChosen] = useState(new Set(listed));
const toggle = (/** @type {string} */ branch) =>
setChosen((current) => {
const next = new Set(current);
if (next.has(branch)) next.delete(branch);
else next.add(branch);
return next;
});
const wanted = [...chosen].sort();
return (
<SettingsPanel
title="Branch protection"
note="A protected branch refuses a force push and refuses deletion. This server enforces that on the copy it holds."
value={wanted}
onSave={() => write({ protectedBranches: wanted })}
>
{known.length === 0 ? (
<p className="text-[12.5px] text-faint">
Branches appear here after the first push.
</p>
) : (
<div className="flex flex-col">
{known.map((branch) => (
<OptionRow
key={branch}
id={`protect-${branch}`}
checked={chosen.has(branch)}
onCheckedChange={() => toggle(branch)}
name={branch}
note={
branches.includes(branch) ? undefined : 'no branch by this name'
}
/>
))}
</div>
)}
</SettingsPanel>
);
}
/**
* The account whose check records this repository trusts. A check is a
* statement in its author's own repo, so anybody may publish one about a
* public repository; naming one account is how a reader decides whose runs
* are this repository's.
*/
function RunnerPanel({ record, write }) {
const [runner, setRunner] = useState(record.runner ?? '');
const save = async () => {
const wanted = runner.trim();
if (!wanted) return write({ runner: null });
// A handle is what an owner knows their runner by, and the record holds
// the DID a handle can be moved away from.
const { did } = await resolveIdentity(wanted);
await write({ runner: did });
};
return (
<SettingsPanel
title="Checks"
note="The account this repository takes its check runs from. Leave it empty for no checks."
value={runner.trim()}
onSave={save}
>
<div>
<Label htmlFor="settings-runner">Check runner</Label>
<Input
id="settings-runner"
value={runner}
onChange={(event) => setRunner(event.target.value)}
placeholder="runner.example.com"
className="font-mono"
/>
<p className="mt-1.5 text-[12.5px] text-faint">
A handle or a DID. The runner's results open in the Checks section.
</p>
</div>
</SettingsPanel>
);
}
/**
* What this repository builds. A registry record names no repository and
* atproto has no reverse index, so this list is the only thing connecting
* the two.
*/
function PackagesPanel({ record, write }) {
const linked = record.packages ?? [];
const { data: published } = useQuery({
queryKey: ['published-packages'],
queryFn: publishedPackages,
staleTime: Number.POSITIVE_INFINITY,
});
// A linked package that is no longer published stays listed, so saving an
// unrelated change does not drop the link with it.
const choices = [
...(published ?? []),
...linked.filter(
(entry) =>
!(published ?? []).some(
(option) => packageKey(option) === packageKey(entry),
),
),
].sort((a, b) => packageKey(a).localeCompare(packageKey(b)));
const [chosen, setChosen] = useState(new Set(linked.map(packageKey)));
const toggle = (/** @type {string} */ key) =>
setChosen((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const wanted = choices.filter((entry) => chosen.has(packageKey(entry)));
return (
<SettingsPanel
title="Packages"
note="What this repository builds. The git browser lists a linked package beside the repository, with the command that installs it."
value={wanted}
onSave={() => write({ packages: wanted })}
>
{choices.length === 0 ? (
<p className="text-[12.5px] text-faint">
Packages appear here after the first publish to this account's
registries.
</p>
) : (
<div className="flex flex-col">
{choices.map((entry) => (
<OptionRow
key={packageKey(entry)}
id={`package-${packageKey(entry)}`}
checked={chosen.has(packageKey(entry))}
onCheckedChange={() => toggle(packageKey(entry))}
name={entry.name}
note={REGISTRY_LABELS[entry.registry] ?? entry.registry}
/>
))}
</div>
)}
</SettingsPanel>
);
}
/**
* Renaming the repository. The record key is its identity, so the issues,
* pull requests and checks are unmoved. What moves is the address, which is
* why this says so rather than leaving it to be discovered by a push that
* fails.
*
* A repository in a space keeps its name: the space is what an address
* resolves to and is keyed by that name.
*/
function RenamePanel({ repo, space, session, queryClient }) {
const navigate = useNavigate();
const [name, setName] = useState(repo);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const rename = async (/** @type {{preventDefault: () => void}} */ event) => {
event.preventDefault();
setBusy(true);
setError('');
try {
await renameRepo(session, repo, name.trim());
await loadRepos();
await queryClient.invalidateQueries();
navigate(`/${encodeURIComponent(name.trim())}/settings`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
};
if (space) {
return (
<SettingsPanel
title="Repository name"
note="A private repository is addressed by the space holding it, and a space is keyed by the name it was made under. This one keeps the name it has."
value={repo}
onSave={async () => {}}
>
<p className="font-mono text-[13px] text-faint">{repo}</p>
</SettingsPanel>
);
}
return (
<form
onSubmit={rename}
className="overflow-hidden rounded-lg border border-border bg-card"
>
<div className="border-border border-b px-4 py-3">
<h3 className="font-semibold text-[14px] leading-tight">
Repository name
</h3>
<p className="mt-1 text-[12.5px] text-muted-foreground">
The issues, pull requests and checks stay with the repository. The
address changes, so every clone has to be re-pointed with git remote
set-url.
</p>
</div>
<div className="flex flex-col gap-4 px-4 py-4">
<div>
<Label htmlFor="settings-name">Name</Label>
<div className="flex items-center gap-2">
<span className="shrink-0 text-[13.5px] text-faint">
{account.handle} /
</span>
<Input
id="settings-name"
value={name}
onChange={(event) => setName(event.target.value)}
pattern="[A-Za-z0-9._~\-]{1,512}"
autoComplete="off"
className="font-mono"
/>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 border-border border-t bg-muted px-4 py-3">
<Button
type="submit"
size="sm"
disabled={busy || !name.trim() || name.trim() === repo}
>
{busy ? 'Renaming…' : 'Rename'}
</Button>
{error && (
<span className="text-[12.5px] text-destructive">{error}</span>
)}
</div>
</form>
);
}
/**
* Deleting the repository, which no other panel can be mistaken for: its own
* surround, its own colour, and the name typed out before the button works.
*
* What goes is stated rather than implied. A reader who wants the history
* afterwards has their clone or nothing.
*/
function DangerPanel({ repo, space, session, queryClient }) {
const navigate = useNavigate();
const [typed, setTyped] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const remove = async (/** @type {{preventDefault: () => void}} */ event) => {
event.preventDefault();
setBusy(true);
setError('');
try {
await deleteRepo(session, repo, space ?? '');
await loadRepos();
await queryClient.invalidateQueries();
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
};
return (
<form
onSubmit={remove}
className="overflow-hidden rounded-lg border border-destructive/40 bg-card"
>
<div className="border-destructive/40 border-b px-4 py-3">
<h3 className="font-semibold text-[14px] text-destructive leading-tight">
Delete this repository
</h3>
<p className="mt-1 text-[12.5px] text-muted-foreground">
The record, its settings and every bundle blob go. The history is gone
from this server, and a clone somebody already took is the only copy
left.
{space
? ' The people you added lose their access with it.'
: ' Anyone cloning it gets nothing.'}
</p>
</div>
<div className="flex flex-col gap-4 px-4 py-4">
<div>
<Label htmlFor="settings-confirm">
Type <span className="font-mono text-foreground">{repo}</span> to
confirm
</Label>
<Input
id="settings-confirm"
value={typed}
onChange={(event) => setTyped(event.target.value)}
autoComplete="off"
className="font-mono"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 border-destructive/40 border-t bg-muted px-4 py-3">
<Button
type="submit"
size="sm"
variant="destructive"
disabled={busy || typed !== repo}
>
{busy ? 'Deleting…' : 'Delete this repository'}
</Button>
{error && (
<span className="text-[12.5px] text-destructive">{error}</span>
)}
</div>
</form>
);
}
/**
* Everything about a repository that its owner sets rather than pushes.
*
* The settings are the owner's, so the section is theirs alone: the tab
* appears for their sign-in and nobody else's, and every write runs on their
* own PDS under their own grant. Each group saves on its own, because the
* server takes one setting per request and leaves the rest as it found them.
*/
export function SettingsPage({ repo, space }) {
const record = repoRecord(repo, space);
const { session } = useSession();
const queryClient = useQueryClient();
const mine = Boolean(session && session.did === account.did);
if (!record) return <PageNotice>No such repository.</PageNotice>;
if (!mine) {
return (
<PageNotice>
Settings write into {account.handle}'s own repo, so they need this
account's sign-in rather than permission from anybody else. Sign in as{' '}
{account.handle} from the bar above.
</PageNotice>
);
}
/** @param {import('#/lib/settings.js').SettingsChange} changes */
const write = async (changes) => {
await updateSettings(
/** @type {import('#/lib/oauth.js').Session} */ (session),
repo,
space ?? '',
changes,
);
// Every screen reads the repository from what the listing put in hand, so
// the save is not finished until that listing is read again.
await loadRepos();
await queryClient.invalidateQueries();
};
return (
<Screen>
<div className="flex max-w-3xl flex-col gap-4 px-(--gutter) py-5">
<GeneralPanel record={record} write={write} />
<BranchPanel record={record} write={write} />
<RunnerPanel record={record} write={write} />
<PackagesPanel record={record} write={write} />
<RenamePanel
repo={repo}
space={space}
session={session}
queryClient={queryClient}
/>
<DangerPanel
repo={repo}
space={space}
session={session}
queryClient={queryClient}
/>
</div>
</Screen>
);
}