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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
272 lines · 8.3 KB Raw History
// @pdsjs/spaces/policy - the baseline space policies.
//
// A space's `policy` is an open union. Its `$type` selects the policy that
// governs the space, and its other fields configure that policy. Every PDS
// implements the three baseline policies, and this host implements no other.

export const PERSONALSPACE = 'com.atproto.personalspace.policy';
export const MEMBERSPACE = 'com.atproto.memberspace.policy';
export const MANAGEDSPACE = 'com.atproto.managedspace.policy';

// The collections the authority's PDS publishes a space's configuration to,
// in the authority's own repo in the space.
export const POLICY_COLLECTION = 'com.atproto.space.policy';
export const POLICY_RKEY = 'self';
export const MEMBER_COLLECTION = 'com.atproto.memberspace.member';
export const PUBLISHED_COLLECTIONS = [POLICY_COLLECTION, MEMBER_COLLECTION];

/** @typedef {import('@pdsjs/core/ports').SpacePolicy} SpacePolicy */

export class SpacePolicyError extends Error {
  /**
   * @param {string} message
   * @param {string} code - the XRPC error name
   */
  constructor(message, code = 'InvalidRequest') {
    super(message);
    this.name = 'SpacePolicyError';
    this.code = code;
  }
}

/**
 * @param {unknown} value
 * @param {string} field
 * @returns {boolean}
 */
function flag(value, field) {
  if (value === undefined || value === null) return false;
  if (typeof value !== 'boolean') {
    throw new SpacePolicyError(`${field} must be a boolean`);
  }
  return value;
}

/**
 * Check that this host implements a requested policy, and fill in its
 * defaults, so that the stored policy is the enforced policy. Unknown fields
 * are dropped, as Lexicon evolution allows.
 *
 * @param {unknown} input
 * @returns {SpacePolicy}
 * @throws {SpacePolicyError}
 */
export function normalizePolicy(input) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
    throw new SpacePolicyError('policy is required');
  }
  const policy = /** @type {Record<string, unknown>} */ (input);
  if (policy.$type === PERSONALSPACE) {
    return { $type: PERSONALSPACE };
  }
  if (policy.$type === MEMBERSPACE) {
    const { allowedApps } = policy;
    if (
      allowedApps !== undefined &&
      (!Array.isArray(allowedApps) ||
        allowedApps.some((app) => typeof app !== 'string'))
    ) {
      throw new SpacePolicyError('allowedApps must be a list of client IDs');
    }
    return {
      $type: MEMBERSPACE,
      publicRead: flag(policy.publicRead, 'publicRead'),
      publicWrite: flag(policy.publicWrite, 'publicWrite'),
      ...(allowedApps ? { allowedApps: [...allowedApps] } : {}),
      publishPolicy: flag(policy.publishPolicy, 'publishPolicy'),
      publishMembers: flag(policy.publishMembers, 'publishMembers'),
    };
  }
  if (policy.$type === MANAGEDSPACE) {
    const { managingApp } = policy;
    if (typeof managingApp !== 'string' || !managingApp.startsWith('did:')) {
      throw new SpacePolicyError(
        'managingApp must be a DID with an optional service fragment',
      );
    }
    return {
      $type: MANAGEDSPACE,
      managingApp,
      publishPolicy: flag(policy.publishPolicy, 'publishPolicy'),
    };
  }
  throw new SpacePolicyError(
    `Unsupported policy: ${String(policy.$type ?? '(none)')}`,
    'UnsupportedPolicy',
  );
}

/**
 * The member list and the methods that manage it belong to the memberspace
 * policy.
 * @param {SpacePolicy|null} policy
 * @returns {void}
 * @throws {SpacePolicyError}
 */
export function assertMemberspace(policy) {
  if (policy?.$type !== MEMBERSPACE) {
    throw new SpacePolicyError(
      'Space is not governed by the memberspace policy',
      'PolicyMismatch',
    );
  }
}

/**
 * Whether the policy authorizes any user to read. Only then is a credential
 * issued without a delegation token naming the user.
 * @param {SpacePolicy|null} policy
 * @returns {boolean}
 */
export function isPublicRead(policy) {
  return policy?.$type === MEMBERSPACE && policy.publicRead === true;
}

/**
 * @param {SpacePolicy|null} policy
 * @returns {boolean}
 */
