/** * The /account/api/spaces routes: the account's spaces with their members * and record counts, creating one, and managing membership, each behind the * owner's account session. */ /** * @param {Object} ctx * @param {import('@pdsjs/core/ports').ExtensionHost} ctx.host * @param {import('@pdsjs/core/ports').SpaceBrowserPort|null} ctx.spaceBrowser - Absent, the listings report the feature off. * @param {import('@pdsjs/core/ports').SpaceAdminPort|null} ctx.spaceAdmin - Absent, creating a space and editing members refuse. * @returns {Record} */ export function createSpaceAccountRoutes({ host, spaceBrowser, spaceAdmin }) { const { accountApi } = host; /** @param {Request} request @param {URL} url */ async function handleApiSpaces(request, url) { return accountApi(request, url, async () => { const browser = spaceBrowser; if (!browser) return { enabled: false, spaces: [] }; return { enabled: true, spaces: await Promise.all( (await browser.listSpaces()).map(async (space) => ({ uri: space.uri, spaceType: space.spaceType, authority: space.spaceDid, isOwner: space.isOwner, policy: space.policy, createdAt: space.createdAt, deleted: Boolean(space.deletedAt), members: await browser.countMembers(space.uri), collections: await browser.countRecords(space.uri), })), ), }; }); } /** * Resolve a handle to its DID, the two public ways: the well-known * document, then DNS over HTTPS. * @param {string} handle * @returns {Promise} */ async function resolveHandleToDid(handle) { if (!/^[a-z0-9][a-z0-9.-]{0,252}$/i.test(handle)) { throw new Error(`not a handle: ${handle}`); } try { const res = await fetch(`https://${handle}/.well-known/atproto-did`); if (res.ok) { const text = (await res.text()).trim(); if (text.startsWith('did:')) return text; } } catch { // Fall through to DNS. } const res = await fetch( `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, { headers: { accept: 'application/dns-json' } }, ); if (res.ok) { const body = /** @type {{Answer?: Array<{data: string}>}} */ ( await res.json() ); for (const answer of body.Answer ?? []) { const text = answer.data.replace(/^"|"$/g, ''); if (text.startsWith('did=did:')) return text.slice(4); } } throw new Error(`could not resolve ${handle} to a DID`); } /** @param {Request} request @param {URL} url */ async function handleApiSpacesCreate(request, url) { return accountApi( request, url, async (_did, body) => { if (!spaceAdmin) throw new Error('Spaces are not enabled.'); const type = typeof body.type === 'string' ? body.type.trim() : ''; if (!type) throw new Error('A space type is required.'); const skey = typeof body.skey === 'string' && body.skey.trim() ? body.skey.trim() : undefined; return await spaceAdmin.createSpace({ type, skey }); }, { write: true }, ); } /** @param {Request} request @param {URL} url */ async function handleApiSpaceMembers(request, url) { return accountApi(request, url, async () => { if (!spaceBrowser) return { enabled: false, members: [] }; const space = url.searchParams.get('space'); if (!space) throw new Error('space parameter required'); return { enabled: true, members: await spaceBrowser.listMembers(space) }; }); } /** @param {Request} request @param {URL} url */ async function handleApiSpaceMembersPut(request, url) { return accountApi( request, url, async (_did, body) => { if (!spaceAdmin) throw new Error('Spaces are not enabled.'); const space = typeof body.space === 'string' ? body.space : ''; const actor = typeof body.actor === 'string' ? body.actor.trim() : ''; if (!space || !actor) { throw new Error('A space and a handle or DID are required.'); } const memberDid = actor.startsWith('did:') ? actor : await resolveHandleToDid(actor); // A member the page adds without saying reads and writes, which is what // membership means under the default policy on both sides. const access = { read: body.read === undefined ? true : body.read === true, write: body.write === undefined ? true : body.write === true, }; await spaceAdmin.putMember(space, memberDid, access); return { did: memberDid, ...access }; }, { write: true }, ); } /** @param {Request} request @param {URL} url */ async function handleApiSpaceMembersRemove(request, url) { return accountApi( request, url, async (_did, body) => { if (!spaceAdmin) throw new Error('Spaces are not enabled.'); const space = typeof body.space === 'string' ? body.space : ''; const memberDid = typeof body.did === 'string' ? body.did : ''; if (!space || !memberDid) { throw new Error('A space and a member DID are required.'); } await spaceAdmin.removeMember(space, memberDid); return {}; }, { write: true }, ); } return { '/account/api/spaces': { method: 'GET', handler: handleApiSpaces, }, '/account/api/spaces/create': { method: 'POST', handler: handleApiSpacesCreate, }, '/account/api/spaces/members': { method: 'GET', handler: handleApiSpaceMembers, }, '/account/api/spaces/members/put': { method: 'POST', handler: handleApiSpaceMembersPut, }, '/account/api/spaces/members/add': { method: 'POST', handler: handleApiSpaceMembersPut, }, '/account/api/spaces/members/remove': { method: 'POST', handler: handleApiSpaceMembersRemove, }, }; }