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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
268 lines · 7.5 KB Raw History
// The /account/api test harness: fake actor and shared storage with the
// account's own fields, a server over them, and the cookie-session calls
// the section tests make. Each package's account-API suite composes its
// extension over createPds.
import { createAccountJwt } from '../../packages/core/src/auth.js';
import { PersonalDataServer } from '../../packages/core/src/pds.js';

export const DID = 'did:plc:abcdefghij234567abcdefgh';
export const HANDLE = 'test.handle';
export const SECRET = 'test-secret';
export const BASE = 'https://pds.example.com';

/**
 * @param {Object} [overrides] - Port methods that replace the defaults
 */
export function createActorStorage(overrides = {}) {
  /** @type {import('../../packages/core/src/ports.js').EmailState|null} */
  let email = null;
  /** @type {import('../../packages/core/src/ports.js').AccountUiState|null} */
  let uiState = null;
  let currentStatus = 'active';
  /** @type {Array<{seq: number, evt: Uint8Array}>} */
  const events = [];
  return {
    events,
    async getDid() {
      return DID;
    },
    async getHandle() {
      return HANDLE;
    },
    async getAccountStatus() {
      return currentStatus;
    },
    async setAccountStatus(/** @type {string} */ next) {
      currentStatus = next;
    },
    async getEmail() {
      return email;
    },
    async setEmail(/** @type {any} */ state) {
      email = state;
    },
    async getAccountUiState() {
      return uiState;
    },
    async setAccountUiState(/** @type {any} */ state) {
      uiState = state;
    },
    async getPrivateKey() {
      return null;
    },
    async getLatestCommit() {
      return { seq: 7, cid: 'bafycommit', rev: '3lrev' };
    },
    async getLatestSeq() {
      return events.length ? Math.max(...events.map((e) => e.seq)) : 0;
    },
    async putEvent(
      /** @type {number} */ seq,
      /** @type {string} */ _did,
      /** @type {string} */ _prev,
      /** @type {Uint8Array} */ evt,
    ) {
      events.push({ seq, evt });
    },
    async getEventsBefore(
      /** @type {number} */ before,
      /** @type {number} */ limit,
    ) {
      return events
        .filter((e) => e.seq < before)
        .sort((a, b) => b.seq - a.seq)
        .slice(0, limit);
    },
    async getRecord() {
      return null;
    },
    async listAllRecords() {
      return [];
    },
    async listRecords() {
      return { records: [], cursor: null };
    },
    async listBlobs() {
      return { cids: [], cursor: null };
    },
    ...overrides,
  };
}

/**
 * @param {Object} [options]
 * @param {Array<Record<string, unknown>>} [options.oauthTokens]
 * @param {Array<Record<string, unknown>>} [options.sessions]
 * @param {Array<Record<string, unknown>>} [options.passkeys]
 */
export function createSharedStorage({
  oauthTokens = [],
  sessions = [],
  passkeys = [],
} = {}) {
  /** @type {any[]} */
  const passwords = [];
  /** @type {any[]} */
  const sessionRows = [...sessions];
  /** @type {any[]} */
  const passkeyRows = [...passkeys];
  let tokens = [...oauthTokens];
  return {
    get sessionRows() {
      return sessionRows;
    },
    get tokens() {
      return tokens;
    },
    async listOAuthTokensByDid() {
      return tokens;
    },
    async deleteOAuthToken(/** @type {string} */ tokenId) {
      tokens = tokens.filter((token) => token.tokenId !== tokenId);
    },
    async listAppPasswords() {
      return passwords.map(({ hash: _hash, ...entry }) => entry);
    },
    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 putSession(/** @type {any} */ session) {
      const index = sessionRows.findIndex((s) => s.jti === session.jti);
      if (index === -1) sessionRows.push(session);
      else sessionRows[index] = session;
    },
    async getSession(/** @type {string} */ jti) {
      return sessionRows.find((s) => s.jti === jti) || null;
    },
    async listSessions() {
      return sessionRows;
    },
    async deleteSession(/** @type {string} */ jti) {
      const index = sessionRows.findIndex((s) => s.jti === jti);
      if (index === -1) return false;
      sessionRows.splice(index, 1);
      return true;
    },
    async putPasskey(/** @type {any} */ record) {
      passkeyRows.push(record);
      return true;
    },
    async getPasskey(/** @type {string} */ credentialId) {
      return passkeyRows.find((p) => p.credentialId === credentialId) || null;
    },
    async listPasskeys() {
      return passkeyRows;
    },
    async deletePasskey(
      /** @type {string} */ _did,
      /** @type {string} */ credentialId,
    ) {
      const index = passkeyRows.findIndex(
        (p) => p.credentialId === credentialId,
      );
      if (index === -1) return false;
      passkeyRows.splice(index, 1);
      return true;
    },
    async touchPasskey() {},
  };
}

/** An email port that records what it sends and can be told to fail. */
export function createEmailer() {
  /** @type {Array<{to: string, subject: string, text: string}>} */
  const sent = [];
  let failing = false;
  return {
    sent,
    setFailing(/** @type {boolean} */ value) {
      failing = value;
    },
    async send(/** @type {any} */ message) {
      if (failing) throw new Error('smtp down');
      sent.push(message);
    },
  };
}

/**
 * @param {Object} [options]
 * @param {Object} [options.actorStorage]
 * @param {Object} [options.sharedStorage]
 * @param {Object} [options.emailer]
 * @param {Object} [options.spaceStorage]
 * @param {Object} [options.lexiconResolver]
 * @param {boolean} [options.readOnly]
 * @param {import('../../packages/core/src/ports.js').ExtensionFactory[]} [options.extensions]
 */
export function createPds({
  actorStorage = createActorStorage(),
  sharedStorage = createSharedStorage(),
  emailer,
  spaceStorage,
  lexiconResolver,
  readOnly = false,
  extensions,
} = {}) {
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ (actorStorage),
    sharedStorage: /** @type {any} */ (sharedStorage),
    blobs: /** @type {any} */ ({}),
    jwtSecret: SECRET,
    hostname: 'pds.example.com',
    password: 'account-password',
    emailer: /** @type {any} */ (emailer),
    spaceStorage: /** @type {any} */ (spaceStorage),
    lexiconResolver: /** @type {any} */ (lexiconResolver),
    readOnly,
    extensions,
  });
}

export async function sessionCookie() {
  const jwt = await createAccountJwt(DID, SECRET);
  return `pdsjs_account=${encodeURIComponent(jwt)}`;
}

/**
 * @param {PersonalDataServer} pds
 * @param {string} path
 * @param {string} cookie
 */
export function apiGet(pds, path, cookie) {
  return pds.fetch(new Request(BASE + path, { headers: { cookie } }));
}

/**
 * @param {PersonalDataServer} pds
 * @param {string} path
 * @param {unknown} body
 * @param {string} cookie
 * @param {Record<string, string>} [headers]
 */
export function apiPost(pds, path, body, cookie, headers = {}) {
  return pds.fetch(
    new Request(BASE + path, {
      method: 'POST',
      headers: { cookie, 'content-type': 'application/json', ...headers },
      body: JSON.stringify(body),
    }),
  );
}