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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
2784 lines · 88.6 KB Raw History
// Account page tests - sign-in form, cookie session, account screen
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createAccountJwt } from '../packages/core/src/auth.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';
import { cborEncodeDagCbor } from '../packages/core/src/repo.js';

const DID = 'did:plc:test123';
const HANDLE = 'test.handle';
const PASSWORD = 'test-password';
const AVATAR_CID = 'bafkreiabc123def456ghi789jkl012mno345pqr678stu901vwx234yz';
// The storage key for an OAuth session is the refresh token itself
const REFRESH_TOKEN = 'refresh-token-that-must-never-be-rendered';

/**
 * @param {Object} [options]
 * @param {Record<string, unknown>|null} [options.profile] - The account's
 *   app.bsky.actor.profile record, or null when it has none
 * @param {string[]} [options.records] - Record keys, as `collection/rkey`
 * @param {string[]} [options.blobs] - Stored blob CIDs
 * @param {string} [options.status] - Account status
 */
function createMockStorage({
  profile = null,
  records = [],
  blobs = [],
  status = 'active',
} = {}) {
  let currentStatus = status;
  /** @type {import('../packages/core/src/ports.js').EmailState|null} */
  let email = null;
  /** @type {import('../packages/core/src/ports.js').PlcToken|null} */
  let plcToken = null;
  /** @type {import('../packages/core/src/ports.js').AccountUiState|null} */
  let accountUiState = null;
  /** @type {import('../packages/core/src/ports.js').AccountPasswordState|null} */
  let accountPassword = null;
  return {
    async getDid() {
      return DID;
    },
    async getAccountPassword() {
      return accountPassword;
    },
    async setAccountPassword(/** @type {any} */ state) {
      accountPassword = state;
    },
    async getEmail() {
      return email;
    },
    async setEmail(/** @type {any} */ state) {
      email = state;
    },
    async getPlcToken() {
      return plcToken;
    },
    async setPlcToken(/** @type {any} */ token) {
      plcToken = token;
    },
    async getHandle() {
      return HANDLE;
    },
    async getAccountStatus() {
      return currentStatus;
    },
    async setAccountStatus(/** @type {string} */ next) {
      currentStatus = next;
    },
    async getPrivateKey() {
      return null;
    },
    async getRecord(/** @type {string} */ uri) {
      if (!profile || uri !== `at://${DID}/app.bsky.actor.profile/self`) {
        return null;
      }
      return { cid: 'bafytest', value: cborEncodeDagCbor(profile) };
    },
    async listAllRecords() {
      return records.map((key) => ({ key, cid: 'bafytest' }));
    },
    async listRecords(
      /** @type {string} */ collection,
      /** @type {string|null} */ _cursor,
      /** @type {number} */ limit,
    ) {
      const matching = records
        .filter((key) => key.startsWith(`${collection}/`))
        .slice(0, limit)
        .map((key) => ({
          uri: `at://${DID}/${key}`,
          cid: 'bafyrecord',
          value: cborEncodeDagCbor({
            $type: collection,
            text: `record ${key.split('/')[1]}`,
          }),
        }));
      return { records: matching, cursor: null };
    },
    async listBlobs() {
      return { cids: blobs, cursor: null };
    },
    async getLatestCommit() {
      return { seq: 7, cid: 'bafycommit', rev: '3lrev' };
    },
    async getEvents() {
      return { events: [], cursor: 0 };
    },
    async getLatestSeq() {
      return 7;
    },
    async putEvent() {},
    async getAccountUiState() {
      return accountUiState;
    },
    async setAccountUiState(
      /** @type {import('../packages/core/src/ports.js').AccountUiState} */ state,
    ) {
      accountUiState = state;
    },
  };
}

/**
 * @param {Object} [options]
 * @param {Array<{name: string, privileged: boolean, createdAt: string}>} [options.appPasswords]
 * @param {Array<Record<string, unknown>>} [options.oauthTokens]
 */
function createMockSharedStorage({ appPasswords = [], oauthTokens = [] } = {}) {
  /** @type {any[]} */
  const passwords = [...appPasswords];
  /** @type {any[]} */
  const passkeys = [];
  /** @type {any[]} */
  const sessions = [];
  /** @type {Map<string, any>} */
  const oauthRequests = new Map();
  let tokens = [...oauthTokens];
  return {
    get tokens() {
      return tokens;
    },
    async deleteOAuthToken(/** @type {string} */ tokenId) {
      tokens = tokens.filter((token) => token.tokenId !== tokenId);
    },
    async listAppPasswords() {
      // Hashes stay in storage, as the real adapters keep them
      return passwords.map(({ hash: _hash, ...entry }) => entry);
    },
    async getAppPasswordByHash(
      /** @type {string} */ _did,
      /** @type {string} */ hash,
    ) {
      const found = passwords.find((entry) => entry.hash === hash);
      if (!found) return null;
      const { hash: _stored, ...record } = found;
      return record;
    },
    async putAppPassword(
      /** @type {string} */ _did,
      /** @type {string} */ name,
      /** @type {string} */ hash,
      /** @type {boolean} */ privileged,
      /** @type {string} */ createdAt,
    ) {
      if (passwords.some((entry) => entry.name === name)) return false;
      passwords.push({ name, privileged, createdAt, hash });
      return true;
    },
    async deleteAppPassword(
      /** @type {string} */ _did,
      /** @type {string} */ name,
    ) {
      const index = passwords.findIndex((entry) => entry.name === name);
      if (index === -1) return false;
      passwords.splice(index, 1);
      return true;
    },
    async listOAuthTokensByDid() {
      return tokens;
    },
    async getOAuthRequest(/** @type {string} */ uri) {
      return oauthRequests.get(uri) || null;
    },
    async putOAuthRequest(/** @type {string} */ uri, /** @type {any} */ data) {
      oauthRequests.set(uri, data);
    },
    async deleteOAuthRequest(/** @type {string} */ uri) {
      oauthRequests.delete(uri);
    },
    async putSession(/** @type {any} */ session) {
      const index = sessions.findIndex((s) => s.jti === session.jti);
      if (index === -1) sessions.push(session);
      else sessions[index] = session;
    },
    async getSession(/** @type {string} */ jti) {
      return sessions.find((s) => s.jti === jti) || null;
    },
    async listSessions() {
      return sessions;
    },
    async deleteSession(/** @type {string} */ jti) {
      const index = sessions.findIndex((s) => s.jti === jti);
      if (index === -1) return false;
      sessions.splice(index, 1);
      return true;
    },
    async putPasskey(/** @type {any} */ record) {
      if (passkeys.some((p) => p.credentialId === record.credentialId)) {
        return false;
      }
      passkeys.push(record);
      return true;
    },
    async getPasskey(/** @type {string} */ credentialId) {
      return passkeys.find((p) => p.credentialId === credentialId) || null;
    },
    async listPasskeys() {
      return passkeys;
    },
    async deletePasskey(
      /** @type {string} */ _did,
      /** @type {string} */ credentialId,
    ) {
      const index = passkeys.findIndex((p) => p.credentialId === credentialId);
      if (index === -1) return false;
      passkeys.splice(index, 1);
      return true;
    },
    async touchPasskey(
      /** @type {string} */ credentialId,
      /** @type {number} */ signCount,
      /** @type {string} */ lastUsedAt,
    ) {
      const passkey = passkeys.find((p) => p.credentialId === credentialId);
      if (passkey) Object.assign(passkey, { signCount, lastUsedAt });
    },
  };
}

/**
 * A stand-in authenticator: a P-256 key pair plus the byte layouts a real one
 * produces, so the ceremonies can run end to end without a browser.
 * @returns {Promise<{credentialId: string, publicKey: string, privateKey: CryptoKey}>}
 */
async function createTestCredential() {
  const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );
  const spki = new Uint8Array(
    await crypto.subtle.exportKey('spki', keyPair.publicKey),
  );
  return {
    credentialId: b64url(crypto.getRandomValues(new Uint8Array(16))),
    publicKey: b64url(spki),
    privateKey: keyPair.privateKey,
  };
}

/**
 * @param {{credentialId: string, privateKey: CryptoKey}} credential
 * @param {string} challenge
 */
async function signTestAssertion(credential, challenge) {
  const rpIdHash = new Uint8Array(
    await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode('pds.example.com'),
    ),
  );
  const authData = new Uint8Array(37);
  authData.set(rpIdHash, 0);
  authData[32] = 0x01; // user present

  const clientDataJSON = encodeClientData({
    type: 'webauthn.get',
    challenge,
    origin: 'https://pds.example.com',
  });
  const clientDataHash = new Uint8Array(
    await crypto.subtle.digest('SHA-256', fromB64url(clientDataJSON)),
  );
  const signed = new Uint8Array(authData.length + clientDataHash.length);
  signed.set(authData, 0);
  signed.set(clientDataHash, authData.length);

  const raw = new Uint8Array(
    await crypto.subtle.sign(
      { name: 'ECDSA', hash: 'SHA-256' },
      credential.privateKey,
      signed,
    ),
  );

  return {
    credentialId: credential.credentialId,
    authenticatorData: b64url(authData),
    clientDataJSON,
    signature: b64url(toDer(raw)),
  };
}

/** WebCrypto signs raw r||s; a browser sends DER. */
function toDer(/** @type {Uint8Array} */ raw) {
  const encodeInt = (/** @type {Uint8Array} */ bytes) => {
    let start = 0;
    while (start < bytes.length - 1 && bytes[start] === 0) start++;
    let value = bytes.slice(start);
    if (value[0] & 0x80) value = Uint8Array.from([0, ...value]);
    return Uint8Array.from([0x02, value.length, ...value]);
  };
  const r = encodeInt(raw.slice(0, 32));
  const s = encodeInt(raw.slice(32));
  return Uint8Array.from([0x30, r.length + s.length, ...r, ...s]);
}

