@hatk/hatk 0.0.1-alpha.60 → 0.0.1-alpha.62

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.
@@ -1 +1 @@
1
- {"version":3,"file":"backfill.d.ts","sourceRoot":"","sources":["../src/backfill.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAIjD,6CAA6C;AAC7C,UAAU,YAAY;IACpB,wFAAwF;IACxF,MAAM,EAAE,MAAM,CAAA;IACd,8FAA8F;IAC9F,MAAM,EAAE,MAAM,CAAA;IACd,yEAAyE;IACzE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,wDAAwD;IACxD,MAAM,EAAE,cAAc,CAAA;CACvB;AAoGD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkK/G;AA8BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAkIrE"}
1
+ {"version":3,"file":"backfill.d.ts","sourceRoot":"","sources":["../src/backfill.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAIjD,6CAA6C;AAC7C,UAAU,YAAY;IACpB,wFAAwF;IACxF,MAAM,EAAE,MAAM,CAAA;IACd,8FAA8F;IAC9F,MAAM,EAAE,MAAM,CAAA;IACd,yEAAyE;IACzE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,wDAAwD;IACxD,MAAM,EAAE,cAAc,CAAA;CACvB;AA+FD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkK/G;AA8BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAkIrE"}
package/dist/backfill.js CHANGED
@@ -5,14 +5,14 @@ import { setRepoStatus, getRepoStatus, getRepoRev, getRepoRetryInfo, listRetryEl
5
5
  import { emit, timer } from "./logger.js";
6
6
  import { validateRecord } from '@bigmoves/lexicon';
7
7
  import { getLexiconArray } from "./database/schema.js";
8
- /** In-memory cache of DID → PDS resolution results to avoid redundant lookups. */
9
- const pdsCache = new Map();
10
8
  let plcUrl;
11
9
  /**
12
10
  * Resolves a DID to its PDS endpoint and handle by fetching the DID document.
13
11
  *
14
12
  * Supports both `did:web` (fetches `/.well-known/did.json`) and `did:plc`
15
- * (fetches from the PLC directory). Results are cached for the lifetime of the process.
13
+ * (fetches from the PLC directory). Always fetches fresh DID docs change
14
+ * (handle renames, PDS migrations) and a stale cache silently rewrites stale
15
+ * handles back into `_repos` on every backfill.
16
16
  *
17
17
  * @example
18
18
  * ```ts
@@ -22,9 +22,6 @@ let plcUrl;
22
22
  * ```
23
23
  */
24
24
  async function resolvePds(did) {
25
- const cached = pdsCache.get(did);
26
- if (cached)
27
- return cached;
28
25
  let didDoc;
29
26
  if (did.startsWith('did:web:')) {
30
27
  const domain = did.slice('did:web:'.length);
@@ -42,12 +39,10 @@ async function resolvePds(did) {
42
39
  const pds = didDoc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint;
43
40
  if (!pds)
44
41
  throw new Error(`No PDS endpoint in DID document for ${did}`);
45
- // Extract handle from alsoKnownAs (format: "at://handle")
42
+ // First at:// entry in alsoKnownAs is the canonical handle (per @atproto/identity convention)
46
43
  const aka = didDoc.alsoKnownAs?.find((u) => u.startsWith('at://'));
47
44
  const handle = aka ? aka.slice('at://'.length) : null;
48
- const result = { pds, handle };
49
- pdsCache.set(did, result);
50
- return result;
45
+ return { pds, handle };
51
46
  }
52
47
  /**
53
48
  * Paginates through all active repos on a relay/PDS using `com.atproto.sync.listRepos`.
@@ -1 +1 @@
1
- {"version":3,"file":"dev-entry.d.ts","sourceRoot":"","sources":["../src/dev-entry.ts"],"names":[],"mappings":"AA6GA,eAAO,MAAM,OAAO,yCAKlB,CAAA;AAEF,yEAAyE;AACzE,wBAAsB,YAAY,kBAEjC;AAED,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AACpC,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA"}
1
+ {"version":3,"file":"dev-entry.d.ts","sourceRoot":"","sources":["../src/dev-entry.ts"],"names":[],"mappings":"AA8GA,eAAO,MAAM,OAAO,yCAKlB,CAAA;AAEF,yEAAyE;AACzE,wBAAsB,YAAY,kBAEjC;AAED,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AACpC,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA"}
package/dist/dev-entry.js CHANGED
@@ -73,6 +73,7 @@ const collectionSet = new Set(collections);
73
73
  const cursor = await getCursor('relay');
74
74
  startIndexer({
75
75
  relayUrl: config.relay,
76
+ plcUrl: config.plc,
76
77
  collections: collectionSet,
77
78
  signalCollections: config.backfill.signalCollections ? new Set(config.backfill.signalCollections) : undefined,
78
79
  pinnedRepos: config.backfill.repos ? new Set(config.backfill.repos) : undefined,
package/dist/indexer.d.ts CHANGED
@@ -12,6 +12,7 @@ export declare function triggerAutoBackfill(did: string, attempt?: number): Prom
12
12
  /** Configuration for the firehose indexer. */
13
13
  interface IndexerOpts {
14
14
  relayUrl: string;
15
+ plcUrl: string;
15
16
  collections: Set<string>;
16
17
  signalCollections?: Set<string>;
17
18
  pinnedRepos?: Set<string>;
@@ -1 +1 @@
1
- {"version":3,"file":"indexer.d.ts","sourceRoot":"","sources":["../src/indexer.ts"],"names":[],"mappings":"AAmKA;;;;;;;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,CA4EjF;AAED,8CAA8C;AAC9C,UAAU,WAAW;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,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,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B;AAyBD;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC,CAmDxE"}
1
+ {"version":3,"file":"indexer.d.ts","sourceRoot":"","sources":["../src/indexer.ts"],"names":[],"mappings":"AAoKA;;;;;;;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,CA4EjF;AAED,8CAA8C;AAC9C,UAAU,WAAW;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,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,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B;AAyBD;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC,CAoDxE"}
package/dist/indexer.js CHANGED
@@ -29,6 +29,7 @@ let indexerSignalCollections;
29
29
  let indexerPinnedRepos = null;
30
30
  let indexerFetchTimeout;
31
31
  let indexerMaxRetries;
32
+ let indexerPlcUrl;
32
33
  let maxConcurrentBackfills = 3;
33
34
  /**
34
35
  * Flush the write buffer — insert all buffered records, update the relay cursor,
@@ -259,6 +260,7 @@ export async function startIndexer(opts) {
259
260
  indexerPinnedRepos = opts.pinnedRepos || null;
260
261
  indexerFetchTimeout = fetchTimeout;
261
262
  indexerMaxRetries = opts.maxRetries;
263
+ indexerPlcUrl = opts.plcUrl;
262
264
  maxConcurrentBackfills = opts.parallelism ?? 3;
263
265
  // Pre-populate repo status cache from DB so non-signal updates
264
266
  // (e.g. profile changes) are processed for already-tracked DIDs
@@ -287,8 +289,8 @@ export async function startIndexer(opts) {
287
289
  const bytes = new Uint8Array(event.data);
288
290
  processMessage(bytes, collections);
289
291
  }
290
- catch {
291
- // Skip unparseable firehose messages silently
292
+ catch (err) {
293
+ emit('indexer', 'decode_error', { error: err instanceof Error ? err.message : String(err) });
292
294
  }
293
295
  });
294
296
  ws.addEventListener('open', () => log('[indexer] Connected to relay'));
@@ -298,6 +300,60 @@ export async function startIndexer(opts) {
298
300
  });
299
301
  return ws;
300
302
  }
303
+ /**
304
+ * Handle a `#identity` firehose event for a DID. The `handle` field on the
305
+ * event is optional per the lexicon, and some emitters omit it (signalling
306
+ * "re-resolve"). When absent, we re-resolve from the PLC directory so handle
307
+ * renames propagate even when the relay payload is sparse.
308
+ *
309
+ * Only updates DIDs we already track (present in repoStatusCache) to avoid
310
+ * writing rows for the entire network.
311
+ */
312
+ async function handleIdentityEvent(did, payloadHandle) {
313
+ if (!repoStatusCache.has(did))
314
+ return;
315
+ let handle = payloadHandle;
316
+ const payloadHadHandle = handle !== undefined;
317
+ if (!handle) {
318
+ try {
319
+ // Bound the PLC fetch so a slow plc.directory can't pile up unbounded
320
+ // promises during an identity-event burst (fire-and-forget caller).
321
+ const res = await fetch(`${indexerPlcUrl}/${did}`, {
322
+ signal: AbortSignal.timeout(indexerFetchTimeout * 1000),
323
+ });
324
+ if (res.ok) {
325
+ const doc = (await res.json());
326
+ // First at:// entry is the canonical handle (per @atproto/identity convention)
327
+ const aka = doc.alsoKnownAs?.find((u) => u.startsWith('at://'));
328
+ handle = aka ? aka.slice('at://'.length) : undefined;
329
+ }
330
+ else {
331
+ emit('indexer', 'identity_resolve_error', { did, status: res.status });
332
+ }
333
+ }
334
+ catch (err) {
335
+ emit('indexer', 'identity_resolve_error', {
336
+ did,
337
+ error: err instanceof Error ? err.message : String(err),
338
+ });
339
+ }
340
+ }
341
+ if (!handle) {
342
+ emit('indexer', 'identity_no_handle', { did, payload_had_handle: payloadHadHandle });
343
+ return;
344
+ }
345
+ try {
346
+ await updateRepoHandle(did, handle);
347
+ emit('indexer', 'identity_handle_update', { did, handle, payload_had_handle: payloadHadHandle });
348
+ }
349
+ catch (err) {
350
+ emit('indexer', 'identity_update_error', {
351
+ did,
352
+ handle,
353
+ error: err instanceof Error ? err.message : String(err),
354
+ });
355
+ }
356
+ }
301
357
  /**
302
358
  * Process a single firehose message. Decodes the CBOR header/body, filters
303
359
  * for relevant collections, validates records against lexicons, and routes
@@ -306,13 +362,13 @@ export async function startIndexer(opts) {
306
362
  function processMessage(bytes, collections) {
307
363
  const header = cborDecode(bytes, 0);
308
364
  const body = cborDecode(bytes, header.offset);
309
- // Handle identity events (handle changes)
365
+ // Handle identity events (handle changes). Fire-and-forget — keeps
366
+ // processMessage synchronous so the WS event loop drains without backpressure.
310
367
  if (header.value.t === '#identity') {
311
- const did = body.value.did;
312
- const handle = body.value.handle;
313
- if (did && handle && repoStatusCache.has(did)) {
314
- updateRepoHandle(did, handle).catch(() => { });
315
- }
368
+ const did = typeof body.value.did === 'string' ? body.value.did : undefined;
369
+ const handle = typeof body.value.handle === 'string' ? body.value.handle : undefined;
370
+ if (did)
371
+ handleIdentityEvent(did, handle);
316
372
  return;
317
373
  }
318
374
  if (header.value.op !== 1 || header.value.t !== '#commit')
package/dist/main.js CHANGED
@@ -200,6 +200,7 @@ log(` Feeds: ${listFeeds()
200
200
  const cursor = await getCursor('relay');
201
201
  startIndexer({
202
202
  relayUrl: config.relay,
203
+ plcUrl: config.plc,
203
204
  collections: collectionSet,
204
205
  signalCollections: config.backfill.signalCollections ? new Set(config.backfill.signalCollections) : undefined,
205
206
  pinnedRepos: config.backfill.repos ? new Set(config.backfill.repos) : undefined,
@@ -1,4 +1,18 @@
1
1
  import type { OAuthConfig } from '../config.ts';
2
+ /**
3
+ * RFC 6749 §5.2 OAuth token-endpoint error.
4
+ *
5
+ * Surfaces as HTTP `status` (default 400) with body
6
+ * `{ error: code, error_description: description }` so OAuth clients can
7
+ * distinguish a permanently-rejected refresh token (`invalid_grant`) from
8
+ * a transient server failure and decide whether to log the user out.
9
+ */
10
+ export declare class OAuthError extends Error {
11
+ readonly code: 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'unauthorized_client' | 'unsupported_grant_type' | 'invalid_scope';
12
+ readonly description: string;
13
+ readonly status: number;
14
+ constructor(code: 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'unauthorized_client' | 'unsupported_grant_type' | 'invalid_scope', description: string, status?: number);
15
+ }
2
16
  export declare function initOAuth(_config: OAuthConfig, plcUrl: string, relayUrl: string): Promise<void>;
3
17
  export declare function getAuthServerMetadata(issuer: string, config: OAuthConfig): {
4
18
  issuer: string;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/oauth/server.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AA4E/C,wBAAsB,SAAS,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBrG;AAID,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW;;;;;;;;;;;;;;;;;;;EAqBxE;AAED,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW;;;;;EAO/E;AAED,wBAAgB,OAAO;;;;;;;;;;;;;;;;;;;;;;EAWtB;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW;;;;;;;;;EAcpE;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CAwKtD;AAID,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,GAAG,MAAM,CAShF;AAID;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1C,OAAO,CAAC,MAAM,CAAC,CA6HjB;AAID,wBAAsB,cAAc,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,GAAG,IAAI,EACpB,GAAG,EAAE,MAAM,GAAG,IAAI,GACjB,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CA2HrG;AAID,wBAAsB,WAAW,CAC/B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,GAAG,CAAC,CAUd;AA0JD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAChH,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAsEpF;AAID,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CA0BjC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/oauth/server.ts"],"names":[],"mappings":"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"}
@@ -9,6 +9,26 @@ import { emit } from "../logger.js";
9
9
  import { querySQL } from "../database/db.js";
10
10
  import { fireOnLoginHook } from "../hooks.js";
11
11
  const SERVER_KEY_KID = 'appview-oauth-key';
12
+ /**
13
+ * RFC 6749 §5.2 OAuth token-endpoint error.
14
+ *
15
+ * Surfaces as HTTP `status` (default 400) with body
16
+ * `{ error: code, error_description: description }` so OAuth clients can
17
+ * distinguish a permanently-rejected refresh token (`invalid_grant`) from
18
+ * a transient server failure and decide whether to log the user out.
19
+ */
20
+ export class OAuthError extends Error {
21
+ code;
22
+ description;
23
+ status;
24
+ constructor(code, description, status = 400) {
25
+ super(description);
26
+ this.code = code;
27
+ this.description = description;
28
+ this.status = status;
29
+ this.name = 'OAuthError';
30
+ }
31
+ }
12
32
  async function resolveHandleForDid(did) {
13
33
  const rows = (await querySQL('SELECT handle FROM _repos WHERE did = $1', [did]));
14
34
  return rows[0]?.handle || undefined;
@@ -530,14 +550,14 @@ export async function handleCallback(config, code, state, iss) {
530
550
  export async function handleToken(config, body, dpopHeader, requestUrl) {
531
551
  const grantType = body.grant_type;
532
552
  if (!grantType)
533
- throw new Error('grant_type is required');
553
+ throw new OAuthError('invalid_request', 'grant_type is required');
534
554
  if (grantType === 'authorization_code') {
535
555
  return handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl);
536
556
  }
537
557
  else if (grantType === 'refresh_token') {
538
558
  return handleRefreshTokenGrant(config, body, dpopHeader, requestUrl);
539
559
  }
540
- throw new Error(`Unsupported grant_type: ${grantType}`);
560
+ throw new OAuthError('unsupported_grant_type', `Unsupported grant_type: ${grantType}`);
541
561
  }
542
562
  async function handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl) {
543
563
  const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
@@ -610,20 +630,21 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
610
630
  const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
611
631
  const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
612
632
  if (!fresh)
613
- throw new Error('DPoP jti replay detected');
633
+ throw new OAuthError('invalid_request', 'DPoP jti replay detected');
614
634
  const { refresh_token, client_id } = body;
615
635
  if (!refresh_token || !client_id)
616
- throw new Error('Missing required parameters');
617
- // Look up and validate refresh token
636
+ throw new OAuthError('invalid_request', 'Missing required parameters');
637
+ // Look up and validate refresh token. Per RFC 6749 §5.2, all of these are
638
+ // `invalid_grant` — the client must treat them as terminal and re-authenticate.
618
639
  const stored = await getRefreshToken(refresh_token);
619
640
  if (!stored)
620
- throw new Error('Invalid refresh token');
641
+ throw new OAuthError('invalid_grant', 'Invalid refresh token');
621
642
  if (stored.revoked)
622
- throw new Error('Refresh token revoked');
643
+ throw new OAuthError('invalid_grant', 'Refresh token revoked');
623
644
  if (stored.expires_at && stored.expires_at < Math.floor(Date.now() / 1000))
624
- throw new Error('Refresh token expired');
645
+ throw new OAuthError('invalid_grant', 'Refresh token expired');
625
646
  if (stored.client_id !== client_id)
626
- throw new Error('client_id mismatch');
647
+ throw new OAuthError('invalid_grant', 'client_id mismatch');
627
648
  // Revoke old refresh token (rotation)
628
649
  await revokeRefreshToken(refresh_token);
629
650
  const did = stored.did;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAyDA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AA2B9C;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CAgM3F;AAED,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,CA+zB5F;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":"AA0DA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AA2B9C;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CAgM3F;AAED,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,CA00B5F;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
@@ -9,7 +9,7 @@ import { handleOpengraphRequest, buildOgMeta } from "./opengraph.js";
9
9
  import { getLabelDefinitions, rescanLabels } from "./labels.js";
10
10
  import { triggerAutoBackfill } from "./indexer.js";
11
11
  import { emit, timer } from "./logger.js";
12
- import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, handlePar, buildAuthorizeRedirect, handleCallback, serverLogin, handleToken, authenticate, } from "./oauth/server.js";
12
+ import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, handlePar, buildAuthorizeRedirect, handleCallback, serverLogin, handleToken, authenticate, OAuthError, } from "./oauth/server.js";
13
13
  import { createSessionCookie, sessionCookieHeader, clearSessionCookieHeader, parseSessionCookie, } from "./oauth/session.js";
14
14
  import { getOAuthRequest } from "./oauth/db.js";
15
15
  import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsApplyWrites, pdsUploadBlob, ProxyError, ScopeMissingProxyError, } from "./pds-proxy.js";
@@ -849,10 +849,19 @@ export function createHandler(config) {
849
849
  body = JSON.parse(rawBody);
850
850
  }
851
851
  const dpopHeader = request.headers.get('dpop');
852
- if (!dpopHeader)
853
- return withCors(jsonError(400, 'DPoP header required', acceptEncoding));
854
- const result = await handleToken(oauth, body, dpopHeader, `${requestOrigin}/oauth/token`);
855
- return withCors(json(result, 200, acceptEncoding));
852
+ if (!dpopHeader) {
853
+ return withCors(json({ error: 'invalid_request', error_description: 'DPoP header required' }, 400, acceptEncoding));
854
+ }
855
+ try {
856
+ const result = await handleToken(oauth, body, dpopHeader, `${requestOrigin}/oauth/token`);
857
+ return withCors(json(result, 200, acceptEncoding));
858
+ }
859
+ catch (err) {
860
+ if (err instanceof OAuthError) {
861
+ return withCors(json({ error: err.code, error_description: err.description }, err.status, acceptEncoding));
862
+ }
863
+ throw err;
864
+ }
856
865
  }
857
866
  // POST /xrpc/dev.hatk.createRecord — proxy write to user's PDS
858
867
  if (url.pathname === coreXrpc('createRecord') && request.method === 'POST' && oauth) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatk/hatk",
3
- "version": "0.0.1-alpha.60",
3
+ "version": "0.0.1-alpha.62",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "hatk": "dist/cli.js"