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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
318 lines · 11.2 KB Raw History
// Reading and writing a permissioned space (proposal 0016) on behalf of a viewer.
//
// A space's records never reach a firehose, so none of this is indexed: a
// private gallery is assembled by reading the repos that hold it, at request
// time, from the hosts that hold them. That is the role the PDS deliberately
// does not play.
//
// Two ways in, and which one applies depends only on who is asking:
//
//   the author   — their own session, proxied through ctx.pds, because the
//                  records are in their own repo on their own PDS
//   a reader     — a space credential: hatk trades their PDS session for a
//                  delegation token and presents that to the authority
//                  (`ctx.spaceCredential`)
//
// The credential is signed by the authority, so every repo host in the space
// verifies it without contacting them.

import config from "../../hatk.config.ts";

export const GALLERY_SPACE_TYPE = "social.grain.privateGallery";

/**
 * A group's pool of galleries, as a space of its own.
 *
 * Not a gallery space: the authority is the group rather than an author,
 * every member writes into it, and there is one of it — so the skey is fixed
 * and the uri follows from the group's DID. Its type is declared at
 * `social.grain.group`, which is also what a member's PDS resolves when it is
 * asked for the scope to write here.
 */
export const POOL_SPACE_TYPE = "social.grain.group";

/** `at://<group>/space/social.grain.group/self` — the pool a group keeps. */
export function poolUri(group: string): string {
  return `at://${group}/space/${POOL_SPACE_TYPE}/self`;
}

/** Call an XRPC method on the viewer's own PDS. Shape of hatk's `ctx.pds`. */
export type PdsCall = (
  nsid: string,
  options?: {
    method?: "GET" | "POST";
    params?: Record<string, string | number | boolean | string[] | undefined>;
    body?: unknown;
  },
) => Promise<Record<string, unknown>>;

/** A space credential as hatk hands it over: a fetch that presents it. */
export interface SpaceCredential {
  fetch: (input: string | URL, init?: RequestInit) => Promise<Response>;
}

/** What a space read needs from hatk's handler context, as the viewer. */
export interface SpaceReader {
  pds: PdsCall;
  spaceCredential: (space: string, opts?: { refresh?: boolean }) => Promise<SpaceCredential>;
}

export class SpaceError extends Error {
  // Assigned in the body rather than declared as a constructor parameter
  // property: the server runs this TypeScript through Node's type stripping,
  // which erases annotations and cannot emit the assignment a parameter
  // property implies.
  status: number;

  constructor(status: number, message: string) {
    super(message);
    this.status = status;
  }
}

/** `at://<authority>/space/<type>/<skey>` — the space a private gallery is. */
export function spaceUri(authority: string, skey: string): string {
  return `at://${authority}/space/${GALLERY_SPACE_TYPE}/${skey}`;
}

/** The authority DID and skey named by a space URI. */
export function parseSpaceUri(uri: string): { authority: string; skey: string } {
  const match = uri.match(/^at:\/\/([^/]+)\/space\/([^/]+)\/([^/]+)$/);
  if (!match) throw new SpaceError(400, `Not a space uri: ${uri}`);
  return { authority: match[1], skey: match[3] };
}

// --- A gallery's space and its readers, on the author's own PDS ----------------
//
// Three servers serve these, under two sets of names. The reference
// `@atproto/pds` alpha, since 0.0.0-spaces-alpha-20261029171913, creates a
// space with com.atproto.space.createSpace and keeps its member list under
// com.atproto.memberspace. pds.js and zds still answer to
// com.atproto.simplespace, with the proposal's shape (see AGENTS.md, "The second
// implementation"). Each call asks the reference name first and the older one
// when the server says it has no such method.

type PdsAttempt = { nsid: string } & NonNullable<Parameters<PdsCall>[1]>;

/** A server's way of saying it serves no such method: pds.js's 501, zds's 404. */
function notServed(err: unknown): boolean {
  const status = (err as { status?: unknown })?.status;
  return status === 404 || status === 501;
}

/** The first of `attempts` the PDS serves. Any other failure is the answer. */
async function firstServed(pds: PdsCall, attempts: PdsAttempt[]): Promise<Record<string, unknown>> {
  for (const [i, { nsid, ...options }] of attempts.entries()) {
    try {
      return await pds(nsid, options);
    } catch (err) {
      if (i === attempts.length - 1 || !notServed(err)) throw err;
    }
  }
  throw new Error("no attempts");
}

/** Make the space a private gallery is, anchored on its author, readable by its member list. */
export function createGallerySpace(pds: PdsCall, author: string, skey: string) {
  return firstServed(pds, [
    {
      nsid: "com.atproto.space.createSpace",
      method: "POST",
      body: {
        spaceType: GALLERY_SPACE_TYPE,
        skey,
        policy: { $type: "com.atproto.memberspace.policy" },
      },
    },
    {
      // `did` names the authority: pds.js infers it from the session, zds
      // requires it. The config is nested, with the policy as a bare string,
      // the one spelling both read.
      nsid: "com.atproto.simplespace.createSpace",
      method: "POST",
      body: {
        did: author,
        type: GALLERY_SPACE_TYPE,
        skey,
        config: {
          policy: "member-list",
          appAccess: { $type: "com.atproto.simplespace.defs#open" },
        },
      },
    },
  ]);
}

