chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import {
ArrowLeftIcon,
CheckIcon,
CircleIcon,
CopyIcon,
DownloadIcon,
KeyRoundIcon,
Loader2Icon,
TriangleAlertIcon,
UserPlusIcon,
XIcon,
} from 'lucide-react';
import { useRef, useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Heading } from '#/components/atoms/heading.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Panel } from '#/components/atoms/panel.jsx';
import { ErrorNotice } from '#/components/molecules/error-notice.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import { isRateLimitError } from '#/lib/atproto.js';
import { MIGRATION_STEPS, runMigration } from '#/lib/migrate.js';
import { generateRecoveryKey } from '#/lib/rotationKey.js';
/**
* First-run wizard, served by the server on an empty PDS. Two ways in: mint a
* brand-new account, or migrate an existing one here from another PDS.
*/
export function OnboardPage({ hostname }) {
const [choice, setChoice] = useState(
/** @type {null|'create'|'migrate'} */ (null),
);
return (
<div className="mx-auto flex min-h-svh max-w-md flex-col justify-center px-4 py-10">
<Panel>
{choice === null && (
<Chooser hostname={hostname} onChoose={setChoice} />
)}
{choice === 'create' && (
<CreateAccount hostname={hostname} onBack={() => setChoice(null)} />
)}
{choice === 'migrate' && (
<MigrateAccount hostname={hostname} onBack={() => setChoice(null)} />
)}
</Panel>
</div>
);
}
function Chooser({ hostname, onChoose }) {
return (
<>
<Heading>Set up this server</Heading>
<p className="mt-2 text-[13px] text-muted-foreground leading-relaxed">
<span className="text-foreground">{hostname}</span> is a fresh Personal
Data Server with no account yet. Claim it below.
</p>
<div className="mt-5 flex flex-col gap-3">
<button
type="button"
onClick={() => onChoose('create')}
className="flex items-start gap-3 rounded-2xl border border-border bg-muted px-4 py-3.5 text-left transition-colors hover:border-input hover:bg-accent"
>
<UserPlusIcon className="mt-0.5 size-5 shrink-0 text-key" />
<span>
<span className="block font-semibold text-sm">
Create a new account
</span>
<span className="block text-[12.5px] text-muted-foreground leading-relaxed">
Mint a brand-new identity hosted here.
</span>
</span>
</button>
<button
type="button"
onClick={() => onChoose('migrate')}
className="flex items-start gap-3 rounded-2xl border border-border bg-muted px-4 py-3.5 text-left transition-colors hover:border-input hover:bg-accent"
>
<DownloadIcon className="mt-0.5 size-5 shrink-0 text-key" />
<span>
<span className="block font-semibold text-sm">
Migrate an existing account
</span>
<span className="block text-[12.5px] text-muted-foreground leading-relaxed">
Move your account here from another PDS, keeping your identity.
</span>
</span>
</button>
</div>
</>
);
}
function BackButton({ onBack, disabled }) {
return (
<button
type="button"
onClick={onBack}
disabled={disabled}
className="mb-3 inline-flex items-center gap-1 text-[13px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
<ArrowLeftIcon className="size-3.5" /> Back
</button>
);
}
function CreateAccount({ hostname, onBack }) {
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (event) => {
event.preventDefault();
setError('');
setBusy(true);
const form = new FormData(event.currentTarget);
try {
const res = await fetch('/account/onboarding/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
handle: (form.get('handle') || '').toString().trim(),
email: (form.get('email') || '').toString().trim(),
password: (form.get('password') || '').toString(),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message || data.error || 'Failed.');
window.location.href = '/account';
} catch (caught) {
setError(String(caught?.message || caught));
setBusy(false);
}
};
return (
<>
<BackButton onBack={onBack} disabled={busy} />
<Heading>Create a new account</Heading>
<ErrorNotice error={error} className="mt-4" />
<form onSubmit={submit} className="mt-4 flex flex-col gap-4">
<TextField
name="handle"
label="Handle"
defaultValue={hostname}
hint="Defaults to this server's address. Use a domain you own to have your own name."
autoCapitalize="none"
autoCorrect="off"
spellCheck="false"
/>
<TextField
name="email"
label="Email (optional)"
type="email"
autoComplete="email"
/>
<TextField
name="password"
label="Server password"
type="password"
hint="The PDS_PASSWORD set when this server was deployed."
autoComplete="off"
required
/>
<Button type="submit" size="block" disabled={busy}>
{busy ? 'Creating…' : 'Create account'}
</Button>
</form>
</>
);
}
function MigrateAccount({ hostname, onBack }) {
const [phase, setPhase] = useState(
/** @type {'form'|'running'|'error'|'done'} */ ('form'),
);
const [error, setError] = useState('');
const [rateLimited, setRateLimited] = useState(false);
const [steps, setSteps] = useState(() =>
Object.fromEntries(MIGRATION_STEPS.map((s) => [s.key, { status: 'idle' }])),
);
// A pending code prompt: {kind:'2fa'|'plc', resolve}.
const [prompt, setPrompt] = useState(null);
const [result, setResult] = useState(/** @type {any} */ (null));
// The freshly minted recovery key ({didKey, privateKeyHex}) and whether the
// user has confirmed they saved it — gates the irreversible identity move.
const [recovery, setRecovery] = useState(/** @type {any} */ (null));
const [recoverySaved, setRecoverySaved] = useState(false);
const activeStep = useRef('');
const lastInput = useRef(/** @type {any} */ (null));
const onStep = (key, patch) => {
if (patch.status === 'active') activeStep.current = key;
setSteps((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
};
const requestTwoFactor = () =>
new Promise((resolve) => setPrompt({ kind: '2fa', resolve }));
const requestPlcCode = () =>
new Promise((resolve) => setPrompt({ kind: 'plc', resolve }));
const run = async (input) => {
setError('');
setRateLimited(false);
setPhase('running');
setSteps(
Object.fromEntries(
MIGRATION_STEPS.map((s) => [s.key, { status: 'idle' }]),
),
);
try {
const outcome = await runMigration(input, {
onStep,
requestTwoFactor,
requestPlcCode,
});
setResult(outcome);
setPhase('done');
} catch (caught) {
onStep(activeStep.current, { status: 'error' });
setPrompt(null);
setRateLimited(isRateLimitError(caught));
setError(String(caught?.message || caught));
setPhase('error');
}
};
const submitForm = (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
// Mint the recovery key up front (default on) so its public half rides along
// into the identity move and the user can be shown the private half to save.
let minted = null;
if (form.get('makeRecoveryKey')) {
minted = generateRecoveryKey();
}
setRecovery(minted);
setRecoverySaved(false);
const input = {
identifier: (form.get('identifier') || '').toString().trim(),
password: (form.get('password') || '').toString(),
newHandle: (form.get('newHandle') || hostname).toString().trim(),
email: (form.get('email') || '').toString().trim(),
serverPassword: (form.get('serverPassword') || '').toString(),
recoveryKey: minted?.didKey || null,
};
lastInput.current = input;
run(input);
};
const submitCode = (event) => {
event.preventDefault();
const value = new FormData(event.currentTarget)
.get('code')
.toString()
.trim();
const pending = prompt;
setPrompt(null);
pending?.resolve(value);
};
if (phase === 'done') {
return (
<>
<Heading>You're all set</Heading>
<Notice variant="success" className="mt-4">
Your account now lives on {hostname}. Your identity points here and
your old server has been retired.
</Notice>
{result?.skippedBlobs?.length > 0 && (
<Notice variant="warning" className="mt-3">
{result.skippedBlobs.length}{' '}
{result.skippedBlobs.length === 1 ? 'attachment' : 'attachments'}{' '}
could not be read from your old server and were left behind — the
posts came over, but those images or videos will be missing.
</Notice>
)}
{recovery && (
<RecoveryKeyCard privateKeyHex={recovery.privateKeyHex} reminder />
)}
<Button
size="block"
className="mt-4"
onClick={() => {
window.location.href = '/account';
}}
>
Go to your account
</Button>
</>
);
}
if (phase === 'running' || phase === 'error') {
return (
<>
<Heading>
{phase === 'error' ? 'Migration paused' : 'Migrating…'}
</Heading>
{phase === 'error' && (
<Notice variant="error" className="mt-4">
{error}
{rateLimited && (
<span className="mt-1 block text-[12.5px]">
The other server is rate-limiting the transfer. This usually
resets after 24 hours — retry then; already-copied data is kept.
</span>
)}
</Notice>
)}
<StepList steps={steps} />
{recovery && (
<RecoveryKeyCard
privateKeyHex={recovery.privateKeyHex}
saved={recoverySaved}
onSavedChange={setRecoverySaved}
/>
)}
{prompt && (
<CodePrompt
kind={prompt.kind}
onSubmit={submitCode}
blocked={prompt.kind === 'plc' && !!recovery && !recoverySaved}
/>
)}
{phase === 'error' && (
<>
<Button
size="block"
className="mt-4"
onClick={() => run(lastInput.current)}
>
Try again
</Button>
<button
type="button"
onClick={() => setPhase('form')}
className="mt-2 w-full text-center text-[13px] text-muted-foreground transition-colors hover:text-foreground"
>
Edit details
</button>
</>
)}
</>
);
}
// phase === 'form'
return (
<>
<BackButton onBack={onBack} />
<Heading>Migrate an existing account</Heading>
<p className="mt-2 text-[13px] text-muted-foreground leading-relaxed">
Move your account here from another PDS. You'll need your{' '}
<span className="text-foreground">current</span> login — your real
password, not an app password.
</p>
<form onSubmit={submitForm} className="mt-4 flex flex-col gap-4">
<TextField
name="identifier"
label="Current handle or DID"
placeholder="you.bsky.social"
autoCapitalize="none"
autoCorrect="off"
spellCheck="false"
required
/>
<TextField
name="password"
label="Current password"
type="password"
autoComplete="off"
required
/>
<hr className="border-border" />
<TextField
name="newHandle"
label="Handle here"
defaultValue={hostname}
hint="What you'll be known as on this server."
autoCapitalize="none"
autoCorrect="off"
spellCheck="false"
/>
<TextField name="email" label="Email (optional)" type="email" />
<TextField
name="serverPassword"
label="This server's password"
type="password"
hint="The PDS_PASSWORD set when this server was deployed."
autoComplete="off"
required
/>
<label className="flex items-start gap-2.5 rounded-xl border border-border bg-muted px-3.5 py-3 text-left">
<input
type="checkbox"
name="makeRecoveryKey"
defaultChecked
className="mt-0.5 size-4 shrink-0 accent-key"
/>
<span>
<span className="block font-medium text-[13px]">
Create a recovery key
</span>
<span className="block text-[12px] text-muted-foreground leading-relaxed">
A key you alone hold, so you can recover your identity if this
server is ever lost. You'll save it before the move finishes.
Recommended.
</span>
</span>
</label>
<Button type="submit" size="block">
Start migration
</Button>
</form>
</>
);
}
function StepList({ steps }) {
return (
<ul className="mt-4 flex flex-col gap-2.5">
{MIGRATION_STEPS.map(({ key, label }) => {
const { status, detail } = steps[key];
return (
<li key={key} className="flex items-center gap-2.5 text-sm">
<StepIcon status={status} />
<span
className={
status === 'idle'
? 'text-faint'
: status === 'error'
? 'text-destructive'
: status === 'warn'
? 'text-warning'
: 'text-foreground'
}
>
{label}
</span>
{detail && (
<span className="ml-auto text-[12px] text-muted-foreground">
{detail}
</span>
)}
</li>
);
})}
</ul>
);
}
function StepIcon({ status }) {
if (status === 'done')
return <CheckIcon className="size-4 shrink-0 text-success" />;
if (status === 'warn')
return <TriangleAlertIcon className="size-4 shrink-0 text-warning" />;
if (status === 'error')
return <XIcon className="size-4 shrink-0 text-destructive" />;
if (status === 'active')
return <Loader2Icon className="size-4 shrink-0 animate-spin text-key" />;
return <CircleIcon className="size-4 shrink-0 text-faint" />;
}
function CodePrompt({ kind, onSubmit, blocked }) {
return (
<form onSubmit={onSubmit} className="mt-4 flex flex-col gap-3">
<Notice variant="info">
{kind === '2fa'
? 'Your old server emailed a sign-in code. Enter it to continue.'
: 'Your old server emailed a confirmation code to move your identity. Enter it to finish.'}
</Notice>
<TextField
name="code"
label={kind === '2fa' ? 'Sign-in code' : 'Confirmation code'}
autoComplete="one-time-code"
autoCapitalize="none"
spellCheck="false"
required
autoFocus
/>
{blocked && (
<p className="text-[12.5px] text-warning">
Save your recovery key and check the box above before finishing.
</p>
)}
<Button type="submit" size="block" disabled={blocked}>
Continue
</Button>
</form>
);
}
/**
* Shows a freshly minted recovery key's private half for the user to save. In
* `reminder` mode (post-migration) it's informational; otherwise it carries the
* "I've saved this" checkbox that unblocks the identity move.
*/
function RecoveryKeyCard({ privateKeyHex, saved, onSavedChange, reminder }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(privateKeyHex);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard can be unavailable (permissions/insecure context); the key is
// still selectable in the box.
}
};
return (
<div className="mt-4 rounded-xl border border-warning/40 bg-warning-dim p-3.5">
<div className="flex items-center gap-2 text-warning">
<KeyRoundIcon className="size-4 shrink-0" />
<span className="font-semibold text-[13px]">Your recovery key</span>
</div>
<p className="mt-1.5 text-[12.5px] text-muted-foreground leading-relaxed">
Save this private key somewhere safe (a password manager). It's the only
way to recover your identity if this server is ever lost, and it{' '}
<span className="text-foreground">won't be shown again</span>.
</p>
<div className="mt-2.5 flex items-stretch gap-2">
<code className="min-w-0 flex-1 select-all break-all rounded-lg bg-card px-2.5 py-2 font-mono text-[11px] text-foreground">
{privateKeyHex}
</code>
<button
type="button"
onClick={copy}
className="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2.5 text-[12px] text-muted-foreground transition-colors hover:text-foreground"
>
{copied ? (
<CheckIcon className="size-3.5" />
) : (
<CopyIcon className="size-3.5" />
)}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
{!reminder && (
<label className="mt-2.5 flex items-center gap-2 text-[13px] text-foreground">
<input
type="checkbox"
checked={saved}
onChange={(e) => onSavedChange(e.target.checked)}
className="size-4 shrink-0 accent-key"
/>
I've saved my recovery key
</label>
)}
</div>
);
}