// 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|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} */ 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(`

Not configured

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 config.json.

`); return; } render(`

Your own personal data server

This wizard deploys pds.js into your Cloudflare account: one Worker, one R2 bucket, your keys. It runs on Cloudflare's free plan with R2 enabled.

What happens:

1. You sign in with Cloudflare and approve limited access.
2. The wizard deploys the server and generates your signing key here in this tab.
3. Your new identity is registered in the PLC directory and your credentials are yours to download.

`); 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('

Loading your accounts…

'); 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(`

Could not list accounts

${message(err)}

`); document .getElementById('restart') ?.addEventListener('click', () => showConnect()); return; } if (accounts.length === 1) { state.account = accounts[0]; next(); return; } render(`

Pick an account

The server deploys into this Cloudflare account.

`); for (const button of app.querySelectorAll('.account-list button')) { button.addEventListener('click', () => { state.account = accounts[Number(button.getAttribute('data-index'))]; next(); }); } } function showChoice() { render(`

New account, or bring yours?

Both paths deploy the same server into your account.

`); 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(`

One dashboard step first

Your account has no workers.dev subdomain yet. Pick one in the Workers dashboard, then come back and continue.

`); document .getElementById('again') ?.addEventListener('click', () => showConfigure()); return; } if (!state.password) state.password = generatedPassword(); const domainOptions = [ ``, ...state.zones.map( (z) => ``, ), ].join(''); render(`

Configure

Generated for you. It logs in to your server's account pages. Save it in your password manager.

`); const domainHint = /** @type {HTMLElement} */ ( document.getElementById('domain-hint') ); const updateDomainHint = () => { if (state.zone && state.setup === 'migrate') { domainHint.innerHTML = `Server: pds.${state.zone.name}. Your handle stays with your account.`; } else if (state.zone && state.handleChecking) { domainHint.innerHTML = `Checking whether ${state.zone.name} is already a handle…`; } else if (state.zone && state.handleDid) { domainHint.innerHTML = `${state.zone.name} is already the handle of an account (${state.handleDid}). A new account here would break it. Pick another domain, or go back and choose Migrate an existing account.`; } else if (state.zone) { domainHint.innerHTML = `Server: pds.${state.zone.name}. Handle: ${state.zone.name}, 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: ${hostname()}. Your handle stays with your account.`; } else { domainHint.innerHTML = `Your server and your handle: ${hostname()}`; } }; 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(`

Deploying

    ${stepLabels() .map((label, i) => { const cls = i === failed ? 'failed' : i < active ? 'done' : i === active ? 'active' : ''; const mark = i === failed ? '✗' : i < active ? '✓' : i === active ? '…' : ' '; return `
  1. ${mark} ${label}
  2. `; }) .join('')}
`); } 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} */ ( 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 = `

${message(err)}

Every step is safe to run again. Fix the cause if it needs you, then retry.

`; 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 ? `

The certificate for ${pdsUrl} can take a few minutes. The account link above uses the workers.dev address, which is live now.

` : ''; render(`

Your server is live and empty

Server: ${pdsUrl}
Password: ${state.password}

Your account moves in on the server's own pages. Open your account page and choose Migrate an existing account. It signs in to your current PDS, copies your data and repoints your identity at ${new URL(pdsUrl).host}.

${certNote}

Save the password first. It is the key to this server, and this site keeps nothing.

`); } /** @param {string} pdsUrl @param {string} handle @param {string} did */ function showDone(pdsUrl, handle, did) { const identity = /** @type {ReturnType} */ ( 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 ? `

The certificate for ${new URL(pdsUrl).host} can take a few minutes; the server also answers at ${hostname()} meanwhile.

` : ''; render(`

Your PDS is live

Handle: ${handle}
DID: ${did}
Server: ${pdsUrl}
Account pages: ${pdsUrl}/account

${certNote}

Download your credentials now. The file holds the only copy of your signing key, and your server password with it. This site keeps nothing.

Sign in to Bluesky or any atproto app with the handle ${handle} and your server password.

`); 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(`

Update a PDS

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.

`); 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 = '

Asking the server for its version…

'; 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 = `

No PDS answered at ${host}: ${message(err)}

`; 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 = `

The server runs pds.js ${current}. ${carrier} carries ${available}.${ current === available ? ' It is already on the latest.' : '' }

`; if (!scriptName) { outcome.innerHTML = `${summary}

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.

`; return; } if (!state.token) { outcome.innerHTML = `${summary}
`; 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}
`; 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 = '

Uploading the new worker…

'; // 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 = `

${message(err)}

`; document .getElementById('retry') ?.addEventListener('click', () => runUpdate(scriptName, available)); return; } outcome.innerHTML = '

Waiting for the server to come back…

'; 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 ? `

Done. The server runs ${installed}.

` : `

The upload succeeded, and the server still reports ${version}. Give it a minute and check again.

`; } // === 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(`

Sign-in failed

${message(err)}

`); 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();