···11{
22 "name": "@skyware/labeler",
33 "type": "module",
44- "description": "A lightweight alternative to Ozone for operating an atproto labeler",
44+ "description": "A lightweight alternative to Ozone for operating an atproto labeler.",
55 "version": "0.0.1",
66 "main": "dist/index.js",
77 "types": "dist/index.d.ts",
···2323import { formatLabel, labelIsSigned, signLabel } from "./util/labels.js";
2424import { SignedLabel } from "./util/types.js";
25252626+/**
2727+ * Options for the {@link LabelerServer} class.
2828+ */
2629export interface LabelerOptions {
3030+ /** The DID of the labeler account. */
2731 did: string;
3232+3333+ /**
3434+ * The private signing key used for the labeler.
3535+ * If you don't have a key, generate and set one using {@link plcSetupLabeler}.
3636+ */
2837 signingKey: string;
3838+3939+ /**
4040+ * A function that returns whether a DID is authorized to create labels.
4141+ * By default, only the labeler account is authorized.
4242+ * @param did The DID to check.
4343+ */
2944 auth?: (did: string) => boolean | Promise<boolean>;
3030- dbFile?: string;
4545+ /**
4646+ * The path to the SQLite `.db` database file.
4747+ * @default labels.db
4848+ */
4949+ dbPath?: string;
3150}
32513352export class LabelerServer {
5353+ /** The Express application instance. */
3454 app: Application;
35555656+ /** The HTTP server instance. */
3657 server?: Server;
37585959+ /** The SQLite database instance. */
3860 db: SQLiteDatabase;
39616262+ /** The DID of the labeler account. */
4063 did: string;
41646565+ /** A function that returns whether a DID is authorized to create labels. */
4266 private auth: (did: string) => boolean | Promise<boolean>;
43676868+ /** The signing key used for the labeler. */
4469 private signingKey: Keypair;
45707171+ /** The ID resolver instance. */
4672 private idResolver = new IdResolver();
47734848- private subscriptions = new Set<WebSocket>();
7474+ /** Open WebSocket connections, mapped by request NSID. */
7575+ private connections = new Map<string, Set<WebSocket>>();
49767777+ /**
7878+ * Create a labeler server.
7979+ * @param options Configuration options.
8080+ */
5081 constructor(options: LabelerOptions) {
5182 this.did = options.did;
5283 this.signingKey = new Secp256k1Keypair(ui8FromString(options.signingKey, "hex"), false);
5384 this.auth = options.auth ?? ((did) => did === this.did);
54855555- this.db = new Database(options.dbFile ?? "labels.db");
8686+ this.db = new Database(options.dbPath ?? "labels.db");
5687 this.db.pragma("journal_mode = WAL");
5788 this.db.exec(`
5889 CREATE TABLE IF NOT EXISTS labels (
···74105 this.app.post("/xrpc/tools.ozone.moderation.emitEvent", this.emitEventHandler);
75106 }
76107108108+ /**
109109+ * Start the server.
110110+ * @param port The port to listen on.
111111+ * @param callback A callback to run when the server is started.
112112+ */
77113 start(port = 443, callback?: () => void) {
78114 this.server = this.app.listen(port, callback);
79115 }
80116117117+ /**
118118+ * Stop the server.
119119+ * @param callback A callback to run when the server is stopped.
120120+ */
81121 stop(callback?: () => void) {
82122 if (this.server?.listening) this.server?.close(callback);
83123 }
84124125125+ /**
126126+ * Create and insert a label into the database, emitting it to subscribers.
127127+ * @param label The label to create.
128128+ */
85129 async createLabel(label: ComAtprotoLabelDefs.Label): Promise<SignedLabel> {
86130 const signed = labelIsSigned(label) ? label : await signLabel(label, this.signingKey);
87131 const stmt = this.db.prepare(`
···130174 }
131175 }
132176177177+ /**
178178+ * Ensure a label is signed, updating if necessary.
179179+ * @param label The label to ensure is signed.
180180+ */
133181 private async ensureSignedLabel(label: ComAtprotoLabelDefs.Label): Promise<SignedLabel> {
134182 if (!labelIsSigned(label)) {
135183 const signed = await signLabel(label, this.signingKey);
···144192 return formatLabel(label);
145193 }
146194195195+ /**
196196+ * Emit a label to all subscribers.
197197+ * @param label The label to emit.
198198+ */
147199 private async emitLabel(label: ComAtprotoLabelDefs.Label) {
148200 const signed = await this.ensureSignedLabel(label);
149201 const frame = new MessageFrame({ seq: label.id, labels: [signed] }, { type: "#labels" });
150150- this.subscriptions.forEach((ws) => ws.send(frame.toBytes()));
202202+ this.connections.get("com.atproto.label.subscribeLabels")?.forEach((ws) => { ws.send(frame.toBytes()); });
151203 }
152204205205+ /**
206206+ * Parse a user DID from an Authorization header JWT.
207207+ * @param req The Express request object.
208208+ */
153209 private async parseAuthHeaderDid(req: express.Request): Promise<string> {
154210 const authHeader = req.get("Authorization");
155211 if (!authHeader) throw new AuthRequiredError("Authorization header is required");
···170226 return payload.iss;
171227 }
172228229229+ /**
230230+ * Handler for com.atproto.label.queryLabels.
231231+ */
173232 queryLabelsHandler: RequestHandler = async (req, res) => {
174233 try {
175234 const { uriPatterns, sources, limit: limitStr, cursor: cursorStr } = req.query as {
···221280 } else {
222281 console.error(e);
223282 res.status(500).json({
224224- error: "InternalError",
283283+ error: "InternalServerError",
225284 message: "An unknown error occurred",
226285 });
227286 }
···229288 }
230289 };
231290291291+ /**
292292+ * Handler for com.atproto.label.subscribeLabels.
293293+ */
232294 subscribeLabelsHandler: WebsocketRequestHandler = async (ws, req) => {
233295 const cursor = parseInt(req.params.cursor);
234296···251313 ORDER BY id ASC
252314 `);
253315254254- for (const row of stmt.iterate(cursor)) {
255255- await this.ensureSignedLabel(row);
256256- const { id: seq, ...label } = row;
257257- const frame = new MessageFrame({ seq, labels: [label] }, { type: "#labels" });
258258- ws.send(frame.toBytes());
316316+ try {
317317+ for (const row of stmt.iterate(cursor)) {
318318+ await this.ensureSignedLabel(row);
319319+ const { id: seq, ...label } = row;
320320+ const frame = new MessageFrame({ seq, labels: [label] }, { type: "#labels" });
321321+ ws.send(frame.toBytes());
322322+ }
323323+ } catch (e) {
324324+ console.error(e);
325325+ const errorFrame = new ErrorFrame({
326326+ error: "InternalServerError",
327327+ message: "An unknown error occurred",
328328+ });
329329+ ws.send(errorFrame.toBytes());
330330+ ws.terminate();
259331 }
260332 }
261333262262- this.subscriptions.add(ws);
334334+ this.addSubscription("com.atproto.label.subscribeLabels", ws);
263335264336 ws.on("close", () => {
265265- this.subscriptions.delete(ws);
337337+ this.removeSubscription("com.atproto.label.subscribeLabels", ws);
266338 });
267339 };
268340341341+ /**
342342+ * Handler for tools.ozone.moderation.emitEvent.
343343+ */
269344 emitEventHandler: RequestHandler = async (req, res) => {
270345 try {
271346 const actorDid = await this.parseAuthHeaderDid(req);
···313388 } else {
314389 console.error(e);
315390 res.status(500).json({
316316- error: "InternalError",
391391+ error: "InternalServerError",
317392 message: "An unknown error occurred",
318393 });
319394 }
320395 }
321396 };
397397+398398+ /**
399399+ * Add a WebSocket connection to the list of subscribers for a given lexicon.
400400+ * @param nsid The NSID of the lexicon to subscribe to.
401401+ * @param ws The WebSocket connection to add.
402402+ */
403403+ private addSubscription(nsid: string, ws: WebSocket) {
404404+ const subs = this.connections.get(nsid) ?? new Set();
405405+ subs.add(ws);
406406+ this.connections.set(nsid, subs);
407407+ }
408408+409409+ /**
410410+ * Remove a WebSocket connection from the list of subscribers for a given lexicon.
411411+ * @param nsid The NSID of the lexicon to unsubscribe from.
412412+ * @param ws The WebSocket connection to remove.
413413+ */
414414+ private removeSubscription(nsid: string, ws: WebSocket) {
415415+ const subs = this.connections.get(nsid);
416416+ if (subs) {
417417+ subs.delete(ws);
418418+ if (!subs.size) this.connections.delete(nsid);
419419+ }
420420+ }
322421}
+5
src/index.ts
···11+export { LabelerServer, type LabelerOptions } from "./LabelerServer.js";
22+export type { SignedLabel } from "./util/types.js";
33+export { formatLabel, signLabel, labelIsSigned } from "./util/labels.js";
44+55+export * as scripts from "./scripts/index.js";
+6-9
src/util/labels.ts
···11-import type { ComAtprotoLabelDefs } from "@atproto/api";
22-import type { SignedLabel, StrictPartial } from "./types.js";
31import { encode as cborEncode } from "@atcute/cbor";
22+import type { ComAtprotoLabelDefs } from "@atproto/api";
43import type { Keypair } from "@atproto/crypto";
44+import type { SignedLabel, StrictPartial } from "./types.js";
5566const LABEL_VERSION = 1;
77···1919 } as never;
2020}
21212222-export async function signLabel(label: ComAtprotoLabelDefs.Label, signingKey: Keypair): Promise<SignedLabel> {
2222+export async function signLabel(
2323+ label: ComAtprotoLabelDefs.Label,
2424+ signingKey: Keypair,
2525+): Promise<SignedLabel> {
2326 const toSign = formatLabel(label);
2427 const bytes = cborEncode(toSign);
2528 const sig = await signingKey.sign(bytes);
···3134): label is T & { sig: Uint8Array } {
3235 return label.sig !== undefined;
3336}
3434-3535-export function assertLabelIsSigned<T extends ComAtprotoLabelDefs.Label>(
3636- label: T,
3737-): asserts label is T & { sig: Uint8Array } {
3838- if (!label.sig) throw new Error("Label is not signed");
3939-}
+1-1
src/util/types.ts
···44 & { [K in keyof T as undefined extends T[K] ? never : K]: T[K] }
55 & { [K in keyof T as undefined extends T[K] ? K : never]?: T[K] };
6677-export type SignedLabel = ComAtprotoLabelDefs.Label & { sig: Uint8Array };
77+export type SignedLabel = ComAtprotoLabelDefs.Label & { sig: Uint8Array };