~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
867 lines · 28.1 KB Raw History
// 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();