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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
268 lines · 9.4 KB Raw History
// The permissioned-space read path, against the two pds.js instances docker
// compose brings up. Skipped unless SPACES_LIVE=1, since it needs them running:
//
//   docker compose up -d && ./seeds/pdsjs-accounts.sh
//   SPACES_LIVE=1 npx vitest run test/spaces.live.test.ts
//
// What it proves is the part that cannot be checked against a mock: that a
// reader who owns none of the data can mint a credential from a space they were
// added to and pull records out of a host they have no session on, presenting
// it with hatk's HTTP message signatures.

import { readFileSync } from "node:fs";
import { generateSpaceSigKey, spaceSigHeaders } from "@hatk/hatk/spaces";
import { beforeAll, describe, expect, test } from "vitest";
import {
  addGalleryReader,
  createGallerySpace,
  listSpaceRecords,
  listSpaceRepos,
  type PdsCall,
  parseSpaceUri,
  resolvePds,
  type SpaceReader,
  spaceUri,
} from "../server/spaces/client.ts";

const live = process.env.SPACES_LIVE === "1";
const CREDS = "data/pdsjs";
const PASSWORD = "dev-password";

type Account = { did: string; handle: string; pdsUrl: string };

/** A PdsCall backed by a password session — what ctx.pds is in production. */
function pdsCallFor(account: Account, jwt: string): PdsCall {
  return async (nsid, options) => {
    const method = options?.method ?? "GET";
    const url = new URL(`${account.pdsUrl}/xrpc/${nsid}`);
    for (const [k, v] of Object.entries(options?.params ?? {})) {
      if (v !== undefined) url.searchParams.append(k, String(v));
    }
    const res = await fetch(url, {
      method,
      headers: {
        authorization: `Bearer ${jwt}`,
        ...(method === "POST" ? { "content-type": "application/json" } : {}),
      },
      body: method === "POST" ? JSON.stringify(options?.body ?? {}) : undefined,
    });
    const text = await res.text();
    if (!res.ok) throw new Error(`${nsid} → ${res.status} ${text}`);
    return text ? JSON.parse(text) : {};
  };
}

/**
 * A SpaceReader over a password session — what hatk's handler context is in
 * production, with `spaceCredential` doing the exchange `ctx.spaceCredential`
 * does: a delegation token from the reader's PDS, traded at the authority for a
 * credential bound to a fresh key named in the request, then presented signed
 * for the repo read.
 */
function readerFor(account: Account, jwt: string): SpaceReader {
  const pds = pdsCallFor(account, jwt);
  return {
    pds,
    spaceCredential: async (space) => {
      const { token } = (await pds("com.atproto.space.getDelegationToken", {
        params: { space },
      })) as { token: string };
      const { authority } = parseSpaceUri(space);
      const key = await generateSpaceSigKey();
      const res = await fetch(
        `${await resolvePds(authority)}/xrpc/com.atproto.space.getSpaceCredential`,
        {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ space, credentialKid: key.did(), delegationToken: token }),
        },
      );
      if (!res.ok) throw Object.assign(new Error(await res.text()), { status: res.status });
      const { credential } = (await res.json()) as { credential: string };
      return {
        fetch: async (input, init) => {
          const url = new URL(String(input));
          const audience = url.searchParams.get("repo") ?? authority;
          const headers = new Headers(init?.headers);
          const signed = await spaceSigHeaders(key, credential, audience);
          for (const [name, value] of Object.entries(signed)) headers.set(name, value);
          return fetch(url, { ...init, headers });
        },
      };
    },
  };
}

async function login(account: Account): Promise<string> {
  const res = await fetch(`${account.pdsUrl}/xrpc/com.atproto.server.createSession`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ identifier: account.did, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`login failed for ${account.handle}: ${await res.text()}`);
  return ((await res.json()) as { accessJwt: string }).accessJwt;
}

// One PNG pixel, so the blob round trip compares real bytes.
const PIXEL = Buffer.from(
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
  "base64",
);

