// 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, minted by trading their PDS session for // a delegation token and presenting that to the authority // // 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"; import { dpopJkt, dpopProof } from "./dpop.ts"; export const GALLERY_SPACE_TYPE = "social.grain.gallery"; /** * A community's pool of galleries, as a space of its own. * * Not a gallery space: the authority is the community 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 community'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:///space/social.grain.group/self` — the pool a community 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; body?: unknown; }, ) => Promise>; 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:///space//` — 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] }; } // --- Identity --------------------------------------------------------------- const pdsCache = new Map(); 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 { 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; } // --- Credentials ------------------------------------------------------------ /** * Credentials are cached per viewer and space. Minting one costs two round * trips across two hosts, and a gallery view makes several reads. * * Held well inside the token's own lifetime rather than tracking it: an expiry * that arrives early is one re-mint, and `readSpace` re-mints on a 401 anyway. */ const credentials = new Map(); const CREDENTIAL_TTL_MS = 4 * 60 * 1000; /** * A credential admitting this viewer to this space. * * The delegation token is minted by the viewer's own PDS — it proves the viewer * delegated to us, signed by their key and addressed to the authority, so it is * useless anywhere else and expires in a minute. The authority checks it, checks * the viewer against the space's policy, and binds the credential it returns to * the key named by `dpopJkt`. */ export async function spaceCredential( pds: PdsCall, viewerDid: string, space: string, ): Promise { const key = `${viewerDid}${space}`; const cached = credentials.get(key); if (cached && cached.expires > Date.now()) return cached.credential; const { token } = (await pds("com.atproto.space.getDelegationToken", { method: "GET", params: { space }, })) as { token?: string }; if (!token) throw new SpaceError(502, "PDS returned no delegation token"); const { authority } = parseSpaceUri(space); const url = `${await resolvePds(authority)}/xrpc/com.atproto.space.getSpaceCredential`; const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}`, dpop: await dpopProof("POST", url), }, body: JSON.stringify({ space, dpopJkt: await dpopJkt() }), }); const body = (await res.json().catch(() => ({}))) as { credential?: string; message?: string }; if (!res.ok || !body.credential) { throw new SpaceError(res.status, body.message || "Could not get a space credential"); } credentials.set(key, { credential: body.credential, expires: Date.now() + CREDENTIAL_TTL_MS }); return body.credential; } // --- Reads ------------------------------------------------------------------ async function credentialedGet( endpoint: string, nsid: string, params: Record, credential: string, ): Promise { const url = new URL(`${endpoint}/xrpc/${nsid}`); for (const [k, v] of Object.entries(params)) { if (v !== undefined) url.searchParams.append(k, v); } return fetch(url, { headers: { authorization: `DPoP ${credential}`, dpop: await dpopProof("GET", url.toString(), credential), }, }); } /** * 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 cached one may * have expired, and the alternative is a failed page load over a token we could * simply replace. */ export async function readSpace( pds: PdsCall, viewerDid: string, space: string, host: string, nsid: string, params: Record, ): Promise> { for (const attempt of [0, 1]) { if (attempt === 1) credentials.delete(`${viewerDid}${space}`); const credential = await spaceCredential(pds, viewerDid, space); const res = await credentialedGet(host, nsid, params, credential); if (res.ok) return (await res.json()) as Record; 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; } /** * 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( pds: PdsCall, viewerDid: string, space: string, repo: string, collection: string, limit = 100, ): Promise { const params = { space, repo, collection, limit: String(limit) }; const body = viewerDid === repo ? await pds("com.atproto.space.listRecords", { method: "GET", params }) : await readSpace( pds, viewerDid, 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, the way fetchSpaceBlob * does: 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( pds: PdsCall, viewerDid: string, space: string, ): Promise<{ did: string; rev: string }[]> { const { authority } = parseSpaceUri(space); const body = await readSpace( pds, viewerDid, space, await resolvePds(authority), "com.atproto.space.listRepos", { space }, ); return (body.repos ?? []) as { did: string; rev: string }[]; } /** * A blob a space record references, fetched from the repo that holds it. * * These cannot go through the CDN: `com.atproto.space.getBlob` serves only a * blob the named space references, and only to a credential holder, while the * CDN is an unauthenticated cache keyed by a public URL. */ export async function fetchSpaceBlob( pds: PdsCall, viewerDid: string, space: string, did: string, cid: string, ): Promise { // A credential every time, including for the author's own blobs: ctx.pds // parses JSON and this answers with bytes, and the authority is always // authorized for its own space, so there is nothing the session buys here. const credential = await spaceCredential(pds, viewerDid, space); // `repo`, not `did`: the repo holding the blob is named the same way here as // in every other space read. pds.js treats it as optional and falls back to // the one account it hosts, which is why `did` ever appeared to work. const url = new URL(`${await resolvePds(did)}/xrpc/com.atproto.space.getBlob`); for (const [k, v] of Object.entries({ space, repo: did, cid })) url.searchParams.append(k, v); const res = await fetch(url, { headers: { authorization: `DPoP ${credential}`, dpop: await dpopProof("GET", url.toString(), credential), }, }); if (!res.ok) throw new SpaceError(res.status, "Could not read blob"); return res; }