chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// The wizard. Four screens: connect, pick an account, configure, deploy.
// State lives in this module; a reload during deploy keeps the token in
// sessionStorage but starts the screens over.
import { bytesToHex } from '@pdsjs/core/crypto';
import { createPlcOperation, generateIdentity } from './bootstrap.js';
import { cfClient } from './cf.js';
import {
attachCustomDomain,
createBucket,
enableSubdomain,
getWorkersSubdomain,
listZones,
putHandleRecord,
putSecrets,
updateWorker,
uploadWorker,
waitForPds,
} from './deploy.js';
import { beginOAuth, clearToken, completeOAuth, storedToken } from './oauth.js';
const app = /** @type {HTMLElement} */ (document.getElementById('app'));
/**
* @typedef {Object} WizardConfig
* @property {string|null} clientId
* @property {string} scopes
* @property {string} cfApiUrl - The PDS's Cloudflare API passthrough
* @property {string} artifactUrl - The published release, e.g. https://artifacts.pdsjs.dev/latest
* @property {string} plcUrl
* @property {string} relayUrl
*/
const state = {
/** @type {WizardConfig} */
config: {
clientId: null,
scopes: '',
cfApiUrl: '/cf-api',
artifactUrl: 'https://artifacts.pdsjs.dev/latest',
plcUrl: 'https://plc.directory',
relayUrl: 'https://bsky.network',
},
/** @type {string|null} */
token: null,
/** @type {{id: string, name: string}|null} */
account: null,
/** @type {string|null} */
subdomain: null,
scriptName: 'pds',
password: '',
/** @type {ReturnType<typeof generateIdentity>|null} */
identity: null,
/** @type {string|null} */
did: null,
/** @type {string} */
updateHost: '',
/** @type {string} */
updateScript: '',
/** @type {'stable'|'canary'} */
updateChannel: 'stable',
/** @type {'create'|'migrate'} */
setup: 'create',
/** @type {{id: string, name: string}|null} */
zone: null,
/** @type {{id: string, name: string}[]} */
zones: [],
/** @type {string|null} */
handleDid: null,
/** @type {boolean} */
handleChecking: false,
};
/**
* The DID a handle already resolves to, or null when it is free. The public
* resolver sees both verification paths, the DNS record and the well-known
* file. An unreachable resolver reads as free: the check warns, it does not
* gate the network.
* @param {string} handle
* @returns {Promise<string|null>}
*/
async function resolveExistingHandle(handle) {
try {
const response = await fetch(
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
);
if (!response.ok) return null;
const { did } = await response.json();
return typeof did === 'string' ? did : null;
} catch {
return null;
}
}
function generatedPassword() {
const bytes = crypto.getRandomValues(new Uint8Array(18));
return btoa(String.fromCharCode(...bytes))
.replaceAll('+', '-')
.replaceAll('/', '_');
}
function hostname() {
return `${state.scriptName}.${state.subdomain}.workers.dev`;
}
/** The hostname the server answers on. The workers.dev host stays enabled
* either way, and the bootstrap talks to it: it is live immediately, while
* a custom domain's certificate can take minutes. */
function pdsHost() {
return state.zone ? `pds.${state.zone.name}` : hostname();
}
function handleName() {
return state.zone ? state.zone.name : hostname();
}
/** @param {string} html */
function render(html) {
app.innerHTML = html;
}
/** @param {unknown} err */
function message(err) {
return err instanceof Error ? err.message : String(err);
}
// === Screens ===
function showConnect() {
if (!state.config.clientId) {
render(`
<h1>Not configured</h1>
<p class="lede">This deployment has no OAuth client id. The operator
registers one in the Cloudflare dashboard under Manage account, then
OAuth clients, and publishes it in this site's
<code>config.json</code>.</p>
`);
return;
}
render(`
<h1>Your own personal data server</h1>
<p class="lede">This wizard deploys <a href="https://pdsjs.dev">pds.js</a>
into your Cloudflare account: one Worker, one R2 bucket, your keys. It
runs on Cloudflare's free plan with R2 enabled.</p>
<div class="card">
<p>What happens:</p>
<p>1. You sign in with Cloudflare and approve limited access.<br>
2. The wizard deploys the server and generates your signing key here in
this tab.<br>
3. Your new identity is registered in the PLC directory and your
credentials are yours to download.</p>
</div>
<div class="actions">
<button id="connect">Connect Cloudflare</button>
</div>
`);
document.getElementById('connect')?.addEventListener('click', () => {
beginOAuth({
clientId: /** @type {string} */ (state.config.clientId),
scopes: state.config.scopes,
});
});
}
/** @param {() => void} [next] - The screen after an account is chosen */
async function showAccounts(next = showChoice) {
render('<p class="lede">Loading your accounts…</p>');
const cf = cfClient(
/** @type {string} */ (state.token),
state.config.cfApiUrl,
);
/** @type {{id: string, name: string}[]} */
let accounts;
try {
accounts = await cf.get('/client/v4/accounts');
} catch (err) {
clearToken();
render(`
<h1>Could not list accounts</h1>
<p class="error">${message(err)}</p>
<div class="actions"><button id="restart">Start over</button></div>
`);
document
.getElementById('restart')
?.addEventListener('click', () => showConnect());
return;
}
if (accounts.length === 1) {
state.account = accounts[0];
next();
return;
}
render(`
<h1>Pick an account</h1>
<p class="lede">The server deploys into this Cloudflare account.</p>
<div class="account-list">
${accounts
.map(
(a, i) =>
`<button data-index="${i}">${a.name}<br><span class="hint">${a.id}</span></button>`,
)
.join('')}
</div>
`);
for (const button of app.querySelectorAll('.account-list button')) {
button.addEventListener('click', () => {
state.account = accounts[Number(button.getAttribute('data-index'))];
next();
});
}
}
function showChoice() {
render(`
<h1>New account, or bring yours?</h1>
<p class="lede">Both paths deploy the same server into your account.</p>
<div class="account-list">
<button id="create">Start fresh<br>
<span class="hint">A new identity. Your handle is your server's
workers.dev hostname.</span></button>
<button id="migrate">Migrate an existing account<br>
<span class="hint">Deploys an empty server. Your account moves in
from your current PDS, keeping your identity and handle.</span></button>
</div>
`);
document.getElementById('create')?.addEventListener('click', () => {
state.setup = 'create';
showConfigure();
});
document.getElementById('migrate')?.addEventListener('click', () => {
state.setup = 'migrate';
showConfigure();
});
}
async function showConfigure() {
const cf = cfClient(
/** @type {string} */ (state.token),
state.config.cfApiUrl,
);
// An older OAuth grant may lack the zone scopes; the picker then offers
// workers.dev alone.
[state.subdomain, state.zones] = await Promise.all([
getWorkersSubdomain(cf, /** @type {{id: string}} */ (state.account).id),
listZones(cf).catch(() => []),
]);
if (!state.subdomain) {
render(`
<h1>One dashboard step first</h1>
<p class="lede">Your account has no workers.dev subdomain yet. Pick one
in the <a href="https://dash.cloudflare.com/?to=/:account/workers-and-pages"
target="_blank" rel="noreferrer">Workers dashboard</a>, then come back
and continue.</p>
<div class="actions"><button id="again">Check again</button></div>
`);
document
.getElementById('again')
?.addEventListener('click', () => showConfigure());
return;
}
if (!state.password) state.password = generatedPassword();
const domainOptions = [
`<option value="">${hostname()} (workers.dev, free)</option>`,
...state.zones.map(
(z) =>
`<option value="${z.id}" ${state.zone?.id === z.id ? 'selected' : ''}>${z.name}</option>`,
),
].join('');
render(`
<h1>Configure</h1>
<label for="name">Worker name</label>
<input id="name" value="${state.scriptName}" spellcheck="false">
<label for="domain">Domain</label>
<select id="domain">${domainOptions}</select>
<p class="hint" id="domain-hint"></p>
<label for="password">Server password</label>
<input id="password" value="${state.password}" spellcheck="false">
<p class="hint">Generated for you. It logs in to your server's account
pages. Save it in your password manager.</p>
<div class="actions">
<button id="deploy">Deploy my PDS</button>
</div>
`);
const domainHint = /** @type {HTMLElement} */ (
document.getElementById('domain-hint')
);
const updateDomainHint = () => {
if (state.zone && state.setup === 'migrate') {
domainHint.innerHTML = `Server: <span class="mono">pds.${state.zone.name}</span>.
Your handle stays with your account.`;
} else if (state.zone && state.handleChecking) {
domainHint.innerHTML = `Checking whether
<span class="mono">${state.zone.name}</span> is already a handle…`;
} else if (state.zone && state.handleDid) {
domainHint.innerHTML = `<span class="error"><span class="mono">${state.zone.name}</span>
is already the handle of an account
(<span class="mono">${state.handleDid}</span>). A new account here
would break it. Pick another domain, or go back and choose Migrate an
existing account.</span>`;
} else if (state.zone) {
domainHint.innerHTML = `Server: <span class="mono">pds.${state.zone.name}</span>.
Handle: <span class="mono">${state.zone.name}</span>, proven by a DNS
record the wizard creates. A site already on the domain stays as it is.`;
} else if (state.setup === 'migrate') {
domainHint.innerHTML = `Server: <span class="mono">${hostname()}</span>.
Your handle stays with your account.`;
} else {
domainHint.innerHTML = `Your server and your handle:
<span class="mono">${hostname()}</span>`;
}
};
const checkHandle = async () => {
state.handleDid = null;
state.handleChecking = false;
if (!state.zone || state.setup !== 'create') {
updateDomainHint();
return;
}
const checking = state.zone;
state.handleChecking = true;
updateDomainHint();
const did = await resolveExistingHandle(checking.name);
// A slow answer for a zone no longer selected changes nothing.
if (state.zone?.id !== checking.id) return;
state.handleChecking = false;
state.handleDid = did;
updateDomainHint();
};
checkHandle();
const domainSelect = /** @type {HTMLSelectElement} */ (
document.getElementById('domain')
);
domainSelect.addEventListener('change', () => {
state.zone = state.zones.find((z) => z.id === domainSelect.value) ?? null;
checkHandle();
});
const nameInput = /** @type {HTMLInputElement} */ (
document.getElementById('name')
);
nameInput.addEventListener('input', () => {
state.scriptName = nameInput.value.trim().toLowerCase();
const options = domainSelect.options;
if (options[0]) options[0].text = `${hostname()} (workers.dev, free)`;
updateDomainHint();
});
const passwordInput = /** @type {HTMLInputElement} */ (
document.getElementById('password')
);
passwordInput.addEventListener('input', () => {
state.password = passwordInput.value;
});
document.getElementById('deploy')?.addEventListener('click', () => {
if (!/^[a-z0-9][a-z0-9-]*$/.test(state.scriptName)) {
alert('The worker name may use lowercase letters, digits and hyphens.');
return;
}
if (state.password.length < 8) {
alert('Use a password of at least 8 characters.');
return;
}
if (state.setup === 'create' && state.zone && state.handleDid) {
alert(
`${state.zone.name} is already the handle of an account. A new ` +
'account here would break it. Pick another domain, or choose ' +
'Migrate an existing account.',
);
return;
}
runDeploy();
});
}
const INFRA_STEPS = [
'Create the blob bucket',
'Upload the worker',
'Set the secrets',
'Serve it on workers.dev',
'Wait for the server to answer',
];
// The identity steps apply to a fresh account. A migration deploys the
// server empty; the account moves in through the server's own onboard page.
function stepLabels() {
const labels = [...INFRA_STEPS];
if (state.zone) labels.push('Connect your domain');
if (state.setup === 'migrate') return labels;
labels.push('Register your identity in the PLC directory');
if (state.zone) labels.push('Prove your handle with a DNS record');
labels.push('Initialize the server', 'Ask the relay to crawl');
return labels;
}
/** @param {number} active @param {number} failed */
function renderSteps(active, failed = -1) {
render(`
<h1>Deploying</h1>
<ol class="steps">
${stepLabels()
.map((label, i) => {
const cls =
i === failed
? 'failed'
: i < active
? 'done'
: i === active
? 'active'
: '';
const mark =
i === failed ? '✗' : i < active ? '✓' : i === active ? '…' : ' ';
return `<li class="${cls}">${mark} ${label}</li>`;
})
.join('')}
</ol>
<div id="outcome"></div>
`);
}
async function runDeploy() {
const cf = cfClient(
/** @type {string} */ (state.token),
state.config.cfApiUrl,
);
const accountId = /** @type {{id: string}} */ (state.account).id;
// The bootstrap talks to the workers.dev host, live immediately; the
// server's identity carries the chosen domain, whose certificate may
// still be provisioning.
const bootUrl = `https://${hostname()}`;
const serverHost = pdsHost();
const handle = handleName();
const pdsUrl = `https://${serverHost}`;
const bucketName = `${state.scriptName}-blobs`;
// The identity survives a retry, so a second run keeps the same DID. A
// migration brings its own: the server stays empty and no key is made.
if (state.setup === 'create' && !state.identity) {
state.identity = generateIdentity();
}
const jwtSecret = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
let step = 0;
try {
renderSteps(step);
await createBucket(cf, accountId, bucketName);
renderSteps(++step);
await uploadWorker(cf, {
accountId,
scriptName: state.scriptName,
bucketName,
hostname: serverHost,
updateUrl: `${location.origin}/update`,
artifactUrl: state.config.artifactUrl,
relayUrl: state.config.relayUrl,
});
renderSteps(++step);
await putSecrets(cf, accountId, state.scriptName, {
JWT_SECRET: jwtSecret,
PDS_PASSWORD: state.password,
});
renderSteps(++step);
await enableSubdomain(cf, accountId, state.scriptName);
renderSteps(++step);
await waitForPds(bootUrl);
if (state.zone) {
renderSteps(++step);
await attachCustomDomain(cf, {
accountId,
zoneId: state.zone.id,
hostname: serverHost,
scriptName: state.scriptName,
});
}
if (state.setup === 'migrate') {
showMigrateDone(pdsUrl, bootUrl);
return;
}
// Past the migration branch this is a create, and the guard above made
// the key for it.
const identity = /** @type {ReturnType<typeof generateIdentity>} */ (
state.identity
);
renderSteps(++step);
const { operation, did } = await createPlcOperation(
identity,
handle,
pdsUrl,
);
state.did = did;
const plcResponse = await fetch(
`${state.config.plcUrl}/${encodeURIComponent(did)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(operation),
},
);
if (!plcResponse.ok) {
throw new Error(
`PLC registration failed: ${plcResponse.status} ${await plcResponse.text()}`,
);
}
if (state.zone) {
renderSteps(++step);
await putHandleRecord(cf, {
zoneId: state.zone.id,
zoneName: state.zone.name,
did,
});
}
renderSteps(++step);
const initResponse = await fetch(`${bootUrl}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
did,
privateKey: identity.privateKeyHex,
kemKey: identity.kemKeyHex,
curve: 'secp256k1',
handle,
password: state.password,
}),
});
if (!initResponse.ok) {
throw new Error(
`Initialization failed: ${initResponse.status} ${await initResponse.text()}`,
);
}
renderSteps(++step);
await fetch(`${state.config.relayUrl}/xrpc/com.atproto.sync.requestCrawl`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname: serverHost }),
});
showDone(pdsUrl, handle, /** @type {string} */ (state.did));
} catch (err) {
renderSteps(step, step);
const outcome = /** @type {HTMLElement} */ (
document.getElementById('outcome')
);
outcome.innerHTML = `
<p class="error">${message(err)}</p>
<p class="hint">Every step is safe to run again. Fix the cause if it
needs you, then retry.</p>
<div class="actions">
<button id="retry">Retry</button>
<button id="back" class="secondary">Back to settings</button>
</div>
`;
document.getElementById('retry')?.addEventListener('click', runDeploy);
document
.getElementById('back')
?.addEventListener('click', () => showConfigure());
}
}
/** @param {string} pdsUrl @param {string} host */
/** @param {string} pdsUrl @param {string} bootUrl - the workers.dev URL, live immediately */
function showMigrateDone(pdsUrl, bootUrl) {
const certNote = state.zone
? `<p class="hint">The certificate for ${pdsUrl} can take a few minutes.
The account link above uses the workers.dev address, which is live
now.</p>`
: '';
render(`
<h1>Your server is live and empty</h1>
<div class="card">
<p>Server: <a href="${pdsUrl}" target="_blank" rel="noreferrer">${pdsUrl}</a><br>
Password: <span class="mono">${state.password}</span></p>
</div>
<p>Your account moves in on the server's own pages. Open your
<a href="${bootUrl}/account" target="_blank" rel="noreferrer">account
page</a> and choose <b>Migrate an existing account</b>. It signs in to
your current PDS, copies your data and repoints your identity at
<span class="mono">${new URL(pdsUrl).host}</span>.</p>
${certNote}
<p><strong>Save the password first.</strong> It is the key to this
server, and this site keeps nothing.</p>
`);
}
/** @param {string} pdsUrl @param {string} handle @param {string} did */
function showDone(pdsUrl, handle, did) {
const identity = /** @type {ReturnType<typeof generateIdentity>} */ (
state.identity
);
const credentials = {
handle,
did,
didKey: identity.didKey,
curve: 'secp256k1',
privateKeyHex: identity.privateKeyHex,
pdsUrl,
serverPassword: state.password,
createdAt: new Date().toISOString(),
};
const certNote = state.zone
? `<p class="hint">The certificate for ${new URL(pdsUrl).host} can take a
few minutes; the server also answers at ${hostname()} meanwhile.</p>`
: '';
render(`
<h1>Your PDS is live</h1>
<div class="card">
<p>Handle: <span class="mono">${handle}</span><br>
DID: <span class="mono">${did}</span><br>
Server: <a href="${pdsUrl}" target="_blank" rel="noreferrer">${pdsUrl}</a><br>
Account pages: <a href="${pdsUrl}/account" target="_blank" rel="noreferrer">${pdsUrl}/account</a></p>
</div>
${certNote}
<p><strong>Download your credentials now.</strong> The file holds the only
copy of your signing key, and your server password with it. This site
keeps nothing.</p>
<div class="actions">
<button id="download">Download credentials</button>
</div>
<p class="hint">Sign in to Bluesky or any atproto app with the handle
<span class="mono">${handle}</span> and your server password.</p>
`);
document.getElementById('download')?.addEventListener('click', () => {
const blob = new Blob([JSON.stringify(credentials, null, 2)], {
type: 'application/json',
});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `credentials-${handle}.json`;
link.click();
URL.revokeObjectURL(link.href);
});
}
// === Update an existing PDS ===
/** The worker name a workers.dev hostname implies, or null for other hosts. */
function scriptNameFromHost(/** @type {string} */ host) {
return host.endsWith('.workers.dev') ? host.split('.')[0] : null;
}
/**
* The artifact path for the channel this update follows. The configured
* artifactUrl names the stable channel (…/latest); canary swaps that last
* segment. A pinned artifactUrl without a /latest segment ignores the
* channel.
* @param {'stable'|'canary'} channel
*/
function artifactUrlFor(channel) {
const base = state.config.artifactUrl;
if (channel === 'canary' && base.endsWith('/latest')) {
return `${base.slice(0, -'/latest'.length)}/canary`;
}
return base;
}
async function showUpdate() {
const params = new URLSearchParams(location.search);
if (!state.updateHost) state.updateHost = params.get('host') ?? '';
if (!state.updateScript) state.updateScript = params.get('script') ?? '';
if (params.get('channel') === 'canary') state.updateChannel = 'canary';
render(`
<h1>Update a PDS</h1>
<p class="lede">Re-deploys the latest pds.js worker to a server this
wizard created. Your data, your settings and your secrets stay in place;
only the code changes.</p>
<label for="host">Server hostname</label>
<input id="host" value="${state.updateHost}" spellcheck="false"
placeholder="pds.your-name.workers.dev">
<div class="actions">
<button id="check">Check</button>
</div>
<div id="outcome"></div>
`);
const hostInput = /** @type {HTMLInputElement} */ (
document.getElementById('host')
);
hostInput.addEventListener('input', () => {
state.updateHost = hostInput.value.trim().toLowerCase();
});
document.getElementById('check')?.addEventListener('click', checkUpdate);
if (state.updateHost) checkUpdate();
}
async function checkUpdate() {
const outcome = /** @type {HTMLElement} */ (
document.getElementById('outcome')
);
const host = state.updateHost;
if (!host) return;
outcome.innerHTML = '<p class="lede">Asking the server for its version…</p>';
let current;
let available;
try {
const [health, manifest] = await Promise.all([
fetch(`https://${host}/xrpc/_health`).then((r) => r.json()),
fetch(`${artifactUrlFor(state.updateChannel)}/manifest.json`).then((r) =>
r.json(),
),
]);
current = health.version;
available = manifest.version;
} catch (err) {
outcome.innerHTML = `<p class="error">No PDS answered at ${host}: ${message(err)}</p>`;
return;
}
// The update link from the server's own account page names the worker; a
// workers.dev hostname names it too.
const scriptName = state.updateScript || scriptNameFromHost(host);
const carrier =
state.updateChannel === 'canary' ? 'The canary channel' : 'This wizard';
const summary = `<p>The server runs pds.js <b>${current}</b>. ${carrier}
carries <b>${available}</b>.${
current === available ? ' It is already on the latest.' : ''
}</p>`;
if (!scriptName) {
outcome.innerHTML = `${summary}
<p class="error">This hostname does not name its worker. Follow the
update link from the server's own account page, which carries the
worker name, or update with wrangler.</p>`;
return;
}
if (!state.token) {
outcome.innerHTML = `${summary}
<div class="actions"><button id="connect">Connect Cloudflare</button></div>`;
document.getElementById('connect')?.addEventListener('click', () => {
const returnParams = new URLSearchParams({ host });
if (state.updateScript) returnParams.set('script', state.updateScript);
if (state.updateChannel === 'canary') {
returnParams.set('channel', 'canary');
}
sessionStorage.setItem('return_to', `/update?${returnParams}`);
beginOAuth({
clientId: /** @type {string} */ (state.config.clientId),
scopes: state.config.scopes,
});
});
return;
}
if (!state.account) {
showAccounts(showUpdate);
return;
}
outcome.innerHTML = `${summary}
<div class="actions"><button id="run">Update to ${available}</button></div>`;
document
.getElementById('run')
?.addEventListener('click', () => runUpdate(scriptName, available));
}
/** @param {string} scriptName @param {string} available */
async function runUpdate(scriptName, available) {
const outcome = /** @type {HTMLElement} */ (
document.getElementById('outcome')
);
const cf = cfClient(
/** @type {string} */ (state.token),
state.config.cfApiUrl,
);
const accountId = /** @type {{id: string}} */ (state.account).id;
outcome.innerHTML = '<p class="lede">Uploading the new worker…</p>';
// What actually went up, which the canary pointer may have moved to since
// this page read it. Waiting for the older version would never finish.
let installed = available;
try {
const manifest = await updateWorker(cf, {
accountId,
scriptName,
artifactUrl: artifactUrlFor(state.updateChannel),
relayUrl: state.config.relayUrl,
});
installed = manifest.version;
} catch (err) {
outcome.innerHTML = `
<p class="error">${message(err)}</p>
<div class="actions"><button id="retry">Retry</button></div>`;
document
.getElementById('retry')
?.addEventListener('click', () => runUpdate(scriptName, available));
return;
}
outcome.innerHTML =
'<p class="lede">Waiting for the server to come back…</p>';
const deadline = Date.now() + 60_000;
let version = null;
while (Date.now() < deadline) {
try {
const health = await fetch(
`https://${state.updateHost}/xrpc/_health`,
).then((r) => r.json());
version = health.version;
if (version === installed) break;
} catch {
// The worker restarts under the upload.
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
outcome.innerHTML =
version === installed
? `<p>Done. The server runs <b>${installed}</b>.</p>`
: `<p class="error">The upload succeeded, and the server still reports
${version}. Give it a minute and check again.</p>`;
}
// === Boot ===
async function boot() {
// The deployment's values, published as a static file beside the page.
// Missing or unreadable, the defaults stand and the page reports itself
// unconfigured.
const raw = await fetch('/config.json')
.then((r) => (r.ok ? r.json() : {}))
.catch(() => ({}));
state.config = { ...state.config, ...raw };
if (location.pathname === '/callback') {
try {
state.token = await completeOAuth({
clientId: /** @type {string} */ (state.config.clientId),
});
} catch (err) {
render(`
<h1>Sign-in failed</h1>
<p class="error">${message(err)}</p>
<div class="actions"><button id="restart">Start over</button></div>
`);
document.getElementById('restart')?.addEventListener('click', () => {
history.replaceState(null, '', '/');
showConnect();
});
return;
}
// An update flow parks its path before the OAuth redirect and resumes.
const returnTo = sessionStorage.getItem('return_to');
sessionStorage.removeItem('return_to');
history.replaceState(null, '', returnTo ?? '/');
if (returnTo?.startsWith('/update')) {
showUpdate();
} else {
showAccounts();
}
return;
}
state.token = storedToken();
if (location.pathname === '/update') {
showUpdate();
return;
}
if (state.token) {
showAccounts();
} else {
showConnect();
}
}
boot();