diff --git a/hatk.config.ts b/hatk.config.ts index 19b2b40..b99213e 100644 --- a/hatk.config.ts +++ b/hatk.config.ts @@ -19,6 +19,22 @@ const grainScopes = [ "repo:app.bsky.feed.post?action=create", ].join(" "); +// Private galleries live in permissioned spaces (proposal 0016). `authority=*` +// because a reader's session has to reach a space anchored on somebody else's +// account; `manage=` because the author's session creates the space itself. +// +// Requested only from PDSes that serve spaces — see conditionalScopes below. +// Almost no PDS does, and a permission nobody can honor has no business on +// everybody's consent screen. +const spaceScopes = [ + "space:social.grain.gallery?authority=*&skey=*", + "collection=social.grain.gallery", + "collection=social.grain.gallery.item", + "collection=social.grain.photo", + "action=read&action=create&action=update&action=delete", + "manage=create&manage=update&manage=delete", +].join("&"); + export default defineConfig({ relay: isProd ? "wss://bsky.network" : "ws://localhost:2583", // Jetstream filters server-side, so we stop decoding the whole network to @@ -52,7 +68,13 @@ export default defineConfig({ }, oauth: { issuer: isProd && prodDomain ? `https://${prodDomain}` : undefined, - scopes: grainScopes.split(" "), + // Dev asks for the space scopes outright, and has to ask here too: the + // server-initiated login builds its request from this list, and it must + // match what the loopback client_id encodes or the PDS grants neither. + scopes: (isProd ? grainScopes : `${grainScopes} ${spaceScopes}`).split(" "), + conditionalScopes: [ + { whenMethod: "com.atproto.simplespace.createSpace", scopes: [spaceScopes] }, + ], clients: [ ...(prodDomain ? [ @@ -69,9 +91,12 @@ export default defineConfig({ ] : []), { + // Dev asks for the space scopes outright. Negotiation is skipped for + // loopback clients — the scope is encoded in the client_id, which the + // token exchange rebuilds from this config, so the two would disagree. client_id: "http://127.0.0.1:3000/oauth-client-metadata.json", client_name: "grain", - scope: grainScopes, + scope: `${grainScopes} ${spaceScopes}`, redirect_uris: ["http://127.0.0.1:3000/oauth/callback", "http://127.0.0.1:3000/admin"], }, { diff --git a/lexicons/social/grain/unspecced/createPrivateGallery.json b/lexicons/social/grain/unspecced/createPrivateGallery.json new file mode 100644 index 0000000..6ecf125 --- /dev/null +++ b/lexicons/social/grain/unspecced/createPrivateGallery.json @@ -0,0 +1,60 @@ +{ + "lexicon": 1, + "id": "social.grain.unspecced.createPrivateGallery", + "defs": { + "main": { + "type": "procedure", + "description": "Create a gallery that lives in a permissioned space (proposal 0016) rather than in the public repo. The gallery, its photos and its items are written to the author's space repo, and only the accounts named in members can read them.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["rkey", "title"], + "properties": { + "rkey": { + "type": "string", + "description": "Record key for the gallery. Doubles as the space's skey, so one gallery is one space.", + "maxLength": 64 + }, + "title": { "type": "string", "maxLength": 100 }, + "description": { "type": "string", "maxLength": 1000 }, + "members": { + "type": "array", + "description": "DIDs allowed to read the gallery. The author always can.", + "maxLength": 100, + "items": { "type": "string", "format": "did" } + }, + "photos": { + "type": "array", + "maxLength": 100, + "items": { "type": "ref", "ref": "#photoInput" } + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["space", "uri"], + "properties": { + "space": { "type": "string", "description": "The space URI the gallery lives in." }, + "uri": { "type": "string", "format": "at-uri" } + } + } + }, + "errors": [ + { "name": "SpacesUnsupported", "description": "The author's PDS does not serve permissioned spaces." } + ] + }, + "photoInput": { + "type": "object", + "required": ["photo", "aspectRatio"], + "properties": { + "photo": { "type": "blob", "accept": ["image/*"], "maxSize": 1000000 }, + "alt": { "type": "string" }, + "aspectRatio": { "type": "ref", "ref": "social.grain.defs#aspectRatio" } + } + } + } +} diff --git a/lexicons/social/grain/unspecced/getPrivateBlob.json b/lexicons/social/grain/unspecced/getPrivateBlob.json new file mode 100644 index 0000000..757e79c --- /dev/null +++ b/lexicons/social/grain/unspecced/getPrivateBlob.json @@ -0,0 +1,23 @@ +{ + "lexicon": 1, + "id": "social.grain.unspecced.getPrivateBlob", + "defs": { + "main": { + "type": "query", + "description": "Serve a blob referenced by a permissioned space, to a viewer the space admits. These cannot go through the CDN: the space serves a blob only to a credential holder, and a CDN is an unauthenticated cache keyed by a public URL.", + "parameters": { + "type": "params", + "required": ["space", "did", "cid"], + "properties": { + "space": { "type": "string", "description": "Space URI the blob is referenced from." }, + "did": { "type": "string", "format": "did", "description": "Repo that holds the blob." }, + "cid": { "type": "string", "format": "cid" } + } + }, + "output": { "encoding": "*/*" }, + "errors": [ + { "name": "NotAuthorized", "description": "The viewer is not a member of this space." } + ] + } + } +} diff --git a/lexicons/social/grain/unspecced/getPrivateGallery.json b/lexicons/social/grain/unspecced/getPrivateGallery.json new file mode 100644 index 0000000..858cb38 --- /dev/null +++ b/lexicons/social/grain/unspecced/getPrivateGallery.json @@ -0,0 +1,56 @@ +{ + "lexicon": 1, + "id": "social.grain.unspecced.getPrivateGallery", + "defs": { + "main": { + "type": "query", + "description": "Assemble a gallery that lives in a permissioned space. Read from the repos that hold it at request time — space records never reach a firehose, so none of this is in the index.", + "parameters": { + "type": "params", + "required": ["space"], + "properties": { + "space": { "type": "string", "description": "Space URI: at:///space/social.grain.gallery/" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["space", "authority", "items"], + "properties": { + "space": { "type": "string" }, + "authority": { "type": "string", "format": "did" }, + "viewerIsAuthor": { "type": "boolean" }, + "gallery": { "type": "ref", "ref": "#privateGallery" }, + "items": { "type": "array", "items": { "type": "ref", "ref": "#privateItem" } } + } + } + }, + "errors": [ + { "name": "NotAuthorized", "description": "The viewer is not a member of this space." } + ] + }, + "privateGallery": { + "type": "object", + "required": ["uri", "title"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "createdAt": { "type": "string", "format": "datetime" } + } + }, + "privateItem": { + "type": "object", + "required": ["uri", "did", "cid", "position"], + "properties": { + "uri": { "type": "string", "description": "Space URI of the photo record." }, + "did": { "type": "string", "format": "did", "description": "Repo holding the photo, and its blob." }, + "cid": { "type": "string", "format": "cid", "description": "Blob CID. Fetch through the space, never the CDN." }, + "alt": { "type": "string" }, + "aspectRatio": { "type": "ref", "ref": "social.grain.defs#aspectRatio" }, + "position": { "type": "integer" } + } + } + } +} diff --git a/server/spaces/client.ts b/server/spaces/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcfec1baa54dccf1e1c6ceffc3ebf6af8c2ed81b GIT binary patch literal 9996 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 literal 0 HcmV?d00001 diff --git a/server/spaces/dpop.ts b/server/spaces/dpop.ts new file mode 100644 index 0000000..cb82e6b --- /dev/null +++ b/server/spaces/dpop.ts @@ -0,0 +1,94 @@ +// The key grain presents a space credential with (RFC 9449). +// +// A space credential reads a whole space, and its holder presents it to every +// repo host in that space — so the authority binds it to a key rather than +// issuing a bearer token, and each request carries a fresh proof signed by that +// key. A host handed a bearer credential to serve its own repo could otherwise +// replay it against every other host. +// +// The key needs no registration anywhere, so it is generated per process and +// never persisted. A restart invalidates every credential minted against it, +// which costs one round trip to mint another. + +let keyPair: CryptoKeyPair | null = null; +let publicJwk: JsonWebKey | null = null; +let thumbprint: string | null = null; + +function b64url(bytes: ArrayBuffer | Uint8Array): string { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let binary = ""; + for (const b of view) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlJson(value: unknown): string { + return b64url(new TextEncoder().encode(JSON.stringify(value))); +} + +async function ensureKey(): Promise<{ pair: CryptoKeyPair; jwk: JsonWebKey; jkt: string }> { + if (keyPair && publicJwk && thumbprint) { + return { pair: keyPair, jwk: publicJwk, jkt: thumbprint }; + } + + keyPair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, [ + "sign", + "verify", + ])) as CryptoKeyPair; + + const exported = (await crypto.subtle.exportKey("jwk", keyPair.publicKey)) as JsonWebKey; + publicJwk = { kty: exported.kty, crv: exported.crv, x: exported.x, y: exported.y }; + + // RFC 7638: SHA-256 over the required members, lexicographically ordered, + // with no whitespace. The member order below is that order for an EC key. + const canonical = JSON.stringify({ + crv: publicJwk.crv, + kty: publicJwk.kty, + x: publicJwk.x, + y: publicJwk.y, + }); + thumbprint = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical))); + + return { pair: keyPair, jwk: publicJwk, jkt: thumbprint }; +} + +/** The thumbprint a credential is bound to. Stable for the life of the process. */ +export async function dpopJkt(): Promise { + return (await ensureKey()).jkt; +} + +/** + * A proof covering one request. + * + * `ath` binds the proof to the credential it is presented with, and is omitted + * when asking for a credential — a delegation token is an authorization grant + * rather than an access token, so there is nothing to hash yet. + */ +export async function dpopProof(method: string, url: string, credential?: string): Promise { + const { pair, jwk } = await ensureKey(); + + // htu is the request URI with query and fragment stripped, per RFC 9449. + const htu = new URL(url); + htu.search = ""; + htu.hash = ""; + + const header = { typ: "dpop+jwt", alg: "ES256", jwk }; + const payload: Record = { + jti: crypto.randomUUID(), + htm: method.toUpperCase(), + htu: htu.toString(), + iat: Math.floor(Date.now() / 1000), + }; + if (credential) { + payload.ath = b64url( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(credential)), + ); + } + + const signingInput = `${b64urlJson(header)}.${b64urlJson(payload)}`; + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + pair.privateKey, + new TextEncoder().encode(signingInput), + ); + return `${signingInput}.${b64url(signature)}`; +} diff --git a/server/xrpc/createPrivateGallery.ts b/server/xrpc/createPrivateGallery.ts new file mode 100644 index 0000000..1b077ff --- /dev/null +++ b/server/xrpc/createPrivateGallery.ts @@ -0,0 +1,113 @@ +// Create a gallery inside a permissioned space. +// POST /xrpc/social.grain.unspecced.createPrivateGallery +// +// One gallery is one space: the space's skey is the gallery's rkey, and its +// authority is the author. Members are readers — in this shape only the author +// writes, which keeps curation whole. A member's records would live in their own +// repo, where the authority can evict them from the space but cannot remove a +// single photo from the gallery. +// +// Blobs are uploaded beforehand through the ordinary blob endpoint: a space +// record's blobs are ordinary account blobs, and only reading them back goes +// through the space. + +import { defineProcedure, InvalidRequestError } from "$hatk"; +import { getSpaceSupport, pdsEndpointFor } from "../helpers/spaceSupport.ts"; +import { GALLERY_SPACE_TYPE, spaceUri } from "../spaces/client.ts"; + +interface PhotoInput { + photo: unknown; + alt?: string; + aspectRatio: { width: number; height: number }; +} + +export default defineProcedure("social.grain.unspecced.createPrivateGallery", async (ctx) => { + const { ok, db, viewer, pds, input } = ctx; + if (!viewer) throw new InvalidRequestError("Authentication required"); + + const { rkey, title, description } = input; + const members = (input.members ?? []) as string[]; + const photos = (input.photos ?? []) as PhotoInput[]; + + const endpoint = await pdsEndpointFor(db, viewer.did); + if (!endpoint) throw new InvalidRequestError("No PDS session for this account"); + const support = await getSpaceSupport(db, endpoint); + if (!support.supported) { + throw new InvalidRequestError( + "Your PDS does not serve permissioned spaces", + "SpacesUnsupported", + ); + } + + const space = spaceUri(viewer.did, rkey); + const createdAt = new Date().toISOString(); + + await pds("com.atproto.simplespace.createSpace", { + method: "POST", + body: { + type: GALLERY_SPACE_TYPE, + skey: rkey, + policy: { $type: "com.atproto.simplespace.defs#memberListPolicy" }, + appAccess: { $type: "com.atproto.simplespace.defs#open" }, + }, + }); + + // Sequential rather than concurrent: the authority applies these to one + // member list, and a handful of readers is not worth racing. + for (const did of members) { + if (did === viewer.did) continue; // the authority is authorized without being listed + await pds("com.atproto.simplespace.addMember", { method: "POST", body: { space, did } }); + } + + // The gallery, its photos and the items joining them, in one commit. A + // half-written gallery is worse here than in the public repo: there is no + // indexer to reconcile it against later. + const writes: Record[] = [ + { + $type: "com.atproto.space.applyWrites#create", + collection: "social.grain.gallery", + rkey, + value: { + $type: "social.grain.gallery", + title, + ...(description ? { description } : {}), + createdAt, + }, + }, + ]; + + photos.forEach((photo, index) => { + const photoRkey = `${rkey}-${index}`; + writes.push({ + $type: "com.atproto.space.applyWrites#create", + collection: "social.grain.photo", + rkey: photoRkey, + value: { + $type: "social.grain.photo", + photo: photo.photo, + ...(photo.alt ? { alt: photo.alt } : {}), + aspectRatio: photo.aspectRatio, + createdAt, + }, + }); + writes.push({ + $type: "com.atproto.space.applyWrites#create", + collection: "social.grain.gallery.item", + rkey: photoRkey, + value: { + $type: "social.grain.gallery.item", + gallery: `at://${viewer.did}/social.grain.gallery/${rkey}`, + item: `at://${viewer.did}/social.grain.photo/${photoRkey}`, + position: index, + createdAt, + }, + }); + }); + + await pds("com.atproto.space.applyWrites", { + method: "POST", + body: { space, repo: viewer.did, writes }, + }); + + return ok({ space, uri: `at://${viewer.did}/social.grain.gallery/${rkey}` }); +}); diff --git a/server/xrpc/getPrivateBlob.ts b/server/xrpc/getPrivateBlob.ts new file mode 100644 index 0000000..104e084 --- /dev/null +++ b/server/xrpc/getPrivateBlob.ts @@ -0,0 +1,53 @@ +// Serve a photo that lives in a permissioned space. +// GET /xrpc/social.grain.unspecced.getPrivateBlob?space=&did=&cid= +// +// Every other image in grain is served by the CDN. These cannot be: the space +// hands over a blob only to a holder of a credential bound to a key, and the CDN +// is an unauthenticated cache keyed by a public URL — putting a private photo +// behind one would turn its URL into the capability the credential exists to +// replace. +// +// So the bytes come through here, on the viewer's behalf, and go no further: +// `private, no-store` keeps them out of every shared cache between us and the +// browser. + +import { defineQuery, InvalidRequestError } from "$hatk"; +import { fetchSpaceBlob, SpaceError } from "../spaces/client.ts"; + +/** What we are willing to hand back, whatever the repo claims it is. */ +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif", "image/gif"]; + +export default defineQuery("social.grain.unspecced.getPrivateBlob", async (ctx) => { + const { viewer, pds, params } = ctx; + if (!viewer) throw new InvalidRequestError("Authentication required"); + + const { space, did, cid } = params; + + let upstream: Response; + try { + upstream = await fetchSpaceBlob(pds, viewer.did, space, did, cid); + } catch (err) { + if (err instanceof SpaceError && (err.status === 403 || err.status === 404)) { + throw new InvalidRequestError("Not a member of this space", "NotAuthorized"); + } + throw err; + } + + // A repo we do not control names the type. Serving it back unchecked would let + // that repo choose what the browser executes in grain's origin. + const upstreamType = (upstream.headers.get("content-type") ?? "").split(";")[0].trim(); + const contentType = ALLOWED_TYPES.includes(upstreamType) + ? upstreamType + : "application/octet-stream"; + + return new Response(upstream.body, { + status: 200, + headers: { + "content-type": contentType, + "cache-control": "private, no-store", + "content-security-policy": "default-src 'none'; sandbox", + "x-content-type-options": "nosniff", + "content-disposition": "inline", + }, + }); +}); diff --git a/server/xrpc/getPrivateGallery.ts b/server/xrpc/getPrivateGallery.ts new file mode 100644 index 0000000..3897243 --- /dev/null +++ b/server/xrpc/getPrivateGallery.ts @@ -0,0 +1,102 @@ +// Assemble a gallery that lives in a permissioned space. +// GET /xrpc/social.grain.unspecced.getPrivateGallery?space=... +// +// Nothing here comes from the index, because nothing in a space ever reaches a +// firehose to be indexed from. The gallery is read from the repo that holds it, +// on the host that holds it, on every request — through the author's own session +// when the author is asking, and through a credential the space's authority +// issues when anyone else is. +// +// FOLLOW-UP: photo bytes still need an endpoint. `cid` is returned so the client +// knows what to ask for, but com.atproto.space.getBlob answers with bytes and +// hatk's XRPC layer JSON-encodes every handler result. + +import { defineQuery, InvalidRequestError } from "$hatk"; +import { listSpaceRecords, parseSpaceUri, SpaceError } from "../spaces/client.ts"; + +interface PhotoValue { + photo?: { ref?: { $link?: string } }; + alt?: string; + aspectRatio?: { width: number; height: number }; +} + +interface ItemValue { + item?: string; + position?: number; +} + +export default defineQuery("social.grain.unspecced.getPrivateGallery", async (ctx) => { + const { ok, viewer, pds, params } = ctx; + if (!viewer) throw new InvalidRequestError("Authentication required"); + + const space = params.space; + let authority: string; + let skey: string; + try { + ({ authority, skey } = parseSpaceUri(space)); + } catch { + throw new InvalidRequestError(`Not a space uri: ${space}`); + } + + try { + const [galleries, items, photos] = await Promise.all([ + listSpaceRecords(pds, viewer.did, space, authority, "social.grain.gallery"), + listSpaceRecords(pds, viewer.did, space, authority, "social.grain.gallery.item"), + listSpaceRecords(pds, viewer.did, space, authority, "social.grain.photo"), + ]); + + // An item names its photo by the at-uri the record would have in a public + // repo, so photos are keyed the same way. A space record has no uri of its + // own — its address is the space, the repo, the collection and the rkey. + const byUri = new Map(photos.map((p) => [`at://${authority}/social.grain.photo/${p.rkey}`, p])); + + const view = items + .map((item) => { + const value = item.value as ItemValue; + const photo = value.item ? byUri.get(value.item) : undefined; + if (!photo) return null; + const photoValue = photo.value as PhotoValue; + const cid = photoValue.photo?.ref?.$link; + if (!cid) return null; + return { + uri: `at://${authority}/social.grain.photo/${photo.rkey}`, + did: authority, + cid, + ...(photoValue.alt ? { alt: photoValue.alt } : {}), + ...(photoValue.aspectRatio ? { aspectRatio: photoValue.aspectRatio } : {}), + position: value.position ?? 0, + }; + }) + .filter((item): item is NonNullable => item !== null) + .sort((a, b) => a.position - b.position); + + const galleryRecord = galleries.find((g) => g.rkey === skey) ?? galleries[0]; + const galleryValue = galleryRecord?.value as + | { title?: string; description?: string; createdAt?: string } + | undefined; + + return ok({ + space, + authority, + viewerIsAuthor: viewer.did === authority, + ...(galleryRecord && galleryValue?.title + ? { + gallery: { + uri: `at://${authority}/social.grain.gallery/${skey}`, + title: galleryValue.title, + ...(galleryValue.description ? { description: galleryValue.description } : {}), + ...(galleryValue.createdAt ? { createdAt: galleryValue.createdAt } : {}), + }, + } + : {}), + items: view, + }); + } catch (err) { + // The authority refusing to mint a credential is the space saying no, which + // is a 403 about membership rather than a broken request. + if (err instanceof SpaceError && (err.status === 403 || err.status === 404)) { + throw new InvalidRequestError("Not a member of this space", "NotAuthorized"); + } + throw err; + } +}); diff --git a/test/spaces.live.test.ts b/test/spaces.live.test.ts new file mode 100644 index 0000000..30847de --- /dev/null +++ b/test/spaces.live.test.ts @@ -0,0 +1,249 @@ +// 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 and blobs out of a host they have no session on, +// with DPoP proofs this code signs itself. + +import { readFileSync } from "node:fs"; +import { beforeAll, describe, expect, test } from "vitest"; +import { + fetchSpaceBlob, + listSpaceRecords, + listSpaceRepos, + type PdsCall, + 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) : {}; + }; +} + +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: PdsCall; + let readerPds: PdsCall; + let space: string; + let closedSpace: string; + let photoCid: string; + + beforeAll(async () => { + author = JSON.parse(readFileSync(`${CREDS}/credentials-spacehost.json`, "utf8")); + reader = JSON.parse(readFileSync(`${CREDS}/credentials-spacemember.json`, "utf8")); + authorPds = pdsCallFor(author, await login(author)); + readerPds = pdsCallFor(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); + + await authorPds("com.atproto.simplespace.createSpace", { + method: "POST", + body: { + type: "social.grain.gallery", + skey, + policy: { $type: "com.atproto.simplespace.defs#memberListPolicy" }, + appAccess: { $type: "com.atproto.simplespace.defs#open" }, + }, + }); + await authorPds("com.atproto.simplespace.addMember", { + method: "POST", + body: { space, did: 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 authorPds("com.atproto.simplespace.createSpace", { + method: "POST", + body: { + type: "social.grain.gallery", + skey: closedSkey, + policy: { $type: "com.atproto.simplespace.defs#memberListPolicy" }, + appAccess: { $type: "com.atproto.simplespace.defs#open" }, + }, + }); + await authorPds("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 } } }; + photoCid = blob.ref.$link; + + await authorPds("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.gallery", + 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 DPoP proof we sign. + 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, reader.did, space); + + expect(repos.map((r) => r.did)).toContain(author.did); + }); + + test("a member reads a blob the space references", async () => { + const res = await fetchSpaceBlob(readerPds, reader.did, space, author.did, photoCid); + const bytes = Buffer.from(await res.arrayBuffer()); + + expect(bytes.equals(PIXEL)).toBe(true); + }); + + 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 }); + }); +});