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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
261 lines · 8.3 KB Raw History
// Backend for first-run onboarding: the fresh-signup endpoint that provisions
// a brand-new did:plc identity, and the routing that serves the onboarding
// wizard on an empty server.

import { afterEach, describe, expect, it, vi } from 'vitest';
import { verifyAccountJwt } from '../packages/core/src/auth.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';

const HOSTNAME = 'pds.example.com';
const PASSWORD = 'test-password';
const JWT_SECRET = 'test-secret';
const ACCOUNT_APP = '<!doctype html><html><head></head><body></body></html>';

/** A stateful, initially-empty actor store. */
function createEmptyStorage() {
  /** @type {Record<string, any>} */
  const state = {
    did: null,
    handle: null,
    privateKey: null,
    email: null,
    status: null,
  };
  let seq = 0;
  /** @type {any[]} */
  const events = [];
  return {
    _state: state,
    _events: events,
    async getDid() {
      return state.did;
    },
    async setDid(/** @type {string} */ did) {
      state.did = did;
    },
    async getHandle() {
      return state.handle;
    },
    async setHandle(/** @type {string} */ handle) {
      state.handle = handle;
    },
    async getPrivateKey() {
      return state.privateKey;
    },
    async setPrivateKey(/** @type {Uint8Array} */ key) {
      state.privateKey = key;
    },
    async getKemKey() {
      return state.kemKey ?? null;
    },
    async setKemKey(/** @type {Uint8Array} */ key) {
      state.kemKey = key;
    },
    async getAccountStatus() {
      return state.status;
    },
    async setEmail(/** @type {any} */ email) {
      state.email = email;
    },
    async getLatestSeq() {
      return seq;
    },
    async putEvent(
      /** @type {number} */ s,
      /** @type {string} */ _did,
      /** @type {string} */ _cid,
      /** @type {Uint8Array} */ evt,
    ) {
      seq = s;
      events.push(evt);
    },
    async getLatestCommit() {
      return null;
    },
  };
}

/**
 * @param {ReturnType<typeof createEmptyStorage>} storage
 * @param {{accountApp?: string}} [opts]
 */
function createServer(storage, { accountApp } = {}) {
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ (storage),
    sharedStorage: /** @type {any} */ ({ putActor: async () => {} }),
    blobs: /** @type {any} */ ({}),
    jwtSecret: JWT_SECRET,
    hostname: HOSTNAME,
    password: PASSWORD,
    plcUrl: 'https://plc.test',
    accountApp,
  });
}

/**
 * Drive the fresh-signup endpoint.
 * @param {PersonalDataServer} pds
 * @param {Record<string, unknown>} body
 * @param {Record<string, string>} [headers]
 */
function createAccount(pds, body, headers = {}) {
  return pds.fetch(
    new Request(`https://${HOSTNAME}/account/onboarding/create`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...headers },
      body: JSON.stringify(body),
    }),
  );
}

