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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
112 lines · 4.4 KB Raw History
import { defineProcedure, InvalidRequestError } from "$hatk";
import { callGroupHost, provisioningHost } from "../helpers/groupHost.ts";
import { acceptMembership } from "../helpers/membership.ts";
import { POOL_SPACE_TYPE } from "../spaces/client.ts";

// What Grain asks for on a group it starts: to speak for it on Grain. Its
// profile, and answering the pool's submissions, both in the group's own repo.
// The host only grants what Grain's client metadata already declares.
const GROUP_SCOPE = [
  "atproto",
  "repo:social.grain.actor.profile",
  "repo:social.grain.group.item",
  "repo:social.grain.group.decline",
].join(" ");

/**
 * Start a group without leaving Grain.
 *
 * The viewer asks Grain's group host to found it (service auth from their own
 * PDS names them the founder) and Grain authenticates to the host as itself;
 * the host creates the group and hands Grain a session on it, which hatk keeps
 * like any other. Then the group gets what a Grain group is: a pool, opened by
 * the viewer as its founding admin, and a Grain profile, written by Grain as
 * the group.
 */
export default defineProcedure("social.grain.unspecced.createGroup", async (ctx) => {
  const { input, ok, viewer } = ctx;
  if (!viewer) throw new InvalidRequestError("Sign in to start a group");
  const host = await provisioningHost();
  if (!host) throw new InvalidRequestError("This Grain does not start groups", "NotSupported");

  const name = input.name.trim().toLowerCase();
  // A PDS takes 3 to 18 characters before its own domain.
  if (!/^[a-z0-9][a-z0-9-]{1,16}[a-z0-9]$/.test(name))
    throw new InvalidRequestError(
      "Use 3 to 18 letters, numbers and hyphens, not starting or ending with a hyphen",
      "InvalidName",
    );
  const handle = `${name}${host.handleDomain}`;

  const lxm = "fyi.opensocial.provisionGroup";
  const { token } = (await ctx.pds("com.atproto.server.getServiceAuth", {
    params: { aud: host.did, lxm },
  })) as { token: string };
  // What the host says when it refuses is for the log, not the person: they
  // get what they can act on.
  const { did } = await ctx
    .obtainSession(
      `${host.url}/xrpc/${lxm}`,
      {
        handle,
        displayName: input.displayName,
        ...(input.description ? { description: input.description } : {}),
        scope: GROUP_SCOPE,
      },
      { headers: { authorization: `Bearer ${token}` } },
    )
    .catch((err: { errorName?: string; message?: string }) => {
      console.error(`[createGroup] ${handle}: ${err.errorName ?? ""} ${err.message ?? err}`);
      throw /Handle/i.test(err.errorName ?? "")
        ? new InvalidRequestError(
            "That handle is taken or not allowed. Try another one",
            "HandleUnavailable",
          )
        : new InvalidRequestError(
            "The group couldn't be started right now. Try again later",
            "CreateFailed",
          );
    });

  // The pool is a host decision (a space, and who may read it), so it is the
  // founder who asks for it.
  const pool = await callGroupHost(ctx.pds, did, "fyi.opensocial.createSpace", {
    group: did,
    type: POOL_SPACE_TYPE,
    skey: "self",
    name: "Pool",
    readableBy: ["member"],
  });
  if (!pool.ok) {
    console.error(`[createGroup] ${did}: pool ${pool.status} ${pool.body?.message ?? ""}`);
    throw new InvalidRequestError(
      "The group was started, but its pool couldn't be set up. Try again later",
      "PoolFailed",
    );
  }

  // The group's face on Grain, written as the group with the session the
  // host just handed over.
  await ctx.asAccount(did).putRecord("social.grain.actor.profile", "self", {
    displayName: input.displayName,
    ...(input.description ? { description: input.description } : {}),
    createdAt: new Date().toISOString(),
  });

  // The founder's half of membership, so the group is theirs on Grain at once.
  await acceptMembership(ctx.pds, viewer.did, did).catch(() => {});

  // Grain knows a group by its declaration, which reaches the index from the
  // host's firehose moments after the host writes it. The caller goes straight
  // to the group's page, so answer once it is there — or after a few seconds,
  // when the page's own retries take over.
  for (let i = 0; i < 25; i++) {
    const found = await ctx.db.query(
      `SELECT 1 FROM "fyi.opensocial.declaration" WHERE did = $1 LIMIT 1`,
      [did],
    );
    if (found.length) break;
    await new Promise((r) => setTimeout(r, 200));
  }
  return ok({ did, handle });
});