···11+# @atcute/labeler
22+33+label signing and subscription engine for AT Protocol.
44+55+```sh
66+npm install @atcute/labeler
77+```
88+99+this package provides the core logic for building an AT Protocol labeler: signing labels with a
1010+service key, persisting them to a store, and streaming them to subscribers. it's framework-agnostic
1111+— wire it up to `@atcute/xrpc-server` or any other server to expose the
1212+`com.atproto.label.subscribeLabels` endpoint.
1313+1414+## usage
1515+1616+### creating a labeler
1717+1818+```ts
1919+import { Labeler, MemoryLabelStore } from '@atcute/labeler';
2020+import { P256PrivateKey, parsePrivateMultikey } from '@atcute/crypto';
2121+2222+const { privateKeyBytes } = parsePrivateMultikey(process.env.SIGNING_KEY!);
2323+2424+const labeler = new Labeler({
2525+ serviceDid: 'did:plc:my-labeler',
2626+ signingKey: await P256PrivateKey.importRaw(privateKeyBytes),
2727+ store: new MemoryLabelStore(),
2828+});
2929+```
3030+3131+`MemoryLabelStore` works for development and testing. for production, implement the `LabelStore`
3232+interface backed by a database.
3333+3434+### applying labels
3535+3636+use `applyLabel()` for a single label or `applyLabels()` for a batch. each label operation specifies
3737+a target URI and a label value:
3838+3939+```ts
4040+// label a post as spam
4141+await labeler.applyLabel({
4242+ uri: 'at://did:plc:alice/app.bsky.feed.post/abc123',
4343+ value: 'spam',
4444+});
4545+4646+// negate a previous label
4747+await labeler.applyLabel({
4848+ uri: 'at://did:plc:alice/app.bsky.feed.post/abc123',
4949+ value: 'spam',
5050+ negate: true,
5151+});
5252+5353+// batch of labels with shared defaults
5454+await labeler.applyLabels(
5555+ [
5656+ { uri: 'at://did:plc:alice/app.bsky.feed.post/1', value: 'spam' },
5757+ { uri: 'at://did:plc:alice/app.bsky.feed.post/2', value: 'nudity' },
5858+ ],
5959+ { issuedAt: new Date().toISOString() },
6060+);
6161+```
6262+6363+labels are CBOR-encoded, signed with the service key, and persisted to the store. each stored label
6464+gets a monotonically increasing sequence number.
6565+6666+### subscribing to label events
6767+6868+`subscribeLabels()` returns an async iterator of label events. pass a `cursor` to replay from a
6969+previous sequence number, or omit it to only receive live events:
7070+7171+```ts
7272+// replay from sequence 0 and continue with live events
7373+for await (const event of labeler.subscribeLabels({ cursor: 0, signal: controller.signal })) {
7474+ console.log(event.seq, event.labels);
7575+}
7676+```
7777+7878+the subscription handles backfill (draining stored events) and then seamlessly transitions to live
7979+tailing. if a subscriber falls too far behind, a `ConsumerTooSlowError` is thrown.
8080+8181+### wiring up to an XRPC server
8282+8383+use `@atcute/xrpc-server` with a runtime-specific WebSocket adapter to serve the subscription
8484+endpoint:
8585+8686+```ts
8787+import { XRPCRouter, XRPCSubscriptionError } from '@atcute/xrpc-server';
8888+import { createBunWebSocket } from '@atcute/xrpc-server-bun';
8989+import { ComAtprotoLabelSubscribeLabels } from '@atcute/atproto';
9090+9191+import { FutureCursorError } from '@atcute/labeler';
9292+9393+const ws = createBunWebSocket();
9494+const router = new XRPCRouter({ websocket: ws.adapter });
9595+9696+router.addSubscription(ComAtprotoLabelSubscribeLabels, {
9797+ async *handler({ params, signal }) {
9898+ try {
9999+ yield* labeler.subscribeLabels({ cursor: params.cursor, signal });
100100+ } catch (err) {
101101+ if (err instanceof FutureCursorError) {
102102+ throw new XRPCSubscriptionError({ error: 'FutureCursor' });
103103+ }
104104+ throw err;
105105+ }
106106+ },
107107+});
108108+109109+export default ws.wrap(router);
110110+```
111111+112112+## custom label stores
113113+114114+implement the `LabelStore` interface for durable persistence:
115115+116116+```ts
117117+import type { LabelStore, LabelEvent, SignedLabel } from '@atcute/labeler';
118118+119119+class SqliteLabelStore implements LabelStore {
120120+ async appendLabels(labels: SignedLabel[]): Promise<LabelEvent[]> {
121121+ // insert labels and assign sequence numbers
122122+ }
123123+124124+ async getLatestSeq(): Promise<number | null> {
125125+ // return the highest sequence number, or null if empty
126126+ }
127127+128128+ async listLabelEvents(options: { after?: number; limit?: number }): Promise<LabelEvent[]> {
129129+ // return events after the cursor in ascending sequence order
130130+ }
131131+}
132132+```
133133+134134+the store must assign a unique, monotonically increasing `seq` to each event. the labeler uses these
135135+for cursor-based pagination during subscription backfill.