export function isPolicyPublished(policy) {
  return (
    (policy?.$type === MEMBERSPACE || policy?.$type === MANAGEDSPACE) &&
    policy.publishPolicy === true
  );
}

/**
 * @param {SpacePolicy|null} policy
 * @returns {boolean}
 */
export function areMembersPublished(policy) {
  return policy?.$type === MEMBERSPACE && policy.publishMembers === true;
}

/**
 * Only memberspace restricts apps. The check uses the attested client_id, so
 * an app that presents no client attestation is never an allowed app.
 * @param {SpacePolicy|null} policy
 * @param {string} [clientId]
 * @returns {boolean}
 */
export function isAppAuthorized(policy, clientId) {
  if (policy?.$type !== MEMBERSPACE) return true;
  const allowed = /** @type {string[]|undefined} */ (policy.allowedApps);
  if (!allowed) return true;
  return clientId !== undefined && allowed.includes(clientId);
}

/**
 * The PDS writes the policy and member records itself, so a direct write to
 * one is refused in any repo.
 * @param {string} collection
 * @returns {void}
 * @throws {SpacePolicyError}
 */
export function assertNotPolicyRecord(collection) {
  if (PUBLISHED_COLLECTIONS.includes(collection)) {
    throw new SpacePolicyError(
      `${collection} records are published by the PDS from a space's configuration and cannot be written directly`,
    );
  }
}

/**
 * A member's DID as the key of its published record. A `did:web` with a port
 * percent-encodes the colon, which a record key cannot hold.
 * @param {string} did
 * @returns {string}
 */
export function didToRecordKey(did) {
  return did.replace('%3A', ':');
}

/**
 * The writes that publish, update or withdraw the policy record to match the
 * policy.
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} storage
 * @param {string} space
 * @param {SpacePolicy} policy
 * @returns {Promise<import('./writer.js').SpaceWriteInput[]>}
 */
export async function policyRecordWrites(storage, space, policy) {
  if (isPolicyPublished(policy)) {
    return [
      {
        action: 'put',
        collection: POLICY_COLLECTION,
        rkey: POLICY_RKEY,
        record: { $type: POLICY_COLLECTION, policy },
      },
    ];
  }
  return deleteIfExists(storage, space, POLICY_COLLECTION, POLICY_RKEY);
}

/**
 * The writes that publish, update or withdraw one member's record to match
 * the member list. `access` is absent for a DID that is no longer a member.
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} storage
 * @param {string} space
 * @param {SpacePolicy|null} policy
 * @param {string} did
 * @param {import('@pdsjs/core/ports').SpaceMemberAccess} [access]
 * @returns {Promise<import('./writer.js').SpaceWriteInput[]>}
 */
export async function memberRecordWrites(storage, space, policy, did, access) {
  const rkey = didToRecordKey(did);
  if (access && areMembersPublished(policy)) {
    return [
      {
        action: 'put',
        collection: MEMBER_COLLECTION,
        rkey,
        record: {
          $type: MEMBER_COLLECTION,
          read: access.read,
          write: access.write,
        },
      },
    ];
  }
  return deleteIfExists(storage, space, MEMBER_COLLECTION, rkey);
}

/**
 * The writes that publish or withdraw the whole member list to match the
 * policy.
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} storage
 * @param {string} space
 * @param {SpacePolicy} policy
 * @returns {Promise<import('./writer.js').SpaceWriteInput[]>}
 */
export async function memberListWrites(storage, space, policy) {
  /** @type {import('./writer.js').SpaceWriteInput[]} */
  const writes = [];
  /** @type {string|null} */
  let cursor = null;
  do {
    const listed = await storage.listMembers(space, cursor, 500);
    for (const member of listed.members) {
      writes.push(
        ...(await memberRecordWrites(storage, space, policy, member.did, {
          read: member.read,
          write: member.write,
        })),
      );
    }
    cursor = listed.cursor;
  } while (cursor);
  return writes;
}

/**
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} storage
 * @param {string} space
 * @param {string} collection
 * @param {string} rkey
 * @returns {Promise<import('./writer.js').SpaceWriteInput[]>}
 */
async function deleteIfExists(storage, space, collection, rkey) {
  const cid = await storage.getSpaceRecordCid(space, collection, rkey);
  return cid ? [{ action: 'delete', collection, rkey }] : [];
}