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