@kumiai/hub-server 0.7.0 → 0.9.0

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/lib/handlers.d.ts CHANGED
@@ -63,6 +63,13 @@ export type AuthorizeRequest = {
63
63
  } | {
64
64
  action: 'wake/unregister';
65
65
  did: string;
66
+ } | {
67
+ action: 'receive';
68
+ did: string;
69
+ } | {
70
+ action: 'receive/deliver';
71
+ did: string;
72
+ topicID: string;
66
73
  };
67
74
  /**
68
75
  * A plain `boolean` is shorthand for `{ allow: boolean }`, with no reason, code, or retry hint.
@@ -150,6 +157,12 @@ export declare const DEFAULT_KEYPACKAGE_FETCH_LIMITS: KeyPackageFetchLimits;
150
157
  * writer health. The default of 256 is safely above it.
151
158
  */
152
159
  export declare const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
160
+ /**
161
+ * Default TTL (ms) for a receive channel's per-(did, topicID) delivery-authorization decision.
162
+ * Bounds how long an already-resolved allow is reused before the hook is re-consulted — i.e. the
163
+ * maximum window a removed member can keep draining a topic. See the receive handler's `gate`.
164
+ */
165
+ export declare const DEFAULT_RECEIVE_AUTH_CACHE_TTL = 5000;
153
166
  export type CreateHandlersParams = {
154
167
  registry: HubClientRegistry;
155
168
  store: HubStore;
@@ -159,6 +172,12 @@ export type CreateHandlersParams = {
159
172
  /** Max frames queued-but-unflushed on a receive channel. See {@link DEFAULT_RECEIVE_BUFFER_LIMIT}
160
173
  * for the >= 50-frame-page floor this should respect. Default: 256 */
161
174
  receiveBufferLimit?: number;
175
+ /**
176
+ * TTL (ms) for the per-(did, topicID) receive delivery-authorization cache. `0` disables reuse
177
+ * (the hook is consulted for every frame). Non-finite or negative values fall back to
178
+ * {@link DEFAULT_RECEIVE_AUTH_CACHE_TTL}. Default: 5000.
179
+ */
180
+ receiveAuthCacheTTL?: number;
162
181
  /**
163
182
  * Called when a `HubStore` operation fails at a point where the hub deliberately does not fail
164
183
  * the request. Fire-and-forget; a throw here is swallowed.
package/lib/handlers.js CHANGED
@@ -84,6 +84,11 @@ export const DEFAULT_KEYPACKAGE_FETCH_LIMITS = {
84
84
  * before awaiting, so a limit below 50 would trip the cap on the very first page regardless of
85
85
  * writer health. The default of 256 is safely above it.
86
86
  */ export const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
87
+ /**
88
+ * Default TTL (ms) for a receive channel's per-(did, topicID) delivery-authorization decision.
89
+ * Bounds how long an already-resolved allow is reused before the hook is re-consulted — i.e. the
90
+ * maximum window a removed member can keep draining a topic. See the receive handler's `gate`.
91
+ */ export const DEFAULT_RECEIVE_AUTH_CACHE_TTL = 5000;
87
92
  function getClientDID(ctx) {
88
93
  const iss = ctx.message.payload.iss;
89
94
  if (typeof iss !== 'string' || iss.length === 0) {
@@ -105,6 +110,13 @@ function getClientDID(ctx) {
105
110
  }
106
111
  throw error;
107
112
  }
113
+ function resolveReceiveAuthCacheTTL(value) {
114
+ if (value === undefined || !Number.isFinite(value) || value < 0) {
115
+ return DEFAULT_RECEIVE_AUTH_CACHE_TTL;
116
+ }
117
+ return value // 0 allowed: no reuse
118
+ ;
119
+ }
108
120
  function toReceiveFrame(message) {
109
121
  return {
110
122
  sequenceID: message.sequenceID,
@@ -134,6 +146,7 @@ export function createHandlers(params) {
134
146
  const didLimiter = createRateLimiter(rateLimits.perDID);
135
147
  const topicLimiter = createRateLimiter(rateLimits.perTopic);
136
148
  const receiveBufferLimit = params.receiveBufferLimit ?? DEFAULT_RECEIVE_BUFFER_LIMIT;
149
+ const receiveAuthCacheTTL = resolveReceiveAuthCacheTTL(params.receiveAuthCacheTTL);
137
150
  const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
138
151
  const fetchLimits = {
139
152
  ...DEFAULT_KEYPACKAGE_FETCH_LIMITS,
@@ -273,7 +286,9 @@ export function createHandlers(params) {
273
286
  // retry returns `deduped`, and the block below is gated on `!deduped`.
274
287
  let subscribers;
275
288
  try {
276
- subscribers = await store.getSubscribers(topicID);
289
+ subscribers = await store.getSubscribers({
290
+ topicID
291
+ });
277
292
  } catch (error) {
278
293
  storeErrorReporter({
279
294
  method: 'getSubscribers',
@@ -406,7 +421,10 @@ export function createHandlers(params) {
406
421
  });
407
422
  }
408
423
  try {
409
- await store.unsubscribe(clientDID, topicID);
424
+ await store.unsubscribe({
425
+ subscriberDID: clientDID,
426
+ topicID
427
+ });
410
428
  } catch (error) {
411
429
  rethrowAsHandlerError(error);
412
430
  }
@@ -417,7 +435,43 @@ export function createHandlers(params) {
417
435
  'hub/v1/receive': async (ctx)=>{
418
436
  const clientDID = getClientDID(ctx);
419
437
  const { after } = ctx.param ?? {};
438
+ const connectDecision = normalizeAuthorizeDecision(await authorize({
439
+ action: 'receive',
440
+ did: clientDID
441
+ }));
442
+ if (!connectDecision.allow) {
443
+ throw new HandlerError({
444
+ code: HUB_ERROR_CODES.authorizationDenied,
445
+ message: connectDecision.reason ?? 'Not authorized to receive'
446
+ });
447
+ }
420
448
  registry.register(clientDID);
449
+ // Per-(did, topicID) delivery-authorization cache, local to this channel (torn down with it).
450
+ // Both allow AND deny are cached for the TTL, so re-grant is not immediate: a topic that was
451
+ // just re-authorized keeps being denied for up to `receiveAuthCacheTTL` ms, and a frame denied
452
+ // in-session redelivers on the recipient's next connect.
453
+ const authCache = new Map();
454
+ const gate = async (topicID)=>{
455
+ const key = `${clientDID}${topicID}`;
456
+ const now = Date.now();
457
+ const cached = authCache.get(key);
458
+ if (cached != null && cached.expiresAt > now) return cached.allow;
459
+ const decision = normalizeAuthorizeDecision(await authorize({
460
+ action: 'receive/deliver',
461
+ did: clientDID,
462
+ topicID
463
+ }));
464
+ if (receiveAuthCacheTTL > 0) {
465
+ // Expiry is measured from when the decision is stored, AFTER the hook resolves — so the
466
+ // reuse window excludes the hook's own execution time. Re-read the clock here rather than
467
+ // reusing `now` (captured before the await).
468
+ authCache.set(key, {
469
+ allow: decision.allow,
470
+ expiresAt: Date.now() + receiveAuthCacheTTL
471
+ });
472
+ }
473
+ return decision.allow;
474
+ };
421
475
  const writer = ctx.writable.getWriter();
422
476
  const reader = ctx.readable.getReader();
423
477
  let endEvicted;
@@ -464,6 +518,7 @@ export function createHandlers(params) {
464
518
  writeChain = writeChain.then(async ()=>{
465
519
  if (tornDown) return;
466
520
  try {
521
+ if (!await gate(frame.topicID)) return; // denied: skip, leave pending, no ack
467
522
  await writer.ready;
468
523
  await writer.write(frame);
469
524
  if (frame.sequenceID > lastServed) lastServed = frame.sequenceID;
@@ -634,9 +689,16 @@ export function createHandlers(params) {
634
689
  // The last-resort path has no cap to cross: the slot is one entry, replaced in place.
635
690
  try {
636
691
  if (lastResortPackage != null) {
637
- await store.storeLastResortKeyPackage(clientDID, lastResortPackage);
692
+ await store.storeLastResortKeyPackage({
693
+ ownerDID: clientDID,
694
+ keyPackage: lastResortPackage
695
+ });
638
696
  } else {
639
- await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage(clientDID, kp, notAfter)));
697
+ await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage({
698
+ ownerDID: clientDID,
699
+ keyPackage: kp,
700
+ notAfter
701
+ })));
640
702
  }
641
703
  } catch (error) {
642
704
  rethrowAsHandlerError(error);
@@ -695,7 +757,9 @@ export function createHandlers(params) {
695
757
  let lastResort = null;
696
758
  let readFailure;
697
759
  try {
698
- lastResort = await store.fetchLastResortKeyPackage(targetDID);
760
+ lastResort = await store.fetchLastResortKeyPackage({
761
+ ownerDID: targetDID
762
+ });
699
763
  } catch (error) {
700
764
  readFailure = error;
701
765
  }
@@ -728,7 +792,10 @@ export function createHandlers(params) {
728
792
  // the request when nothing has been consumed and there is therefore nothing to lose.
729
793
  let consumed;
730
794
  try {
731
- consumed = await store.fetchKeyPackages(targetDID, cappedCount);
795
+ consumed = await store.fetchKeyPackages({
796
+ ownerDID: targetDID,
797
+ count: cappedCount
798
+ });
732
799
  } catch (error) {
733
800
  rethrowAsHandlerError(error);
734
801
  }
@@ -737,7 +804,9 @@ export function createHandlers(params) {
737
804
  };
738
805
  let lastResort = null;
739
806
  try {
740
- lastResort = await store.fetchLastResortKeyPackage(targetDID);
807
+ lastResort = await store.fetchLastResortKeyPackage({
808
+ ownerDID: targetDID
809
+ });
741
810
  } catch (error) {
742
811
  // Nothing consumed, so nothing is lost by surfacing it. Otherwise the top-up was a bonus
743
812
  // the caller never paid for: hand back what the store has already given up.
@@ -784,8 +853,12 @@ export function createHandlers(params) {
784
853
  // Two independent reads for one owner: concurrent, since neither informs the other.
785
854
  ;
786
855
  [count, stored] = await Promise.all([
787
- store.countKeyPackages(clientDID),
788
- store.fetchLastResortKeyPackage(clientDID)
856
+ store.countKeyPackages({
857
+ ownerDID: clientDID
858
+ }),
859
+ store.fetchLastResortKeyPackage({
860
+ ownerDID: clientDID
861
+ })
789
862
  ]);
790
863
  } catch (error) {
791
864
  rethrowAsHandlerError(error);
package/lib/hub.d.ts CHANGED
@@ -30,7 +30,12 @@ export type CreateHubParams = {
30
30
  identity: Identity;
31
31
  /** Access rules enforced by the server. Defaults to {@link DEFAULT_HUB_ACCESS_RULES}. */
32
32
  accessRules?: AccessRules;
33
- /** Per-procedure publish/subscribe authorization. Defaults to allow-any-authed. */
33
+ /**
34
+ * Per-action authorization hook. Consulted for publish, subscribe, topic/fetch, keypackage/*,
35
+ * wake/*, and receive — the coarse `receive` gate at channel open plus a per-frame
36
+ * `receive/deliver` gate by topic (see {@link CreateHubParams.receiveAuthCacheTTL}). Defaults to
37
+ * allow-any-authed.
38
+ */
34
39
  authorize?: AuthorizeHook;
35
40
  /** Publish rate limits. Merged over {@link DEFAULT_RATE_LIMITS}. */
36
41
  rateLimits?: Partial<HubRateLimits>;
@@ -39,6 +44,11 @@ export type CreateHubParams = {
39
44
  * {@link DEFAULT_KEYPACKAGE_FETCH_LIMITS}.
40
45
  */
41
46
  keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
47
+ /**
48
+ * TTL (ms) for the per-(did, topicID) receive delivery-authorization cache. `0` consults the
49
+ * hook for every frame. Forwarded to {@link createHandlers}. Default: 5000.
50
+ */
51
+ receiveAuthCacheTTL?: number;
42
52
  /**
43
53
  * Called when a `HubStore` operation fails where the hub deliberately does not fail the
44
54
  * request. Forwarded to {@link createHandlers} and used by the purge timer. Fire-and-forget.
package/lib/hub.js CHANGED
@@ -34,6 +34,7 @@ export function createHub(params) {
34
34
  authorize: params.authorize,
35
35
  rateLimits: params.rateLimits,
36
36
  keyPackageFetchLimits: params.keyPackageFetchLimits,
37
+ receiveAuthCacheTTL: params.receiveAuthCacheTTL,
37
38
  onStoreError: storeErrorReporter,
38
39
  wake: params.wake == null ? undefined : {
39
40
  registry: params.wake.registry,
@@ -360,7 +360,7 @@ function isLive(entry, nowSeconds) {
360
360
  }
361
361
  ownTopics.add(params.topicID);
362
362
  },
363
- async unsubscribe (subscriberDID, topicID) {
363
+ async unsubscribe ({ subscriberDID, topicID }) {
364
364
  const subscribers = subscriptions.get(topicID);
365
365
  if (subscribers != null) {
366
366
  subscribers.delete(subscriberDID);
@@ -386,13 +386,13 @@ function isLive(entry, nowSeconds) {
386
386
  }
387
387
  }
388
388
  },
389
- async getSubscribers (topicID) {
389
+ async getSubscribers ({ topicID }) {
390
390
  const subscribers = subscriptions.get(topicID);
391
391
  return subscribers == null ? [] : [
392
392
  ...subscribers.keys()
393
393
  ];
394
394
  },
395
- async storeKeyPackage (ownerDID, keyPackage, notAfter) {
395
+ async storeKeyPackage ({ ownerDID, keyPackage, notAfter }) {
396
396
  let packages = keyPackages.get(ownerDID);
397
397
  if (packages == null) {
398
398
  packages = [];
@@ -417,7 +417,7 @@ function isLive(entry, nowSeconds) {
417
417
  } : {}
418
418
  });
419
419
  },
420
- async fetchKeyPackages (ownerDID, count) {
420
+ async fetchKeyPackages ({ ownerDID, count }) {
421
421
  const packages = keyPackages.get(ownerDID);
422
422
  if (packages == null || packages.length === 0) return [];
423
423
  const nowSeconds = Math.floor(Date.now() / 1000);
@@ -432,16 +432,16 @@ function isLive(entry, nowSeconds) {
432
432
  }
433
433
  return served;
434
434
  },
435
- async countKeyPackages (ownerDID) {
435
+ async countKeyPackages ({ ownerDID }) {
436
436
  const packages = keyPackages.get(ownerDID);
437
437
  if (packages == null) return 0;
438
438
  const nowSeconds = Math.floor(Date.now() / 1000);
439
439
  return packages.filter((entry)=>isLive(entry, nowSeconds)).length;
440
440
  },
441
- async storeLastResortKeyPackage (ownerDID, keyPackage) {
441
+ async storeLastResortKeyPackage ({ ownerDID, keyPackage }) {
442
442
  lastResortKeyPackages.set(ownerDID, keyPackage);
443
443
  },
444
- async fetchLastResortKeyPackage (ownerDID) {
444
+ async fetchLastResortKeyPackage ({ ownerDID }) {
445
445
  // No splice, ever. Reuse is the point here — this package is marked last-resort in MLS, so
446
446
  // serving it again is by design rather than the init-key reuse `fetchKeyPackages` avoids.
447
447
  return lastResortKeyPackages.get(ownerDID) ?? null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-server",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Hub server for Enkaku E2EE group message routing",
5
5
  "keywords": [
6
6
  "hub",
@@ -25,19 +25,19 @@
25
25
  "lib/*"
26
26
  ],
27
27
  "dependencies": {
28
- "@enkaku/protocol": "^0.21.0",
29
- "@enkaku/server": "^0.21.1",
28
+ "@enkaku/protocol": "^0.21.1",
29
+ "@enkaku/server": "^0.21.2",
30
30
  "@kokuin/token": "^0.5.0",
31
+ "@kumiai/hub-protocol": "^0.9.0",
31
32
  "@sozai/codec": "^0.4.0",
32
33
  "@sozai/event": "^0.1.3",
33
- "@sozai/log": "^0.3.1",
34
- "@kumiai/hub-protocol": "^0.7.0"
34
+ "@sozai/log": "^0.3.1"
35
35
  },
36
36
  "devDependencies": {
37
- "@enkaku/client": "^0.21.0",
37
+ "@enkaku/client": "^0.21.2",
38
38
  "@enkaku/transport": "^0.21.0",
39
- "@kumiai/hub-conformance": "^0.7.0",
40
- "@kumiai/hub-wake": "^0.7.0"
39
+ "@kumiai/hub-conformance": "^0.9.0",
40
+ "@kumiai/hub-wake": "^0.9.0"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"