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