char-slop/stars.hackery.site

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

Charlotte Somsyntactic pass: no casts, assertions, or void92004f9

main
5.9 KiB200 linesraw
1import { DatabaseSync } from "node:sqlite";
2import {
3  MemoryStore,
4  OAuthClient,
5  type OAuthSession,
6  type StoredSession,
7  type StoredState,
8} from "@atcute/oauth-node-client";
9import {
10  CompositeDidDocumentResolver,
11  CompositeHandleResolver,
12  LocalActorResolver,
13  PlcDidDocumentResolver,
14  WebDidDocumentResolver,
15  WellKnownHandleResolver,
16} from "@atcute/identity-resolver";
17import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node";
18import { cookie, readCookie } from "./http.ts";
19import { type Did, Follow, isDid, parseAtUri, Star } from "./records.ts";
20
21export const scope = `atproto repo:${Star.id} repo:${Follow.id}`;
22const SESSION_TTL = 14 * 86400;
23
24export type Session = {
25  id: string;
26  did: Did;
27  csrf: string;
28  oauth: OAuthSession;
29};
30
31// A key/value table with JSON values, as the OAuth client's store interface.
32function jsonTable<V>(db: DatabaseSync, name: string) {
33  db.exec(
34    `CREATE TABLE IF NOT EXISTS ${name} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
35  );
36  return {
37    get: (key: string): V | undefined => {
38      const row = db.prepare(`SELECT value FROM ${name} WHERE key = ?`).get(
39        key,
40      );
41      return row && JSON.parse(String(row.value));
42    },
43    set: (key: string, value: V) => {
44      db.prepare(`INSERT OR REPLACE INTO ${name} VALUES (?, ?)`).run(
45        key,
46        JSON.stringify(value),
47      );
48    },
49    delete: (key: string) => {
50      db.prepare(`DELETE FROM ${name} WHERE key = ?`).run(key);
51    },
52    clear: () => {
53      db.prepare(`DELETE FROM ${name}`).run();
54    },
55  };
56}
57
58export class Auth {
59  readonly client: OAuthClient;
60  constructor(readonly db: DatabaseSync, readonly origin: URL) {
61    db.exec(
62      "CREATE TABLE IF NOT EXISTS browser_session (id TEXT PRIMARY KEY, did TEXT NOT NULL, csrf TEXT NOT NULL, expires INTEGER NOT NULL)",
63    );
64    const states = jsonTable<StoredState>(db, "oauth_state");
65    const local = origin.hostname === "127.0.0.1";
66    this.client = new OAuthClient({
67      metadata: {
68        ...(!local && {
69          client_id: `${origin.origin}/oauth-client-metadata.json`,
70          client_name: "Stars",
71          client_uri: origin.origin,
72        }),
73        redirect_uris: [`${origin.origin}/oauth/callback`],
74        scope,
75      },
76      // the default caches purge on a timer, which keeps the process alive after SIGTERM
77      stores: {
78        prMetadata: new MemoryStore({ maxSize: 100, ttl: 60e3 }),
79        asMetadata: new MemoryStore({ maxSize: 100, ttl: 60e3 }),
80        dpopNonces: new MemoryStore({ maxSize: 100, ttl: 60e3 }),
81        sessions: jsonTable<StoredSession>(db, "oauth_session"),
82        states: {
83          ...states,
84          get: (key) => {
85            const state = states.get(key);
86            if (state && state.expiresAt <= Date.now()) {
87              states.delete(key);
88              return;
89            }
90            return state;
91          },
92        },
93      },
94      actorResolver: new LocalActorResolver({
95        handleResolver: new CompositeHandleResolver({
96          methods: {
97            dns: new NodeDnsHandleResolver(),
98            http: new WellKnownHandleResolver(),
99          },
100        }),
101        didDocumentResolver: new CompositeDidDocumentResolver({
102          methods: {
103            plc: new PlcDidDocumentResolver(),
104            web: new WebDidDocumentResolver(),
105          },
106        }),
107      }),
108    });
109  }
110
111  get secure(): boolean {
112    return this.origin.protocol === "https:";
113  }
114
115  private browser(req: Request) {
116    const id = readCookie(req, "stars_session");
117    if (!id || !/^[0-9a-f]{64}$/.test(id)) return;
118    const row = this.db.prepare(
119      "SELECT did, csrf FROM browser_session WHERE id = ? AND expires > ?",
120    ).get(id, Date.now());
121    if (!row) return;
122    const did = String(row.did), csrf = String(row.csrf);
123    if (isDid(did)) return { id, did, csrf };
124  }
125
126  async session(req: Request): Promise<Session | undefined> {
127    const browser = this.browser(req);
128    if (!browser) return;
129    try {
130      return { ...browser, oauth: await this.client.restore(browser.did) };
131    } catch {
132      return;
133    }
134  }
135
136  createBrowser(did: string): string {
137    const id = randomHex(), csrf = randomHex();
138    this.db.prepare("INSERT INTO browser_session VALUES (?, ?, ?, ?)").run(
139      id,
140      did,
141      csrf,
142      Date.now() + SESSION_TTL * 1000,
143    );
144    return id;
145  }
146
147  deleteBrowser(id: string): void {
148    this.db.prepare("DELETE FROM browser_session WHERE id = ?").run(id);
149  }
150
151  sessionCookie(id: string, maxAge = SESSION_TTL): string {
152    return cookie("stars_session", id, this.secure, maxAge);
153  }
154}
155
156function randomHex(): string {
157  return Array.from(
158    crypto.getRandomValues(new Uint8Array(32)),
159    (byte) => byte.toString(16).padStart(2, "0"),
160  ).join("");
161}
162
163export async function writeRecord(
164  oauth: OAuthSession,
165  collection: string,
166  record: unknown,
167): Promise<string> {
168  const res = await oauth.handle("/xrpc/com.atproto.repo.createRecord", {
169    method: "POST",
170    headers: { "content-type": "application/json" },
171    body: JSON.stringify({ repo: oauth.did, collection, record }),
172  });
173  if (!res.ok) throw new Error(`PDS write failed: ${res.status}`);
174  const data = await res.json();
175  if (
176    typeof data.uri !== "string" ||
177    !data.uri.startsWith(`at://${oauth.did}/${collection}/`)
178  ) throw new Error("Invalid PDS write response");
179  return data.uri;
180}
181
182export async function deleteRecord(
183  oauth: OAuthSession,
184  uri: string,
185): Promise<void> {
186  const at = parseAtUri(uri);
187  const owned = at?.did === oauth.did &&
188    (at.collection === Star.id || at.collection === Follow.id);
189  if (!owned) throw new Error("Invalid owned record URI");
190  const res = await oauth.handle("/xrpc/com.atproto.repo.deleteRecord", {
191    method: "POST",
192    headers: { "content-type": "application/json" },
193    body: JSON.stringify({
194      repo: oauth.did,
195      collection: at.collection,
196      rkey: at.rkey,
197    }),
198  });
199  if (!res.ok) throw new Error(`PDS deletion failed: ${res.status}`);
200}