chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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<string, import('@pdsjs/core/ports').ExtensionRoute>}
*/
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<string>}
*/
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,
},
};
}