@hatk/hatk 0.0.1-alpha.83 → 0.0.1-alpha.85

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.
package/dist/hooks.d.ts CHANGED
@@ -65,6 +65,14 @@ export declare function defineHook(event: 'on-commit', options: {
65
65
  /**
66
66
  * Discover and load the on-login hook from the project's `hooks/` directory.
67
67
  * Looks for `on-login.ts` or `on-login.js`. Safe to call if no hook exists.
68
+ *
69
+ * The documented contract is `export default defineHook('on-login', handler)`,
70
+ * which is an object carrying the handler — the same shape the `server/`
71
+ * scanner recognizes by its `__type` tag. A bare async function is still
72
+ * accepted, because that is what the legacy `hooks/` directory used before
73
+ * `defineHook` existed. Anything else is reported rather than installed: a
74
+ * non-callable hook would otherwise fail silently on every login, since
75
+ * `fireOnLoginHook` swallows what the hook throws.
68
76
  */
69
77
  export declare function loadOnLoginHook(hooksDir: string): Promise<void>;
70
78
  /** Register a hook from a scanned server/ module. */
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"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"}
package/dist/hooks.js CHANGED
@@ -63,6 +63,14 @@ const onCommitHooks = [];
63
63
  /**
64
64
  * Discover and load the on-login hook from the project's `hooks/` directory.
65
65
  * Looks for `on-login.ts` or `on-login.js`. Safe to call if no hook exists.
66
+ *
67
+ * The documented contract is `export default defineHook('on-login', handler)`,
68
+ * which is an object carrying the handler — the same shape the `server/`
69
+ * scanner recognizes by its `__type` tag. A bare async function is still
70
+ * accepted, because that is what the legacy `hooks/` directory used before
71
+ * `defineHook` existed. Anything else is reported rather than installed: a
72
+ * non-callable hook would otherwise fail silently on every login, since
73
+ * `fireOnLoginHook` swallows what the hook throws.
66
74
  */
67
75
  export async function loadOnLoginHook(hooksDir) {
68
76
  const tsPath = resolve(hooksDir, 'on-login.ts');
@@ -71,9 +79,24 @@ export async function loadOnLoginHook(hooksDir) {
71
79
  if (!path)
72
80
  return;
73
81
  const mod = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ `${path}?t=${Date.now()}`));
74
- onLoginHook = mod.default;
82
+ const handler = resolveHookHandler(mod.default);
83
+ if (!handler) {
84
+ emit('hooks', 'on_login_load_error', {
85
+ path,
86
+ error: 'default export is not a function or a defineHook("on-login", ...) result',
87
+ });
88
+ return;
89
+ }
90
+ onLoginHook = handler;
75
91
  log('[hooks] on-login hook loaded');
76
92
  }
93
+ /** Unwrap a hook module's default export into the callable handler, if it has one. */
94
+ function resolveHookHandler(exported) {
95
+ if (typeof exported === 'function')
96
+ return exported;
97
+ const handler = exported?.handler;
98
+ return typeof handler === 'function' ? handler : null;
99
+ }
77
100
  /** Mark a DID as pending, trigger auto-backfill, and wait for completion. */
