// @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} */ (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} */ 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} */ 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} */ 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} */ async function deleteIfExists(storage, space, collection, rkey) { const cid = await storage.getSpaceRecordCid(space, collection, rkey); return cid ? [{ action: 'delete', collection, rkey }] : []; }