// 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 { 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 }); }); });