import { P256PrivateKeyExportable } from "@atcute/crypto"; import { getAtprotoServiceEndpoint, getPdsEndpoint } from "@atcute/identity"; import { toBase64 } from "@atcute/multibase"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import type { JSONType } from "../components/json"; import { getPDS, resolveDidDoc } from "./api"; export interface SpaceView { uri: string; } export interface SpaceRecord { collection: string; rkey: string; cid: string; value?: JSONType; } export interface SpaceRepo { did: string; rev: string; } export type SimpleSpacePolicy = | { kind: "public" } | { kind: "member-list" } | { kind: "managing-app"; managingApp: string } | { kind: "unknown"; type: string }; export type SimpleSpaceAppAccess = | { kind: "open" } | { kind: "allow-list"; allowed: string[] } | { kind: "unknown"; type: string }; export interface SimpleSpaceInfo { uri: string; readPolicy: SimpleSpacePolicy; writePolicy: SimpleSpacePolicy; appAccess: SimpleSpaceAppAccess; } export type NewSimpleSpacePolicy = Exclude; export type NewSimpleSpaceAppAccess = Exclude; export interface SimpleSpaceConfiguration { readPolicy: NewSimpleSpacePolicy; writePolicy: NewSimpleSpacePolicy; appAccess: NewSimpleSpaceAppAccess; } export interface CreateSimpleSpaceOptions extends SimpleSpaceConfiguration { type: string; skey?: string; } export interface CreateSimpleSpaceResult { uri: string; } export interface SimpleSpaceMember { did: string; read: boolean; write: boolean; } interface ListSpacesResult { cursor?: string; spaces: SpaceView[]; } interface ListSpaceRecordsResult { cursor?: string; records: SpaceRecord[]; } export interface ListSpaceBlobsResult { cursor?: string; cids: string[]; } interface ListSpaceReposResult { cursor?: string; repos: SpaceRepo[]; } interface ListSimpleSpaceMembersResult { cursor?: string; members: SimpleSpaceMember[]; } export interface GetSpaceRecordResult { uri: string; cid: string; value: JSONType; } export interface SpaceRecordWriteResult { uri: string; cid: string; validationStatus?: string; } interface ParsedSpaceUri { authority: string; type: string; skey: string; } const isObject = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); const parseSpaceRecordWriteResult = ( data: unknown, operation: "creation" | "update", ): SpaceRecordWriteResult => { if (!isObject(data) || typeof data.uri !== "string" || typeof data.cid !== "string") { throw new Error(`The PDS returned an invalid Space record ${operation} response`); } return { uri: data.uri, cid: data.cid, validationStatus: typeof data.validationStatus === "string" ? data.validationStatus : undefined, }; }; const getErrorMessage = (data: unknown, status: number) => { if (isObject(data)) { if (typeof data.message === "string" && data.message) return data.message; if (typeof data.error === "string" && data.error) return data.error; } return `Spaces request failed with HTTP ${status}`; }; class SpaceRequestError extends Error { readonly code?: string; readonly status: number; constructor(data: unknown, status: number) { super(getErrorMessage(data, status)); this.name = "SpaceRequestError"; this.status = status; this.code = isObject(data) && typeof data.error === "string" ? data.error : undefined; } } const readJson = async (response: Response): Promise => { try { return await response.json(); } catch { return undefined; } }; const oauthQuery = async ( auth: OAuthUserAgent, method: string, params: Record, ): Promise => { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined) search.set(key, String(value)); } const suffix = search.size ? `?${search}` : ""; const response = await auth.handle(`/xrpc/${method}${suffix}`, { headers: { accept: "application/json" }, }); const data = await readJson(response); if (!response.ok) throw new SpaceRequestError(data, response.status); return data; }; const oauthProcedure = async ( auth: OAuthUserAgent, method: string, input: Record, ): Promise => { const response = await auth.handle(`/xrpc/${method}`, { method: "POST", headers: { accept: "application/json", "content-type": "application/json", }, body: JSON.stringify(input), }); const data = await readJson(response); if (!response.ok) throw new SpaceRequestError(data, response.status); return data; }; interface SpaceCredentialSession { credential: string; expiresAt: number; key: P256PrivateKeyExportable; } const spaceHostCache = new Map>(); const credentialSessions = new Map(); const pendingCredentialSessions = new Map>(); const requireSecureEndpoint = (endpoint: string, label: string): string => { const url = new URL(endpoint); const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"; if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { throw new Error(`${label} must use HTTPS`); } return endpoint; }; const resolveSpaceHost = async (space: string): Promise => { const parsed = parseSpaceUri(space); if (!parsed) throw new Error("Invalid Space reference"); const existing = spaceHostCache.get(parsed.authority); if (existing) return existing; const pending = (async () => { const document = await resolveDidDoc(parsed.authority as `did:${string}:${string}`); const endpoint = getAtprotoServiceEndpoint(document, { id: "#atproto_space_host" }) ?? getPdsEndpoint(document); if (!endpoint) throw new Error("The Space authority does not publish a Space host"); return requireSecureEndpoint(endpoint, "The Space host"); })(); spaceHostCache.set(parsed.authority, pending); try { return await pending; } catch (err) { spaceHostCache.delete(parsed.authority); throw err; } }; const resolveRepoHost = async (repo: string): Promise => requireSecureEndpoint(await getPDS(repo), "The repo host"); const getJwtExpiry = (jwt: string): number | undefined => { try { const encoded = jwt.split(".")[1]; if (!encoded) return; const padded = encoded .replace(/-/g, "+") .replace(/_/g, "/") .padEnd(Math.ceil(encoded.length / 4) * 4, "="); const payload: unknown = JSON.parse(atob(padded)); if (isObject(payload) && typeof payload.exp === "number") return payload.exp * 1000; } catch { return; } }; /** * Builds HTTP Message Signature headers (RFC 9421) for a Space request. The credential * exchange signs the `Authorization` field alone; requests carrying a credential also * bind the `Atproto-Space-Audience` field. */ const spaceSignatureHeaders = async ( key: P256PrivateKeyExportable, authorization: string, audience?: string, ): Promise> => { const signatureInput = audience === undefined ? `("authorization");keyid="${await key.exportPublicKey("did")}"` : '("authorization" "atproto-space-audience")'; const lines = [`"authorization": ${authorization.trim()}`]; if (audience !== undefined) lines.push(`"atproto-space-audience": ${audience.trim()}`); lines.push(`"@signature-params": ${signatureInput}`); const signature = await key.sign(new TextEncoder().encode(lines.join("\n"))); return { authorization, ...(audience === undefined ? undefined : { "atproto-space-audience": audience }), "signature-input": `atproto-space=${signatureInput}`, signature: `atproto-space=:${toBase64(signature)}:`, }; }; const acquireSpaceCredential = async ( auth: OAuthUserAgent, space: string, ): Promise => { const [host, key] = await Promise.all([ resolveSpaceHost(space), P256PrivateKeyExportable.createKeypair(), ]); const delegation = await oauthQuery(auth, "com.atproto.space.getDelegationToken", { space }); if (!isObject(delegation) || typeof delegation.token !== "string") { throw new Error("The PDS returned an invalid Space delegation token"); } const exchangeUrl = new URL("/xrpc/com.atproto.space.getSpaceCredential", host); const response = await fetch(exchangeUrl, { method: "POST", headers: { ...(await spaceSignatureHeaders(key, `Bearer ${delegation.token}`)), accept: "application/json", "content-type": "application/json", }, body: JSON.stringify({ space }), }); const data = await readJson(response); if (!response.ok) { const message = getErrorMessage(data, response.status); if (isObject(data) && data.error === "AppNotAuthorized") { throw new Error(`${message}. This Space requires an approved application.`); } throw new Error(message); } if (!isObject(data) || typeof data.credential !== "string") { throw new Error("The Space authority returned an invalid credential"); } return { credential: data.credential, expiresAt: getJwtExpiry(data.credential) ?? Date.now() + 60 * 60 * 1000, key, }; }; const getSpaceCredential = async ( auth: OAuthUserAgent, space: string, ): Promise => { const key = `${auth.sub}\n${space}`; const existing = credentialSessions.get(key); if (existing && existing.expiresAt > Date.now() + 30_000) return existing; credentialSessions.delete(key); const pending = pendingCredentialSessions.get(key); if (pending) return pending; const request = acquireSpaceCredential(auth, space); pendingCredentialSessions.set(key, request); try { const session = await request; if (pendingCredentialSessions.get(key) === request) { credentialSessions.set(key, session); } return session; } finally { if (pendingCredentialSessions.get(key) === request) { pendingCredentialSessions.delete(key); } } }; export const clearSpaceCredentials = (did: string) => { const prefix = `${did}\n`; for (const key of credentialSessions.keys()) { if (key.startsWith(prefix)) credentialSessions.delete(key); } for (const key of pendingCredentialSessions.keys()) { if (key.startsWith(prefix)) pendingCredentialSessions.delete(key); } }; const clearCredentialsForSpace = (space: string) => { const suffix = `\n${space}`; for (const key of credentialSessions.keys()) { if (key.endsWith(suffix)) credentialSessions.delete(key); } for (const key of pendingCredentialSessions.keys()) { if (key.endsWith(suffix)) pendingCredentialSessions.delete(key); } }; const credentialQuery = async ( auth: OAuthUserAgent, space: string, audience: string, service: string, method: string, params: Record, ): Promise => { const session = await getSpaceCredential(auth, space); const url = new URL(`/xrpc/${method}`, service); for (const [key, value] of Object.entries(params)) { if (value !== undefined) url.searchParams.set(key, String(value)); } const response = await fetch(url, { headers: { ...(await spaceSignatureHeaders( session.key, `Atproto-Space ${session.credential}`, audience, )), accept: "application/json", }, }); const data = await readJson(response); if (!response.ok) throw new SpaceRequestError(data, response.status); return data; }; const parseSimpleSpacePolicy = (value: unknown): SimpleSpacePolicy => { if (!isObject(value) || typeof value.$type !== "string") { throw new Error("The Space authority returned an invalid SimpleSpace policy"); } switch (value.$type) { case "com.atproto.simplespace.defs#publicPolicy": return { kind: "public" }; case "com.atproto.simplespace.defs#memberListPolicy": return { kind: "member-list" }; case "com.atproto.simplespace.defs#managingAppPolicy": if (typeof value.managingApp !== "string") { throw new Error("The Space authority returned an invalid managing app policy"); } return { kind: "managing-app", managingApp: value.managingApp }; default: return { kind: "unknown", type: value.$type }; } }; const parseSimpleSpaceAppAccess = (value: unknown): SimpleSpaceAppAccess => { if (!isObject(value) || typeof value.$type !== "string") { throw new Error("The Space authority returned an invalid SimpleSpace app policy"); } switch (value.$type) { case "com.atproto.simplespace.defs#open": return { kind: "open" }; case "com.atproto.simplespace.defs#allowList": if ( !Array.isArray(value.allowed) || !value.allowed.every((item) => typeof item === "string") ) { throw new Error("The Space authority returned an invalid application allow list"); } return { kind: "allow-list", allowed: value.allowed }; default: return { kind: "unknown", type: value.$type }; } }; const encodeSimpleSpacePolicy = (policy: NewSimpleSpacePolicy) => policy.kind === "managing-app" ? { $type: "com.atproto.simplespace.defs#managingAppPolicy", managingApp: policy.managingApp, } : { $type: policy.kind === "public" ? "com.atproto.simplespace.defs#publicPolicy" : "com.atproto.simplespace.defs#memberListPolicy", }; const encodeSimpleSpaceAppAccess = (appAccess: NewSimpleSpaceAppAccess) => appAccess.kind === "allow-list" ? { $type: "com.atproto.simplespace.defs#allowList", allowed: appAccess.allowed, } : { $type: "com.atproto.simplespace.defs#open" }; const assertSimpleSpaceAuthority = (auth: OAuthUserAgent, space: string, action: string) => { const parsed = parseSpaceUri(space); if (!parsed) throw new Error("Invalid Space reference"); if (parsed.authority !== auth.sub) { throw new Error(`Only the Space authority can ${action} this Space`); } }; export const createSimpleSpace = async ( auth: OAuthUserAgent, options: CreateSimpleSpaceOptions, ): Promise => { const data = await oauthProcedure(auth, "com.atproto.simplespace.createSpace", { type: options.type, skey: options.skey, readPolicy: encodeSimpleSpacePolicy(options.readPolicy), writePolicy: encodeSimpleSpacePolicy(options.writePolicy), appAccess: encodeSimpleSpaceAppAccess(options.appAccess), }); if (!isObject(data) || typeof data.uri !== "string" || !parseSpaceUri(data.uri)) { throw new Error("The PDS returned an invalid SimpleSpace creation response"); } clearCredentialsForSpace(data.uri); return { uri: data.uri }; }; export const updateSimpleSpace = async ( auth: OAuthUserAgent, space: string, config: SimpleSpaceConfiguration, ): Promise => { assertSimpleSpaceAuthority(auth, space, "configure"); await oauthProcedure(auth, "com.atproto.simplespace.updateSpace", { space, readPolicy: encodeSimpleSpacePolicy(config.readPolicy), writePolicy: encodeSimpleSpacePolicy(config.writePolicy), appAccess: encodeSimpleSpaceAppAccess(config.appAccess), }); clearCredentialsForSpace(space); }; export const deleteSimpleSpace = async (auth: OAuthUserAgent, space: string): Promise => { assertSimpleSpaceAuthority(auth, space, "delete"); await oauthProcedure(auth, "com.atproto.simplespace.deleteSpace", { space }); clearCredentialsForSpace(space); }; export const putSimpleSpaceMember = async ( auth: OAuthUserAgent, space: string, member: SimpleSpaceMember, ): Promise => { assertSimpleSpaceAuthority(auth, space, "manage members of"); await oauthProcedure(auth, "com.atproto.simplespace.putMember", { space, ...member }); clearCredentialsForSpace(space); }; export const removeSimpleSpaceMember = async ( auth: OAuthUserAgent, space: string, did: string, ): Promise => { assertSimpleSpaceAuthority(auth, space, "remove members from"); await oauthProcedure(auth, "com.atproto.simplespace.removeMember", { space, did }); clearCredentialsForSpace(space); }; export const getSimpleSpace = async ( auth: OAuthUserAgent, space: string, ): Promise => { const parsed = parseSpaceUri(space); if (!parsed) throw new Error("Invalid Space reference"); let data: unknown; try { if (parsed.authority === auth.sub) { data = await oauthQuery(auth, "com.atproto.simplespace.getSpace", { space }); } else { const host = await resolveSpaceHost(space); data = await credentialQuery( auth, space, parsed.authority, host, "com.atproto.simplespace.getSpace", { space }, ); } } catch (err) { if ( err instanceof SpaceRequestError && (err.status === 404 || err.code === "SpaceNotFound" || err.code === "XRPCNotSupported" || err.code === "MethodNotImplemented") ) { return undefined; } throw err; } if (!isObject(data) || data.uri !== space) { throw new Error("The Space authority returned an invalid SimpleSpace response"); } return { uri: data.uri, readPolicy: parseSimpleSpacePolicy(data.readPolicy), writePolicy: parseSimpleSpacePolicy(data.writePolicy), appAccess: parseSimpleSpaceAppAccess(data.appAccess), }; }; export const listSimpleSpaceMembers = async ( auth: OAuthUserAgent, space: string, options: { cursor?: string; limit?: number } = {}, ): Promise => { const limit = options.limit ?? 100; const data = await oauthQuery(auth, "com.atproto.simplespace.listMembers", { space, cursor: options.cursor, limit, }); if (!isObject(data) || !Array.isArray(data.members)) { throw new Error("The PDS returned an invalid SimpleSpace member list"); } return { // The alpha server can return the final member as a cursor even when the // page is already exhausted. cursor: data.members.length >= limit && typeof data.cursor === "string" ? data.cursor : undefined, members: data.members.flatMap((member): SimpleSpaceMember[] => { if ( !isObject(member) || typeof member.did !== "string" || typeof member.read !== "boolean" || typeof member.write !== "boolean" ) { throw new Error("The PDS returned invalid SimpleSpace member access"); } return [{ did: member.did, read: member.read, write: member.write }]; }), }; }; export const parseSpaceUri = (uri: string): ParsedSpaceUri | undefined => { const match = /^at:\/\/([^/]+)\/space\/([^/]+)\/([^/]+)$/.exec(uri); if (!match) return; return { authority: match[1], type: match[2], skey: match[3] }; }; export const listSpaces = async ( auth: OAuthUserAgent, options: { cursor?: string; limit?: number } = {}, ): Promise => { const data = await oauthQuery(auth, "com.atproto.space.listSpaces", { cursor: options.cursor, limit: options.limit ?? 100, }); if (!isObject(data) || !Array.isArray(data.spaces)) { throw new Error("The PDS returned an invalid Spaces response"); } return { cursor: typeof data.cursor === "string" ? data.cursor : undefined, spaces: data.spaces.filter( (space): space is SpaceView => isObject(space) && typeof space.uri === "string", ), }; }; export const listSpaceRepos = async ( auth: OAuthUserAgent, space: string, options: { cursor?: string; limit?: number } = {}, ): Promise => { const limit = options.limit ?? 1000; const parsed = parseSpaceUri(space); if (!parsed) throw new Error("Invalid Space reference"); const host = await resolveSpaceHost(space); const data = await credentialQuery( auth, space, parsed.authority, host, "com.atproto.space.listRepos", { space, cursor: options.cursor, limit, }, ); if (!isObject(data) || !Array.isArray(data.repos)) { throw new Error("The Space authority returned an invalid writer list"); } return { // Unlike the other list endpoints, the space host always returns the last // writer's spaceRev as the cursor, so a short page is what signals the end. cursor: data.repos.length >= limit && typeof data.cursor === "string" ? data.cursor : undefined, repos: data.repos.flatMap((repo): SpaceRepo[] => { if (!isObject(repo) || typeof repo.did !== "string" || typeof repo.repoRev !== "string") { return []; } return [{ did: repo.did, rev: repo.repoRev }]; }), }; }; export const listSpaceRecords = async ( auth: OAuthUserAgent, space: string, repo: string, options: { collection?: string; cursor?: string; excludeValues?: boolean; limit?: number; reverse?: boolean; } = {}, ): Promise => { const pds = await resolveRepoHost(repo); const data = await credentialQuery(auth, space, repo, pds, "com.atproto.space.listRecords", { space, repo, collection: options.collection, cursor: options.cursor, excludeValues: options.excludeValues, limit: options.limit ?? 1000, reverse: options.reverse, }); if (!isObject(data) || !Array.isArray(data.records)) { throw new Error("The PDS returned an invalid Space records response"); } return { cursor: typeof data.cursor === "string" ? data.cursor : undefined, records: data.records.filter( (record): record is SpaceRecord => isObject(record) && typeof record.collection === "string" && typeof record.rkey === "string" && typeof record.cid === "string", ), }; }; export const listSpaceBlobs = async ( auth: OAuthUserAgent, space: string, repo: string, options: { cursor?: string; limit?: number; since?: string } = {}, ): Promise => { const pds = await resolveRepoHost(repo); const data = await credentialQuery(auth, space, repo, pds, "com.atproto.space.listBlobs", { space, repo, since: options.since, cursor: options.cursor, limit: options.limit ?? 1000, }); if (!isObject(data) || !Array.isArray(data.cids)) { throw new Error("The PDS returned an invalid Space blobs response"); } return { cursor: typeof data.cursor === "string" ? data.cursor : undefined, cids: data.cids.filter((cid): cid is string => typeof cid === "string"), }; }; export const getSpaceRecord = async ( auth: OAuthUserAgent, space: string, repo: string, collection: string, rkey: string, ): Promise => { const pds = await resolveRepoHost(repo); const data = await credentialQuery(auth, space, repo, pds, "com.atproto.space.getRecord", { space, repo, collection, rkey, }); if ( !isObject(data) || typeof data.uri !== "string" || typeof data.cid !== "string" || data.value === undefined ) { throw new Error("The PDS returned an invalid Space record response"); } return { uri: data.uri, cid: data.cid, value: data.value as JSONType, }; }; export const deleteSpaceRecord = async ( auth: OAuthUserAgent, space: string, repo: string, collection: string, rkey: string, ): Promise => { if (repo !== auth.sub) throw new Error("You can only delete your own Space records"); await oauthProcedure(auth, "com.atproto.space.deleteRecord", { space, repo, collection, rkey, }); }; export const createSpaceRecord = async ( auth: OAuthUserAgent, space: string, collection: string, record: JSONType, rkey?: string, validate?: boolean, ): Promise => { const data = await oauthProcedure(auth, "com.atproto.space.createRecord", { space, repo: auth.sub, collection, rkey, validate, record, }); return parseSpaceRecordWriteResult(data, "creation"); }; export const putSpaceRecord = async ( auth: OAuthUserAgent, space: string, repo: string, collection: string, rkey: string, record: JSONType, validate?: boolean, ): Promise => { if (repo !== auth.sub) throw new Error("You can only edit your own Space records"); const data = await oauthProcedure(auth, "com.atproto.space.putRecord", { space, repo, collection, rkey, validate, record, }); return parseSpaceRecordWriteResult(data, "update"); }; const MAX_SPACE_WRITES = 200; export const deleteSpaceRecords = async ( auth: OAuthUserAgent, space: string, repo: string, records: { collection: string; rkey: string }[], ): Promise => { if (repo !== auth.sub) throw new Error("You can only delete your own Space records"); for (let index = 0; index < records.length; index += MAX_SPACE_WRITES) { const writes = records.slice(index, index + MAX_SPACE_WRITES).map((record) => ({ $type: "com.atproto.space.applyWrites#delete", collection: record.collection, rkey: record.rkey, })); await oauthProcedure(auth, "com.atproto.space.applyWrites", { space, repo, writes, }); } }; export const getSpaceBlob = async ( auth: OAuthUserAgent, space: string, repo: string, cid: string, ): Promise => { const [pds, session] = await Promise.all([ resolveRepoHost(repo), getSpaceCredential(auth, space), ]); const url = new URL("/xrpc/com.atproto.space.getBlob", pds); url.search = new URLSearchParams({ space, repo, cid }).toString(); const response = await fetch(url, { headers: { ...(await spaceSignatureHeaders(session.key, `Atproto-Space ${session.credential}`, repo)), accept: "*/*", }, }); if (!response.ok) { const data = await readJson(response); throw new Error(getErrorMessage(data, response.status)); } return response.blob(); };