describe('onboarding: fresh signup', () => {
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  /** Stub fetch so the PLC directory registration succeeds and is captured. */
  function stubPlc(status = 200) {
    const calls = /** @type {{url: string, op: any}[]} */ ([]);
    vi.stubGlobal(
      'fetch',
      vi.fn(async (/** @type {string} */ url, /** @type {any} */ init) => {
        calls.push({ url, op: JSON.parse(init.body) });
        return new Response('', { status });
      }),
    );
    return calls;
  }

  it('provisions a new did:plc identity and returns a cookie session', async () => {
    const calls = stubPlc();
    const storage = createEmptyStorage();
    const pds = createServer(storage);

    const res = await createAccount(pds, {
      handle: 'alice.pds.example.com',
      password: PASSWORD,
      email: 'alice@example.com',
    });
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.did).toMatch(/^did:plc:[a-z2-7]{24}$/);
    expect(data.handle).toBe('alice.pds.example.com');

    // Persisted identity.
    expect(storage._state.did).toBe(data.did);
    expect(storage._state.handle).toBe('alice.pds.example.com');
    expect(storage._state.privateKey).toBeInstanceOf(Uint8Array);
    expect(storage._state.email).toEqual({
      address: 'alice@example.com',
      verified: false,
    });

    // A cookie session naming the new DID.
    const cookie = res.headers.get('set-cookie') || '';
    expect(cookie).toContain('pdsjs_account=');
    const match = /pdsjs_account=([^;]+)/.exec(cookie);
    const token = decodeURIComponent(match?.[1] || '');
    const payload = await verifyAccountJwt(token, JWT_SECRET);
    expect(payload.sub).toBe(data.did);

    // Registered a signed genesis op with the PLC directory for that DID.
    expect(calls).toHaveLength(1);
    expect(calls[0].url).toContain(encodeURIComponent(data.did));
    expect(calls[0].op.type).toBe('plc_operation');
    expect(typeof calls[0].op.sig).toBe('string');
    expect(calls[0].op.alsoKnownAs).toEqual(['at://alice.pds.example.com']);
    expect(calls[0].op.services.atproto_pds.endpoint).toBe(
      `https://${HOSTNAME}`,
    );
  });

  it('strips a port that rides along from a local host', async () => {
    stubPlc();
    const storage = createEmptyStorage();
    const res = await createAccount(createServer(storage), {
      handle: 'alice.example.com:3999',
      password: PASSWORD,
    });
    expect(res.status).toBe(200);
    expect((await res.json()).handle).toBe('alice.example.com');
  });

  it('defaults the handle to the server hostname', async () => {
    stubPlc();
    const storage = createEmptyStorage();
    const res = await createAccount(createServer(storage), {
      password: PASSWORD,
    });
    expect(res.status).toBe(200);
    expect((await res.json()).handle).toBe(HOSTNAME);
  });

  it('rejects the wrong server password', async () => {
    const calls = stubPlc();
    const storage = createEmptyStorage();
    const res = await createAccount(createServer(storage), {
      handle: 'alice.pds.example.com',
      password: 'wrong',
    });
    expect(res.status).toBe(401);
    expect(storage._state.did).toBeNull();
    expect(calls).toHaveLength(0);
  });

  it('refuses when the server already hosts an account', async () => {
    stubPlc();
    const storage = createEmptyStorage();
    storage._state.did = 'did:plc:alreadyhereaccount000000000';
    const res = await createAccount(createServer(storage), {
      handle: 'alice.pds.example.com',
      password: PASSWORD,
    });
    expect(res.status).toBe(400);
  });

  it('surfaces a PLC directory rejection and stays empty', async () => {
    stubPlc(400);
    const storage = createEmptyStorage();
    const res = await createAccount(createServer(storage), {
      handle: 'alice.pds.example.com',
      password: PASSWORD,
    });
    expect(res.status).toBe(502);
    // Nothing persisted, so the operator can retry.
    expect(storage._state.did).toBeNull();
  });
});

describe('onboarding: first-run routing', () => {
  it('serves the onboard mode on an empty server with the account app', async () => {
    const pds = createServer(createEmptyStorage(), { accountApp: ACCOUNT_APP });
    const res = await pds.fetch(new Request(`https://${HOSTNAME}/account`));
    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toContain('text/html');
    const html = await res.text();
    expect(html).toContain('window.__PDS__');
    expect(html).toContain('onboard');
  });

  it('does not serve onboarding once the account is active', async () => {
    const storage = createEmptyStorage();
    storage._state.did = 'did:plc:existingaccount0000000000';
    storage._state.status = null; // null == active
    const pds = createServer(storage, { accountApp: ACCOUNT_APP });
    const res = await pds.fetch(new Request(`https://${HOSTNAME}/account`));
    const html = await res.text();
    expect(html).not.toContain('onboard');
  });

  it('keeps serving onboarding while a migration is deactivated (resume)', async () => {
    // createAccount lands the adopted account deactivated until the move
    // finishes; the wizard must stay reachable to resume it.
    const storage = createEmptyStorage();
    storage._state.did = 'did:plc:migratinginprogress0000000';
    storage._state.status = 'deactivated';
    const pds = createServer(storage, { accountApp: ACCOUNT_APP });
    const res = await pds.fetch(new Request(`https://${HOSTNAME}/account`));
    const html = await res.text();
    expect(html).toContain('onboard');
  });
});