@hatk/hatk 0.0.1-alpha.7 → 0.0.1-alpha.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +108 -0
  4. package/dist/backfill.d.ts +21 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +116 -43
  7. package/dist/car.d.ts +42 -10
  8. package/dist/car.d.ts.map +1 -1
  9. package/dist/car.js +154 -14
  10. package/dist/cli.js +243 -1043
  11. package/dist/config.d.ts +46 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +42 -9
  14. package/dist/database/adapter-factory.d.ts +6 -0
  15. package/dist/database/adapter-factory.d.ts.map +1 -0
  16. package/dist/database/adapter-factory.js +20 -0
  17. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  18. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  19. package/dist/database/adapters/duckdb-search.js +27 -0
  20. package/dist/database/adapters/duckdb.d.ts +25 -0
  21. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  22. package/dist/database/adapters/duckdb.js +161 -0
  23. package/dist/database/adapters/sqlite-search.d.ts +23 -0
  24. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  25. package/dist/database/adapters/sqlite-search.js +74 -0
  26. package/dist/database/adapters/sqlite.d.ts +18 -0
  27. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  28. package/dist/database/adapters/sqlite.js +88 -0
  29. package/dist/{db.d.ts → database/db.d.ts} +57 -6
  30. package/dist/database/db.d.ts.map +1 -0
  31. package/dist/{db.js → database/db.js} +755 -552
  32. package/dist/database/dialect.d.ts +45 -0
  33. package/dist/database/dialect.d.ts.map +1 -0
  34. package/dist/database/dialect.js +72 -0
  35. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  36. package/dist/database/fts.d.ts.map +1 -0
  37. package/dist/{fts.js → database/fts.js} +116 -32
  38. package/dist/database/index.d.ts +7 -0
  39. package/dist/database/index.d.ts.map +1 -0
  40. package/dist/database/index.js +6 -0
  41. package/dist/database/ports.d.ts +50 -0
  42. package/dist/database/ports.d.ts.map +1 -0
  43. package/dist/database/ports.js +1 -0
  44. package/dist/{schema.d.ts → database/schema.d.ts} +14 -3
  45. package/dist/database/schema.d.ts.map +1 -0
  46. package/dist/{schema.js → database/schema.js} +81 -41
  47. package/dist/dev-entry.d.ts +8 -0
  48. package/dist/dev-entry.d.ts.map +1 -0
  49. package/dist/dev-entry.js +119 -0
  50. package/dist/feeds.d.ts +12 -8
  51. package/dist/feeds.d.ts.map +1 -1
  52. package/dist/feeds.js +51 -6
  53. package/dist/hooks.d.ts +85 -0
  54. package/dist/hooks.d.ts.map +1 -0
  55. package/dist/hooks.js +163 -0
  56. package/dist/hydrate.d.ts +7 -6
  57. package/dist/hydrate.d.ts.map +1 -1
  58. package/dist/hydrate.js +4 -16
  59. package/dist/indexer.d.ts +109 -3
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +338 -66
  62. package/dist/jetstream.d.ts +82 -0
  63. package/dist/jetstream.d.ts.map +1 -0
  64. package/dist/jetstream.js +161 -0
  65. package/dist/labels.d.ts +36 -0
  66. package/dist/labels.d.ts.map +1 -1
  67. package/dist/labels.js +71 -6
  68. package/dist/lexicon-resolve.d.ts.map +1 -1
  69. package/dist/lexicon-resolve.js +27 -112
  70. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  71. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  72. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  73. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  74. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  75. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  76. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  77. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  78. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  79. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  80. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  81. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  82. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  83. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  84. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  85. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  86. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  88. package/dist/logger.d.ts +29 -0
  89. package/dist/logger.d.ts.map +1 -1
  90. package/dist/logger.js +29 -0
  91. package/dist/main.js +161 -72
  92. package/dist/mst.d.ts +18 -1
  93. package/dist/mst.d.ts.map +1 -1
  94. package/dist/mst.js +19 -8
  95. package/dist/oauth/db.d.ts +3 -1
  96. package/dist/oauth/db.d.ts.map +1 -1
  97. package/dist/oauth/db.js +56 -19
  98. package/dist/oauth/discovery.d.ts.map +1 -1
  99. package/dist/oauth/discovery.js +13 -1
  100. package/dist/oauth/server.d.ts +38 -0
  101. package/dist/oauth/server.d.ts.map +1 -1
  102. package/dist/oauth/server.js +228 -31
  103. package/dist/oauth/session.d.ts +11 -0
  104. package/dist/oauth/session.d.ts.map +1 -0
  105. package/dist/oauth/session.js +65 -0
  106. package/dist/opengraph.d.ts +10 -0
  107. package/dist/opengraph.d.ts.map +1 -1
  108. package/dist/opengraph.js +80 -40
  109. package/dist/pds-proxy.d.ts +60 -0
  110. package/dist/pds-proxy.d.ts.map +1 -0
  111. package/dist/pds-proxy.js +277 -0
  112. package/dist/private-collections.d.ts +8 -0
  113. package/dist/private-collections.d.ts.map +1 -0
  114. package/dist/private-collections.js +22 -0
  115. package/dist/push.d.ts +34 -0
  116. package/dist/push.d.ts.map +1 -0
  117. package/dist/push.js +186 -0
  118. package/dist/renderer.d.ts +27 -0
  119. package/dist/renderer.d.ts.map +1 -0
  120. package/dist/renderer.js +46 -0
  121. package/dist/resolve-hatk.d.ts +6 -0
  122. package/dist/resolve-hatk.d.ts.map +1 -0
  123. package/dist/resolve-hatk.js +20 -0
  124. package/dist/response.d.ts +16 -0
  125. package/dist/response.d.ts.map +1 -0
  126. package/dist/response.js +69 -0
  127. package/dist/scanner.d.ts +21 -0
  128. package/dist/scanner.d.ts.map +1 -0
  129. package/dist/scanner.js +88 -0
  130. package/dist/seed.d.ts +19 -0
  131. package/dist/seed.d.ts.map +1 -1
  132. package/dist/seed.js +43 -4
  133. package/dist/server-init.d.ts +8 -0
  134. package/dist/server-init.d.ts.map +1 -0
  135. package/dist/server-init.js +62 -0
  136. package/dist/server.d.ts +26 -3
  137. package/dist/server.d.ts.map +1 -1
  138. package/dist/server.js +729 -638
  139. package/dist/setup.d.ts +28 -1
  140. package/dist/setup.d.ts.map +1 -1
  141. package/dist/setup.js +50 -3
  142. package/dist/templates/feed.tpl +14 -0
  143. package/dist/templates/hook.tpl +5 -0
  144. package/dist/templates/label.tpl +15 -0
  145. package/dist/templates/og.tpl +17 -0
  146. package/dist/templates/seed.tpl +11 -0
  147. package/dist/templates/setup.tpl +5 -0
  148. package/dist/templates/test-feed.tpl +19 -0
  149. package/dist/templates/test-xrpc.tpl +19 -0
  150. package/dist/templates/xrpc.tpl +41 -0
  151. package/dist/test.d.ts +1 -1
  152. package/dist/test.d.ts.map +1 -1
  153. package/dist/test.js +41 -32
  154. package/dist/views.js +1 -1
  155. package/dist/vite-plugin.d.ts +1 -1
  156. package/dist/vite-plugin.d.ts.map +1 -1
  157. package/dist/vite-plugin.js +254 -66
  158. package/dist/xrpc.d.ts +75 -11
  159. package/dist/xrpc.d.ts.map +1 -1
  160. package/dist/xrpc.js +189 -39
  161. package/package.json +17 -9
  162. package/public/admin-auth.js +271 -306
  163. package/public/admin.html +177 -61
  164. package/dist/db.d.ts.map +0 -1
  165. package/dist/fts.d.ts.map +0 -1
  166. package/dist/oauth/hooks.d.ts +0 -10
  167. package/dist/oauth/hooks.d.ts.map +0 -1
  168. package/dist/oauth/hooks.js +0 -40
  169. package/dist/schema.d.ts.map +0 -1
  170. package/dist/test-browser.d.ts +0 -14
  171. package/dist/test-browser.d.ts.map +0 -1
  172. package/dist/test-browser.js +0 -26
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Jetstream v2 live tail — an alternative stream source to the relay firehose.
3
+ *
4
+ * Where `com.atproto.sync.subscribeRepos` ships every commit on the network as
5
+ * DAG-CBOR frames wrapping a CAR block store, Jetstream filters server-side and
6
+ * delivers records as already-decoded JSON. For an AppView tracking a handful
7
+ * of collections that is the difference between decoding the whole network and
8
+ * decoding only what it indexes.
9
+ *
10
+ * What this trades away: the CAR/MST proofs. hatk never verified them — it
11
+ * already trusts the relay — so nothing is lost that was being used. The
12
+ * `getRepo` backfill path in backfill.ts still speaks CAR and is unaffected.
13
+ *
14
+ * @see https://bsky.network/docs/jetstream
15
+ * @module
16
+ */
17
+ import { applyCommit, configureIndexer, getLastSeq, handleIdentityEvent, isIndexableCollection, noteSeq, resumeCursor, setCursorKey, } from "./indexer.js";
18
+ import { log, emit } from "./logger.js";
19
+ /** Server-side filter caps, rejected before the WebSocket upgrade. */
20
+ export const MAX_COLLECTIONS = 100;
21
+ export const MAX_DIDS = 10_000;
22
+ /** Event kinds hatk consumes. `commit` carries records; `identity` drives handle renames. */
23
+ const KINDS = ['commit', 'identity'];
24
+ const RECONNECT_DELAY_MS = 3000;
25
+ /**
26
+ * Build the subscribe URL.
27
+ *
28
+ * Collections are sent explicitly rather than as an `ns.*` wildcard: a wildcard
29
+ * would also match sibling NSIDs the app has no lexicon for (and, for private
30
+ * collections, ones it must never accept from the network).
31
+ *
32
+ * Exported for tests.
33
+ */
34
+ export function buildSubscribeUrl(jetstreamUrl, collections, pinnedRepos, cursor) {
35
+ const params = new URLSearchParams();
36
+ for (const collection of collections)
37
+ params.append('collections', collection);
38
+ for (const kind of KINDS)
39
+ params.append('kinds', kind);
40
+ if (pinnedRepos) {
41
+ for (const did of pinnedRepos)
42
+ params.append('dids', did);
43
+ }
44
+ if (cursor)
45
+ params.append('cursor', cursor);
46
+ return `${jetstreamUrl}/xrpc/network.bsky.jetstream.subscribeEvents?${params}`;
47
+ }
48
+ /**
49
+ * Reject filters the server would reject at the handshake, where the message
50
+ * can name the actual limit instead of surfacing as a failed connection.
51
+ * Exported for tests.
52
+ */
53
+ export function assertFilterLimits(collections, pinnedRepos) {
54
+ if (collections.size > MAX_COLLECTIONS) {
55
+ throw new Error(`Jetstream accepts at most ${MAX_COLLECTIONS} collections, got ${collections.size}. ` +
56
+ `Narrow the indexed collections or use the relay firehose instead.`);
57
+ }
58
+ if (pinnedRepos && pinnedRepos.size > MAX_DIDS) {
59
+ throw new Error(`Jetstream accepts at most ${MAX_DIDS} dids, got ${pinnedRepos.size}. ` +
60
+ `Unpin some repos or use the relay firehose instead.`);
61
+ }
62
+ }
63
+ /**
64
+ * Translate one Jetstream commit payload into hatk's wire-agnostic op shape.
65
+ *
66
+ * Deletes carry no `record` or `cid` — only the collection and rkey that
67
+ * identify what went away. Returns `null` when the payload is unusable or
68
+ * names a collection this AppView must not accept from the network.
69
+ *
70
+ * Exported for tests.
71
+ */
72
+ export function commitToOp(commit, collections) {
73
+ const { collection, rkey, operation } = commit;
74
+ if (!collection || !rkey || !operation)
75
+ return null;
76
+ if (!isIndexableCollection(collection, collections))
77
+ return null;
78
+ if (operation === 'delete') {
79
+ return { action: 'delete', collection, rkey };
80
+ }
81
+ if (operation !== 'create' && operation !== 'update')
82
+ return null;
83
+ if (!commit.cid || !commit.record)
84
+ return null;
85
+ return { action: operation, collection, rkey, cid: commit.cid, record: commit.record };
86
+ }
87
+ /**
88
+ * Handle one decoded frame. Kept synchronous so the socket's event queue drains
89
+ * without backpressure — identity resolution is fire-and-forget, matching the
90
+ * relay path.
91
+ *
92
+ * Exported for tests.
93
+ */
94
+ export function processEvent(payload, collections) {
95
+ const kind = typeof payload?.$type === 'string' ? payload.$type.split('#')[1] : undefined;
96
+ if (kind === 'identity') {
97
+ const did = typeof payload.did === 'string' ? payload.did : undefined;
98
+ const handle = typeof payload.handle === 'string' ? payload.handle : undefined;
99
+ if (did)
100
+ handleIdentityEvent(did, handle);
101
+ return;
102
+ }
103
+ if (kind !== 'commit')
104
+ return;
105
+ // Track sequence for the cursor before filtering: a stream of events for
106
+ // collections we don't index still advances the position we must resume from.
107
+ if (typeof payload.seq === 'number')
108
+ noteSeq(payload.seq);
109
+ const did = typeof payload.did === 'string' ? payload.did : undefined;
110
+ if (!did)
111
+ return;
112
+ const op = commitToOp(payload, collections);
113
+ if (!op)
114
+ return;
115
+ applyCommit(did, [op]);
116
+ }
117
+ /**
118
+ * Connect to a Jetstream v2 instance and begin indexing.
119
+ *
120
+ * Reconnects on disconnect after {@link RECONNECT_DELAY_MS}, resuming from the
121
+ * highest seq seen rather than the boot-time cursor. Jetstream's cursor is
122
+ * inclusive and delivery is at-least-once, so the event at the resume point
123
+ * arrives again — harmless, since writes upsert on the record's `at://` URI.
124
+ *
125
+ * @returns The WebSocket connection (for shutdown coordination)
126
+ */
127
+ export async function startJetstreamIndexer(opts) {
128
+ const { jetstreamUrl, collections, cursor } = opts;
129
+ const pinnedRepos = opts.pinnedRepos || null;
130
+ assertFilterLimits(collections, pinnedRepos);
131
+ setCursorKey('jetstream');
132
+ await configureIndexer(opts);
133
+ const wsUrl = buildSubscribeUrl(jetstreamUrl, collections, pinnedRepos, cursor);
134
+ if (cursor)
135
+ log(`[jetstream] Resuming from cursor ${cursor}`);
136
+ log(`[jetstream] Connecting to ${jetstreamUrl} (${collections.size} collections)...`);
137
+ // The lexicon default is identical framing, so an empty subprotocol echo is
138
+ // fine; we offer it so the server can pick the JSON arm explicitly.
139
+ const ws = new WebSocket(wsUrl, ['xrpc.v1.json']);
140
+ ws.addEventListener('message', (event) => {
141
+ try {
142
+ if (typeof event.data !== 'string')
143
+ return;
144
+ const frame = JSON.parse(event.data);
145
+ // Events arrive wrapped in an envelope with the event under `payload`.
146
+ if (frame?.payload)
147
+ processEvent(frame.payload, collections);
148
+ }
149
+ catch (err) {
150
+ emit('jetstream', 'decode_error', { error: err instanceof Error ? err.message : String(err) });
151
+ }
152
+ });
153
+ ws.addEventListener('open', () => log('[jetstream] Connected'));
154
+ ws.addEventListener('close', () => {
155
+ log(`[jetstream] Disconnected, reconnecting in ${RECONNECT_DELAY_MS / 1000}s...`);
156
+ // Read the seq at close time, not connect time — reusing the boot cursor on
157
+ // every reconnect would replay everything received since the process started.
158
+ setTimeout(() => startJetstreamIndexer({ ...opts, cursor: resumeCursor(getLastSeq(), opts.cursor) }), RECONNECT_DELAY_MS);
159
+ });
160
+ return ws;
161
+ }
package/dist/labels.d.ts CHANGED
@@ -13,7 +13,36 @@ export interface LabelRuleContext {
13
13
  value: Record<string, any>;
14
14
  };
