// @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.simplespace.* does. import { cborDecode, createTid } from '@pdsjs/core/repo'; import { LtHash } from './lthash.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 { 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}`); } await spaceStorage.putSpace(makeSpaceRow(uri, { isOwner: true })); // The owner joins their own space with both rights: a member-list space // checks membership before it issues a credential, and that is the // default policy. await spaceStorage.putMember(uri, did, { read: true, write: true }); return { uri }; }, async putMember(space, did, access) { if (!did.startsWith('did:')) { throw new Error(`not a DID: ${did}`); } await requireOwnedSpace(space); await spaceStorage.putMember(space, did, access); }, async removeMember(space, did) { const owner = await requireDid(); if (did === owner) { throw new Error('the owner cannot leave their own space'); } await requireOwnedSpace(space); await spaceStorage.removeMember(space, did); }, async getRecord(space, collection, rkey) { await requireOwnedSpace(space); const row = await spaceStorage.getSpaceRecord(space, collection, rkey); return row ? /** @type {Object} */ (cborDecode(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) { const did = await requireDid(); await requireOwnedSpace(space); const commit = await applyWrites(spaceStorage, { space, writes: [{ action, collection, rkey, ...(record ? { record } : {}) }], }); // What fireNotifyWrite 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 hash = await new LtHash(commit.setHash).digest(); await recordWriterUpdate(spaceStorage, { space, did, repoRev: commit.rev, hash, }); } }