atmosphere explorer pds.ls
tool typescript atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849import { getAtprotoServiceEndpoint, getPdsEndpoint } from "@atcute/identity";import type { OAuthUserAgent } from "@atcute/oauth-browser-client";import { createDpopFetch, generateDpopKey } from "@atcute/oauth-crypto";
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<SimpleSpacePolicy, { kind: "unknown" }>;export type NewSimpleSpaceAppAccess = Exclude<SimpleSpaceAppAccess, { kind: "unknown" }>;
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<string, unknown> => 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<unknown> => { try { return await response.json(); } catch { return undefined; }};
const oauthQuery = async ( auth: OAuthUserAgent, method: string, params: Record<string, string | number | boolean | undefined>,): Promise<unknown> => { 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<string, unknown>,): Promise<unknown> => { 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; fetch: typeof fetch;}
const spaceHostCache = new Map<string, Promise<string>>();const credentialSessions = new Map<string, SpaceCredentialSession>();const pendingCredentialSessions = new Map<string, Promise<SpaceCredentialSession>>();
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<string> => { 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<string> => 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; }};
const acquireSpaceCredential = async ( auth: OAuthUserAgent, space: string,): Promise<SpaceCredentialSession> => { const [host, key] = await Promise.all([resolveSpaceHost(space), generateDpopKey(["ES256"])]);
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 nonceValues = new Map<string, string>(); const dpopFetch = createDpopFetch({ key, nonces: { get: (origin) => nonceValues.get(origin), set: (origin, nonce) => { nonceValues.set(origin, nonce); }, }, }); const exchangeUrl = new URL("/xrpc/com.atproto.space.getSpaceCredential", host); const response = await dpopFetch(exchangeUrl, { method: "POST", headers: { accept: "application/json", authorization: `Bearer ${delegation.token}`, "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, fetch: dpopFetch, };};
const getSpaceCredential = async ( auth: OAuthUserAgent, space: string,): Promise<SpaceCredentialSession> => { 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, service: string, method: string, params: Record<string, string | number | boolean | undefined>,): Promise<unknown> => { 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 session.fetch(url, { headers: { accept: "application/json", authorization: `DPoP ${session.credential}`, }, }); 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<CreateSimpleSpaceResult> => { 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<void> => { 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<void> => { 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<void> => { 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<void> => { 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<SimpleSpaceInfo | undefined> => { 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, 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<ListSimpleSpaceMembersResult> => { 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<ListSpacesResult> => { 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<ListSpaceReposResult> => { const host = await resolveSpaceHost(space); const data = await credentialQuery(auth, space, host, "com.atproto.space.listRepos", { space, cursor: options.cursor, limit: options.limit ?? 1000, });
if (!isObject(data) || !Array.isArray(data.repos)) { throw new Error("The Space authority returned an invalid writer list"); }
return { cursor: typeof data.cursor === "string" ? data.cursor : undefined, repos: data.repos.flatMap((repo): SpaceRepo[] => { if (!isObject(repo) || typeof repo.did !== "string" || typeof repo.rev !== "string") { return []; } return [{ did: repo.did, rev: repo.rev }]; }), };};
export const listSpaceRecords = async ( auth: OAuthUserAgent, space: string, repo: string, options: { collection?: string; cursor?: string; excludeValues?: boolean; limit?: number; reverse?: boolean; } = {},): Promise<ListSpaceRecordsResult> => { const pds = await resolveRepoHost(repo); const data = await credentialQuery(auth, space, 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<ListSpaceBlobsResult> => { const pds = await resolveRepoHost(repo); const data = await credentialQuery(auth, space, 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<GetSpaceRecordResult> => { const pds = await resolveRepoHost(repo); const data = await credentialQuery(auth, space, 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<void> => { 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<SpaceRecordWriteResult> => { 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<SpaceRecordWriteResult> => { 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<void> => { 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<Blob> => { 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 session.fetch(url, { headers: { accept: "*/*", authorization: `DPoP ${session.credential}`, }, });
if (!response.ok) { const data = await readJson(response); throw new Error(getErrorMessage(data, response.status)); }
return response.blob();};