// Does a PDS serve permissioned spaces (proposal 0016)? // // Asked two ways, because one is not enough yet. // // `community.lexicon.service.describe` is the direct question and the answer to // trust when it comes. It is also still a proposal, and the reference spaces // implementation does not serve it — the alpha `@atproto/pds` build does not // contain the NSID at all. Treating a missing describe as "no spaces" therefore // mislabels every alpha deployment, which is the opposite of the intent. // // So when describe is absent we ask the server about the methods themselves. // The trick is a control: probe an NSID that certainly does not exist, learn // how that server says "I do not have this", then probe each method we need and // see whether the answer differs. It differs for a method the server knows, // because it gets far enough to complain about something else — missing // parameters, missing auth. Three real servers, three different shapes: // // alpha @atproto/pds unknown -> 401 AuthMissing space -> 400 InvalidRequest // pds.js with spaces unknown -> 501 MethodNotImplem. space -> 401 AuthRequired // bsky.social unknown -> 401 AuthMissing space -> 401 AuthMissing // // The last is the point: a server with no spaces answers identically either // way, so it reads as "no". The fallback can fail to recognise support it // cannot see, but it cannot invent support that is not there. const DESCRIBE_NSID = "community.lexicon.service.describe"; const PROBE_TIMEOUT_MS = 5000; /** * The space methods grain needs, in the order a shared gallery uses them. * Each entry is a set of accepted spellings: the proposal renamed things after * implementations shipped, so a server may answer to either name and one is * enough. See pds.js docs/permissioned-data.md, "Two spellings". The first * names the method when it is missing. * * The reference alpha since 0.0.0-spaces-alpha-20261029171913 has no * simplespace: it creates a space under com.atproto.space and keeps a member * list under com.atproto.memberspace (spaces/client.ts asks for both). */ const REQUIRED_METHODS: string[][] = [ ["com.atproto.simplespace.createSpace", "com.atproto.space.createSpace"], // The alpha spells this `putMember` and pairs it with `removeMember`; pds.js // says `addMember`. Same operation, and either is enough. [ "com.atproto.simplespace.addMember", "com.atproto.simplespace.putMember", "com.atproto.memberspace.putMember", ], ["com.atproto.simplespace.listMembers", "com.atproto.memberspace.listMembers"], ["com.atproto.simplespace.getSpace", "com.atproto.space.getSpace"], ["com.atproto.space.createRecord"], ["com.atproto.space.listRecords"], ["com.atproto.space.listRepos"], ["com.atproto.space.getDelegationToken"], ["com.atproto.space.getSpaceCredential"], ["com.atproto.space.getBlob"], ]; /** * Re-probe this often. A PDS gains spaces by redeploying, not by telling us. * * A yes and a no are not held for the same length of time, because they do not * go stale in the same direction. A yes that has gone stale costs one failed * call, which says what happened; the operator who turns spaces off is rare and * the app finds out immediately. * * A no is the answer that traps people. An operator enables spaces, and for the * rest of the hour every account on that server is told it has none — with * nothing to do about it, because the thing that would change the answer has * already happened. A no also absorbs every reason a probe can fail to land: a * restart, a timeout, a blip on the way. None of those are evidence about the * server, and none should outlive the minute they happened in. So a no is kept * only long enough to spare the PDS a probe per page load. */ const CACHE_TTL_MS = 60 * 60 * 1000; const NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000; export interface SpaceSupport { supported: boolean; pds: string; checkedAt: string; /** Required methods this PDS does not serve. Empty exactly when supported. */ missing: string[]; } type Db = { query: (sql: string, params?: unknown[]) => Promise; run: (sql: string, params?: unknown[]) => Promise; }; /** Every method name in a describe response, or null if it isn't one. */ async function fetchMethods(pdsEndpoint: string): Promise | null> { const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/${DESCRIBE_NSID}`; let res: Response; try { res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }); } catch { return null; } if (!res.ok) return null; const body = (await res.json().catch(() => null)) as { methods?: { value?: unknown }[]; } | null; if (!Array.isArray(body?.methods)) return null; const names = new Set(); for (const m of body.methods) { if (typeof m?.value === "string") names.add(m.value); } return names; } /** * An NSID no server implements, used to learn how this one says "no such * method". Namespaced under grain so it cannot collide with something real. */ const CONTROL_NSID = "social.grain.unspecced.methodThatDoesNotExist"; /** * How a server answered, as a comparable string. Status and error code * together: some servers vary the status, some only the error name. */ async function signature(pdsEndpoint: string, nsid: string): Promise { const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/${nsid}`; try { const res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }); const body = (await res.json().catch(() => null)) as { error?: string } | null; return `${res.status}:${body?.error ?? ""}`; } catch { return null; } } /** * Which of the methods we need this server appears to serve, judged against how * it answers for a method that does not exist. Null when the server could not * be reached at all, which is not evidence about the server. */ async function probeMethods(pdsEndpoint: string): Promise | null> { const control = await signature(pdsEndpoint, CONTROL_NSID); if (control === null) return null; const spellings = REQUIRED_METHODS.flat(); const answers = await Promise.all(spellings.map((n) => signature(pdsEndpoint, n))); const found = new Set(); spellings.forEach((nsid, i) => { const a = answers[i]; // Same answer as a method that does not exist tells us nothing, so it // counts as absent. Only a different answer is evidence of the method. if (a !== null && a !== control) found.add(nsid); }); return found; } /** Probe a PDS directly, bypassing the cache. */ export async function probeSpaceSupport(pdsEndpoint: string): Promise { // describe first: an explicit list beats inference whenever it is offered. const methods = (await fetchMethods(pdsEndpoint)) ?? (await probeMethods(pdsEndpoint)); const checkedAt = new Date().toISOString(); // Neither question got an answer — the server is unreachable, or it says // nothing that distinguishes a method it has from one it does not. if (!methods) { return { supported: false, pds: pdsEndpoint, checkedAt, missing: REQUIRED_METHODS.map((spellings) => spellings[0]), }; } const missing = REQUIRED_METHODS.filter( (spellings) => !spellings.some((name) => methods.has(name)), ).map((spellings) => spellings[0]); return { supported: missing.length === 0, pds: pdsEndpoint, checkedAt, missing }; } /** * Cached support for a PDS, re-probed once the cached answer has aged out. * A probe that fails to reach the server is not an error here — it lands as * "not supported", which is what an unreachable PDS means for this feature. */ export async function getSpaceSupport( db: Db, pdsEndpoint: string, opts?: { force?: boolean }, ): Promise { const rows = (await db.query( `SELECT supported, missing, checked_at FROM _space_support WHERE pds_endpoint = $1`, [pdsEndpoint], )) as { supported: number; missing: string; checked_at: string }[]; const cached = rows[0]; if (cached && !opts?.force) { const age = Date.now() - Date.parse(cached.checked_at); const ttl = cached.supported ? CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS; if (Number.isFinite(age) && age < ttl) { return { supported: !!cached.supported, pds: pdsEndpoint, checkedAt: cached.checked_at, missing: JSON.parse(cached.missing) as string[], }; } } const fresh = await probeSpaceSupport(pdsEndpoint); await db.run( `INSERT INTO _space_support (pds_endpoint, supported, missing, checked_at) VALUES ($1, $2, $3, $4) ON CONFLICT (pds_endpoint) DO UPDATE SET supported = excluded.supported, missing = excluded.missing, checked_at = excluded.checked_at`, [pdsEndpoint, fresh.supported ? 1 : 0, JSON.stringify(fresh.missing), fresh.checkedAt], ); return fresh; } /** The PDS endpoint grain holds a session for, or null if there isn't one. */ export async function pdsEndpointFor(db: Db, did: string): Promise { const rows = (await db.query(`SELECT pds_endpoint FROM _oauth_sessions WHERE did = $1`, [ did, ])) as { pds_endpoint: string }[]; return rows[0]?.pds_endpoint ?? null; }