/** @param {object} data */
function encodeClientData(data) {
  return b64url(new TextEncoder().encode(JSON.stringify(data)));
}

/** @param {Uint8Array} bytes */
function b64url(bytes) {
  return Buffer.from(bytes).toString('base64url');
}

/** @param {string} value */
function fromB64url(value) {
  return new Uint8Array(Buffer.from(value, 'base64url'));
}

/**
 * Sign in and return the cookie header value for subsequent requests.
 * @param {PersonalDataServer} pds
 * @returns {Promise<string>}
 */
async function signedInCookie(pds) {
  const cookie = sessionCookie(
    await signIn(pds, { username: HANDLE, password: PASSWORD }),
  );
  return `pdsjs_account=${encodeURIComponent(cookie)}`;
}

/**
 * @param {PersonalDataServer} pds
 * @param {string} path
 * @param {string} cookie
 * @returns {Promise<{status: number, html: string}>}
 */
async function getPage(pds, path, cookie) {
  const response = await pds.fetch(
    new Request(`https://pds.example.com${path}`, {
      headers: { Cookie: cookie },
    }),
  );
  return { status: response.status, html: await response.text() };
}

/**
 * @param {PersonalDataServer} pds
 * @param {string} path
 * @param {Record<string, string>} fields
 * @param {string} cookie
 * @returns {Promise<Response>}
 */
function postForm(pds, path, fields, cookie) {
  return pds.fetch(
    new Request(`https://pds.example.com${path}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        Cookie: cookie,
      },
      body: new URLSearchParams(fields).toString(),
    }),
  );
}

/**
 * @param {Response} response
 * @returns {string} The session cookie value, or '' when the cookie is cleared
 */
function sessionCookie(response) {
  const header = response.headers.get('set-cookie') || '';
  return decodeURIComponent(header.match(/pdsjs_account=([^;]*)/)?.[1] ?? '');
}

/**
 * @param {PersonalDataServer} pds
 * @param {Record<string, string>} fields
 * @returns {Promise<Response>}
 */
function signIn(pds, fields) {
  return pds.fetch(
    new Request('https://pds.example.com/account/sign-in', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams(fields).toString(),
    }),
  );
}

describe('Account pages', () => {
  /** @type {PersonalDataServer} */
  let pds;

  beforeEach(() => {
    pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ ({}),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
  });

  it('serves the sign-in form when signed out', async () => {
    const response = await pds.fetch(
      new Request('https://pds.example.com/account'),
    );
    expect(response.status).toBe(200);
    expect(response.headers.get('content-type')).toContain('text/html');
    expect(response.headers.get('cache-control')).toBe('no-store');

    const html = await response.text();
    // The server knows its one account: the form names the handle in a
    // hidden field and asks for the password alone.
    expect(html).toContain(`value="${HANDLE}"`);
    expect(html).toContain('type="hidden"');
    expect(html).not.toContain('placeholder="handle or DID"');
    expect(html).toContain('name="password"');
    expect(html).not.toContain(DID);
  });

  it('signs in with the handle and redirects to the account screen', async () => {
    const response = await signIn(pds, {
      username: HANDLE,
      password: PASSWORD,
    });
    expect(response.status).toBe(303);
    expect(response.headers.get('location')).toBe('/account');

    const cookie = response.headers.get('set-cookie') || '';
    expect(cookie).toContain('HttpOnly');
    expect(cookie).toContain('SameSite=Lax');
    expect(cookie).toContain('Secure');
    expect(sessionCookie(response)).not.toBe('');
  });

  it('accepts the DID as identifier', async () => {
    const response = await signIn(pds, { username: DID, password: PASSWORD });
    expect(response.status).toBe(303);
  });

  it('rejects an unknown identifier', async () => {
    const response = await signIn(pds, {
      username: 'someone.else',
      password: PASSWORD,
    });
    expect(response.status).toBe(401);
    expect(await response.text()).toContain('Invalid identifier');
    expect(sessionCookie(response)).toBe('');
  });

  it('rejects a wrong password and keeps the account named', async () => {
    const response = await signIn(pds, { username: HANDLE, password: 'nope' });
    expect(response.status).toBe(401);

    const html = await response.text();
    expect(html).toContain('Invalid password');
    expect(html).toContain(`value="${HANDLE}"`);
    expect(sessionCookie(response)).toBe('');
  });

  it('refuses to sign in when no password is configured', async () => {
    const noPassword = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ ({}),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
    });
    const response = await signIn(noPassword, {
      username: HANDLE,
      password: '',
    });
    expect(response.status).toBe(401);
    expect(await response.text()).toContain('not configured');
  });

  it('shows the account screen to a session cookie holder', async () => {
    const cookie = sessionCookie(
      await signIn(pds, { username: HANDLE, password: PASSWORD }),
    );

    const response = await pds.fetch(
      new Request('https://pds.example.com/account', {
        headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` },
      }),
    );
    expect(response.status).toBe(200);

    const html = await response.text();
    expect(html).toContain(DID);
    expect(html).toContain(`@${HANDLE}`);
    expect(html).toContain('/account/sign-out');
    expect(html).not.toContain('name="password"');
  });

  it('shows the display name and avatar from the local profile record', async () => {
    const withProfile = new PersonalDataServer({
      actorStorage: /** @type {any} */ (
        createMockStorage({
          profile: {
            $type: 'app.bsky.actor.profile',
            displayName: 'Demo Account',
            avatar: {
              $type: 'blob',
              ref: { $link: AVATAR_CID },
              mimeType: 'image/jpeg',
              size: 1234,
            },
          },
        })
      ),
      sharedStorage: /** @type {any} */ ({}),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    const cookie = sessionCookie(
      await signIn(withProfile, { username: HANDLE, password: PASSWORD }),
    );

    const html = await (
      await withProfile.fetch(
        new Request('https://pds.example.com/account', {
          headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` },
        }),
      )
    ).text();

    expect(html).toContain('Demo Account');
    // Served from this PDS's own blob store, not from an AppView. The query
    // separator is `&amp;` because the URL sits in an HTML attribute.
    expect(html).toContain(
      `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(DID)}&amp;cid=${AVATAR_CID}`,
    );
  });

  it('renders without a name or avatar when there is no profile record', async () => {
    const cookie = sessionCookie(
      await signIn(pds, { username: HANDLE, password: PASSWORD }),
    );

    const html = await (
      await pds.fetch(
        new Request('https://pds.example.com/account', {
          headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` },
        }),
      )
    ).text();

    expect(html).not.toContain('<img');
    expect(html).toContain(`@${HANDLE}`);
  });

  it('never calls out to a third-party AppView', async () => {
    const cookie = sessionCookie(
      await signIn(pds, { username: HANDLE, password: PASSWORD }),
    );

    const html = await (
      await pds.fetch(
        new Request('https://pds.example.com/account', {
          headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` },
        }),
      )
    ).text();

    expect(html).not.toContain('bsky.app');
    expect(html).not.toContain('<script');
  });

  it('ignores a cookie signed with another secret', async () => {
    const cookie = sessionCookie(
      await signIn(pds, { username: HANDLE, password: PASSWORD }),
    );
    const other = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ ({}),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'other-secret',
      password: PASSWORD,
    });

    const response = await other.fetch(
      new Request('https://pds.example.com/account', {
        headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` },
      }),
    );
    expect(await response.text()).toContain('name="password"');
  });

  it('does not accept an API access token as a session cookie', async () => {
    const session = await pds.fetch(
      new Request(
        'https://pds.example.com/xrpc/com.atproto.server.createSession',
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ identifier: HANDLE, password: PASSWORD }),
        },
      ),
    );
    const { accessJwt } = await session.json();

    const response = await pds.fetch(
      new Request('https://pds.example.com/account', {
        headers: { Cookie: `pdsjs_account=${accessJwt}` },
      }),
    );
    expect(await response.text()).toContain('name="password"');
  });

  it('signs out by expiring the cookie', async () => {
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/sign-out', {
        method: 'POST',
      }),
    );
    expect(response.status).toBe(303);
    expect(response.headers.get('location')).toBe('/account');
    expect(response.headers.get('set-cookie')).toContain('Max-Age=0');
  });

  it('leaves a fresh session cookie alone', async () => {
    const cookie = await signedInCookie(pds);
    const response = await pds.fetch(
      new Request('https://pds.example.com/account', {
        headers: { Cookie: cookie },
      }),
    );
    expect(response.headers.get('set-cookie')).toBe(null);
  });

  it('reissues a session cookie that has spent a day of its life', async () => {
    const spent = await createAccountJwt(DID, 'test-secret', 60);
    const response = await pds.fetch(
      new Request('https://pds.example.com/account', {
        headers: { Cookie: `pdsjs_account=${encodeURIComponent(spent)}` },
      }),
    );
    expect(response.status).toBe(200);

    const reissued = sessionCookie(response);
    expect(reissued).not.toBe('');
    expect(reissued).not.toBe(spent);
    expect(response.headers.get('set-cookie')).toContain('Max-Age=604800');

    // The replacement opens the account screen on its own
    const { html } = await getPage(
      pds,
      '/account',
      `pdsjs_account=${encodeURIComponent(reissued)}`,
    );
    expect(html).toContain(DID);
  });

  it('reissues nothing for a session cookie it did not sign', async () => {
    const forged = await createAccountJwt(DID, 'other-secret', 60);
    const response = await pds.fetch(
      new Request('https://pds.example.com/account', {
        headers: { Cookie: `pdsjs_account=${encodeURIComponent(forged)}` },
      }),
    );
    expect(response.headers.get('set-cookie')).toBe(null);
    expect(await response.text()).toContain('name="password"');
  });

  it('signs out a part-spent session rather than reissuing it', async () => {
    const spent = await createAccountJwt(DID, 'test-secret', 60);
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/sign-out', {
        method: 'POST',
        headers: { Cookie: `pdsjs_account=${encodeURIComponent(spent)}` },
      }),
    );
    expect(response.headers.getSetCookie()).toHaveLength(1);
    expect(response.headers.get('set-cookie')).toContain('Max-Age=0');
  });

  it('rejects cross-origin form posts', async () => {
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/sign-in', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Origin: 'https://evil.example',
        },
        body: new URLSearchParams({
          username: HANDLE,
          password: PASSWORD,
        }).toString(),
      }),
    );
    expect(response.status).toBe(403);
  });

  it('rejects a GET to the sign-in endpoint', async () => {
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/sign-in'),
    );
    expect(response.status).toBe(405);
  });

  it('turns away every section when signed out', async () => {
    for (const path of [
      '/account/apps',
      '/account/app-passwords',
      '/account/repo',
      '/account/identity',
    ]) {
      const { status, html } = await getPage(pds, path, '');
      expect(status, path).toBe(401);
      expect(html, path).toContain('name="password"');
    }
  });
});