78
101
  async function ensureRepo(did) {
79
102
  await setRepoStatus(did, 'pending');
package/dist/indexer.d.ts CHANGED
@@ -33,9 +33,10 @@ export declare function resumeCursor(liveSeq: number | null, bootCursor: string
33
33
  /** Reset module cursor state between tests. */
34
34
  export declare function _resetCursorStateForTests(): void;
35
35
  /**
36
- * Drain the write buffer synchronously instead of waiting out
37
- * FLUSH_INTERVAL_MS. Lets end-to-end tests assert on rows immediately after
38
- * feeding a frame.
36
+ * Drain the write buffer instead of waiting out FLUSH_INTERVAL_MS. Lets
37
+ * end-to-end tests assert on rows immediately after feeding a frame — deletes
38
+ * included, since they are buffered alongside puts and so are covered by the
39
+ * same await.
39
40
  */
40
41
  export declare function _flushForTests(): Promise<void>;
41
42
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"indexer.d.ts","sourceRoot":"","sources":["../src/indexer.ts"],"names":[],"mappings":"AA8BA,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAA;IACtC,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,yBAAyB;IACzB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,yBAAyB;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAC7B;AA0JD,6GAA6G;AAC7G,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAEzC;AAED,6EAA6E;AAC7E,wBAAgB,UAAU,IAAI,MAAM,GAAG,IAAI,CAE1C;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAStD;AAED,uFAAuF;AACvF,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CAE7D;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAErH;AAED,+CAA+C;AAC/C,wBAAgB,yBAAyB,IAAI,IAAI,CAIhD;AAED;;;;GAIG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAMpD;AAED;;;;;;;GAOG;AACH,iEAAiE;AACjE,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGxD;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,SAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CA8EjF;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,iBAAiB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IAC/B,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACzB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,oDAAoD;AACpD,UAAU,WAAY,SAAQ,eAAe;IAC3C,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAyBD;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA6B3E;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC,CAkCxE;AAED,6EAA6E;AAC7E,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,wEAAwE;AACxE,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAErD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,cAAc,GAAG,SAAS,CAgD/D;AAED;;;;;;;;GAQG;AACH,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CA4CvG;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,OAAO,CAE3F;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,GAAG,IAAI,CAwE9D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,UAAU,EACjB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EACxB,KAAK,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAc,GACrC,IAAI,CAoDN"}
1
+ {"version":3,"file":"indexer.d.ts","sourceRoot":"","sources":["../src/indexer.ts"],"names":[],"mappings":"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"}
package/dist/indexer.js CHANGED
@@ -45,9 +45,10 @@ let indexerMaxRetries;
45
45
  let indexerPlcUrl;
46
46
  let maxConcurrentBackfills = 3;
47
47
  /**
48
- * Flush the write buffer — insert all buffered records, update the relay cursor,
49
- * run label rules on inserted records, and trigger FTS rebuilds when the write
50
- * threshold is reached. Emits a wide event with batch stats.
48
+ * Flush the write buffer — apply all buffered puts and deletes in arrival
49
+ * order, update the relay cursor, run label rules on inserted records, and
50
+ * trigger FTS rebuilds when the write threshold is reached. Emits a wide event
51
+ * with batch stats.
51
52
  */
52
53
  async function flushBuffer() {
53
54
  if (buffer.length === 0)
@@ -55,14 +56,38 @@ async function flushBuffer() {
55
56
  const elapsed = timer();
56
57
  const batch = buffer.splice(0);
57
58
  let insertedCount = 0;
59
+ let deletedCount = 0;
58
60
  const errors = [];
59
61
  let cursorError;
60
62
  const inserted = [];
63
+ const applied = [];
64
+ // Strictly sequential: two writes to the same URI in one batch must land in
65
+ // the order they arrived, so nothing here may run concurrently.
61
66
  for (const item of batch) {
62
67
  try {
63
- await insertRecord(item.collection, item.uri, item.cid, item.authorDid, item.record);
64
- insertedCount++;
65
- inserted.push(item);
68
+ if (item.action === 'delete') {
69
+ await deleteRecord(item.collection, item.uri);
70
+ deletedCount++;
71
+ applied.push({
72
+ action: 'delete',
73
+ collection: item.collection,
74
+ uri: item.uri,
75
+ authorDid: item.authorDid,
76
+ record: null,
77
+ });
78
+ }
79
+ else {
80
+ await insertRecord(item.collection, item.uri, item.cid, item.authorDid, item.record);
81
+ insertedCount++;
82
+ inserted.push(item);
83
+ applied.push({
84
+ action: 'create',
85
+ collection: item.collection,
86
+ uri: item.uri,
87
+ authorDid: item.authorDid,
88
+ record: item.record,
89
+ });
90
+ }
66
91
  }
67
92
  catch (err) {
68
93
  errors.push(err.message);
@@ -88,14 +113,9 @@ async function flushBuffer() {
88
113
  value: item.record,
89
114
  }).catch(() => { });
90
115
  }
91
- // Fire on-commit hooks for inserted records (async, non-blocking)
92
- fireOnCommitHooks(inserted.map((item) => ({
93
- action: 'create',
94
- collection: item.collection,
95
- uri: item.uri,
96
- authorDid: item.authorDid,
97
- record: item.record,
98
- })));
116
+ // Fire on-commit hooks for everything the batch applied, in the order it was
117
+ // applied (async, non-blocking)
118
+ fireOnCommitHooks(applied);
99
119
  // Aggregate collection counts and unique DIDs for wide event
100
120
  const collections = {};
101
121
  const dids = new Set();
@@ -106,6 +126,7 @@ async function flushBuffer() {
106
126
  emit('indexer', 'flush', {
107
127
  batch_size: batch.length,
108
128
  inserted_count: insertedCount,
129
+ deleted_count: deletedCount,
109
130
  error_count: errors.length,
110
131
  cursor_seq: lastSeq,
111
132
  duration_ms: elapsed(),
@@ -125,16 +146,32 @@ async function flushBuffer() {
125
146
  }
126
147
  }
127
148
  }
149
+ /**
150
+ * Run a flush once every flush already queued has finished.
151
+ *
152
+ * Nothing awaits the flush a full batch triggers, and the interval timer can
153
+ * fire while that flush is still waiting on the database. Two flushes in flight
154
+ * hold disjoint batches but interleave their awaits, so a write in the later
155
+ * batch can reach the database before one in the earlier batch — which would
156
+ * give back exactly the cross-batch reordering the single buffer removes.
157
+ * Chaining them costs nothing on an idle indexer and keeps the queue global.
158
+ */
159
+ let flushChain = Promise.resolve();
160
+ function enqueueFlush() {
161
+ const next = flushChain.then(() => flushBuffer(), () => flushBuffer());
162
+ flushChain = next;
163
+ return next;
164
+ }
128
165
  /** Schedule a flush after FLUSH_INTERVAL_MS if one isn't already pending. */
129
166
  function scheduleFlush() {
130
167
  if (flushTimer)
131
168
  return;
132
- flushTimer = setTimeout(async () => {
169
+ flushTimer = setTimeout(() => {
133
170
  flushTimer = null;
134
- await flushBuffer();
171
+ void enqueueFlush().catch(() => { });
135
172
  }, FLUSH_INTERVAL_MS);
136
173
  }
137
- /** Add a record to the write buffer. Flushes immediately if BATCH_SIZE is reached. */
174
+ /** Add a write to the buffer. Flushes immediately if BATCH_SIZE is reached. */
138
175
  function bufferWrite(item) {
139
176
  buffer.push(item);
140
177
  if (buffer.length >= BATCH_SIZE) {
@@ -142,7 +179,7 @@ function bufferWrite(item) {
142
179
  clearTimeout(flushTimer);
143
180
  flushTimer = null;
144
181
  }
145
- flushBuffer();
182
+ void enqueueFlush().catch(() => { });
146
183
  }
147
184
  else {
148
185
  scheduleFlush();
@@ -196,16 +233,17 @@ export function _resetCursorStateForTests() {
196
233
  cursorKey = 'relay';
197
234
  }
198
235
  /**
199
- * Drain the write buffer synchronously instead of waiting out
200
- * FLUSH_INTERVAL_MS. Lets end-to-end tests assert on rows immediately after
201
- * feeding a frame.
236
+ * Drain the write buffer instead of waiting out FLUSH_INTERVAL_MS. Lets
237
+ * end-to-end tests assert on rows immediately after feeding a frame — deletes
238
+ * included, since they are buffered alongside puts and so are covered by the
239
+ * same await.
202
240
  */
203
241
  export async function _flushForTests() {
204
242
  if (flushTimer) {
205
243
  clearTimeout(flushTimer);
206
244
  flushTimer = null;
207
245
  }
208
- await flushBuffer();
246
+ await enqueueFlush();
209
247
  }
210
248
  /**
211
249
  * Auto-backfill a DID's repo when first seen on the firehose.
@@ -262,7 +300,14 @@ export async function triggerAutoBackfill(did, attempt = 0) {
262
300
  backfillInFlight.delete(did);
263
301
  for (const item of buffered) {
264
302
  try {
265
- await insertRecord(item.collection, item.uri, item.cid, item.authorDid, item.record);
303
+ if (item.action === 'delete') {
304
+ // A delete that arrived mid-backfill has to be replayed after the CAR
305
+ // export lands, or the export's snapshot of the record outlives it.
306
+ await deleteRecord(item.collection, item.uri);
307
+ }
308
+ else {
309
+ await insertRecord(item.collection, item.uri, item.cid, item.authorDid, item.record);
310
+ }
266
311
  }
267
312
  catch {
268
313
  replayErrors++;
@@ -295,28 +340,6 @@ export async function triggerAutoBackfill(did, attempt = 0) {
295
340
  }, delayMs);
296
341
  }
297
342
  }
298
- /** Emit a memory diagnostics wide event every 30s for observability. */
299
- function startMemoryDiagnostics() {
300
- setInterval(() => {
301
- const mem = process.memoryUsage();
302
- let pendingBufferItems = 0;
303
- for (const [, items] of pendingBuffers) {
304
- pendingBufferItems += items.length;
305
- }
306
- emit('diagnostics', 'memory', {
307
- heap_used_mb: Math.round(mem.heapUsed / 1024 / 1024),
308
- heap_total_mb: Math.round(mem.heapTotal / 1024 / 1024),
309
- rss_mb: Math.round(mem.rss / 1024 / 1024),
310
- external_mb: Math.round(mem.external / 1024 / 1024),
311
- array_buffers_mb: Math.round(mem.arrayBuffers / 1024 / 1024),
312
- write_buffer_len: buffer.length,
313
- pending_buffer_dids: pendingBuffers.size,
314
- pending_buffer_items: pendingBufferItems,
315
- backfill_in_flight: backfillInFlight.size,
316
- repo_status_cache_size: repoStatusCache.size,
317
- });
318
- }, 30_000);
319
- }
320
343
  /**
321
344
  * Connect to the AT Protocol relay firehose and begin indexing.
322
345
  *
@@ -346,7 +369,6 @@ export async function configureIndexer(opts) {
346
369
  }
347
370
  log(`[indexer] Warmed repo status cache with ${statuses.length} entries`);
348
371
  }
349
- // startMemoryDiagnostics()
350
372
  // Checkpoint the cursor on a timer regardless of write activity (see
351
373
  // checkpointCursor). Guarded so reconnects don't stack intervals; unref'd so
352
374
  // it never keeps the process alive.
@@ -558,19 +580,21 @@ export function applyCommit(did, ops) {
558
580
  // For non-signal ops (e.g. profile updates), only process if this DID is already tracked
559
581
  if (!hasSignalOp && repoStatus === null)
560
582
  return;
583
+ /** Queue a write behind anything already queued for this DID. */
584
+ const enqueue = (item) => {
585
+ // If DID is mid-backfill, buffer instead of writing directly
586
+ if (pendingBuffers.has(did))
587
+ pendingBuffers.get(did).push(item);
588
+ else
589
+ bufferWrite(item);
590
+ };
561
591
  for (const op of ops) {
562
592
  const uri = `at://${did}/${op.collection}/${op.rkey}`;
563
593
  if (op.action === 'delete') {
564
- deleteRecord(op.collection, uri);
565
- fireOnCommitHooks([
566
- {
567
- action: 'delete',
568
- collection: op.collection,
569
- uri,
570
- authorDid: did,
571
- record: null,
572
- },
573
- ]);
594
+ // Buffered, not applied here: see {@link WriteBuffer}. The on-commit hook
595
+ // fires from the flush too, so a handler never sees a delete announced
596
+ // before the row is actually gone.
597
+ enqueue({ action: 'delete', collection: op.collection, uri, authorDid: did });
574
598
  continue;
575
599
  }
576
600
  const record = op.record;
@@ -588,14 +612,7 @@ export function applyCommit(did, ops) {
588
612
  });
589
613
  continue;
590
614
  }
591
- const item = { collection: op.collection, uri, cid: op.cid, authorDid: did, record };
592
- // If DID is mid-backfill, buffer instead of writing directly
593
- if (pendingBuffers.has(did)) {
594
- pendingBuffers.get(did).push(item);
595
- }
596
- else {
597
- bufferWrite(item);
598
- }
615
+ enqueue({ action: 'put', collection: op.collection, uri, cid: op.cid, authorDid: did, record });
599
616
  }
600
617
  }
601
618
  /**
@@ -2,20 +2,31 @@
2
2
  * What a PDS says it serves, and the scopes worth asking it for.
3
3
  *
4
4
  * `community.lexicon.service.describe` answers with a service's roles and every
5
- * XRPC method it routes. It is the only way to ask: atproto publishes no method
6
- * list, so the alternative is to call a method and read the failure, where a
7
- * server without an optional feature and one that is broken look alike.
5
+ * XRPC method it routes. It is the direct question and the answer to trust when
6
+ * it comes — but it is still a proposal, and implementations exist that serve
7
+ * the features while serving no describe. The reference spaces build of
8
+ * `@atproto/pds` is one: it routes every space method and does not contain the
9
+ * describe NSID at all. Reading its silence as "no optional features" denies it
10
+ * scopes it could honor.
11
+ *
12
+ * So when describe says nothing, the methods a caller actually cares about are
13
+ * probed directly. Calling one and reading the failure cannot tell a missing
14
+ * feature from a broken server *on its own* — but it can against a control: ask
15
+ * for an NSID that certainly does not exist, learn how this server says "no such
16
+ * method", and a different answer for a real method is evidence it has one.
8
17
  *
9
18
  * Used before pushing an authorization request, so a PDS is asked for a
10
19
  * feature's scopes only when it implements the feature.
11
20
  */
12
21
  import type { OAuthConfig } from '../config.ts';
13
22
  /**
14
- * Every XRPC method a PDS serves. An empty set for a server that answers no
15
- * describe — which is every PDS that predates the method, so absence has to
16
- * read as "no optional features" rather than as an error.
23
+ * Every XRPC method a PDS serves, as far as it can be established.
24
+ *
25
+ * `candidates` are the methods the caller needs an answer about. They are only
26
+ * used when describe says nothing — an explicit list is always preferred to
27
+ * inference, and a server that answers describe is taken at its word.
17
28
  */
18
- export declare function describeMethods(pdsEndpoint: string): Promise<Set<string>>;
29
+ export declare function describeMethods(pdsEndpoint: string, candidates?: string[]): Promise<Set<string>>;
19
30
  /**
20
31
  * The scope string to request from this PDS: `base`, plus any conditional set
21
32
  * whose `whenMethod` the server serves.
@@ -1 +1 @@
1
- {"version":3,"file":"service-describe.d.ts","sourceRoot":"","sources":["../../src/oauth/service-describe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAU/C;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAwB/E;AAED;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,UAAU,EAAE,OAAO,GAClB,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED,+CAA+C;AAC/C,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC"}
1
+ {"version":3,"file":"service-describe.d.ts","sourceRoot":"","sources":["../../src/oauth/service-describe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAkD/C;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,GAAE,MAAM,EAAO,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CA+B1G;AAED;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,UAAU,EAAE,OAAO,GAClB,OAAO,CAAC,MAAM,CAAC,CAajB;AAED,+CAA+C;AAC/C,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC"}
@@ -4,12 +4,53 @@ const PROBE_TIMEOUT_MS = 5000;
4
4
  const CACHE_TTL_MS = 60 * 60 * 1000;
5
5
  /** Answers are a property of the server, so one entry covers every account on it. */
6
6
  const cache = new Map();
7
+ /** An NSID no server routes, for learning how one says "no such method". */
8
+ const CONTROL_NSID = 'dev.hatk.unspecced.methodThatDoesNotExist';
9
+ /** How a server answered, as a comparable string: status and error code. */
10
+ async function signature(pdsEndpoint, nsid) {
11
+ try {
12
+ const res = await fetch(`${pdsEndpoint.replace(/\/$/, '')}/xrpc/${nsid}`, {
13
+ headers: { accept: 'application/json' },
14
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
15
+ });
16
+ const body = (await res.json().catch(() => null));
17
+ return `${res.status}:${body?.error ?? ''}`;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
7
23
  /**
8
- * Every XRPC method a PDS serves. An empty set for a server that answers no
9
- * describe — which is every PDS that predates the method, so absence has to
10
- * read as "no optional features" rather than as an error.
24
+ * Which of `candidates` this server appears to route, judged against how it
25
+ * answers for a method that does not exist.
26
+ *
27
+ * Conservative by construction. A server that answers identically either way —
28
+ * bsky.social returns `401 AuthMissing` for both — yields nothing, so a feature
29
+ * it does not have is never claimed. The cost is failing to see support that
30
+ * cannot be distinguished, which is where this started anyway.
11
31
  */
12
- export async function describeMethods(pdsEndpoint) {
32
+ async function probeMethods(pdsEndpoint, candidates) {
33
+ const control = await signature(pdsEndpoint, CONTROL_NSID);
34
+ // Unreachable is not evidence about the server, so claim nothing.
35
+ if (control === null)
36
+ return new Set();
37
+ const answers = await Promise.all(candidates.map((n) => signature(pdsEndpoint, n)));
38
+ const found = new Set();
39
+ candidates.forEach((nsid, i) => {
40
+ const a = answers[i];
41
+ if (a !== null && a !== control)
42
+ found.add(nsid);
43
+ });
44
+ return found;
45
+ }
46
+ /**
47
+ * Every XRPC method a PDS serves, as far as it can be established.
48
+ *
49
+ * `candidates` are the methods the caller needs an answer about. They are only
50
+ * used when describe says nothing — an explicit list is always preferred to
51
+ * inference, and a server that answers describe is taken at its word.
52
+ */
53
+ export async function describeMethods(pdsEndpoint, candidates = []) {
13
54
  const cached = cache.get(pdsEndpoint);
14
55
  if (cached && cached.expires > Date.now())
15
56
  return cached.methods;
@@ -32,6 +73,12 @@ export async function describeMethods(pdsEndpoint) {
32
73
  catch {
33
74
  methods = new Set();
34
75
  }
76
+ // No describe, or one that listed nothing: fall back to asking about the
77
+ // methods this caller actually needs. Only those — a probe per method is a
78
+ // request per method, and the conditional sets name two.
79
+ if (methods.size === 0 && candidates.length > 0) {
80
+ methods = await probeMethods(pdsEndpoint, candidates);
81
+ }
35
82
  cache.set(pdsEndpoint, { methods, expires: Date.now() + CACHE_TTL_MS });
36
83
  return methods;
37
84
  }
@@ -48,7 +95,7 @@ export async function negotiateScope(config, base, pdsEndpoint, isLoopback) {
48
95
  const conditional = config.conditionalScopes ?? [];
49
96
  if (conditional.length === 0 || !pdsEndpoint || isLoopback)
50
97
  return base;
51
- const methods = await describeMethods(pdsEndpoint);
98
+ const methods = await describeMethods(pdsEndpoint, conditional.map((c) => c.whenMethod));
52
99
  const granted = conditional.filter((c) => methods.has(c.whenMethod)).flatMap((c) => c.scopes);
53
100
  if (granted.length === 0)
54
101
  return base;
@@ -1 +1 @@
1
- {"version":3,"file":"opengraph.d.ts","sourceRoot":"","sources":["../src/opengraph.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAE5C,4CAA4C;AAC5C,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;QAC3B,QAAQ,CAAC,EAAE,CAAC,UAAU,GAAG,MAAM,CAAC,EAAE,GAAG,MAAM,CAAA;QAC3C,GAAG,CAAC,EAAE,MAAM,CAAA;QACZ,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KACnB,CAAA;CACF;AAED,uDAAuD;AACvD,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;CACpD;AAED,qDAAqD;AACrD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,UAAU,CAAA;IACnB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAA;KAAE,CAAA;IAC5D,IAAI,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAChD;AAED,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,eAAe,CAAC;;;oBAA7C,gBAAgB,KAAK,OAAO,CAAC,eAAe,CAAC;EAEnG;AAkCD,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0EhE;AAED,kEAAkE;AAClE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE;IACvC,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;CAC9D,GAAG,IAAI,CAsDP;AAED,wBAAsB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA+BrF;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyC3E"}
1
+ {"version":3,"file":"opengraph.d.ts","sourceRoot":"","sources":["../src/opengraph.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAE5C,4CAA4C;AAC5C,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;QAC3B,QAAQ,CAAC,EAAE,CAAC,UAAU,GAAG,MAAM,CAAC,EAAE,GAAG,MAAM,CAAA;QAC3C,GAAG,CAAC,EAAE,MAAM,CAAA;QACZ,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KACnB,CAAA;CACF;AAED,uDAAuD;AACvD,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;CACpD;AAED,qDAAqD;AACrD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,UAAU,CAAA;IACnB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAA;KAAE,CAAA;IAC5D,IAAI,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAChD;AAED,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,eAAe,CAAC;;;oBAA7C,gBAAgB,KAAK,OAAO,CAAC,eAAe,CAAC;EAEnG;AAiDD,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAyEhE;AAED,kEAAkE;AAClE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE;IACvC,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;CAC9D,GAAG,IAAI,CAqDP;AAED,wBAAsB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA+BrF;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyC3E"}
package/dist/opengraph.js CHANGED
@@ -32,6 +32,20 @@ let defaultFont = null;
32
32
  const cache = new Map();
33
33
  const CACHE_TTL = 5 * 60 * 1000;
34
34
  const CACHE_MAX = 200;
35
+ /**
36
+ * Read a font file as an ArrayBuffer holding *only* that font's bytes.
37
+ *
38
+ * `readFileSync` returns a Buffer that, for small files, is a view into Node's
39
+ * shared 64 KB allocation pool — so `buffer.buffer` is the whole pool, not the
40
+ * font, and the font generally starts at a nonzero `byteOffset`. Handing that
41
+ * to satori makes it read whatever happened to be pooled before the font and
42
+ * reject the result with "Unsupported OpenType signature". Slice out the
43
+ * view's own range instead.
44
+ */
45
+ function readFontData(fontPath) {
46
+ const fontData = readFileSync(fontPath);
47
+ return fontData.buffer.slice(fontData.byteOffset, fontData.byteOffset + fontData.byteLength);
48
+ }
35
49
  function compilePath(path) {
36
50
  const paramNames = [];
37
51
  const re = path.replace(/:([^/]+)/g, (_, name) => {
@@ -44,8 +58,7 @@ export async function initOpengraph(ogDir) {
44
58
  // Load default font
45
59
  try {
46
60
  const fontPath = resolve(import.meta.dirname, '..', 'fonts', 'Inter-Regular.woff');
47
- const fontData = readFileSync(fontPath);
48
- defaultFont = { name: 'Inter', data: fontData.buffer, weight: 400, style: 'normal' };
61
+ defaultFont = { name: 'Inter', data: readFontData(fontPath), weight: 400, style: 'normal' };
49
62
  log('[opengraph] loaded default font: Inter');
50
63
  }
51
64
  catch {
@@ -121,8 +134,7 @@ export function registerOgHandler(ogMod) {
121
134
  if (!defaultFont) {
122
135
  try {
123
136
  const fontPath = resolve(import.meta.dirname, '..', 'fonts', 'Inter-Regular.woff');
124
- const fontData = readFileSync(fontPath);
125
- defaultFont = { name: 'Inter', data: fontData.buffer, weight: 400, style: 'normal' };
137
+ defaultFont = { name: 'Inter', data: readFontData(fontPath), weight: 400, style: 'normal' };
126
138
  }
127
139
  catch { }
128
140
  }
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAsEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AA4B9C;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CAqM3F;AAgDD,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,WAAW,GAAG,IAAI,CAAA;IACzB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxF,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA2iC5F;AAGD,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EAAE,EACrB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,KAAK,EAAE,WAAW,GAAG,IAAI,EACzB,MAAM,GAAE,MAAM,EAAO,EACrB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,GACpB,OAAO,WAAW,EAAE,MAAM,CAG5B"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAsEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAyD9C;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CAuM3F;AAgDD,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,WAAW,GAAG,IAAI,CAAA;IACzB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxF,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAwkC5F;AAGD,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EAAE,EACrB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,KAAK,EAAE,WAAW,GAAG,IAAI,EACzB,MAAM,GAAE,MAAM,EAAO,EACrB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,GACpB,OAAO,WAAW,EAAE,MAAM,CAG5B"}
package/dist/server.js CHANGED
@@ -20,6 +20,36 @@ import { json, jsonError, cors, withCors, file, notFound } from "./response.js";
20
20
  import { collectionFromUri, isPrivateCollection } from "./private-collections.js";
21
21
  import { serve } from "./adapter.js";
22
22
  import { renderPage } from "./renderer.js";
23
+ /**
24
+ * Parse a client-supplied `limit`, or null if it is not a usable one.
25
+ *
26
+ * `parseInt` alone lets `limit=abc` through as NaN and `limit=-5` through as a
27
+ * negative bound, both of which reach the database driver: the first as a
28
+ * datatype error, the second as "no limit at all" on SQLite. Neither is a
29
+ * server fault, so the caller turns a null into a 400.
30
+ */
31
+ function parseLimit(raw, fallback) {
32
+ if (raw === null || raw === '')
33
+ return fallback;
34
+ if (!/^\d+$/.test(raw))
35
+ return null;
36
+ const value = Number(raw);
37
+ return value >= 1 ? value : null;
38
+ }
39
+ /** Whether `sort` names a column queryRecords can order this collection by. */
40
+ function isSortableField(collection, sort) {
41
+ if (sort === 'indexed_at')
42
+ return true;
43
+ const schema = getSchema(collection);
44
+ return !!schema?.columns.some((c) => c.originalName === sort || c.name === sort);
45
+ }
46
+ /** Narrow a client-supplied `order` to the two values that may reach ORDER BY. */
47
+ function parseOrder(raw) {
48
+ if (!raw)
49
+ return undefined;
50
+ const lowered = raw.toLowerCase();
51
+ return lowered === 'asc' || lowered === 'desc' ? lowered : null;
52
+ }
23
53
  function scopeMissingResponse(acceptEncoding, handle) {
24
54
  const res = withCors(json({ error: 'ScopeMissingError', ...(handle ? { handle } : {}) }, 401, acceptEncoding));
25
55
  res.headers.append('Set-Cookie', clearSessionCookieHeader());
@@ -45,7 +75,11 @@ export function registerCoreHandlers(collections, oauth) {
45
75
  if (!getSchema(collection))
46
76
  throw new NotFoundError(`Unknown collection: ${collection}`);
47
77
  const sort = params.sort || undefined;
48
- const order = (params.order || undefined);
78
+ if (sort && !isSortableField(collection, sort))
79
+ throw new InvalidRequestError(`Invalid sort field: ${sort}`);
80
+ const order = parseOrder(params.order);
81
+ if (order === null)
82
+ throw new InvalidRequestError(`Invalid order: ${params.order}`);
49
83
  const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']);
50
84
  const filters = {};
51
85
  for (const [key, value] of Object.entries(params)) {
@@ -358,11 +392,23 @@ export function createHandler(config) {
358
392
  return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
359
393
  if (!getSchema(collection))
360
394
  return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
361
- const limit = parseInt(url.searchParams.get('limit') || '20');
395
+ // Every one of these is attacker-controlled, and queryRecords trusts
396
+ // what it is handed: `sort` and `order` reach the ORDER BY clause and a
397
+ // non-numeric `limit` reaches the driver. Rejecting them here keeps a
398
+ // bad query string a 400 instead of a 500 (or worse).
399
+ const limit = parseLimit(url.searchParams.get('limit'), 20);
400
+ if (limit === null)
401
+ return withCors(jsonError(400, 'Invalid limit parameter', acceptEncoding));
362
402
  const cursor = url.searchParams.get('cursor') || undefined;
363
403
  const sort = url.searchParams.get('sort') || undefined;
364
- const order = (url.searchParams.get('order') || undefined);
365
- // Collect field filters (everything except reserved params)
404
+ const order = parseOrder(url.searchParams.get('order'));
405
+ if (order === null)
406
+ return withCors(jsonError(400, `Invalid order: ${url.searchParams.get('order')}`, acceptEncoding));
407
+ if (sort && !isSortableField(collection, sort))
408
+ return withCors(jsonError(400, `Invalid sort field: ${sort}`, acceptEncoding));
409
+ // Collect field filters (everything except reserved params). An
410
+ // unknown field is ignored rather than rejected — queryRecords does
411
+ // the same, and clients rely on passing through extra params.
366
412
  const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']);
367
413
  const filters = {};
368
414
  for (const [key, value] of url.searchParams) {
@@ -920,7 +966,24 @@ export function createHandler(config) {
920
966
  }
921
967
  if (!code)
922
968
  return withCors(jsonError(400, 'Missing code', acceptEncoding));
923
- const result = await handleCallback(oauth, code, state, iss);
969
+ let result;
970
+ try {
971
+ result = await handleCallback(oauth, code, state, iss);
972
+ }
973
+ catch (err) {
974
+ // Same convention as the token endpoint below: an error that names
975
+ // its own status keeps it, anything else is a genuine failure and
976
+ // falls through to the 500.
977
+ if (err instanceof OAuthError)
978
+ return withCors(json({ error: err.code, error_description: err.description }, err.status, acceptEncoding));
979
+ // A callback matching no pending authorization request is a stale
980
+ // redirect, not a server fault — the request expired while a login
981
+ // tab sat open, or the user came back to a login they already
982
+ // finished. Answer like the other client errors on this route.
983
+ if (err instanceof Error && err.message === 'No matching authorization request found')
984
+ return withCors(jsonError(400, err.message, acceptEncoding));
985
+ throw err;
986
+ }
924
987
  const isSecure = requestOrigin.startsWith('https');
925
988
  const handle = (await getRepoHandle(result.did)) ?? result.did;
926
989
  const session = { did: result.did, handle };