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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
165 lines · 5.1 KB Raw History
// @pdsjs/spaces/admin - space management for the account page.
//
// The account page runs behind the owner's own session, so these skip the
// scope checks the XRPC management endpoints apply to third-party callers.
// They mutate the same storage the same way com.atproto.space.createSpace and
// com.atproto.memberspace.* do.

import { cborDecodeJson, createTid } from '@pdsjs/core/repo';
import { LtHash } from './lthash.js';
import {
  assertMemberspace,
  assertNotPolicyRecord,
  MEMBERSPACE,
  memberRecordWrites,
} from './policy.js';
import { recordWriterUpdate } from './sequence.js';
import { makeSpaceRow } from './space-row.js';
import { formatSpaceUri } from './uri.js';
import { applyWrites } from './writer.js';

/**
 * @param {Object} ctx
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
 * @param {() => Promise<string|null>} ctx.getDid
 * @returns {import('@pdsjs/core/ports').SpaceAdminPort}
 */
export function createSpaceAdmin({ spaceStorage, getDid }) {
  /** @returns {Promise<string>} */
  async function requireDid() {
    const did = await getDid();
    if (!did) throw new Error('server not initialised');
    return did;
  }

  /**
   * @param {string} space
   * @returns {Promise<import('@pdsjs/core/ports').SpaceRow>}
   */
  async function requireOwnedSpace(space) {
    const row = await spaceStorage.getSpace(space);
    if (!row?.isOwner || row.deletedAt) {
      throw new Error(`no such space: ${space}`);
    }
    return row;
  }

  return {
    async createSpace({ type, skey }) {
      if (!type.includes('.')) {
        throw new Error(`space type must be an NSID: ${type}`);
      }
      if (skey && skey.length > 512) {
        throw new Error('skey is too long');
      }
      const did = await requireDid();
      const uri = formatSpaceUri({
        spaceDid: did,
        spaceType: type,
        skey: skey || createTid(),
      });
      if (await spaceStorage.getSpace(uri)) {
        throw new Error(`space already exists: ${uri}`);
      }
      // Readable and writable by its members alone, with any app.
      const policy = {
        $type: MEMBERSPACE,
        publicRead: false,
        publicWrite: false,
        publishPolicy: false,
        publishMembers: false,
      };
      await spaceStorage.putSpace(makeSpaceRow(uri, { isOwner: true, policy }));
      return { uri };
    },

    async putMember(space, did, access) {
      if (!did.startsWith('did:')) {
        throw new Error(`not a DID: ${did}`);
      }
      const row = await requireOwnedSpace(space);
      assertMemberspace(row.policy);
      await spaceStorage.putMember(space, did, access);
      if (!access.write) await spaceStorage.removeSpaceWriter(space, did);
      await commitOwn(
        space,
        await memberRecordWrites(spaceStorage, space, row.policy, did, access),
      );
    },

    async removeMember(space, did) {
      const owner = await requireDid();
      if (did === owner) {
        throw new Error('the owner cannot leave their own space');
      }
      const row = await requireOwnedSpace(space);
      assertMemberspace(row.policy);
      await spaceStorage.removeMember(space, did);
      await spaceStorage.removeSpaceWriter(space, did);
      await commitOwn(
        space,
        await memberRecordWrites(spaceStorage, space, row.policy, did),
      );
    },

    async getRecord(space, collection, rkey) {
      await requireOwnedSpace(space);
      const row = await spaceStorage.getSpaceRecord(space, collection, rkey);
      return row ? /** @type {Object} */ (cborDecodeJson(row.value)) : null;
    },

    async createRecord(space, collection, rkey, record) {
      await writeRecord(space, 'create', collection, rkey, record);
    },

    async putRecord(space, collection, rkey, record) {
      await writeRecord(space, 'put', collection, rkey, record);
    },

    async deleteRecord(space, collection, rkey) {
      await writeRecord(space, 'delete', collection, rkey);
    },
  };

  /**
   * @param {string} space
   * @param {'create'|'put'|'delete'} action
   * @param {string} collection
   * @param {string} rkey
   * @param {Object} [record] - absent on a delete
   * @returns {Promise<void>}
   */
  async function writeRecord(space, action, collection, rkey, record) {
    assertNotPolicyRecord(collection);
    await requireOwnedSpace(space);
    await commitOwn(space, [
      { action, collection, rkey, ...(record ? { record } : {}) },
    ]);
  }

  /**
   * Commit writes to the owner's repo in an owned space.
   * @param {string} space
   * @param {import('./writer.js').SpaceWriteInput[]} writes
   * @returns {Promise<void>}
   */
  async function commitOwn(space, writes) {
    if (writes.length === 0) return;
    const did = await requireDid();
    const commit = await applyWrites(spaceStorage, {
      space,
      writes,
      owner: did,
    });
    // What the write notifier does after an XRPC write into the owner's own
    // space: record the owner in the writer set, so listRepos knows this
    // repo before its first push.
    const data = await new LtHash(commit.setHash).digest();
    await recordWriterUpdate(spaceStorage, {
      space,
      did,
      repoRev: commit.rev,
      data,
    });
  }
}