describe('Account sections', () => {
  /** @type {PersonalDataServer} */
  let pds;
  /** @type {string} */
  let cookie;

  beforeEach(async () => {
    pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (
        createMockStorage({
          records: [
            'app.bsky.feed.post/aaa',
            'app.bsky.feed.post/bbb',
            'app.bsky.actor.profile/self',
          ],
          blobs: ['bafblob1', 'bafblob2'],
        })
      ),
      sharedStorage: /** @type {any} */ (
        createMockSharedStorage({
          appPasswords: [
            { name: 'phone', privileged: false, createdAt: '2026-01-02' },
          ],
          oauthTokens: [
            {
              did: DID,
              clientId: 'https://app.example/client-metadata.json',
              scope: 'atproto repo:app.bsky.feed.post',
              dpopJkt: 'thumbprint-abcdefghijklmnop',
              createdAt: Date.parse('2026-02-03T00:00:00Z'),
              tokenId: REFRESH_TOKEN,
            },
          ],
        })
      ),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
      plcUrl: 'http://localhost:2582',
    });
    cookie = await signedInCookie(pds);
  });

  it('counts records, blobs, apps and app passwords on the home page', async () => {
    const { html } = await getPage(pds, '/account', cookie);
    expect(html).toContain('>3</div><div class="l">Records');
    expect(html).toContain('>2</div><div class="l">Blobs');
    // Apps counts distinct app domains, not sessions: bsky.app (from the
    // app.bsky.* records, no session) plus app.example (the one session) = 2.
    expect(html).toContain('>2</div><div class="l">Apps');
    expect(html).toContain('>1</div><div class="l">App passwords');
  });

  it('lists connected apps with scope and authorization date', async () => {
    const { html } = await getPage(pds, '/account/apps', cookie);
    expect(html).toContain('https://app.example/client-metadata.json');
    expect(html).toContain('repo:app.bsky.feed.post');
    expect(html).toContain('2026-02-03');
    // Only a prefix of the DPoP thumbprint, enough to tell sessions apart
    expect(html).toContain('thumbprint-a');
    expect(html).not.toContain('thumbprint-abcdefghijklmnop');
  });

  it('never renders the refresh token behind a session', async () => {
    const { html } = await getPage(pds, '/account/apps', cookie);
    expect(html).not.toContain(REFRESH_TOKEN);
    expect(html).toContain('name="session"');
  });

  it('revokes an app session', async () => {
    const listing = await getPage(pds, '/account/apps', cookie);
    const sessionId = listing.html.match(/name="session" value="([^"]+)"/)?.[1];
    expect(sessionId).toBeTruthy();

    const response = await postForm(
      pds,
      '/account/apps/revoke',
      { session: /** @type {string} */ (sessionId) },
      cookie,
    );
    expect(response.status).toBe(200);

    const html = await response.text();
    expect(html).toContain('Revoked the session');
    expect(html).toContain('No app has been authorized yet.');

    // Gone from storage, not just from the page
    const after = await getPage(pds, '/account/apps', cookie);
    expect(after.html).not.toContain(
      'https://app.example/client-metadata.json',
    );
  });

  it('refuses a session handle it did not issue', async () => {
    const response = await postForm(
      pds,
      '/account/apps/revoke',
      { session: 'not-a-real-session-handle' },
      cookie,
    );
    expect(response.status).toBe(400);
    expect(await response.text()).toContain('no longer exists');

    // The real session survives a failed guess
    const { html } = await getPage(pds, '/account/apps', cookie);
    expect(html).toContain('https://app.example/client-metadata.json');
  });

  it('offers no revoke button when storage does not return its keys', async () => {
    const legacy = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (
        createMockSharedStorage({
          // An adapter predating the tokenId field
          oauthTokens: [{ did: DID, clientId: 'https://old.example' }],
        })
      ),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    const { html } = await getPage(
      legacy,
      '/account/apps',
      await signedInCookie(legacy),
    );
    expect(html).toContain('https://old.example');
    expect(html).not.toContain('name="session"');
  });

  it('creates an app password and shows it exactly once', async () => {
    const response = await postForm(
      pds,
      '/account/app-passwords/create',
      { name: 'laptop' },
      cookie,
    );
    expect(response.status).toBe(200);

    const html = await response.text();
    const shown = html.match(/class="pw">([^<]+)</)?.[1];
    expect(shown).toMatch(/^[a-z2-7]{4}-[a-z2-7]{4}-[a-z2-7]{4}-[a-z2-7]{4}$/);

    // Gone from the listing on the next load — only the hash was kept
    const next = await getPage(pds, '/account/app-passwords', cookie);
    expect(next.html).toContain('laptop');
    expect(next.html).not.toContain(shown);
  });

  it('creates a privileged app password when asked', async () => {
    await postForm(
      pds,
      '/account/app-passwords/create',
      { name: 'trusted', privileged: 'true' },
      cookie,
    );
    const { html } = await getPage(pds, '/account/app-passwords', cookie);
    expect(html).toContain('privileged');
  });

  it('refuses a duplicate app password name', async () => {
    const response = await postForm(
      pds,
      '/account/app-passwords/create',
      { name: 'phone' },
      cookie,
    );
    expect(response.status).toBe(400);
    expect(await response.text()).toContain('already exists');
  });

  it('refuses an app password with no name', async () => {
    const response = await postForm(
      pds,
      '/account/app-passwords/create',
      { name: '  ' },
      cookie,
    );
    expect(response.status).toBe(400);
    expect(await response.text()).toContain('name is required');
  });

  it('revokes an app password', async () => {
    const response = await postForm(
      pds,
      '/account/app-passwords/revoke',
      { name: 'phone' },
      cookie,
    );
    expect(response.status).toBe(200);

    const html = await response.text();
    // Escaped, because the name is interpolated into the page
    expect(html).toContain('Revoked &quot;phone&quot;');
    expect(html).toContain('No app passwords yet.');
  });

  it('reports an unknown app password on revoke', async () => {
    const response = await postForm(
      pds,
      '/account/app-passwords/revoke',
      { name: 'nothing' },
      cookie,
    );
    expect(response.status).toBe(400);
    expect(await response.text()).toContain('No app password named');
  });

  it('groups repository records by collection', async () => {
    const { html } = await getPage(pds, '/account/repo', cookie);
    expect(html).toContain('app.bsky.feed.post');
    expect(html).toContain('2 records');
    expect(html).toContain('app.bsky.actor.profile');
    expect(html).toContain('1 record<');
    expect(html).toContain('3lrev');
  });

  it('falls back to the request origin when no hostname is configured', async () => {
    const { html } = await getPage(pds, '/account/identity', cookie);
    // Before the hostname fallback this rendered "https://undefined", the
    // same string signPlcOperation would have published to the directory.
    expect(html).not.toContain('undefined');
    expect(html).toContain('https://pds.example.com');
  });

  it('reads the records in a repo collection', async () => {
    const { status, html } = await getPage(
      pds,
      '/account/repo/records?collection=app.bsky.feed.post',
      cookie,
    );
    expect(status).toBe(200);
    // The card is the getRecord response shape, labelled in its own terms
    expect(html).toContain('<span class="k">uri:</span>');
    expect(html).toContain(`at://${DID}/app.bsky.feed.post/aaa`);
    expect(html).toContain('<span class="k">cid:</span>');
    // Record fields as a key/value tree, not JSON text
    expect(html).toContain('<span class="k">text:</span>');
    expect(html).toContain('record aaa');
    expect(html).not.toContain('&quot;text&quot;');
    expect(html).toContain('Public records');
    // Only the named collection
    expect(html).not.toContain('app.bsky.actor.profile');
  });

  it('links each repo collection to its records', async () => {
    const { html } = await getPage(pds, '/account/repo', cookie);
    expect(html).toContain(
      '/account/repo/records?collection=app.bsky.feed.post',
    );
  });

  it('refuses a repo record view with no collection', async () => {
    const { status, html } = await getPage(
      pds,
      '/account/repo/records',
      cookie,
    );
    expect(status).toBe(400);
    expect(html).toContain('Name a collection');
  });

  it('turns away the repo record view when signed out', async () => {
    const { status } = await getPage(
      pds,
      '/account/repo/records?collection=app.bsky.feed.post',
      '',
    );
    expect(status).toBe(401);
  });

  it('shows identity and the configured directory', async () => {
    const { html } = await getPage(pds, '/account/identity', cookie);
    expect(html).toContain(DID);
    expect(html).toContain('http://localhost:2582');
    expect(html).toContain('Active');
    expect(html).toContain('value="deactivate"');
  });

  it('deactivates and reactivates the account', async () => {
    const off = await postForm(
      pds,
      '/account/status',
      { action: 'deactivate' },
      cookie,
    );
    expect(off.status).toBe(303);
    expect(off.headers.get('location')).toBe('/account/identity');

    const deactivated = await getPage(pds, '/account/identity', cookie);
    expect(deactivated.html).toContain('Deactivated');
    expect(deactivated.html).toContain('value="activate"');

    await postForm(pds, '/account/status', { action: 'activate' }, cookie);
    const reactivated = await getPage(pds, '/account/identity', cookie);
    expect(reactivated.html).toContain('Active');
  });

  it('rejects an unknown status action', async () => {
    const response = await postForm(
      pds,
      '/account/status',
      { action: 'destroy' },
      cookie,
    );
    expect(response.status).toBe(400);
    expect(await response.text()).toContain('Unknown status action');
  });

  it('refuses account writes on a read-only server', async () => {
    const readOnly = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
      readOnly: true,
    });
    const readOnlyCookie = await signedInCookie(readOnly);

    const created = await postForm(
      readOnly,
      '/account/app-passwords/create',
      { name: 'nope' },
      readOnlyCookie,
    );
    expect(created.status).toBe(403);
    expect(await created.text()).toContain('read-only');

    // The status control is not offered at all
    const { html } = await getPage(
      readOnly,
      '/account/identity',
      readOnlyCookie,
    );
    expect(html).not.toContain('value="deactivate"');
  });
});

