chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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 }] : [];
}