~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
179 lines · 6.0 KB Raw History
/**
 * 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,
    },
  };
}