// 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:///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; body?: unknown; }, ) => Promise>; /** A space credential as hatk hands it over: a fetch that presents it. */ export interface SpaceCredential { fetch: (input: string | URL, init?: RequestInit) => Promise; } /** 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; } 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] }; } // --- 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[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> { 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(); 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; } // --- Reads ------------------------------------------------------------------ async function credentialedGet( credential: SpaceCredential, endpoint: string, nsid: string, params: Record, ): 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 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, ): Promise> { 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; 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( reader: SpaceReader, viewerDid: string, space: string, repo: string, collection: string, limit = 100, ): Promise { 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 }[]; }