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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
150 lines · 5.7 KB Raw History
// Which groups the viewer is in, and the viewer's half of being in one.
//
// A group's roster is members-only: it lives in the group's members
// space, which grain cannot index and a stranger cannot read. What grain can
// read is the viewer's own side. Membership is bidirectional — the group
// writes a `membership`, the member writes an `acceptance` into the same space,
// in their own repo — so the viewer's PDS holds a repo in the members space of
// every group they have accepted, and `listSpaces` on that PDS lists them.
// Private, and no probing every group on the network.
//
// That list is only candidates. A PDS keeps listing a space after the
// acceptance in it is deleted — the repo stays, empty — and it stores an
// acceptance from someone the group has not admitted, because the host is
// told about the write only afterwards and refuses it there. So every
// candidate is confirmed the way the host decides it: a credential for the
// members space, which only members may read.

import type { PdsCall, SpaceReader } from "../spaces/client.ts";

export const MEMBERS_SPACE_TYPE = "fyi.opensocial.members";
const ACCEPTANCE = "fyi.opensocial.acceptance";

/** `at://<group>/space/fyi.opensocial.members/self`. */
export function membersSpaceOf(group: string): string {
  return `at://${group}/space/${MEMBERS_SPACE_TYPE}/self`;
}

/** Groups the viewer's PDS lists a members-space repo for. Unconfirmed. */
export async function candidateGroups(pds: PdsCall): Promise<string[]> {
  const out = new Set<string>();
  let cursor: string | undefined;
  do {
    const res = (await pds("com.atproto.space.listSpaces", {
      // `spaceType` is the reference PDS's name for the filter, `type` pds.js's.
      params: {
        spaceType: MEMBERS_SPACE_TYPE,
        type: MEMBERS_SPACE_TYPE,
        limit: 100,
        ...(cursor ? { cursor } : {}),
      },
    })) as { spaces?: { uri: string }[]; cursor?: string };
    for (const s of res.spaces ?? []) {
      const did = /^at:\/\/(did:[^/]+)\/space\//.exec(s.uri)?.[1];
      if (did) out.add(did);
    }
    cursor = res.cursor;
  } while (cursor);
  return [...out];
}

type Db = { query: (sql: string, params?: unknown[]) => Promise<unknown[]> };

/**
 * Whether this account is a group itself.
 *
 * A group is not a member of groups, and whoever signs in as one is granted
 * none of what asking would take: its host withholds the members-space scope,
 * because a group's roles confer nothing in other groups. Asking anyway is
 * worse than a no — a PDS answers a missing scope with ScopeMissingError, and
 * hatk ends the session on that, signing the group out of Grain on the first
 * page that looks.
 */
export async function isGroup(db: Db, did: string): Promise<boolean> {
  const rows = await db.query(
    `SELECT 1 AS v FROM "fyi.opensocial.declaration" WHERE did = $1 LIMIT 1`,
    [did],
  );
  return rows.length > 0;
}

// A refusal is remembered briefly, so a page reloaded by a non-member does not
// mint a delegation token each time. A yes needs no cache of its own:
// hatk already holds the credential.
const refused = new Map<string, number>();
const REFUSED_TTL_MS = 2 * 60_000;

/**
 * Whether the host admits the viewer to this group's members space.
 * `fresh` skips a remembered refusal: the group's own page is where someone
 * admitted elsewhere turns up, and it should not tell them no for two minutes.
 */
export async function isMember(
  db: Db,
  reader: SpaceReader,
  viewerDid: string,
  group: string,
  opts: { fresh?: boolean } = {},
): Promise<boolean> {
  if (await isGroup(db, viewerDid)) return false;
  const key = `${viewerDid} ${group}`;
  if (!opts.fresh && Date.now() - (refused.get(key) ?? 0) < REFUSED_TTL_MS) return false;
  try {
    await reader.spaceCredential(membersSpaceOf(group));
    refused.delete(key);
    return true;
  } catch {
    refused.set(key, Date.now());
    return false;
  }
}

/** The groups the viewer is in: their PDS's candidates, each confirmed with the host. */
export async function groupsOf(db: Db, reader: SpaceReader, viewerDid: string): Promise<string[]> {
  if (await isGroup(db, viewerDid)) return [];
  const candidates = await candidateGroups(reader.pds);
  const confirmed = await Promise.all(candidates.map((g) => isMember(db, reader, viewerDid, g)));
  return candidates.filter((_, i) => confirmed[i]);
}

/** Forget a refusal, after something that may have changed the answer. */
export function forgetRefusal(viewerDid: string, group: string) {
  refused.delete(`${viewerDid} ${group}`);
}

/** After leaving: the credential still cached would say they are in. */
export function forgetMembership(
  reader: { forgetSpaceCredential: (space: string) => void },
  viewerDid: string,
  group: string,
) {
  reader.forgetSpaceCredential(membersSpaceOf(group));
  refused.set(`${viewerDid} ${group}`, Date.now());
}

/**
 * Write the viewer's acceptance. Only for someone already confirmed a member:
 * their PDS would store it either way, and an acceptance nobody admitted is a
 * candidate that never confirms.
 */
export async function acceptMembership(pds: PdsCall, viewerDid: string, group: string) {
  await pds("com.atproto.space.putRecord", {
    method: "POST",
    body: {
      space: membersSpaceOf(group),
      repo: viewerDid,
      collection: ACCEPTANCE,
      rkey: "self",
      validate: false,
      record: { $type: ACCEPTANCE, createdAt: new Date().toISOString() },
    },
  });
}

/** Take the acceptance back. Before leaving: once out, the space refuses the delete. */
export async function withdrawAcceptance(pds: PdsCall, viewerDid: string, group: string) {
  await pds("com.atproto.space.deleteRecord", {
    method: "POST",
    body: { space: membersSpaceOf(group), repo: viewerDid, collection: ACCEPTANCE, rkey: "self" },
  });
}