char-slop/stars.hackery.site

git clone https://git.t4t.associates/char-slop/stars.hackery.site

Charlotte Somuse @cerulea/lexicon 0.2.0 and its typed format schemas8463413

main
9.6 KiB299 linesraw
1import * as l from "@cerulea/lexicon";
2import { isBareOrigin, readBytes } from "./http.ts";
3import {
4  type Did,
5  isDid,
6  parseAtUri,
7  Profile,
8  Star,
9  starUrl,
10} from "./records.ts";
11
12export type Entry = { uri: string; value: unknown };
13export type StarEntry = { uri: string; star: Star; link: string };
14export type Actor = {
15  did: Did;
16  handle: string;
17  pds: string;
18  handleVerified?: boolean;
19  avatar?: string;
20};
21
22// Public services consulted for accounts Hydrant doesn't track.
23const SLINGSHOT = new URL("https://slingshot.microcosm.blue");
24const PLC = new URL("https://plc.directory");
25const BSKY = new URL("https://public.api.bsky.app");
26const CDN = new URL("https://cdn.cerulea.blue");
27
28type Json = Record<string, unknown>;
29type Params = Record<string, string | number | boolean | undefined>;
30const isJson = (data: unknown): data is Json =>
31  typeof data === "object" && data !== null && !Array.isArray(data);
32
33const xrpc = (base: URL, method: string, params: Params): URL => {
34  const url = new URL(`/xrpc/${method}`, base);
35  for (const [key, value] of Object.entries(params)) {
36    if (value !== undefined) url.searchParams.set(key, String(value));
37  }
38  return url;
39};
40
41// A bounded JSON object fetch; 404 is absence rather than failure.
42async function getJson(url: URL): Promise<Json | undefined> {
43  const res = await fetch(url, {
44    headers: { accept: "application/json" },
45    redirect: "error",
46    signal: AbortSignal.timeout(10000),
47  });
48  if (res.status === 404) return;
49  if (!res.ok || Number(res.headers.get("content-length")) > 4_194_304) {
50    throw new Error(`Upstream ${url.origin}: ${res.status}`);
51  }
52  const bytes = await readBytes(res.body ?? [], 4_194_304);
53  const data: unknown = JSON.parse(new TextDecoder().decode(bytes));
54  if (!isJson(data)) throw new Error(`Invalid response from ${url.origin}`);
55  return data;
56}
57
58// For lookups with a fallback, where failure is just absence.
59const tryJson = (url: URL) => getJson(url).catch(() => undefined);
60
61const MiniDoc = l.object({
62  did: l.string,
63  pds: l.string,
64  handle: l.optional(l.string),
65});
66
67// What we know of an account before checking its avatar.
68type Identity = Omit<Actor, "avatar">;
69
70function identity(
71  did: Did,
72  pds: string,
73  claimed: string | undefined,
74  handleVerified: boolean,
75): Identity | undefined {
76  const endpoint = new URL(pds);
77  if (!isBareOrigin(endpoint) || endpoint.protocol !== "https:") return;
78  const usable = claimed && claimed !== "handle.invalid" &&
79    l.handle.validate(claimed).success;
80  return {
81    did,
82    pds: endpoint.href,
83    handle: usable ? claimed : did,
84    handleVerified,
85  };
86}
87
88// A DID document's PDS endpoint and the handle it claims.
89function plcPds(doc: Json): string | undefined {
90  for (const service of Array.isArray(doc.service) ? doc.service : []) {
91    if (
92      service?.id === "#atproto_pds" &&
93      typeof service.serviceEndpoint === "string"
94    ) {
95      return service.serviceEndpoint;
96    }
97  }
98}
99function plcHandle(doc: Json): string | undefined {
100  for (const alias of Array.isArray(doc.alsoKnownAs) ? doc.alsoKnownAs : []) {
101    const claimed = typeof alias === "string" && alias.startsWith("at://") &&
102      alias.slice(5);
103    if (claimed && l.handle.validate(claimed).success) return claimed;
104  }
105}
106
107// Stars whose link we can't render are treated as absent.
108function parseStar(entry: Entry): StarEntry | undefined {
109  const parsed = Star.validate(entry.value);
110  const link = parsed.success ? starUrl(parsed.value.url) : undefined;
111  if (parsed.success && link) {
112    return { uri: entry.uri, star: parsed.value, link };
113  }
114}
115
116export class Hydrant {
117  constructor(readonly base: URL) {}
118
119  private get(method: string, params: Params): Promise<Json | undefined> {
120    return getJson(xrpc(this.base, method, params));
121  }
122
123  // Hydrant's identity cache, then the public one.
124  private async miniDoc(identifier: string) {
125    const method = "blue.microcosm.identity.resolveMiniDoc";
126    const doc = await tryJson(xrpc(this.base, method, { identifier })) ??
127      await tryJson(xrpc(SLINGSHOT, method, { identifier }));
128    const parsed = MiniDoc.validate(doc);
129    if (parsed.success && isDid(parsed.value.did)) {
130      return { ...parsed.value, did: parsed.value.did };
131    }
132  }
133
134  // A PLC account whose handle can't be verified still has a claimed one in
135  // its DID document, which we show as unverified.
136  private async unverified(
137    identifier: string,
138    pds?: string,
139  ): Promise<Identity | undefined> {
140    if (!isDid(identifier) || !identifier.startsWith("did:plc:")) return;
141    const doc = await getJson(new URL(`/${identifier}`, PLC));
142    if (doc?.id !== identifier) return;
143    const endpoint = pds ?? plcPds(doc);
144    if (!endpoint) return;
145    return identity(identifier, endpoint, plcHandle(doc), false);
146  }
147
148  async actor(identifier: string): Promise<Actor | undefined> {
149    const doc = await this.miniDoc(identifier);
150    const known = doc && doc.handle !== "handle.invalid"
151      ? identity(doc.did, doc.pds, doc.handle, true)
152      : await this.unverified(
153        doc?.did ?? identifier,
154        doc?.pds,
155      );
156    if (!known) return;
157    const avatar = await this.avatar(known.did);
158    return avatar ? { ...known, avatar } : known;
159  }
160
161  private async avatar(did: Did): Promise<string | undefined> {
162    const profile = await this.get("com.atproto.repo.getRecord", {
163      repo: did,
164      collection: "app.bsky.actor.profile",
165      rkey: "self",
166    });
167    const parsed = Profile.validate(profile?.value);
168    if (parsed.success && parsed.value.avatar) {
169      return new URL(`/${did}/${parsed.value.avatar.ref.$link}`, CDN).href;
170    }
171    // Hydrant only holds tracked profiles; Bluesky's AppView has the rest.
172    const view = await tryJson(
173      xrpc(BSKY, "app.bsky.actor.getProfile", { actor: did }),
174    );
175    const url = view?.did === did && typeof view.avatar === "string"
176      ? URL.parse(view.avatar)
177      : null;
178    if (url?.protocol === "https:" && url.hostname === "cdn.bsky.app") {
179      return url.href;
180    }
181  }
182
183  async records(
184    did: string,
185    collection: string,
186    cursor?: string,
187    limit = 50,
188  ): Promise<{ entries: Entry[]; cursor?: string }> {
189    const data = await this.get("com.atproto.repo.listRecords", {
190      repo: did,
191      collection,
192      limit,
193      cursor,
194      reverse: false,
195    });
196    if (!data) return { entries: [] };
197    const { records, cursor: next } = data;
198    if (
199      !Array.isArray(records) || records.length > limit ||
200      (next !== undefined && typeof next !== "string")
201    ) throw new Error("Invalid record page");
202    const entries = records.filter((entry): entry is Entry =>
203      typeof entry?.uri === "string" &&
204      entry.uri.startsWith(`at://${did}/${collection}/`)
205    );
206    return { entries, cursor: next };
207  }
208
209  // Follows a collection's listRecords cursor to the end.
210  async *allRecords(did: string, collection: string): AsyncGenerator<Entry> {
211    let cursor: string | undefined;
212    do {
213      const page = await this.records(did, collection, cursor, 100);
214      yield* page.entries;
215      if (page.cursor && page.cursor === cursor) {
216        throw new Error("Repeated Hydrant cursor");
217      }
218      cursor = page.cursor;
219    } while (cursor);
220  }
221
222  async record(uri: string): Promise<Entry | undefined> {
223    const at = parseAtUri(uri);
224    if (!at) return;
225    const data = await this.get("com.atproto.repo.getRecord", {
226      repo: at.did,
227      collection: at.collection,
228      rkey: at.rkey,
229    });
230    if (data?.uri !== uri) return;
231    return { uri, value: data.value };
232  }
233
234  async star(uri: string): Promise<StarEntry | undefined> {
235    const entry = await this.record(uri);
236    return entry && parseStar(entry);
237  }
238
239  async stars(
240    did: string,
241    cursor?: string,
242  ): Promise<{ stars: StarEntry[]; cursor?: string }> {
243    const page = await this.records(did, Star.id, cursor, 25);
244    return {
245      stars: page.entries.flatMap((entry) => parseStar(entry) ?? []),
246      cursor: page.cursor,
247    };
248  }
249
250  // One page of the repos Hydrant tracks, from its management API.
251  private async repoPage(cursor?: string): Promise<string[]> {
252    const url = new URL("/repos", this.base);
253    url.searchParams.set("limit", "1000");
254    if (cursor) url.searchParams.set("cursor", cursor);
255    const res = await fetch(url, {
256      headers: { accept: "application/json" },
257      signal: AbortSignal.timeout(30000),
258    });
259    if (!res.ok) throw new Error(`Hydrant repos: ${res.status}`);
260    const repos: unknown = await res.json();
261    if (!Array.isArray(repos) || repos.length > 1000) {
262      throw new Error("Invalid repo list");
263    }
264    const dids: unknown[] = repos.map((row) => row?.did);
265    const valid = dids.every((did): did is string => typeof did === "string");
266    if (!valid || (cursor && dids.includes(cursor))) {
267      throw new Error("Invalid repo page");
268    }
269    return dids;
270  }
271
272  async *repos(): AsyncGenerator<string> {
273    let page: string[], cursor: string | undefined;
274    do {
275      page = await this.repoPage(cursor);
276      yield* page;
277      cursor = page.at(-1);
278    } while (page.length);
279  }
280
281  async track(did: string): Promise<void> {
282    const res = await fetch(new URL("/repos", this.base), {
283      method: "PUT",
284      headers: { "content-type": "application/json" },
285      body: JSON.stringify([{ did }]),
286      signal: AbortSignal.timeout(10000),
287    });
288    if (!res.ok) throw new Error(`Could not track repo: ${res.status}`);
289    await res.body?.cancel();
290  }
291
292  // The event stream, resuming from the given event ID.
293  stream(cursor: number): URL {
294    const url = new URL("/stream", this.base);
295    url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
296    url.searchParams.set("cursor", String(cursor));
297    return url;
298  }
299}