describe('Account email', () => {
  /** @type {PersonalDataServer} */
  let pds;
  /** @type {string} */
  let cookie;
  /** @type {Array<{to: string, subject: string, text: string}>} */
  let sent;

  beforeEach(async () => {
    sent = [];
    pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
      emailer: {
        async send(message) {
          sent.push(message);
        },
      },
    });
    cookie = await signedInCookie(pds);
  });

  /** @returns {string} The code from the message just sent */
  const codeFromEmail = () =>
    /** @type {string} */ (sent.at(-1)?.text.match(/(\d{6})/)?.[1]);

  it('sends a code and holds the address pending', async () => {
    const response = await postForm(
      pds,
      '/account/email',
      { address: 'someone@example.com' },
      cookie,
    );
    expect(response.status).toBe(200);
    expect(sent).toHaveLength(1);
    expect(sent[0].to).toBe('someone@example.com');
    expect(codeFromEmail()).toMatch(/^\d{6}$/);

    const html = await response.text();
    expect(html).toContain('Sent a code');
    // Not the account's address until the code comes back
    expect(html).toContain('No email address on this account.');
  });

  it('confirms the address with the code', async () => {
    await postForm(
      pds,
      '/account/email',
      { address: 'someone@example.com' },
      cookie,
    );
    const response = await postForm(
      pds,
      '/account/email/verify',
      { code: codeFromEmail() },
      cookie,
    );
    expect(response.status).toBe(200);

    const html = await response.text();
    expect(html).toContain('someone@example.com is confirmed');
    expect(html).toContain('confirmed');
  });

  it('rejects a wrong code and keeps the address pending', async () => {
    await postForm(
      pds,
      '/account/email',
      { address: 'someone@example.com' },
      cookie,
    );
    const response = await postForm(
      pds,
      '/account/email/verify',
      { code: '000000' },
      cookie,
    );
    expect(response.status).toBe(400);

    const html = await response.text();
    expect(html).toContain('That code is wrong');
    expect(html).toContain('Confirm someone@example.com');
  });

  it('refuses a code minted for a different address', async () => {
    await postForm(pds, '/account/email', { address: 'a@example.com' }, cookie);
    const firstCode = codeFromEmail();

    // Superseded by a second request, so the first code must not confirm it
    await postForm(pds, '/account/email', { address: 'b@example.com' }, cookie);
    const response = await postForm(
      pds,
      '/account/email/verify',
      { code: firstCode },
      cookie,
    );
    expect(response.status).toBe(400);
  });

  it('rejects a malformed address before sending anything', async () => {
    const response = await postForm(
      pds,
      '/account/email',
      { address: 'not-an-address' },
      cookie,
    );
    expect(response.status).toBe(400);
    expect(sent).toHaveLength(0);
  });

  it('drops the pending address when sending fails', async () => {
    const broken = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
      emailer: {
        async send() {
          throw new Error('smtp is down');
        },
      },
    });
    const brokenCookie = await signedInCookie(broken);

    const response = await postForm(
      broken,
      '/account/email',
      { address: 'someone@example.com' },
      brokenCookie,
    );
    expect(response.status).toBe(502);

    // No confirmation form for a code that never went out
    const { html } = await getPage(broken, '/account/email', brokenCookie);
    expect(html).not.toContain('Confirm someone@example.com');
  });

  it('cancels a pending change', async () => {
    await postForm(
      pds,
      '/account/email',
      { address: 'someone@example.com' },
      cookie,
    );
    const response = await postForm(pds, '/account/email/cancel', {}, cookie);
    expect(await response.text()).toContain('Change cancelled');
  });

  it('removes a confirmed address', async () => {
    await postForm(
      pds,
      '/account/email',
      { address: 'someone@example.com' },
      cookie,
    );
    await postForm(
      pds,
      '/account/email/verify',
      { code: codeFromEmail() },
      cookie,
    );

    const response = await postForm(pds, '/account/email/remove', {}, cookie);
    expect(await response.text()).toContain(
      'No email address on this account.',
    );
  });

  it('stores an unconfirmed address when no sender is configured', async () => {
    const noMail = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    const noMailCookie = await signedInCookie(noMail);

    const response = await postForm(
      noMail,
      '/account/email',
      { address: 'someone@example.com' },
      noMailCookie,
    );
    const html = await response.text();
    expect(html).toContain('stays unconfirmed');
    expect(html).toContain('unconfirmed');
  });
});

describe('Account passkeys', () => {
  /** @type {PersonalDataServer} */
  let pds;
  /** @type {string} */
  let cookie;

  beforeEach(async () => {
    pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    cookie = await signedInCookie(pds);
  });

  /**
   * Drive the browser half of a registration: read the challenge the page
   * embedded, then answer it with a synthetic authenticator.
   */
  async function register(name = 'laptop') {
    const page = await pds.fetch(
      new Request('https://pds.example.com/account/passkeys', {
        headers: { Cookie: cookie },
      }),
    );
    const html = await page.text();
    const challenge = html.match(/unb64\("([^"]+)"\)/)?.[1];
    const ceremonyCookie = (page.headers.get('set-cookie') || '').split(';')[0];

    const credential = await createTestCredential();
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/passkeys/register', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Cookie: `${cookie}; ${ceremonyCookie}`,
        },
        body: JSON.stringify({
          name,
          credentialId: credential.credentialId,
          publicKey: credential.publicKey,
          algorithm: -7,
          clientDataJSON: encodeClientData({
            type: 'webauthn.create',
            challenge,
            origin: 'https://pds.example.com',
          }),
        }),
      }),
    );
    return { response, credential, challenge };
  }

  it('registers a passkey and lists it', async () => {
    const { response } = await register('laptop');
    expect(response.status).toBe(200);
    expect(await response.json()).toMatchObject({ ok: true, name: 'laptop' });

    const { html } = await getPage(pds, '/account/passkeys', cookie);
    expect(html).toContain('laptop');
    expect(html).toContain('never used');
  });

  it('refuses a registration with no challenge cookie', async () => {
    const credential = await createTestCredential();
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/passkeys/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Cookie: cookie },
        body: JSON.stringify({
          credentialId: credential.credentialId,
          publicKey: credential.publicKey,
          algorithm: -7,
          clientDataJSON: encodeClientData({
            type: 'webauthn.create',
            challenge: 'made-up',
            origin: 'https://pds.example.com',
          }),
        }),
      }),
    );
    expect(response.status).toBe(400);
    expect((await response.json()).error).toMatch(/expired/);
  });

  it('refuses a registration when not signed in', async () => {
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/passkeys/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({}),
      }),
    );
    expect(response.status).toBe(401);
  });

  it('signs in with a registered passkey', async () => {
    const { credential } = await register();

    const start = await pds.fetch(
      new Request('https://pds.example.com/account/passkey/challenge'),
    );
    const { challenge, rpId } = await start.json();
    expect(rpId).toBe('pds.example.com');
    const ceremonyCookie = (start.headers.get('set-cookie') || '').split(
      ';',
    )[0];

    const assertion = await signTestAssertion(credential, challenge);
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/passkey/sign-in', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Cookie: ceremonyCookie,
        },
        body: JSON.stringify(assertion),
      }),
    );
    expect(response.status).toBe(200);

    // The response opens a real account session
    const issued = sessionCookie(response);
    expect(issued).not.toBe('');
    const { html } = await getPage(
      pds,
      '/account',
      `pdsjs_account=${encodeURIComponent(issued)}`,
    );
    expect(html).toContain(DID);
  });

  it('refuses an assertion signed by an unregistered key', async () => {
    await register();
    const start = await pds.fetch(
      new Request('https://pds.example.com/account/passkey/challenge'),
    );
    const { challenge } = await start.json();
    const ceremonyCookie = (start.headers.get('set-cookie') || '').split(
      ';',
    )[0];

    const impostor = await createTestCredential();
    const assertion = await signTestAssertion(impostor, challenge);
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/passkey/sign-in', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Cookie: ceremonyCookie,
        },
        body: JSON.stringify(assertion),
      }),
    );
    expect(response.status).toBe(401);
    expect(sessionCookie(response)).toBe('');
  });

  it('refuses a sign-in challenge spent as a registration', async () => {
    const start = await pds.fetch(
      new Request('https://pds.example.com/account/passkey/challenge'),
    );
    const { challenge } = await start.json();
    const ceremonyCookie = (start.headers.get('set-cookie') || '').split(
      ';',
    )[0];

    const credential = await createTestCredential();
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/passkeys/register', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Cookie: `${cookie}; ${ceremonyCookie}`,
        },
        body: JSON.stringify({
          credentialId: credential.credentialId,
          publicKey: credential.publicKey,
          algorithm: -7,
          clientDataJSON: encodeClientData({
            type: 'webauthn.create',
            challenge,
            origin: 'https://pds.example.com',
          }),
        }),
      }),
    );
    expect(response.status).toBe(400);
  });

  it('removes a passkey', async () => {
    const { credential } = await register();
    const response = await postForm(
      pds,
      '/account/passkeys/revoke',
      { credentialId: credential.credentialId },
      cookie,
    );
    expect(response.status).toBe(200);

    const html = await response.text();
    expect(html).toContain('Passkey removed.');
    expect(html).toContain('No passkeys yet.');
  });

  it('offers a passkey button on the sign-in page only when supported', async () => {
    const { html } = await getPage(pds, '/account', '');
    expect(html).toContain('Use a passkey');

    const noPasskeys = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ ({}),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    const plain = await getPage(noPasskeys, '/account', '');
    expect(plain.html).not.toContain('Use a passkey');
  });
});