/** Let `did` read a gallery. Only its author writes, so a member never does. */
export function addGalleryReader(pds: PdsCall, space: string, did: string) {
  return firstServed(pds, [
    {
      nsid: "com.atproto.memberspace.putMember",
      method: "POST",
      body: { space, did, read: true, write: false },
    },
    { nsid: "com.atproto.simplespace.addMember", method: "POST", body: { space, did } },
  ]);
}

/** A gallery's member list, as its authority keeps it. */
export function listGalleryMembers(pds: PdsCall, space: string, limit: number) {
  return firstServed(
    pds,
    ["com.atproto.memberspace.listMembers", "com.atproto.simplespace.listMembers"].map((nsid) => ({
      nsid,
      method: "GET" as const,
      params: { space, limit },
    })),
  );
}

// --- Identity ---------------------------------------------------------------

const pdsCache = new Map<string, { endpoint: string; expires: number }>();
const PDS_CACHE_TTL_MS = 60 * 60 * 1000;

/** Where an account's repo lives, from its DID document. */
export async function resolvePds(did: string): Promise<string> {
  const cached = pdsCache.get(did);
  if (cached && cached.expires > Date.now()) return cached.endpoint;

  let doc: { service?: { id?: string; type?: string; serviceEndpoint?: string }[] } | null = null;
  if (did.startsWith("did:plc:")) {
    const res = await fetch(`${config.plc}/${encodeURIComponent(did)}`);
    if (res.ok) doc = await res.json();
  } else if (did.startsWith("did:web:")) {
    const host = decodeURIComponent(did.slice("did:web:".length)).replace(/:/g, "/");
    const res = await fetch(`https://${host}/.well-known/did.json`);
    if (res.ok) doc = await res.json();
  }

  const endpoint = doc?.service?.find(
    (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
  )?.serviceEndpoint;
  if (!endpoint) throw new SpaceError(502, `Could not resolve a PDS for ${did}`);

  pdsCache.set(did, { endpoint, expires: Date.now() + PDS_CACHE_TTL_MS });
  return endpoint;
}

// --- Reads ------------------------------------------------------------------

async function credentialedGet(
  credential: SpaceCredential,
  endpoint: string,
  nsid: string,
  params: Record<string, string | undefined>,
): Promise<Response> {
  const url = new URL(`${endpoint}/xrpc/${nsid}`);
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined) url.searchParams.append(k, v);
  }
  return credential.fetch(url);
}

/**
 * Read from a repo in a space, as a viewer who does not own it.
 *
 * A 401 is retried once with a freshly minted credential: the host refused one
 * that had not expired by our clock — skew, or the authority revoked it — and
 * the alternative is a failed page load over a token we could simply replace.
 * A viewer who may not read the space is refused at the mint, with the
 * authority's status.
 */
export async function readSpace(
  reader: SpaceReader,
  space: string,
  host: string,
  nsid: string,
  params: Record<string, string | undefined>,
): Promise<Record<string, unknown>> {
  for (const attempt of [0, 1]) {
    const credential = await reader.spaceCredential(space, { refresh: attempt === 1 });
    const res = await credentialedGet(credential, host, nsid, params);
    if (res.ok) return (await res.json()) as Record<string, unknown>;
    if (res.status !== 401 || attempt === 1) {
      const body = (await res.json().catch(() => ({}))) as { message?: string };
      throw new SpaceError(res.status, body.message || `${nsid} failed`);
    }
  }
  throw new SpaceError(500, "unreachable");
}

/**
 * The CID of a blob a space record references.
 *
 * Two spellings of the same thing: `com.atproto.repo.listRecords` hands over
 * the lexicon's `$link`, while a space read hands over the DAG-JSON `/` the
 * record was stored as. Servers differ on which they give, so read both.
 */
export function blobCid(blob: unknown): string | undefined {
  const ref = (blob as { ref?: { $link?: string; "/"?: string } } | undefined)?.ref;
  return ref?.$link ?? ref?.["/"];
}

/**
 * A record as a space lists it. There is no `uri`: a space record's address is
 * the space, the repo, the collection and the rkey, and the caller already knows
 * the first two — so callers build whatever URI they need from the rkey.
 */
export interface SpaceRecord {
  collection: string;
  rkey: string;
  cid: string;
  value: Record<string, unknown>;
}

/**
 * Every record of one collection in one repo of a space.
 *
 * The author reads their own repo through their own session; anyone else needs
 * a credential. Both land on the same endpoint at the same host.
 */
export async function listSpaceRecords(
  reader: SpaceReader,
  viewerDid: string,
  space: string,
  repo: string,
  collection: string,
  limit = 100,
): Promise<SpaceRecord[]> {
  const params = { space, repo, collection, limit: String(limit) };

  const body =
    viewerDid === repo
      ? await reader.pds("com.atproto.space.listRecords", { method: "GET", params })
      : await readSpace(
          reader,
          space,
          await resolvePds(repo),
          "com.atproto.space.listRecords",
          params,
        );

  return (body.records ?? []) as SpaceRecord[];
}

/**
 * The accounts that have written to a space, from its authority.
 *
 * A credential every time, including the authority's own: this is the space's
 * writer set rather than one repo's records, and a server may serve it to
 * credential holders only — zds does — so a session gets a 401 here even from
 * the account that owns the space.
 */
export async function listSpaceRepos(
  reader: SpaceReader,
  space: string,
): Promise<{ did: string; repoRev: string }[]> {
  const { authority } = parseSpaceUri(space);
  const body = await readSpace(
    reader,
    space,
    await resolvePds(authority),
    "com.atproto.space.listRepos",
    { space },
  );

  return (body.repos ?? []) as { did: string; repoRev: string }[];
}