0
2
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 });
});