describe('PLC operation confirmation', () => {
  /** @type {Array<{to: string, subject: string, text: string}>} */
  let sent;

  /**
   * @param {boolean} withEmail - Whether a confirmed address exists
   * @returns {Promise<PersonalDataServer>}
   */
  async function createServer(withEmail) {
    sent = [];
    const pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
      emailer: withEmail
        ? {
            async send(message) {
              sent.push(message);
            },
          }
        : undefined,
    });
    if (withEmail) {
      const cookie = await signedInCookie(pds);
      await postForm(
        pds,
        '/account/email',
        { address: 'a@example.com' },
        cookie,
      );
      const code = /** @type {string} */ (sent[0].text.match(/(\d{6})/)?.[1]);
      await postForm(pds, '/account/email/verify', { code }, cookie);
      sent.length = 0;
    }
    return pds;
  }

  /** @param {PersonalDataServer} pds */
  async function accessToken(pds) {
    const session = await pds.fetch(
      new Request(
        'https://pds.example.com/xrpc/com.atproto.server.createSession',
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ identifier: HANDLE, password: PASSWORD }),
        },
      ),
    );
    return (await session.json()).accessJwt;
  }

  /**
   * @param {PersonalDataServer} pds
   * @param {string} path
   * @param {object} body
   * @param {string} token
   */
  function xrpc(pds, path, body, token) {
    return pds.fetch(
      new Request(`https://pds.example.com/xrpc/${path}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify(body),
      }),
    );
  }

  it('mails a code when the account has a confirmed address', async () => {
    const pds = await createServer(true);
    const token = await accessToken(pds);

    const response = await xrpc(
      pds,
      'com.atproto.identity.requestPlcOperationSignature',
      {},
      token,
    );
    expect(response.status).toBe(200);
    expect(sent).toHaveLength(1);
    expect(sent[0].subject).toMatch(/identity change/);
    expect(sent[0].text).toMatch(/\d{6}/);
  });

  it('refuses to sign without the code', async () => {
    const pds = await createServer(true);
    const token = await accessToken(pds);
    await xrpc(
      pds,
      'com.atproto.identity.requestPlcOperationSignature',
      {},
      token,
    );

    const response = await xrpc(
      pds,
      'com.atproto.identity.signPlcOperation',
      { token: '000000' },
      token,
    );
    expect(response.status).toBe(400);
    expect((await response.json()).error).toBe('InvalidToken');
  });

  it('refuses to sign when no code was ever requested', async () => {
    const pds = await createServer(true);
    const token = await accessToken(pds);

    const response = await xrpc(
      pds,
      'com.atproto.identity.signPlcOperation',
      {},
      token,
    );
    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/Ask for a confirmation/);
  });

  it('spends the code, so it signs only one operation', async () => {
    const pds = await createServer(true);
    const token = await accessToken(pds);
    await xrpc(
      pds,
      'com.atproto.identity.requestPlcOperationSignature',
      {},
      token,
    );
    const code = /** @type {string} */ (sent[0].text.match(/(\d{6})/)?.[1]);

    // Signing needs a key this mock account does not have, but the code is
    // checked first: a wrong-token rejection is what proves it was accepted
    const first = await xrpc(
      pds,
      'com.atproto.identity.signPlcOperation',
      { token: code },
      token,
    );
    expect((await first.json()).error).not.toBe('InvalidToken');

    const second = await xrpc(
      pds,
      'com.atproto.identity.signPlcOperation',
      { token: code },
      token,
    );
    expect((await second.json()).error).toBe('InvalidToken');
  });

  it('keeps session-only authorization when no address is confirmed', async () => {
    const pds = await createServer(false);
    const token = await accessToken(pds);

    const requested = await xrpc(
      pds,
      'com.atproto.identity.requestPlcOperationSignature',
      {},
      token,
    );
    expect(requested.status).toBe(200);

    // No code was issued, so signing must not start demanding one
    const response = await xrpc(
      pds,
      'com.atproto.identity.signPlcOperation',
      {},
      token,
    );
    expect((await response.json()).error).not.toBe('InvalidToken');
  });
});

describe('Password sessions', () => {
  /** @type {PersonalDataServer} */
  let pds;
  /** @type {string} */
  let cookie;

  beforeEach(async () => {
    pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    cookie = await signedInCookie(pds);
  });

  /**
   * @param {string} [password]
   * @param {string} [userAgent]
   */
  async function createSession(
    password = PASSWORD,
    userAgent = 'TestBrowser/1.0',
  ) {
    const response = await pds.fetch(
      new Request(
        'https://pds.example.com/xrpc/com.atproto.server.createSession',
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'User-Agent': userAgent,
          },
          body: JSON.stringify({ identifier: HANDLE, password }),
        },
      ),
    );
    return response.json();
  }

  /** @param {string} refreshJwt */
  function refresh(refreshJwt) {
    return pds.fetch(
      new Request(
        'https://pds.example.com/xrpc/com.atproto.server.refreshSession',
        {
          method: 'POST',
          headers: { Authorization: `Bearer ${refreshJwt}` },
        },
      ),
    );
  }

  it('lists a password login with its user agent', async () => {
    await createSession();
    const { html } = await getPage(pds, '/account/sessions', cookie);
    expect(html).toContain('Account password');
    expect(html).toContain('TestBrowser/1.0');
  });

  it('labels an app-password login by its name', async () => {
    await postForm(
      pds,
      '/account/app-passwords/create',
      { name: 'phone' },
      cookie,
    );
    const created = await getPage(pds, '/account/app-passwords', cookie);
    expect(created.html).toContain('phone');

    // Reuse the password the create page revealed
    const revealed = await postForm(
      pds,
      '/account/app-passwords/create',
      { name: 'tablet' },
      cookie,
    );
    const appPassword = /** @type {string} */ (
      (await revealed.text()).match(/class="pw">([^<]+)</)?.[1]
    );

    await createSession(appPassword);
    const { html } = await getPage(pds, '/account/sessions', cookie);
    expect(html).toContain('App password: tablet');
    expect(html).toContain('limited');
  });

  it('rotates the session on refresh rather than adding one', async () => {
    const session = await createSession();
    const refreshed = await refresh(session.refreshJwt);
    expect(refreshed.status).toBe(200);

    const { html } = await getPage(pds, '/account/sessions', cookie);
    expect(html.match(/Account password/g)).toHaveLength(1);
    expect(html).toContain('renewed');
  });

  it('redeems a superseded refresh token onto the same replacement', async () => {
    const session = await createSession();
    const refreshed = await refresh(session.refreshJwt);
    expect(refreshed.status).toBe(200);

    // The superseded token stays redeemable for its grace window, so a client
    // that refreshed twice converges on one token instead of losing the
    // session. It buys no second session either.
    const replay = await refresh(session.refreshJwt);
    expect(replay.status).toBe(200);
    // Compared by id: the two tokens are minted a moment apart, so they differ
    // as strings while naming the same successor.
    const jti = (/** @type {string} */ jwt) =>
      JSON.parse(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
        .jti;
    expect(jti((await replay.json()).refreshJwt)).toBe(
      jti((await refreshed.json()).refreshJwt),
    );

    const { html } = await getPage(pds, '/account/sessions', cookie);
    expect(html.match(/Account password/g)).toHaveLength(1);
  });

  it('revoking from the page stops the session refreshing', async () => {
    const session = await createSession();
    const { html } = await getPage(pds, '/account/sessions', cookie);
    const jti = /** @type {string} */ (
      html.match(/name="jti" value="([^"]+)"/)?.[1]
    );

    const response = await postForm(
      pds,
      '/account/sessions/revoke',
      { jti },
      cookie,
    );
    expect(response.status).toBe(200);
    expect(await response.text()).toContain('Session revoked');

    const after = await refresh(session.refreshJwt);
    expect(after.status).toBe(401);
  });

  it('deleteSession ends the session it names', async () => {
    const session = await createSession();
    const response = await pds.fetch(
      new Request(
        'https://pds.example.com/xrpc/com.atproto.server.deleteSession',
        {
          method: 'POST',
          headers: { Authorization: `Bearer ${session.refreshJwt}` },
        },
      ),
    );
    expect(response.status).toBe(200);

    expect((await refresh(session.refreshJwt)).status).toBe(401);
    const { html } = await getPage(pds, '/account/sessions', cookie);
    expect(html).toContain('No password logins are open.');
  });

  it('deleteSession refuses an access token in place of a refresh token', async () => {
    const session = await createSession();
    const response = await pds.fetch(
      new Request(
        'https://pds.example.com/xrpc/com.atproto.server.deleteSession',
        {
          method: 'POST',
          headers: { Authorization: `Bearer ${session.accessJwt}` },
        },
      ),
    );
    expect(response.status).toBe(401);
    // The session survives the bad attempt
    expect((await refresh(session.refreshJwt)).status).toBe(200);
  });

  it('keeps stateless refresh on storage with no registry', async () => {
    const stateless = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ ({}),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    const session = await (
      await stateless.fetch(
        new Request(
          'https://pds.example.com/xrpc/com.atproto.server.createSession',
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ identifier: HANDLE, password: PASSWORD }),
          },
        ),
      )
    ).json();

    const refreshed = await stateless.fetch(
      new Request(
        'https://pds.example.com/xrpc/com.atproto.server.refreshSession',
        {
          method: 'POST',
          headers: { Authorization: `Bearer ${session.refreshJwt}` },
        },
      ),
    );
    expect(refreshed.status).toBe(200);

    const { html } = await getPage(
      stateless,
      '/account/sessions',
      await signedInCookie(stateless),
    );
    expect(html).toContain('does not keep a session registry');
  });
});

describe('Spaces section', () => {
  /** A space storage stand-in, shaped like @pdsjs/spaces' own. */
  const OWN_SPACE = 'at://did:plc:test123/club.run.space/abc';
  const JOINED_SPACE =
    'at://did:plc:otheraccountotheraccount/club.run.space/xyz';

  function createSpaceStorage() {
    return {
      async listSpaces() {
        return {
          spaces: [
            {
              uri: OWN_SPACE,
              spaceDid: DID,
              spaceType: 'club.run.space',
              isOwner: true,
              policy: { $type: 'com.atproto.memberspace.policy' },
              createdAt: '2026-03-04T00:00:00.000Z',
              deletedAt: null,
            },
            {
              // Joined, not owned: the row a member's own PDS materialises
              // on its first write into someone else's space
              uri: JOINED_SPACE,
              spaceDid: 'did:plc:otheraccountotheraccount',
              spaceType: 'club.run.space',
              isOwner: false,
              policy: null,
              createdAt: '2026-03-05T00:00:00.000Z',
              deletedAt: null,
            },
          ],
          cursor: null,
        };
      },
      async listMembers(/** @type {string} */ space) {
        // A member's PDS is never told the authority's member list
        return space === OWN_SPACE
          ? {
              members: [
                { did: DID, read: true, write: true },
                { did: 'did:plc:friend', read: true, write: true },
              ],
              cursor: null,
            }
          : { members: [], cursor: null };
      },
      async listAllSpaceRecords(/** @type {string} */ space) {
        if (space !== OWN_SPACE) {
          return [{ collection: 'club.run.activity', rkey: 'z', cid: 'bafy9' }];
        }
        return [
          { collection: 'club.run.activity', rkey: 'a', cid: 'bafy1' },
          { collection: 'club.run.activity', rkey: 'b', cid: 'bafy2' },
          { collection: 'club.run.note', rkey: 'c', cid: 'bafy3' },
        ];
      },
      async listSpaceRecords(
        /** @type {string} */ _space,
        /** @type {string} */ collection,
        /** @type {string|null} */ _cursor,
        /** @type {number} */ limit,
      ) {
        const values = [
          { $type: collection, note: 'first', distanceMeters: 8000 },
          { $type: collection, note: 'second', distanceMeters: 5200 },
        ];
        return {
          records: values.slice(0, limit).map((value, index) => ({
            rkey: `rkey-${index}`,
            cid: `bafyrecord${index}`,
            indexedAt: '2026-03-06T00:00:00.000Z',
            value: cborEncodeDagCbor(value),
          })),
          cursor: null,
        };
      },
    };
  }

  /** @param {boolean} enabled */
  async function createServer(enabled) {
    const pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
      spaceStorage: enabled
        ? /** @type {any} */ (createSpaceStorage())
        : undefined,
    });
    return { pds, cookie: await signedInCookie(pds) };
  }

  it('lists spaces with member and record counts', async () => {
    const { pds, cookie } = await createServer(true);
    const { html } = await getPage(pds, '/account/spaces', cookie);

    expect(html).toContain('club.run.space');
    expect(html).toContain('at://did:plc:test123/club.run.space/abc');
    expect(html).toContain('yours');
    expect(html).toContain('2 members');
    expect(html).toContain(' · memberspace · ');
    expect(html).toContain('club.run.activity (2)');
    expect(html).toContain('club.run.note (1)');
  });

  it('marks a space the account joined but does not own', async () => {
    const { pds, cookie } = await createServer(true);
    const { html } = await getPage(pds, '/account/spaces', cookie);

    // The owned one is tagged; the joined one names its authority instead
    expect(html.match(/>yours</g)).toHaveLength(1);
    expect(html).toContain('did:plc:otheraccountotheraccount');
    expect(html).toContain('0 members');
  });

  it('shows the records in a collection', async () => {
    const { pds, cookie } = await createServer(true);
    const { status, html } = await getPage(
      pds,
      `/account/spaces/records?space=${encodeURIComponent(OWN_SPACE)}&collection=club.run.activity`,
      cookie,
    );
    expect(status).toBe(200);
    // Space record uris carry the writer: space/did/collection/rkey
    expect(html).toContain(`${OWN_SPACE}/${DID}/club.run.activity/rkey-0`);
    expect(html).toContain('<span class="k">note:</span>');
    expect(html).toContain('first');
    expect(html).toContain('<span class="k">distanceMeters:</span>');
    expect(html).toContain('<span class="n">8000</span>');
    expect(html).toContain('bafyrecord0');
  });

  it('refuses to read a space this account does not hold', async () => {
    const { pds, cookie } = await createServer(true);
    const { status, html } = await getPage(
      pds,
      '/account/spaces/records?space=at%3A%2F%2Fdid%3Aplc%3Astranger%2Fclub.run.space%2Fnope&collection=club.run.activity',
      cookie,
    );
    expect(status).toBe(404);
    expect(html).toContain('No such space');
  });

  it('turns away the record view when signed out', async () => {
    const { pds } = await createServer(true);
    const { status } = await getPage(
      pds,
      `/account/spaces/records?space=${encodeURIComponent(OWN_SPACE)}&collection=club.run.activity`,
      '',
    );
    expect(status).toBe(401);
  });

  it('says permissioned data is off when no browser is injected', async () => {
    const { pds, cookie } = await createServer(false);
    const { html } = await getPage(pds, '/account/spaces', cookie);
    expect(html).toContain('Permissioned data is off');
    expect(html).not.toContain('club.run.space');
  });
});

describe('Passkey consent', () => {
  /** @type {PersonalDataServer} */
  let pds;
  /** @type {string} */
  let cookie;

  const REQUEST_URI = 'urn:ietf:params:oauth:request_uri:req-1';
  const CLIENT_ID = 'http://localhost:3000';

  beforeEach(async () => {
    pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    cookie = await signedInCookie(pds);
    await pds.sharedStorage.putOAuthRequest(
      REQUEST_URI,
      {
        clientId: CLIENT_ID,
        clientMetadata: { client_name: 'Test App' },
        redirectUri: 'http://localhost:3000/callback',
        scope: 'atproto',
        state: 'state-1',
        responseMode: null,
        codeChallenge: 'challenge',
        codeChallengeMethod: 'S256',
      },
      Date.now() + 600000,
    );
  });

  /** Register a passkey through the account page, as a browser would. */
  async function registerPasskey() {
    const page = await pds.fetch(
      new Request('https://pds.example.com/account/passkeys', {
        headers: { Cookie: cookie },
      }),
    );
    const challenge = (await page.text()).match(/unb64\("([^"]+)"\)/)?.[1];
    const ceremonyCookie = (page.headers.get('set-cookie') || '').split(';')[0];

    const credential = await createTestCredential();
    await pds.fetch(
      new Request('https://pds.example.com/account/passkeys/register', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Cookie: `${cookie}; ${ceremonyCookie}`,
        },
        body: JSON.stringify({
          credentialId: credential.credentialId,
          publicKey: credential.publicKey,
          algorithm: -7,
          clientDataJSON: encodeClientData({
            type: 'webauthn.create',
            challenge,
            origin: 'https://pds.example.com',
          }),
        }),
      }),
    );
    return credential;
  }

  /** Load the consent page and return its passkey challenge and cookie. */
  async function loadConsent() {
    const response = await pds.fetch(
      new Request(
        `https://pds.example.com/oauth/authorize?request_uri=${encodeURIComponent(REQUEST_URI)}&client_id=${encodeURIComponent(CLIENT_ID)}`,
      ),
    );
    const html = await response.text();
    return {
      response,
      html,
      challenge: html.match(/unb64\("([^"]+)"\)/)?.[1],
      ceremonyCookie: (response.headers.get('set-cookie') || '').split(';')[0],
    };
  }

  it('offers a passkey on the consent page and binds a challenge', async () => {
    const { response, html, challenge } = await loadConsent();
    expect(response.status).toBe(200);
    expect(html).toContain('Authorize with a passkey');
    expect(challenge).toBeTruthy();
    expect(response.headers.get('set-cookie')).toContain('Path=/oauth');
    // The password path is still there
    expect(html).toContain('name="password"');
  });

  it('issues the code for a valid assertion', async () => {
    const credential = await registerPasskey();
    const { challenge, ceremonyCookie } = await loadConsent();

    const assertion = await signTestAssertion(
      credential,
      /** @type {string} */ (challenge),
    );
    const response = await pds.fetch(
      new Request('https://pds.example.com/oauth/authorize/passkey', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Cookie: ceremonyCookie,
        },
        body: JSON.stringify({
          request_uri: REQUEST_URI,
          client_id: CLIENT_ID,
          ...assertion,
        }),
      }),
    );
    expect(response.status).toBe(200);

    const { redirect } = await response.json();
    const location = new URL(redirect);
    expect(location.origin).toBe('http://localhost:3000');
    expect(location.searchParams.get('code')).toBeTruthy();
    expect(location.searchParams.get('state')).toBe('state-1');
    expect(location.searchParams.get('iss')).toBe('https://pds.example.com');

    // The challenge cookie is spent alongside the approval
    expect(response.headers.get('set-cookie')).toContain('Max-Age=0');
  });

  it('refuses a sign-in challenge spent as consent', async () => {
    const credential = await registerPasskey();

    // A challenge minted for the account sign-in ceremony, not for consent
    const start = await pds.fetch(
      new Request('https://pds.example.com/account/passkey/challenge'),
    );
    const { challenge } = await start.json();
    const wrongCookie = (start.headers.get('set-cookie') || '').split(';')[0];

    const assertion = await signTestAssertion(credential, challenge);
    const response = await pds.fetch(
      new Request('https://pds.example.com/oauth/authorize/passkey', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Cookie: wrongCookie,
        },
        body: JSON.stringify({
          request_uri: REQUEST_URI,
          client_id: CLIENT_ID,
          ...assertion,
        }),
      }),
    );
    expect(response.status).toBe(400);
    expect((await response.json()).error).toMatch(/expired/);
  });

  it('refuses an assertion from an unregistered key', async () => {
    await registerPasskey();
    const { challenge, ceremonyCookie } = await loadConsent();

    const impostor = await createTestCredential();
    const assertion = await signTestAssertion(
      impostor,
      /** @type {string} */ (challenge),
    );
    const response = await pds.fetch(
      new Request('https://pds.example.com/oauth/authorize/passkey', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Cookie: ceremonyCookie,
        },
        body: JSON.stringify({
          request_uri: REQUEST_URI,
          client_id: CLIENT_ID,
          ...assertion,
        }),
      }),
    );
    expect(response.status).toBe(401);
  });

  it('offers no passkey button when storage cannot check one', async () => {
    const plain = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ ({
        async getOAuthRequest() {
          return {
            clientId: CLIENT_ID,
            clientMetadata: { client_name: 'Test App' },
            redirectUri: 'http://localhost:3000/callback',
            scope: 'atproto',
          };
        },
      }),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    const response = await plain.fetch(
      new Request(
        `https://pds.example.com/oauth/authorize?request_uri=${encodeURIComponent(REQUEST_URI)}&client_id=${encodeURIComponent(CLIENT_ID)}`,
      ),
    );
    const html = await response.text();
    expect(html).not.toContain('passkey');
    expect(html).toContain('name="password"');
  });
});

