~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
234 lines · 9.2 KB Raw History
// 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;
}