0
2
// 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<unknown[]>;
run: (sql: string, params?: unknown[]) => Promise<void>;
};
/** Every method name in a describe response, or null if it isn't one. */
async function fetchMethods(pdsEndpoint: string): Promise<Set<string> | 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<string>();
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<string | null> {
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<Set<string> | 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<string>();
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<SpaceSupport> {
// 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<SpaceSupport> {
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<string | null> {
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;
}