describe('Account API', () => {
  /** @type {PersonalDataServer} */
  let pds;
  /** @type {string} */
  let cookie;

  beforeEach(async () => {
    pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (
        createMockStorage({ records: ['app.bsky.feed.post/aaa'] })
      ),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
      // What a platform package injects when the application is installed
      // A realistic shell: the sign-in bootstrap is injected before </head>.
      accountApp:
        '<!doctype html><html><head></head><body><div id="root"></div></body></html>',
    });
    cookie = await signedInCookie(pds);
  });

  /** @param {string} path @param {string} [withCookie] */
  async function api(path, withCookie = cookie) {
    const response = await pds.fetch(
      new Request(`https://pds.example.com/account/api${path}`, {
        headers: { Cookie: withCookie },
      }),
    );
    return { status: response.status, body: await response.json() };
  }

  it('serves the application for a signed-in section path', async () => {
    for (const path of ['/account', '/account/apps', '/account/identity']) {
      const { html } = await getPage(pds, path, cookie);
      expect(html, path).toContain('<div id="root">');
    }
  });

  it('boots the mounted app into sign-in mode when signed out', async () => {
    const { html } = await getPage(pds, '/account', '');
    // The shell is served either way; signed out it carries a sign-in-mode
    // bootstrap rather than the authenticated application.
    expect(html).toContain('<div id="root">');
    expect(html).toContain('window.__PDS__');
    expect(html).toContain('signin');
  });

  it('leaves the API to answer its own paths', async () => {
    const { status, body } = await api('/overview');
    expect(status).toBe(200);
    expect(body.did).toBe(DID);
    expect(body.stats.records).toBe(1);
    expect(body.features).toMatchObject({ passkeys: true, sessions: true });
  });

  it('turns away every API path when signed out', async () => {
    for (const path of [
      '/overview',
      '/apps',
      '/sessions',
      '/identity',
      '/version',
    ]) {
      const { status, body } = await api(path, '');
      expect(status, path).toBe(401);
      expect(body.error).toBe('Not signed in');
    }
  });

  it('rejects a cross-origin API write', async () => {
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/api/status', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Cookie: cookie,
          Origin: 'https://evil.example',
        },
        body: JSON.stringify({ action: 'deactivate' }),
      }),
    );
    expect(response.status).toBe(403);
  });

  it('creates and revokes an app password through the API', async () => {
    const created = await pds.fetch(
      new Request('https://pds.example.com/account/api/app-passwords/create', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Cookie: cookie },
        body: JSON.stringify({ name: 'laptop' }),
      }),
    );
    const body = await created.json();
    expect(body.password).toMatch(
      /^[a-z2-7]{4}-[a-z2-7]{4}-[a-z2-7]{4}-[a-z2-7]{4}$/,
    );

    const listed = await api('/app-passwords');
    expect(listed.body.passwords[0].name).toBe('laptop');
    // Storage keeps a hash, so a listing cannot leak the credential
    expect(JSON.stringify(listed.body)).not.toContain(body.password);
  });

  it('reads a repo collection through the API', async () => {
    const { status, body } = await api(
      '/records?collection=app.bsky.feed.post',
    );
    expect(status).toBe(200);
    expect(body.records[0].uri).toBe(`at://${DID}/app.bsky.feed.post/aaa`);
    expect(body.records[0].value.text).toBe('record aaa');
    expect(body.blobBase).toContain('com.atproto.sync.getBlob');
  });

  it('reports an unknown collection as an empty read, not an error', async () => {
    const { status, body } = await api(
      '/records?collection=app.bsky.feed.like',
    );
    expect(status).toBe(200);
    expect(body.records).toEqual([]);
  });

  /** @param {Record<string, unknown>} payload */
  async function postAlias(payload) {
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/api/apps/alias', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Cookie: cookie },
        body: JSON.stringify(payload),
      }),
    );
    return { status: response.status, body: await response.json() };
  }

  it('combines two app rows and serves the alias back with the apps list', async () => {
    const combined = await postAlias({
      action: 'combine',
      from: 'tangled.sh',
      to: 'tangled.org',
    });
    expect(combined.status).toBe(200);
    expect(combined.body.aliases).toEqual({ 'tangled.sh': 'tangled.org' });

    const { body } = await api('/apps');
    expect(body.aliases).toEqual({ 'tangled.sh': 'tangled.org' });
    expect(body.aliasesAvailable).toBe(true);
  });

  it('separates a combined row again', async () => {
    await postAlias({
      action: 'combine',
      from: 'tangled.sh',
      to: 'tangled.org',
    });
    const { status, body } = await postAlias({
      action: 'separate',
      from: 'tangled.sh',
    });
    expect(status).toBe(200);
    expect(body.aliases).toEqual({});
  });

  it('remembers a dismissed offer without creating an alias', async () => {
    const { body } = await postAlias({
      action: 'dismiss',
      from: 'tangled.sh',
      to: 'tangled.org',
    });
    expect(body.aliases).toEqual({});
    expect(body.dismissedPairs).toEqual(['tangled.sh>tangled.org']);
  });

  it('rejects an alias that is not a hostname, or an unknown action', async () => {
    for (const payload of [
      { action: 'combine', from: 'not a host', to: 'tangled.org' },
      { action: 'combine', from: 'tangled.sh', to: 'tangled.sh' },
      { action: 'shrug', from: 'tangled.sh', to: 'tangled.org' },
    ]) {
      const { status } = await postAlias(payload);
      expect(status, JSON.stringify(payload)).toBe(400);
    }
  });

  it('never lets a chain of aliases form', async () => {
    // b already folds into c; combining a into b flattens b's own fold away.
    await postAlias({ action: 'combine', from: 'app.org', to: 'app.dev' });
    const { body } = await postAlias({
      action: 'combine',
      from: 'app.sh',
      to: 'app.org',
    });
    expect(body.aliases).toEqual({ 'app.sh': 'app.org' });
  });

  /** @param {Record<string, unknown>} payload */
  async function postAppearance(payload) {
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/api/settings/appearance', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Cookie: cookie },
        body: JSON.stringify(payload),
      }),
    );
    return { status: response.status, body: await response.json() };
  }

  it('defaults the appearance setting to system', async () => {
    const { body } = await api('/overview');
    expect(body.theme).toBe('system');
    expect(body.features.settings).toBe(true);
  });

  it('stores an appearance choice and serves it back everywhere', async () => {
    const { status, body } = await postAppearance({ theme: 'dark' });
    expect(status).toBe(200);
    expect(body.theme).toBe('dark');

    const overview = await api('/overview');
    expect(overview.body.theme).toBe('dark');

    // The signed-in page bootstrap carries the choice for first paint.
    const { html } = await getPage(pds, '/account', cookie);
    expect(html).toContain('window.__PDS__');
    expect(html).toContain('dark');
  });

  it('rejects a theme outside system, light, dark', async () => {
    const { status } = await postAppearance({ theme: 'sepia' });
    expect(status).toBe(400);
  });

  // ── /account/api/password ──

  /** @param {object} payload */
  async function postPassword(payload) {
    const response = await pds.fetch(
      new Request('https://pds.example.com/account/api/password', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Cookie: cookie },
        body: JSON.stringify(payload),
      }),
    );
    return { status: response.status, body: await response.json() };
  }

  it('changes the password and the old one stops signing in', async () => {
    const { status, body } = await postPassword({
      currentPassword: PASSWORD,
      newPassword: 'brand-new-pw',
    });
    expect(status).toBe(200);
    expect(body.ok).toBe(true);

    const oldTry = await signIn(pds, { username: HANDLE, password: PASSWORD });
    expect(oldTry.status).toBe(401);
    const newTry = await signIn(pds, {
      username: HANDLE,
      password: 'brand-new-pw',
    });
    expect(newTry.status).toBe(303);
  });

  it('requires the current password, even inside a session', async () => {
    const { status, body } = await postPassword({
      currentPassword: 'nope',
      newPassword: 'brand-new-pw',
    });
    expect(status).toBe(400);
    expect(body.error).toContain('current password');
    // Unchanged: the original still signs in.
    const check = await signIn(pds, { username: HANDLE, password: PASSWORD });
    expect(check.status).toBe(303);
  });

  it('rejects a short new password', async () => {
    const { status } = await postPassword({
      currentPassword: PASSWORD,
      newPassword: 'short',
    });
    expect(status).toBe(400);
  });

  it('reports the feature in the overview', async () => {
    const { body } = await api('/overview');
    expect(body.features.passwordChange).toBe(true);
  });

  it('a rotated PDS_PASSWORD wins over a changed one', async () => {
    // One storage, two server generations: the first changes the password
    // through the API, the second starts with a rotated secret.
    const actorStorage = /** @type {any} */ (createMockStorage());
    const shared = /** @type {any} */ (createMockSharedStorage());
    const first = new PersonalDataServer({
      actorStorage,
      sharedStorage: shared,
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    const firstCookie = await signedInCookie(first);
    const change = await first.fetch(
      new Request('https://pds.example.com/account/api/password', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Cookie: firstCookie },
        body: JSON.stringify({
          currentPassword: PASSWORD,
          newPassword: 'brand-new-pw',
        }),
      }),
    );
    expect(change.status).toBe(200);

    const second = new PersonalDataServer({
      actorStorage,
      sharedStorage: shared,
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: 'rotated-pw',
    });
    const changed = await signIn(second, {
      username: HANDLE,
      password: 'brand-new-pw',
    });
    expect(changed.status).toBe(401);
    const rotated = await signIn(second, {
      username: HANDLE,
      password: 'rotated-pw',
    });
    expect(rotated.status).toBe(303);
  });

  // ── /account/api/version, with the npm registry stubbed ──

  /** @param {unknown} manifest - Registry answer, or an Error to throw */
  function stubRegistry(manifest) {
    const stub = vi.fn(async (/** @type {string} */ _url) => {
      if (manifest instanceof Error) throw manifest;
      return new Response(JSON.stringify(manifest));
    });
    vi.stubGlobal('fetch', stub);
    return stub;
  }

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  /** The version _health reports, which is what the endpoint compares against. */
  async function runningVersion() {
    const response = await pds.fetch(
      new Request('https://pds.example.com/xrpc/_health'),
    );
    return (await response.json()).version;
  }

  it('reports an available update, once per cache window', async () => {
    const [major, minor, patch] = (await runningVersion())
      .split('.')
      .map(Number);
    const newer = `${major}.${minor}.${patch + 1}`;
    const stub = stubRegistry({ version: newer });

    const { status, body } = await api('/version');
    expect(status).toBe(200);
    expect(body.latest).toBe(newer);
    expect(body.updateAvailable).toBe(true);
    expect(body.releaseNotesUrl).toContain('CHANGELOG');
    // No update page or script name is configured on this server, so the
    // account page has nowhere to link.
    expect(body.updateUrl).toBe(null);
    expect(body.scriptName).toBe(null);

    // Cached: a second request asks the registry nothing.
    await api('/version');
    expect(stub).toHaveBeenCalledTimes(1);
  });

  it('names the configured script and update page', async () => {
    pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (createMockSharedStorage()),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
      updateUrl: 'https://start.example.com/update',
      scriptName: 'pds',
    });
    cookie = await signedInCookie(pds);
    stubRegistry({ version: await runningVersion() });
    const { body } = await api('/version');
    expect(body.updateUrl).toBe('https://start.example.com/update');
    expect(body.scriptName).toBe('pds');
  });

  it('stays quiet when the registry answer is the running release', async () => {
    const current = await runningVersion();
    stubRegistry({ version: current });
    const { body } = await api('/version');
    expect(body.current).toBe(current);
    expect(body.latest).toBe(current);
    expect(body.updateAvailable).toBe(false);
  });

  it('answers without an update when the registry cannot be reached', async () => {
    stubRegistry(new Error('offline'));
    const { status, body } = await api('/version');
    expect(status).toBe(200);
    expect(body.latest).toBe(null);
    expect(body.updateAvailable).toBe(false);
  });

  it('treats a registry answer with no version string as no known update', async () => {
    stubRegistry({ 'dist-tags': {} });
    const { body } = await api('/version');
    expect(body.latest).toBe(null);
    expect(body.updateAvailable).toBe(false);
  });

  // ── the release channel setting ──

  /** @param {Record<string, unknown>} payload */
  async function postChannel(payload) {
    const response = await pds.fetch(
      new Request(
        'https://pds.example.com/account/api/settings/release-channel',
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', Cookie: cookie },
          body: JSON.stringify(payload),
        },
      ),
    );
    return { status: response.status, body: await response.json() };
  }

  it('follows stable by default', async () => {
    stubRegistry({ version: await runningVersion() });
    const { body } = await api('/version');
    expect(body.channel).toBe('stable');
    expect(body.releaseNotesUrl).toContain('CHANGELOG');
  });

  it('asks the artifacts site once the canary channel is chosen', async () => {
    const canary = `${await runningVersion()}-canary.abc1234`;
    const stub = stubRegistry({ version: canary });

    const set = await postChannel({ channel: 'canary' });
    expect(set.status).toBe(200);
    expect(set.body).toEqual({ channel: 'canary' });

    const { body } = await api('/version');
    expect(body.channel).toBe('canary');
    expect(body.latest).toBe(canary);
    // A canary build differs from the running release, so it is an update
    // even though its dotted parts compare as older.
    expect(body.updateAvailable).toBe(true);
    expect(body.releaseNotesUrl).not.toContain('CHANGELOG');
    expect(stub.mock.calls[0][0]).toContain('/canary/manifest.json');
  });

  it('stays quiet when the canary build is the running one', async () => {
    // What a canary deployment reports: the stamped build version matches
    // the channel's manifest exactly.
    stubRegistry({ version: await runningVersion() });
    await postChannel({ channel: 'canary' });
    const { body } = await api('/version');
    expect(body.updateAvailable).toBe(false);
  });

  it('caches each channel on its own clock', async () => {
    const stub = stubRegistry({ version: await runningVersion() });
    await api('/version');
    await postChannel({ channel: 'canary' });
    // The canary answer is not served from the stable cache.
    await api('/version');
    expect(stub).toHaveBeenCalledTimes(2);
    expect(stub.mock.calls[0][0]).toContain('registry.npmjs.org');
    expect(stub.mock.calls[1][0]).toContain('/canary/manifest.json');
  });

  it('rejects a channel outside stable and canary', async () => {
    const { status } = await postChannel({ channel: 'nightly' });
    expect(status).toBe(400);
  });
});

describe('Sign out all logins', () => {
  it('revokes every session and leaves the account page signed in', async () => {
    const storage = createMockSharedStorage();
    const pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (storage),
      blobs: /** @type {any} */ ({}),
      jwtSecret: 'test-secret',
      password: PASSWORD,
    });
    const now = new Date().toISOString();
    for (const jti of ['a', 'b', 'c']) {
      await storage.putSession({
        jti,
        did: DID,
        label: 'Account password',
        scope: 'atproto',
        userAgent: 'node',
        createdAt: now,
        refreshedAt: null,
      });
    }
    const cookie = await signedInCookie(pds);

    const revoke = await pds.fetch(
      new Request('https://pds.example.com/account/api/sessions/revoke-all', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Cookie: cookie },
        body: '{}',
      }),
    );
    expect(revoke.status).toBe(200);
    expect(await revoke.json()).toEqual({ revoked: 3 });

    // The logins are gone, but the same cookie still reaches the account API,
    // so the caller was not signed out of the page.
    const after = await pds.fetch(
      new Request('https://pds.example.com/account/api/sessions', {
        headers: { Cookie: cookie },
      }),
    );
    expect(after.status).toBe(200);
    expect((await after.json()).sessions).toEqual([]);
  });
});