15
15
  }
16
+ export interface LabelModule {
17
+ definition?: LabelDefinition;
18
+ evaluate?: (ctx: LabelRuleContext) => Promise<string[]>;
19
+ }
20
+ export declare function defineLabel(module: LabelModule): {
21
+ definition?: LabelDefinition;
22
+ evaluate?: (ctx: LabelRuleContext) => Promise<string[]>;
23
+ __type: "labels";
24
+ };
25
+ /**
26
+ * Discover and load label rule modules from the `labels/` directory.
27
+ *
28
+ * Each module should default-export an object with an optional `definition`
29
+ * (label metadata like severity and blur behavior) and an optional `evaluate`
30
+ * function that returns label values to apply to a record.
31
+ *
32
+ * @param labelsDir - Absolute path to the `labels/` directory
33
+ */
16
34
  export declare function initLabels(labelsDir: string): Promise<void>;
35
+ /** Clear all registered label definitions and rules (for hot-reload). */
36
+ export declare function clearLabels(): void;
37
+ /** Register a single label module from a scanned server/ module. */
38
+ export declare function registerLabelModule(name: string, labelMod: {
39
+ definition?: LabelDefinition;
40
+ evaluate?: (ctx: LabelRuleContext) => Promise<string[]>;
41
+ }): void;
42
+ /**
43
+ * Evaluate all loaded label rules against a record and persist any resulting labels.
44
+ * Called after each record is indexed. Rule errors are logged but never block indexing.
45
+ */
17
46
  export declare function runLabelRules(record: {
18
47
  uri: string;
19
48
  cid: string;
@@ -21,9 +50,16 @@ export declare function runLabelRules(record: {
21
50
  collection: string;
22
51
  value: Record<string, any>;
23
52
  }): Promise<void>;
53
+ /**
54
+ * Re-evaluate all label rules against every existing record in the given collections.
55
+ * Used by `/admin/rescan-labels` to apply new or updated rules retroactively.
56
+ *
57
+ * @returns Count of records scanned and new labels applied
58
+ */
24
59
  export declare function rescanLabels(collections: string[]): Promise<{
25
60
  scanned: number;
26
61
  labeled: number;
27
62
  }>;
63
+ /** Return all label definitions discovered during {@link initLabels}. */
28
64
  export declare function getLabelDefinitions(): LabelDefinition[];
29
65
  //# sourceMappingURL=labels.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"labels.d.ts","sourceRoot":"","sources":["../src/labels.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAIlD,wDAAwD;AACxD,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE;QACF,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;QACtD,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;KACtD,CAAA;IACD,MAAM,EAAE;QACN,GAAG,EAAE,MAAM,CAAA;QACX,GAAG,EAAE,MAAM,CAAA;QACX,GAAG,EAAE,MAAM,CAAA;QACX,UAAU,EAAE,MAAM,CAAA;QAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;KAC3B,CAAA;CACF;AAWD,wBAAsB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAmCjE;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAC3B,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBhB;AAED,wBAAsB,YAAY,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAuCvG;AAED,wBAAgB,mBAAmB,IAAI,eAAe,EAAE,CAEvD"}
1
+ {"version":3,"file":"labels.d.ts","sourceRoot":"","sources":["../src/labels.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAIlD,wDAAwD;AACxD,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE;QACF,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;QACtD,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;KACtD,CAAA;IACD,MAAM,EAAE;QACN,GAAG,EAAE,MAAM,CAAA;QACX,GAAG,EAAE,MAAM,CAAA;QACX,GAAG,EAAE,MAAM,CAAA;QACX,UAAU,EAAE,MAAM,CAAA;QAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;KAC3B,CAAA;CACF;AAED,MAAM,WAAW,WAAW;IAC1B,UAAU,CAAC,EAAE,eAAe,CAAA;IAC5B,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;CACxD;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW;iBAJhC,eAAe;eACjB,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC;;EAKxD;AAYD;;;;;;;;GAQG;AACH,wBAAsB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAmCjE;AAED,yEAAyE;AACzE,wBAAgB,WAAW,IAAI,IAAI,CAGlC;AAED,oEAAoE;AACpE,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE;IAAE,UAAU,CAAC,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;CAAE,GAClG,IAAI,CAON;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAC3B,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBhB;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAuCvG;AAED,yEAAyE;AACzE,wBAAgB,mBAAmB,IAAI,eAAe,EAAE,CAEvD"}
package/dist/labels.js CHANGED
@@ -6,13 +6,53 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ /**
10
+ * Label system for applying moderation labels to records as they are indexed.
11
+ *
12
+ * Place label modules in the `labels/` directory. Each module default-exports
13
+ * an object with a `definition` (label metadata) and/or an `evaluate` function
14
+ * (rule that returns label values for a given record).
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // labels/nsfw.ts
19
+ * import type { LabelRuleContext } from '@hatk/hatk/labels'
20
+ *
21
+ * export default {
22
+ * definition: {
23
+ * identifier: 'nsfw',
24
+ * severity: 'alert',
25
+ * blurs: 'media',
26
+ * defaultSetting: 'warn',
27
+ * locales: [{ lang: 'en', name: 'NSFW', description: 'Not safe for work' }],
28
+ * },
29
+ *
30
+ * async evaluate(ctx: LabelRuleContext): Promise<string[]> {
31
+ * if (ctx.record.value.nsfw === true) return ['nsfw']
32
+ * return []
33
+ * },
34
+ * }
35
+ * ```
36
+ */
9
37
  import { resolve } from 'node:path';
10
38
  import { readdirSync } from 'node:fs';
11
- import { querySQL, runSQL, insertLabels, getSchema } from "./db.js";
39
+ import { querySQL, runSQL, insertLabels, getSchema } from "./database/db.js";
12
40
  import { log, emit } from "./logger.js";
41
+ export function defineLabel(module) {
42
+ return { __type: 'labels', ...module };
43
+ }
13
44
  const rules = [];
14
45
  let labelDefs = [];
15
46
  let labelSrc = 'self';
47
+ /**
48
+ * Discover and load label rule modules from the `labels/` directory.
49
+ *
50
+ * Each module should default-export an object with an optional `definition`
51
+ * (label metadata like severity and blur behavior) and an optional `evaluate`
52
+ * function that returns label values to apply to a record.
53
+ *
54
+ * @param labelsDir - Absolute path to the `labels/` directory
55
+ */
16
56
  export async function initLabels(labelsDir) {
17
57
  let files;
18
58
  try {
@@ -26,7 +66,7 @@ export async function initLabels(labelsDir) {
26
66
  for (const file of files) {
27
67
  const name = file.replace(/\.(ts|js)$/, '');
28
68
  const scriptPath = resolve(labelsDir, file);
29
- const mod = await import(__rewriteRelativeImportExtension(scriptPath));
69
+ const mod = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`));
30
70
  const handler = mod.default;
31
71
  if (handler.definition) {
32
72
  labelDefs.push(handler.definition);
@@ -45,6 +85,24 @@ export async function initLabels(labelsDir) {
45
85
  log(`[labels] ${labelDefs.length} label definitions loaded`);
46
86
  }
47
87
  }
88
+ /** Clear all registered label definitions and rules (for hot-reload). */
89
+ export function clearLabels() {
90
+ labelDefs.length = 0;
91
+ rules.length = 0;
92
+ }
93
+ /** Register a single label module from a scanned server/ module. */
94
+ export function registerLabelModule(name, labelMod) {
95
+ if (labelMod.definition) {
96
+ labelDefs.push(labelMod.definition);
97
+ }
98
+ if (labelMod.evaluate) {
99
+ rules.push({ name, evaluate: labelMod.evaluate });
100
+ }
101
+ }
102
+ /**
103
+ * Evaluate all loaded label rules against a record and persist any resulting labels.
104
+ * Called after each record is indexed. Rule errors are logged but never block indexing.
105
+ */
48
106
  export async function runLabelRules(record) {
49
107
  if (rules.length === 0)
50
108
  return;
@@ -69,15 +127,21 @@ export async function runLabelRules(record) {
69
127
  emit('labels', 'applied', { count: allLabels.length, uri: record.uri, vals: allLabels.map((l) => l.val) });
70
128
  }
71
129
  }
130
+ /**
131
+ * Re-evaluate all label rules against every existing record in the given collections.
132
+ * Used by `/admin/rescan-labels` to apply new or updated rules retroactively.
133
+ *
134
+ * @returns Count of records scanned and new labels applied
135
+ */
72
136
  export async function rescanLabels(collections) {
73
- const beforeRows = await querySQL(`SELECT COUNT(*) as count FROM _labels`);
137
+ const beforeRows = (await querySQL(`SELECT COUNT(*) as count FROM _labels`));
74
138
  const beforeCount = Number(beforeRows[0]?.count || 0);
75
139
  let scanned = 0;
76
140
  for (const collection of collections) {
77
141
  const schema = getSchema(collection);
78
142
  if (!schema)
79
143
  continue;
80
- const rows = await querySQL(`SELECT * FROM ${schema.tableName}`);
144
+ const rows = (await querySQL(`SELECT * FROM ${schema.tableName}`));
81
145
  for (const row of rows) {
82
146
  scanned++;
83
147
  const value = {};
@@ -85,7 +149,7 @@ export async function rescanLabels(collections) {
85
149
  let v = row[col.name];
86
150
  if (v === null || v === undefined)
87
151
  continue;
88
- if (col.duckdbType === 'JSON' && typeof v === 'string') {
152
+ if (col.isJson && typeof v === 'string') {
89
153
  try {
90
154
  v = JSON.parse(v);
91
155
  }
@@ -102,10 +166,11 @@ export async function rescanLabels(collections) {
102
166
  });
103
167
  }
104
168
  }
105
- const afterRows = await querySQL(`SELECT COUNT(*) as count FROM _labels`);
169
+ const afterRows = (await querySQL(`SELECT COUNT(*) as count FROM _labels`));
106
170
  const afterCount = Number(afterRows[0]?.count || 0);
107
171
  return { scanned, labeled: afterCount - beforeCount };
108
172
  }
173
+ /** Return all label definitions discovered during {@link initLabels}. */
109
174
  export function getLabelDefinitions() {
110
175
  return labelDefs;
111
176
  }
@@ -1 +1 @@
1
- {"version":3,"file":"lexicon-resolve.d.ts","sourceRoot":"","sources":["../src/lexicon-resolve.ts"],"names":[],"mappings":"AAsMA,UAAU,OAAO;IACf,OAAO,EAAE,MAAM,CAAA;IACf,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AA+DD;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAgChF"}
1
+ {"version":3,"file":"lexicon-resolve.d.ts","sourceRoot":"","sources":["../src/lexicon-resolve.ts"],"names":[],"mappings":"AAoFA,UAAU,OAAO;IACf,OAAO,EAAE,MAAM,CAAA;IACf,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AA2FD;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAgChF"}
@@ -1,6 +1,8 @@
1
1
  // Lexicon resolver — fetches lexicons from the AT Protocol registry via DNS → DID → PDS chain
2
2
  // and recursively resolves all $ref dependencies.
3
3
  import { isValidDid } from '@bigmoves/lexicon';
4
+ import { join } from 'node:path';
5
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
4
6
  // --- Authority ---
5
7
  function nsidToDomain(nsid) {
6
8
  const parts = nsid.split('.');
@@ -71,118 +73,31 @@ async function resolveDid(did, opts = {}) {
71
73
  return null;
72
74
  }
73
75
  }
74
- // --- Built-in core schemas (not published via DNS) ---
75
- const coreSchemas = {
76
- 'com.atproto.repo.strongRef': {
77
- lexicon: 1,
78
- id: 'com.atproto.repo.strongRef',
79
- description: 'A URI with a content-hash fingerprint.',
80
- defs: {
81
- main: {
82
- type: 'object',
83
- required: ['uri', 'cid'],
84
- properties: { uri: { type: 'string', format: 'at-uri' }, cid: { type: 'string', format: 'cid' } },
85
- },
86
- },
87
- },
88
- 'com.atproto.label.defs': {
89
- lexicon: 1,
90
- id: 'com.atproto.label.defs',
91
- defs: {
92
- label: {
93
- type: 'object',
94
- description: 'Metadata tag on an atproto resource (eg, repo or record).',
95
- required: ['src', 'uri', 'val', 'cts'],
96
- properties: {
97
- ver: { type: 'integer' },
98
- src: { type: 'string', format: 'did' },
99
- uri: { type: 'string', format: 'uri' },
100
- cid: { type: 'string', format: 'cid' },
101
- val: { type: 'string', maxLength: 128 },
102
- neg: { type: 'boolean' },
103
- cts: { type: 'string', format: 'datetime' },
104
- exp: { type: 'string', format: 'datetime' },
105
- sig: { type: 'bytes' },
106
- },
107
- },
108
- selfLabels: {
109
- type: 'object',
110
- description: 'Metadata tags on an atproto record, published by the author within the record.',
111
- required: ['values'],
112
- properties: { values: { type: 'array', items: { type: 'ref', ref: '#selfLabel' }, maxLength: 10 } },
113
- },
114
- selfLabel: { type: 'object', required: ['val'], properties: { val: { type: 'string', maxLength: 128 } } },
115
- labelValueDefinition: {
116
- type: 'object',
117
- description: 'Declares a label value and its expected interpretations and behaviors.',
118
- required: ['identifier', 'severity', 'blurs', 'locales'],
119
- properties: {
120
- identifier: { type: 'string', maxLength: 100 },
121
- severity: { type: 'string', knownValues: ['inform', 'alert', 'none'] },
122
- blurs: { type: 'string', knownValues: ['content', 'media', 'none'] },
123
- defaultSetting: { type: 'string', knownValues: ['ignore', 'warn', 'hide'] },
124
- adultOnly: { type: 'boolean' },
125
- locales: { type: 'array', items: { type: 'ref', ref: '#labelValueDefinitionStrings' } },
126
- },
127
- },
128
- labelValueDefinitionStrings: {
129
- type: 'object',
130
- required: ['lang', 'name', 'description'],
131
- properties: {
132
- lang: { type: 'string', format: 'language' },
133
- name: { type: 'string', maxLength: 640 },
134
- description: { type: 'string', maxLength: 100000 },
135
- },
136
- },
137
- labelValue: {
138
- type: 'string',
139
- knownValues: [
140
- '!hide',
141
- '!no-promote',
142
- '!warn',
143
- '!no-unauthenticated',
144
- 'dmca-violation',
145
- 'doxxing',
146
- 'porn',
147
- 'sexual',
148
- 'nudity',
149
- 'nsfl',
150
- 'gore',
151
- ],
152
- },
153
- },
154
- },
155
- 'com.atproto.moderation.defs': {
156
- lexicon: 1,
157
- id: 'com.atproto.moderation.defs',
158
- defs: {
159
- reasonType: {
160
- type: 'string',
161
- knownValues: [
162
- 'com.atproto.moderation.defs#reasonSpam',
163
- 'com.atproto.moderation.defs#reasonViolation',
164
- 'com.atproto.moderation.defs#reasonMisleading',
165
- 'com.atproto.moderation.defs#reasonSexual',
166
- 'com.atproto.moderation.defs#reasonRude',
167
- 'com.atproto.moderation.defs#reasonOther',
168
- 'com.atproto.moderation.defs#reasonAppeal',
169
- ],
170
- },
171
- reasonSpam: { type: 'token', description: 'Spam: frequent unwanted promotion, replies, mentions.' },
172
- reasonViolation: { type: 'token', description: 'Direct violation of server rules, laws, terms of service.' },
173
- reasonMisleading: { type: 'token', description: 'Misleading identity, affiliation, or content.' },
174
- reasonSexual: { type: 'token', description: 'Unwanted or mislabeled sexual content.' },
175
- reasonRude: { type: 'token', description: 'Rude, harassing, explicit, or otherwise unwelcoming behavior.' },
176
- reasonOther: { type: 'token', description: 'Reports not falling under another report category.' },
177
- reasonAppeal: { type: 'token', description: 'Appeal a previously taken moderation action.' },
178
- subjectType: {
179
- type: 'string',
180
- description: 'Tag describing a type of subject that might be reported.',
181
- knownValues: ['account', 'record', 'chat'],
182
- },
183
- },
184
- },
185
- };
76
+ // --- Built-in core schemas (loaded from src/lexicons/) ---
77
+ function loadCoreSchemas() {
78
+ const schemas = {};
79
+ const lexDir = join(import.meta.dirname, 'lexicons');
80
+ function walk(dir) {
81
+ for (const entry of readdirSync(dir)) {
82
+ const full = join(dir, entry);
83
+ if (statSync(full).isDirectory()) {
84
+ walk(full);
85
+ }
86
+ else if (entry.endsWith('.json')) {
87
+ const lexicon = JSON.parse(readFileSync(full, 'utf-8'));
88
+ if (lexicon.id)
89
+ schemas[lexicon.id] = lexicon;
90
+ }
91
+ }
92
+ }
93
+ try {
94
+ walk(lexDir);
95
+ }
96
+ catch { }
97
+ return schemas;
98
+ }
99
+ const coreSchemas = loadCoreSchemas();
100
+ // --- Resolver ---
186
101
  function refToNsid(ref) {
187
102
  let nsid = ref.startsWith('lex:') ? ref.slice(4) : ref;
188
103
  const hashIndex = nsid.indexOf('#');
@@ -0,0 +1,75 @@
1
+ {
2
+ "lexicon": 1,
3
+ "id": "com.atproto.label.defs",
4
+ "defs": {
5
+ "label": {
6
+ "type": "object",
7
+ "description": "Metadata tag on an atproto resource (eg, repo or record).",
8
+ "required": ["src", "uri", "val", "cts"],
9
+ "properties": {
10
+ "ver": { "type": "integer" },
11
+ "src": { "type": "string", "format": "did" },
12
+ "uri": { "type": "string", "format": "uri" },
13
+ "cid": { "type": "string", "format": "cid" },
14
+ "val": { "type": "string", "maxLength": 128 },
15
+ "neg": { "type": "boolean" },
16
+ "cts": { "type": "string", "format": "datetime" },
17
+ "exp": { "type": "string", "format": "datetime" },
18
+ "sig": { "type": "bytes" }
19
+ }
20
+ },
21
+ "selfLabels": {
22
+ "type": "object",
23
+ "description": "Metadata tags on an atproto record, published by the author within the record.",
24
+ "required": ["values"],
25
+ "properties": {
26
+ "values": { "type": "array", "items": { "type": "ref", "ref": "#selfLabel" }, "maxLength": 10 }
27
+ }
28
+ },
29
+ "selfLabel": {
30
+ "type": "object",
31
+ "required": ["val"],
32
+ "properties": {
33
+ "val": { "type": "string", "maxLength": 128 }
34
+ }
35
+ },
36
+ "labelValueDefinition": {
37
+ "type": "object",
38
+ "description": "Declares a label value and its expected interpretations and behaviors.",
39
+ "required": ["identifier", "severity", "blurs", "locales"],
40
+ "properties": {
41
+ "identifier": { "type": "string", "maxLength": 100 },
42
+ "severity": { "type": "string", "knownValues": ["inform", "alert", "none"] },
43
+ "blurs": { "type": "string", "knownValues": ["content", "media", "none"] },
44
+ "defaultSetting": { "type": "string", "knownValues": ["ignore", "warn", "hide"] },
45
+ "adultOnly": { "type": "boolean" },
46
+ "locales": { "type": "array", "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } }
47
+ }
48
+ },
49
+ "labelValueDefinitionStrings": {
50
+ "type": "object",
51
+ "required": ["lang", "name", "description"],
52
+ "properties": {
53
+ "lang": { "type": "string", "format": "language" },
54
+ "name": { "type": "string", "maxLength": 640 },
55
+ "description": { "type": "string", "maxLength": 100000 }
56
+ }
57
+ },
58
+ "labelValue": {
59
+ "type": "string",
60
+ "knownValues": [
61
+ "!hide",
62
+ "!no-promote",
63
+ "!warn",
64
+ "!no-unauthenticated",
65
+ "dmca-violation",
66
+ "doxxing",
67
+ "porn",
68
+ "sexual",
69
+ "nudity",
70
+ "nsfl",
71
+ "gore"
72
+ ]
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "lexicon": 1,
3
+ "id": "com.atproto.moderation.defs",
4
+ "defs": {
5
+ "reasonType": {
6
+ "type": "string",
7
+ "knownValues": [
8
+ "com.atproto.moderation.defs#reasonSpam",
9
+ "com.atproto.moderation.defs#reasonViolation",
10
+ "com.atproto.moderation.defs#reasonMisleading",
11
+ "com.atproto.moderation.defs#reasonSexual",
12
+ "com.atproto.moderation.defs#reasonRude",
13
+ "com.atproto.moderation.defs#reasonOther",
14
+ "com.atproto.moderation.defs#reasonAppeal"
15
+ ]
16
+ },
17
+ "reasonSpam": { "type": "token", "description": "Spam: frequent unwanted promotion, replies, mentions." },
18
+ "reasonViolation": { "type": "token", "description": "Direct violation of server rules, laws, terms of service." },
19
+ "reasonMisleading": { "type": "token", "description": "Misleading identity, affiliation, or content." },
20
+ "reasonSexual": { "type": "token", "description": "Unwanted or mislabeled sexual content." },
21
+ "reasonRude": { "type": "token", "description": "Rude, harassing, explicit, or otherwise unwelcoming behavior." },
22
+ "reasonOther": { "type": "token", "description": "Reports not falling under another report category." },
23
+ "reasonAppeal": { "type": "token", "description": "Appeal a previously taken moderation action." },
24
+ "subjectType": {
25
+ "type": "string",
26
+ "description": "Tag describing a type of subject that might be reported.",
27
+ "knownValues": ["account", "record", "chat"]
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "lexicon": 1,
3
+ "id": "com.atproto.repo.strongRef",
4
+ "description": "A URI with a content-hash fingerprint.",
5
+ "defs": {
6
+ "main": {
7
+ "type": "object",
8
+ "required": ["uri", "cid"],
9
+ "properties": {
10
+ "uri": {
11
+ "type": "string",
12
+ "format": "at-uri"
13
+ },
14
+ "cid": {
15
+ "type": "string",
16
+ "format": "cid"
17
+ }
18
+ }
19
+ }
20
+ }
21
+ }