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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
209 lines · 7.3 KB Raw History
// The storage views an extension receives on its host. Each is the server's
// own port behind a check on the record collection: an extension declares
// the collections it owns, and a call that names any other collection is
// refused with a rejected promise. Methods that name no collection (blocks,
// blobs, the event log, the account's own fields) pass through unchanged.

/**
 * The collection an AT-URI names: the segment before the record key, which
 * holds for a public record (`at://did/collection/rkey`) and for a record
 * in a space (`at://did/space/type/skey/did/collection/rkey`) alike.
 * @param {string} uri
 * @returns {string}
 */
export function collectionOfUri(uri) {
  const segments = uri.replace(/^at:\/\//, '').split('/');
  return segments.length >= 3 ? segments[segments.length - 2] : '';
}

/**
 * @typedef {Object} CollectionScope
 * @property {(collection: string) => boolean} allows
 * @property {(collection: string) => void} assert - throws when not allowed
 * @property {() => void} assertAll - throws unless every collection is allowed
 */

/**
 * A scope over the collections an extension declares. The list is read on
 * every check, so a factory may run before its extension exists.
 * @param {() => string} name - the extension's name, for the error
 * @param {() => string[]|undefined} declared - `['*']` allows every collection; absent allows none
 * @returns {CollectionScope}
 */
export function createCollectionScope(name, declared) {
  const allows = (/** @type {string} */ collection) => {
    const list = declared() ?? [];
    return list.includes('*') || list.includes(collection);
  };
  const refuse = (/** @type {string} */ what) => {
    const list = declared();
    const holds = list?.length
      ? `declares ${list.join(', ')}`
      : 'declares no collections';
    throw new Error(`Extension ${name()} may not touch ${what}: it ${holds}`);
  };
  return {
    allows,
    assert(collection) {
      if (!allows(collection)) refuse(collection);
    },
    assertAll() {
      if (!allows('*')) refuse('every collection');
    },
  };
}

/**
 * The actor storage port behind the scope. Record reads and writes check
 * the collection they name; everything else passes through.
 * @param {import('./ports.js').ActorStoragePort} storage
 * @param {CollectionScope} scope
 * @returns {import('./ports.js').ActorStoragePort}
 */
export function scopeActorStorage(storage, scope) {
  /** @type {Partial<import('./ports.js').ActorStoragePort>} */
  const guarded = {
    getRecord: async (uri) => {
      scope.assert(collectionOfUri(uri));
      return storage.getRecord(uri);
    },
    listRecords: async (collection, cursor, limit, reverse) => {
      scope.assert(collection);
      return storage.listRecords(collection, cursor, limit, reverse);
    },
    listAllRecords: async () => {
      scope.assertAll();
      return storage.listAllRecords();
    },
    putRecord: async (uri, cid, collection, rkey, value) => {
      scope.assert(collection);
      return storage.putRecord(uri, cid, collection, rkey, value);
    },
    deleteRecord: async (uri) => {
      scope.assert(collectionOfUri(uri));
      return storage.deleteRecord(uri);
    },
    linkBlobToRecord: async (cid, uri, time) => {
      scope.assert(collectionOfUri(uri));
      return storage.linkBlobToRecord(cid, uri, time);
    },
    unlinkBlobsFromRecord: async (uri) => {
      scope.assert(collectionOfUri(uri));
      return storage.unlinkBlobsFromRecord(uri);
    },
  };
  return passThrough(storage, guarded);
}

/**
 * The space storage port behind the scope: record reads, listings and
 * commits check their collection; space rows, members and repo state pass
 * through.
 * @param {import('./ports.js').SpaceStoragePort} storage
 * @param {CollectionScope} scope
 * @returns {import('./ports.js').SpaceStoragePort}
 */
export function scopeSpaceStorage(storage, scope) {
  /** @type {Partial<import('./ports.js').SpaceStoragePort>} */
  const guarded = {
    getSpaceRecord: async (space, collection, rkey) => {
      scope.assert(collection);
      return storage.getSpaceRecord(space, collection, rkey);
    },
    getSpaceRecordCid: async (space, collection, rkey) => {
      scope.assert(collection);
      return storage.getSpaceRecordCid(space, collection, rkey);
    },
    listSpaceRecords: async (space, opts) => {
      if (opts.collection) scope.assert(opts.collection);
      else scope.assertAll();
      return storage.listSpaceRecords(space, opts);
    },
    listAllSpaceRecords: async (space) => {
      scope.assertAll();
      return storage.listAllSpaceRecords(space);
    },
    commitSpaceWrite: async (space, commit) => {
      for (const write of commit.writes) scope.assert(write.collection);
      return storage.commitSpaceWrite(space, commit);
    },
  };
  return passThrough(storage, guarded);
}

/**
 * The space browser behind the scope: a collection listing checks its
 * collection, the whole-account listing needs every collection, and the
 * space and member views pass through.
 * @param {import('./ports.js').SpaceBrowserPort} browser
 * @param {CollectionScope} scope
 * @returns {import('./ports.js').SpaceBrowserPort}
 */
export function scopeSpaceBrowser(browser, scope) {
  return {
    listSpaces: () => browser.listSpaces(),
    countRecords: (uri) => browser.countRecords(uri),
    countMembers: (uri) => browser.countMembers(uri),
    listMembers: (uri) => browser.listMembers(uri),
    listRecords: async (uri, collection, limit) => {
      scope.assert(collection);
      return browser.listRecords(uri, collection, limit);
    },
    listAllRecords: async () => {
      scope.assertAll();
      return browser.listAllRecords();
    },
  };
}

/** Headers that carry a caller's credentials. */
const CREDENTIAL_HEADERS = ['authorization', 'cookie', 'dpop'];

/**
 * The request without its credentials, so a self-addressed call an
 * extension makes is anonymous whatever request it was built from.
 * @param {Request} request
 * @returns {Request}
 */
export function anonymousRequest(request) {
  if (!CREDENTIAL_HEADERS.some((name) => request.headers.has(name))) {
    return request;
  }
  const headers = new Headers(request.headers);
  for (const name of CREDENTIAL_HEADERS) headers.delete(name);
  return new Request(request, { headers });
}

/**
 * The commit function behind the scope: every op must name a declared
 * collection, the same check a record write passes.
 * @param {import('./ports.js').ExtensionHost['createCommit']} createCommit
 * @param {CollectionScope} scope
 * @returns {import('./ports.js').ExtensionHost['createCommit']}
 */
export function scopeCommits(createCommit, scope) {
  return async (did, ops) => {
    for (const op of ops) scope.assert(collectionOfUri(op.uri));
    return createCommit(did, ops);
  };
}

/**
 * `guarded` in front of `target`: a guarded method answers, any other
 * member reads from the target with methods bound to it.
 * @template {object} T
 * @param {T} target
 * @param {Partial<T>} guarded
 * @returns {T}
 */
function passThrough(target, guarded) {
  return new Proxy(target, {
    get(object, property, receiver) {
      if (property in guarded) {
        return /** @type {Record<PropertyKey, unknown>} */ (guarded)[property];
      }
      const value = Reflect.get(object, property, receiver);
      return typeof value === 'function' ? value.bind(object) : value;
    },
  });
}