chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import { documentHandle, resolveDid } from '#/lib/identity.js';
import { beginSignIn, clientId } from '#/lib/oauth.js';
import { SCOPES } from '#/lib/scopes.js';
/**
* The account whose site is serving this page, or null where the page is served
* from anywhere else. The field is not filled from it: a reader signs in as
* whoever they are, which on a shared deployment is rarely the host. It is kept
* for the fallback below, where a handle that resolves nowhere is the host's
* own and its DID does resolve.
*/
function useHostAccount() {
return useQuery({
queryKey: ['host-account'],
queryFn: async () => {
const response = await fetch('/.well-known/atproto-did');
if (!response.ok) return null;
const did = (await response.text()).trim();
if (!did.startsWith('did:')) return null;
return { did, handle: documentHandle(await resolveDid(did)) };
},
retry: false,
staleTime: Number.POSITIVE_INFINITY,
});
}
/**
* Whether this copy of the app is registered for the origin it is served from.
*
* An OAuth client is named by the URL its registration is served from, so a
* copy deployed for one origin cannot sign in from another. The check is
* skipped on loopback, where the client_id carries its own registration.
*/
function useRegistration() {
const expected = clientId(SCOPES.read);
return useQuery({
queryKey: ['registration', expected],
queryFn: async () => {
if (!expected.startsWith('https://')) return { ok: true };
const response = await fetch('/client-metadata.json');
if (!response.ok) return { ok: false, found: null };
const found = (await response.json()).client_id;
return { ok: found === expected, found };
},
retry: false,
staleTime: Number.POSITIVE_INFINITY,
});
}
export function SignInPage() {
const { data: host } = useHostAccount();
const { data: registration } = useRegistration();
const [identifier, setIdentifier] = useState('');
const [access, setAccess] = useState('read');
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
async function submit(event) {
event.preventDefault();
setError(null);
setBusy(true);
try {
await beginSignIn(identifier, { scope: SCOPES[access] });
} catch (err) {
// The origin publishes its own account's DID, which resolves wherever the
// handle beside it does not. A handle on a domain with no public DNS is
// the case: a local stack, or an account mid-handle-change.
if (host && identifier.trim().toLowerCase() === host.handle) {
try {
await beginSignIn(host.did, { scope: SCOPES[access] });
return;
} catch {
// The DID failed too, so the first message is the useful one.
}
}
setError(err.message);
setBusy(false);
}
}
return (
<div className="mx-auto max-w-md pt-16">
<h1 className="font-semibold text-[22px] text-foreground tracking-tight">
Spaces
</h1>
<p className="mt-2 text-muted-foreground text-sm leading-relaxed">
Sign in to browse the permissioned spaces your account holds: the
records in each one, who writes to them, and what the authority says
about them.
</p>
<form onSubmit={submit} className="mt-8 flex flex-col gap-4">
<TextField
name="identifier"
label="Handle or DID"
placeholder="alice.example.com"
autoCapitalize="none"
autoCorrect="off"
spellCheck="false"
autoComplete="username"
required
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
/>
<fieldset className="flex flex-col gap-2">
<legend className="text-[13px] text-muted-foreground">Access</legend>
{[
{
key: 'read',
title: 'Read only',
detail: 'List and open records. Nothing on the server changes.',
},
{
key: 'write',
title: 'Read and write',
detail:
'Also create, edit and delete records, and manage spaces and their members.',
},
].map((option) => (
<label
key={option.key}
className={`flex cursor-pointer gap-3 rounded-xl border p-3 ${
access === option.key
? 'border-key/40 bg-key/5'
: 'border-border hover:border-input'
}`}
>
<input
type="radio"
name="access"
value={option.key}
checked={access === option.key}
onChange={() => setAccess(option.key)}
className="mt-1 accent-[var(--key)]"
/>
<span className="min-w-0">
<span className="block font-medium text-[13px] text-foreground">
{option.title}
</span>
<span className="block text-muted-foreground text-xs leading-relaxed">
{option.detail}
</span>
</span>
</label>
))}
</fieldset>
{registration && !registration.ok && (
<Notice variant="warning">
This copy is registered for{' '}
{registration.found
? new URL(registration.found).origin
: 'nowhere'}
, not for {window.location.origin}. Deploy it again with
SPACES_ORIGIN set to this origin before signing in.
</Notice>
)}
{error && <Notice variant="error">{error}</Notice>}
<Button
type="submit"
size="block"
disabled={busy || !identifier.trim()}
>
{busy ? 'Redirecting…' : 'Continue'}
</Button>
</form>
<p className="mt-6 text-faint text-xs leading-relaxed">
Your PDS asks you to approve this app before it sees anything. The
access token stays in this browser and is bound to a key this page
cannot read, so a copy of it is no use anywhere else.
</p>
</div>
);
}