describe.skipIf(!live)("permissioned spaces, live", () => {
  let author: Account;
  let reader: Account;
  let authorPds: SpaceReader;
  let readerPds: SpaceReader;
  let space: string;
  let closedSpace: string;

  beforeAll(async () => {
    author = JSON.parse(readFileSync(`${CREDS}/credentials-spacehost.json`, "utf8"));
    reader = JSON.parse(readFileSync(`${CREDS}/credentials-spacemember.json`, "utf8"));
    authorPds = readerFor(author, await login(author));
    readerPds = readerFor(reader, await login(reader));

    // A gallery is a space: skey is the gallery's rkey, authority is its author.
    const skey = `test${Date.now().toString(36)}`;
    space = spaceUri(author.did, skey);

    // The same calls createPrivateGallery makes.
    await createGallerySpace(authorPds.pds, author.did, skey);
    await addGalleryReader(authorPds.pds, space, reader.did);

    // A second gallery the reader is never added to, so the refusal below is
    // about membership rather than about a space that does not exist.
    const closedSkey = `closed${Date.now().toString(36)}`;
    closedSpace = spaceUri(author.did, closedSkey);
    await createGallerySpace(authorPds.pds, author.did, closedSkey);
    await authorPds.pds("com.atproto.space.createRecord", {
      method: "POST",
      body: {
        space: closedSpace,
        repo: author.did,
        collection: "social.grain.photo",
        record: {
          $type: "social.grain.photo",
          photo: {
            $type: "blob",
            ref: { $link: "bafkreiplaceholder" },
            mimeType: "image/png",
            size: 1,
          },
          alt: "not for you",
          aspectRatio: { width: 1, height: 1 },
          createdAt: new Date().toISOString(),
        },
      },
    });

    // Blobs in a space record are ordinary account blobs, uploaded the ordinary
    // way. Only reading them back goes through the space.
    const upload = await fetch(`${author.pdsUrl}/xrpc/com.atproto.repo.uploadBlob`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${await login(author)}`,
        "content-type": "image/png",
      },
      body: PIXEL,
    });
    const { blob } = (await upload.json()) as { blob: { ref: { $link: string } } };

    await authorPds.pds("com.atproto.space.applyWrites", {
      method: "POST",
      body: {
        space,
        repo: author.did,
        writes: [
          {
            $type: "com.atproto.space.applyWrites#create",
            collection: "social.grain.gallery",
            rkey: skey,
            value: {
              $type: "social.grain.privateGallery",
              title: "A private gallery",
              createdAt: new Date().toISOString(),
            },
          },
          {
            $type: "com.atproto.space.applyWrites#create",
            collection: "social.grain.photo",
            rkey: "photo1",
            value: {
              $type: "social.grain.photo",
              photo: blob,
              alt: "one pixel",
              aspectRatio: { width: 1, height: 1 },
              createdAt: new Date().toISOString(),
            },
          },
          {
            $type: "com.atproto.space.applyWrites#create",
            collection: "social.grain.gallery.item",
            rkey: "item1",
            value: {
              $type: "social.grain.gallery.item",
              gallery: `at://${author.did}/social.grain.gallery/${skey}`,
              item: `at://${author.did}/social.grain.photo/photo1`,
              position: 0,
              createdAt: new Date().toISOString(),
            },
          },
        ],
      },
    });
  }, 60_000);

  test("the author reads their own repo through their own session", async () => {
    const items = await listSpaceRecords(
      authorPds,
      author.did,
      space,
      author.did,
      "social.grain.gallery.item",
    );

    expect(items).toHaveLength(1);
    expect(items[0].value.position).toBe(0);
  });

  test("a member reads the author's repo with a space credential", async () => {
    // The reader has no session on the author's PDS at all. Everything here
    // rests on the credential the authority issued and the signature we make.
    const photos = await listSpaceRecords(
      readerPds,
      reader.did,
      space,
      author.did,
      "social.grain.photo",
    );

    expect(photos).toHaveLength(1);
    expect(photos[0].value.alt).toBe("one pixel");
  });

  test("a member reads the writer set from the authority", async () => {
    const repos = await listSpaceRepos(readerPds, space);

    expect(repos.map((r) => r.did)).toContain(author.did);
  });

  test("a non-member is refused a space that exists and holds records", async () => {
    // The author can read it, so the space and the record are real; the reader
    // is simply not on its member list. The refusal is the authority's — it
    // will not mint a credential — so the repo host is never even asked.
    const own = await listSpaceRecords(
      authorPds,
      author.did,
      closedSpace,
      author.did,
      "social.grain.photo",
    );
    expect(own).toHaveLength(1);

    await expect(
      listSpaceRecords(readerPds, reader.did, closedSpace, author.did, "social.grain.photo"),
    ).rejects.toMatchObject({ status: 403 });
  });
});