// Groups: what a group looks like from grain's side. // // A group is an ordinary atproto account that a group host has declared. // The declaration is the one public record, and it reaches grain through the // index. The profile and rules live in the group's meta space, which // grain reads as its host lists it; the roster lives in the members space, // which grain never sees — the viewer's own PDS says which groups they are in. // // The declaration is read from its table by name. That is deliberate: the // namespace moved from `group.opensocial.*` on 2026-09-16, the table names // follow the NSIDs, and a rename that misses one fails silently — an index that // matches nothing lists no groups, which reads as "none yet" rather than as a // fault. These tests are what makes that loud. import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { startTestServer } from "@hatk/hatk/test"; import { acceptMembership, candidateGroups, groupsOf, membersSpaceOf, withdrawAcceptance, } from "../server/helpers/membership.ts"; const CLUB = "did:plc:club"; const QUIET = "did:plc:quiet"; const ALICE = "did:plc:alice"; const BOB = "did:plc:bob"; let server: Awaited>; /** Declare `did` a group, as its host's one public record would. */ async function declareGroup(db: any, did: string, opts: { handle: string; createdAt?: string }) { await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, 'active', $2)`, [ did, opts.handle, ]); await db.run( `INSERT INTO "fyi.opensocial.declaration" (uri, cid, did, indexed_at, meta, created_at) VALUES ($1, $2, $3, 'i', $4, $5)`, [ `at://${did}/fyi.opensocial.declaration/self`, `cid-decl-${did}`, did, `at://${did}/space/fyi.opensocial.meta/self`, opts.createdAt ?? "2026-09-01T00:00:00Z", ], ); } /** What the group's host lists about it: the public half of its meta space. */ const LISTING = [ { did: "did:plc:club", handle: "club.test", displayName: "The Club", description: "A club.", joinPolicy: "fyi.opensocial.profile#request", url: "https://club.example", rules: [ { rkey: "1", title: "Be kind", text: "Because." }, { rkey: "2", title: "Ride safe", text: "Because." }, ], }, ]; /** A gallery with one photo in it, so the feeds' "not empty" rule passes. */ async function gallery(db: any, did: string, id: string, createdAt: string) { const uri = `at://${did}/social.grain.gallery/${id}`; await db.run( `INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, created_at) VALUES ($1, $2, $3, 'i', $4, $5)`, [uri, `cid-${id}`, did, id, createdAt], ); await db.run( `INSERT INTO "social.grain.gallery.item" (uri, cid, did, indexed_at, gallery, item, position, created_at) VALUES ($1, $2, $3, 'i', $4, $5, 0, $6)`, [ `at://${did}/social.grain.gallery.item/${id}`, `cid-item-${id}`, did, uri, `at://${did}/social.grain.photo/${id}`, createdAt, ], ); return uri; } /** The group's record that a gallery is in its pool. */ async function pool(db: any, groupDid: string, galleryUri: string, id: string, createdAt: string) { await db.run( `INSERT INTO "social.grain.group.item" (uri, cid, did, indexed_at, gallery, created_at) VALUES ($1, $2, $3, 'i', $4, $5)`, [ `at://${groupDid}/social.grain.group.item/${id}`, `cid-gi-${id}`, groupDid, galleryUri, createdAt, ], ); } /** The groups feed, as bare gallery ids. */ async function groupsFeed(as?: string) { const path = "/xrpc/dev.hatk.getFeed?feed=groups"; const res = as ? await server.fetchAs(as, path) : await server.fetch(path); expect(res.status).toBe(200); return ((await res.json()).items ?? []).map((i: any) => i.uri.split("/").pop()); } const listGroups = async (as?: string) => { const path = "/xrpc/social.grain.unspecced.listGroups"; const res = as ? await server.fetchAs(as, path) : await server.fetch(path); return (await res.json()).groups as any[]; }; const getGroup = async (actor: string, as?: string) => { const path = `/xrpc/social.grain.unspecced.getGroup?actor=${encodeURIComponent(actor)}`; return as ? await server.fetchAs(as, path) : await server.fetch(path); }; beforeAll(async () => { server = await startTestServer(); const { db } = server; await declareGroup(db, CLUB, { handle: "club.test", createdAt: "2026-09-01T00:00:00Z" }); await declareGroup(db, QUIET, { handle: "quiet.test", createdAt: "2026-09-03T00:00:00Z" }); for (const did of [ALICE, BOB]) { await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, 'active', $2)`, [ did, `${did.split(":").pop()}.test`, ]); } // Two galleries in the club's pool. Grain cannot see the roster, so the // pool is whatever the group itself put in it. const byAlice = await gallery(db, ALICE, "ga", "2026-09-05T00:00:00Z"); const byBob = await gallery(db, BOB, "gb", "2026-09-06T00:00:00Z"); await pool(db, CLUB, byAlice, "pa", "2026-09-05T01:00:00Z"); await pool(db, CLUB, byBob, "pb", "2026-09-06T01:00:00Z"); }); describe("the groups feed", () => { test("carries every gallery the group has pooled", async () => { expect((await groupsFeed()).sort()).toEqual(["ga", "gb"]); }); }); // `groupListing` asks each group's host what it lists, and // `resolveGroupActor` may resolve a DID. Both hosts are stood in for here: the // PLC directory names the club's PDS, the PDS names itself, and it lists the // club. Anything else outbound is refused; the harness drives the server over // loopback through the same global, so those calls pass through. const realFetch = globalThis.fetch; const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); beforeEach(() => { vi.stubGlobal( "fetch", vi.fn(async (input: any, init?: any) => { const url = typeof input === "string" ? input : (input?.url ?? String(input)); // A group's DID document, from whichever PLC directory is asked. const path = decodeURIComponent(url); if (path.endsWith(`/${CLUB}`) || path.endsWith(`/${QUIET}`)) { return json({ service: [{ id: "#atproto_pds", serviceEndpoint: "https://host.example" }] }); } if (/^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])[:/]/.test(url)) { return realFetch(input, init); } if (url === "https://host.example/.well-known/did.json") { return json({ id: "did:web:host.example" }); } if (url === "https://host.example/xrpc/fyi.opensocial.listGroups") { return json({ groups: LISTING }); } // The host admits Alice to the club's members space and nobody else. A // request without a delegation token is told the space does not exist. if (url === "https://host.example/xrpc/com.atproto.space.getSpaceCredential") { const { space, credentialKid, delegationToken } = JSON.parse(init?.body ?? "{}"); if (!credentialKid) { return new Response(JSON.stringify({ error: "InvalidRequest" }), { status: 400 }); } if (!delegationToken) { return new Response(JSON.stringify({ error: "SpaceNotFound" }), { status: 400 }); } return space === membersSpaceOf(CLUB) && delegationToken === `token-${ALICE}` ? json({ credential: "cred" }) : new Response(JSON.stringify({ error: "UserNotAuthorized" }), { status: 400 }); } throw new Error(`refused outbound fetch: ${url}`); }), ); }); afterAll(async () => { vi.unstubAllGlobals(); await server.close(); }); describe("listGroups", () => { test("lists every declared group", async () => { const groups = await listGroups(); expect(groups.map((g) => g.did).sort()).toEqual([CLUB, QUIET].sort()); }); test("carries the group's profile as its host lists it", async () => { const club = (await listGroups()).find((g) => g.did === CLUB); expect(club.displayName).toBe("The Club"); expect(club.handle).toBe("club.test"); expect(club.joinPolicy).toBe("request"); // How a third-party app finds the group's own site. expect(club.url).toBe("https://club.example"); }); test("says nothing about the roster", async () => { // Who belongs is members-only; there is no count to give. const club = (await listGroups()).find((g) => g.did === CLUB); expect(club).not.toHaveProperty("memberCount"); }); test("carries the rules in order, addressed in the meta space", async () => { const club = (await listGroups()).find((g) => g.did === CLUB); expect(club.rules.map((r: any) => r.title)).toEqual(["Be kind", "Ride safe"]); // The address a moderation label cites. expect(club.rules[0].uri).toBe( `at://${CLUB}/space/fyi.opensocial.meta/self/${CLUB}/fyi.opensocial.rule/1`, ); }); test("a group its host does not list shows by handle", async () => { const quiet = (await listGroups()).find((g) => g.did === QUIET); expect(quiet.handle).toBe("quiet.test"); expect(quiet.displayName).toBeUndefined(); }); test("a group with an empty pool still lists", async () => { // Otherwise nobody could ever submit the first gallery to one. expect((await listGroups()).some((g) => g.did === QUIET)).toBe(true); }); }); describe("a profile edited on the host", () => { test("reaches whoever is acting as the group at once, and everyone else later", async () => { await listGroups(); // warm the cache const club = LISTING.find((g) => g.did === CLUB)!; const before = club.url; club.url = "https://moved.example"; try { expect((await listGroups()).find((g) => g.did === CLUB).url).toBe(before); expect((await listGroups(CLUB)).find((g) => g.did === CLUB).url).toBe( "https://moved.example", ); // The fresh read refills the cache for everyone. expect((await listGroups()).find((g) => g.did === CLUB).url).toBe("https://moved.example"); } finally { club.url = before; await listGroups(CLUB); // and put the cache back } }); }); describe("viewer state", () => { test("a viewer whose PDS cannot be asked is not taken for a member", async () => { // No PDS stands behind a test session, so listSpaces fails — and a // failure is "no groups", never an error on the page. const club = (await listGroups(ALICE)).find((g) => g.did === CLUB); expect(club.viewer?.member ?? false).toBe(false); }); test("a signed-out viewer has no membership either way", async () => { const club = (await listGroups()).find((g) => g.did === CLUB); expect(club.viewer?.member ?? false).toBe(false); }); }); describe("getGroup", () => { test("resolves a group by DID", async () => { const res = await getGroup(CLUB); expect(res.status).toBe(200); expect((await res.json()).displayName).toBe("The Club"); }); test("resolves a group by handle", async () => { const res = await getGroup("club.test"); expect(res.status).toBe(200); expect((await res.json()).did).toBe(CLUB); }); test("an account that is not a group is not a group", async () => { // Alice has a repo and a handle; what she does not have is a declaration. expect((await getGroup(ALICE)).status).toBe(400); }); test("an unknown actor is not a group", async () => { expect((await getGroup("nobody.test")).status).toBe(400); }); }); // Which groups a viewer is in: candidates from their own PDS, each confirmed // with the group's host. Both halves matter — a PDS lists a members space // after the acceptance in it is gone, and stores one nobody admitted. describe("groupsOf", () => { /** A viewer's PDS: `listSpaces` pages, and delegation tokens naming them. */ const pdsFor = (viewer: string, pages: { spaces: { uri: string }[]; cursor?: string }[]) => { const listCalls: any[] = []; const pds = vi.fn(async (nsid: string, opts?: any) => { if (nsid === "com.atproto.space.getDelegationToken") return { token: `token-${viewer}` }; listCalls.push({ nsid, ...opts }); return pages[listCalls.length - 1] ?? { spaces: [] }; }); // The viewer's side of the exchange, against the host stood in for above. const reader = { pds, spaceCredential: async (space: string) => { const { token } = (await pds("com.atproto.space.getDelegationToken")) as { token: string; }; const res = await fetch("https://host.example/xrpc/com.atproto.space.getSpaceCredential", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ space, credentialKid: "did:key:zDnaeExample", delegationToken: token, }), }); if (!res.ok) throw Object.assign(new Error("refused"), { status: res.status }); return { fetch }; }, }; return { pds, reader, listCalls }; }; test("asks the viewer's PDS for their members spaces", async () => { const { pds, listCalls } = pdsFor(ALICE, [{ spaces: [{ uri: membersSpaceOf(CLUB) }] }]); expect(await candidateGroups(pds)).toEqual([CLUB]); expect(listCalls[0]).toMatchObject({ nsid: "com.atproto.space.listSpaces", params: { spaceType: "fyi.opensocial.members", type: "fyi.opensocial.members" }, }); }); test("follows the cursor", async () => { const { pds } = pdsFor(ALICE, [ { spaces: [{ uri: membersSpaceOf(CLUB) }], cursor: "c1" }, { spaces: [{ uri: membersSpaceOf(QUIET) }] }, ]); expect(await candidateGroups(pds)).toEqual([CLUB, QUIET]); }); test("keeps only the candidates the host admits", async () => { const { reader } = pdsFor(ALICE, [ { spaces: [{ uri: membersSpaceOf(CLUB) }, { uri: membersSpaceOf(QUIET) }] }, ]); expect(await groupsOf(server.db, reader, ALICE)).toEqual([CLUB]); }); test("drops an acceptance the host never admitted", async () => { // Bob wrote one into the club's members space; his PDS lists it anyway. const { reader } = pdsFor(BOB, [{ spaces: [{ uri: membersSpaceOf(CLUB) }] }]); expect(await groupsOf(server.db, reader, BOB)).toEqual([]); }); test("never asks when the viewer is a group", async () => { // Signed in as a group, the grant has no members-space scope — its host // withholds it — so the question could only be refused. const { pds, reader, listCalls } = pdsFor(CLUB, [{ spaces: [{ uri: membersSpaceOf(QUIET) }] }]); expect(await groupsOf(server.db, reader, CLUB)).toEqual([]); expect(listCalls).toEqual([]); expect(pds).not.toHaveBeenCalled(); }); }); describe("acceptance", () => { test("is written into the members space, in the viewer's own repo", async () => { const pds = vi.fn(async () => ({})); await acceptMembership(pds, ALICE, CLUB); expect(pds).toHaveBeenCalledWith("com.atproto.space.putRecord", { method: "POST", body: expect.objectContaining({ space: membersSpaceOf(CLUB), repo: ALICE, collection: "fyi.opensocial.acceptance", rkey: "self", }), }); }); test("is withdrawn from the same place", async () => { const pds = vi.fn(async () => ({})); await withdrawAcceptance(pds, ALICE, CLUB); expect(pds).toHaveBeenCalledWith("com.atproto.space.deleteRecord", { method: "POST", body: { space: membersSpaceOf(CLUB), repo: ALICE, collection: "fyi.opensocial.acceptance", rkey: "self", }, }); }); });