@kumiai/hub-server 0.4.1 → 0.6.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/README.md CHANGED
@@ -14,6 +14,7 @@ client registry for push fan-out, and an in-memory `HubStore` for development an
14
14
  - `HubClientRegistry` — the live-connection table.
15
15
  - `createRateLimiter`, `DEFAULT_RATE_LIMITS`, `DEFAULT_KEYPACKAGE_FETCH_LIMITS`,
16
16
  `DEFAULT_HUB_ACCESS_RULES`.
17
+ - `HubStoreErrorEvent`, `HubStoreErrorHook` — the `onStoreError` event and hook types.
17
18
 
18
19
  ```ts
19
20
  import { createHub, createMemoryStore } from '@kumiai/hub-server'
@@ -32,7 +33,7 @@ Authorization is two layers, and only the second one knows about topics: `access
32
33
  procedures (the default lets any authenticated DID call them), and the optional
33
34
  `authorize(request)` hook decides publish and subscribe per topic. It takes one discriminated
34
35
  `AuthorizeRequest` — narrow on `request.action` — and returns `AuthorizeDecision`, either a boolean
35
- or `{ allow, reason?, code?, retryAfterMs? }`. `AuthorizeRequest` names six actions, but only
36
+ or `{ allow, reason?, code?, retryAfterMs? }`. `AuthorizeRequest` names seven actions, but only
36
37
  `publish` and `subscribe` are dispatched today. The default allows any authenticated DID.
37
38
 
38
39
  ## Durable subscription, ephemeral connection
@@ -82,3 +83,39 @@ flood.
82
83
 
83
84
  Publish rate limits are per DID (20/s, burst 50) and per topic (100/s, burst 200), merged over
84
85
  `DEFAULT_RATE_LIMITS`; `hub/v1/keypackage/fetch` has its own request quota per requester DID.
86
+
87
+ ## A store failure that is not a request failure
88
+
89
+ Four store operations are deliberately not allowed to fail the request they happen in:
90
+
91
+ - the **last-resort key-package top-up**, read after `fetchKeyPackages` has already consumed
92
+ destructively — surfacing it would destroy packages nobody received, and the client's retry
93
+ would burn the next batch
94
+ - an **ack**, where the frame simply stays pending and the client re-acks next round
95
+ - a scheduled **purge**, retried on the next interval
96
+ - the **subscriber read for live fan-out**, which runs after the publish committed its append and
97
+ delivery rows — every subscriber still receives the frame by pulling, and failing the request
98
+ would instead lose the live push for good, since the caller's `publishID` retry dedups and skips
99
+ fan-out
100
+
101
+ All four are correct, and all four were silent. `createHandlers` and `createHub` take
102
+ `onStoreError`, called with an event discriminated on `method` — the `HubStore` method that threw —
103
+ carrying the subject that method has: `did` for `ack` and `fetchLastResortKeyPackage`, `topicID` for
104
+ `getSubscribers`, neither for `purge`. Wire it to whatever an operator watches:
105
+
106
+ ```ts
107
+ const { server } = createHub({
108
+ transport,
109
+ store,
110
+ identity,
111
+ onStoreError: (event) => metrics.storeFailure(event),
112
+ })
113
+ ```
114
+
115
+ Fire-and-forget — a throw from the hook is swallowed rather than allowed to fail the request.
116
+ Unwired, the failure is reported through `@sozai/log` under `['kumiai', 'hub-server']` at `error`
117
+ rather than passing silently. Pass an empty handler to silence it deliberately.
118
+
119
+ There is no throttling: a permanently broken store reports per request. logtape ships
120
+ `getThrottlingFilter`, so rate control belongs in the app's sink configuration where an operator
121
+ can tune it.
package/lib/handlers.d.ts CHANGED
@@ -3,10 +3,9 @@ import type { HubProtocol, HubStore } from '@kumiai/hub-protocol';
3
3
  import { type RateLimitConfig } from './rateLimit.js';
4
4
  import type { HubClientRegistry } from './registry.js';
5
5
  /**
6
- * A single request to authorize. All six variants ship even though only `publish` and
6
+ * A single request to authorize. All seven variants ship even though only `publish` and
7
7
  * `subscribe` are currently enforced: the union is the exhaustive-switch surface a host's own
8
- * `switch (req.action)` closes over, so adding a seventh variant later is the compatibility break
9
- * this type exists to avoid.
8
+ * `switch (req.action)` closes over.
10
9
  *
11
10
  * A host's `switch` need not handle every action today — an unrecognized action should default to
12
11
  * allow, so a hook written before a new variant shipped doesn't silently start refusing a
@@ -35,11 +34,15 @@ export type AuthorizeRequest = {
35
34
  action: 'keypackage/upload';
36
35
  did: string;
37
36
  count: number;
37
+ lastResort?: boolean;
38
38
  } | {
39
39
  action: 'keypackage/fetch';
40
40
  did: string;
41
41
  targetDID: string;
42
42
  count: number;
43
+ } | {
44
+ action: 'keypackage/status';
45
+ did: string;
43
46
  };
44
47
  /**
45
48
  * A plain `boolean` is shorthand for `{ allow: boolean }`, with no reason, code, or retry hint.
@@ -57,6 +60,45 @@ export type AuthorizeDecision = boolean | {
57
60
  retryAfterMs?: number;
58
61
  };
59
62
  export type AuthorizeHook = (req: AuthorizeRequest) => AuthorizeDecision | Promise<AuthorizeDecision>;
63
+ /**
64
+ * A `HubStore` operation that failed at a point where the hub deliberately does NOT fail the
65
+ * request. Each of these swallows is correct — see the call sites for why — and each was, until
66
+ * this hook existed, completely silent.
67
+ *
68
+ * A union rather than a flat record: each variant carries the subject its own site has, so a site
69
+ * cannot forget one and a topic-keyed site cannot be described with a DID.
70
+ */
71
+ export type HubStoreErrorEvent = {
72
+ method: 'purge';
73
+ error: unknown;
74
+ }
75
+ /** The DID whose ack the store refused. */
76
+ | {
77
+ method: 'ack';
78
+ did: string;
79
+ error: unknown;
80
+ }
81
+ /** The DID whose last-resort slot was being read. */
82
+ | {
83
+ method: 'fetchLastResortKeyPackage';
84
+ did: string;
85
+ error: unknown;
86
+ }
87
+ /** The topic whose subscriber list could not be read for live fan-out. */
88
+ | {
89
+ method: 'getSubscribers';
90
+ topicID: string;
91
+ error: unknown;
92
+ };
93
+ export type HubStoreErrorHook = (event: HubStoreErrorEvent) => void;
94
+ /**
95
+ * Route a swallowed store failure to the host's hook, or to the reporter when it wired none.
96
+ *
97
+ * No throttling, deliberately: a permanently broken store emits per request, and logtape ships
98
+ * `getThrottlingFilter`, so rate control belongs in the app's sink config where an operator can
99
+ * tune it rather than hard-coded here.
100
+ */
101
+ export declare function createStoreErrorReporter(onStoreError?: HubStoreErrorHook): (event: HubStoreErrorEvent) => void;
60
102
  export type HubRateLimits = {
61
103
  perDID: RateLimitConfig;
62
104
  perTopic: RateLimitConfig;
@@ -67,15 +109,37 @@ export type KeyPackageFetchLimits = {
67
109
  maxCount: number;
68
110
  /** Maximum number of fetch requests per requester DID per window. Default: 30 */
69
111
  maxRequests: number;
112
+ /** Maximum number of key packages that may be consumed from ONE target DID per window,
113
+ * summed across all requesters. Bounds collective drain. Default: 60 */
114
+ maxPerTargetConsumed: number;
70
115
  /** Rate-limit window duration in milliseconds. Default: 60000 (1 min) */
71
116
  windowMs: number;
72
117
  };
73
118
  export declare const DEFAULT_KEYPACKAGE_FETCH_LIMITS: KeyPackageFetchLimits;
119
+ /**
120
+ * Max frames queued-but-unflushed on a single receive channel before it falls back to the store.
121
+ *
122
+ * Should be >= the drain's 50-frame fetch page size: the drain enqueues a whole backlog page
123
+ * before awaiting, so a limit below 50 would trip the cap on the very first page regardless of
124
+ * writer health. The default of 256 is safely above it.
125
+ */
126
+ export declare const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
74
127
  export type CreateHandlersParams = {
75
128
  registry: HubClientRegistry;
76
129
  store: HubStore;
77
130
  authorize?: AuthorizeHook;
78
131
  rateLimits?: Partial<HubRateLimits>;
79
132
  keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
133
+ /** Max frames queued-but-unflushed on a receive channel. See {@link DEFAULT_RECEIVE_BUFFER_LIMIT}
134
+ * for the >= 50-frame-page floor this should respect. Default: 256 */
135
+ receiveBufferLimit?: number;
136
+ /**
137
+ * Called when a `HubStore` operation fails at a point where the hub deliberately does not fail
138
+ * the request. Fire-and-forget; a throw here is swallowed.
139
+ *
140
+ * Omitted, the failure is reported through `@sozai/log` instead of passing silently. Pass an
141
+ * empty handler to silence it deliberately.
142
+ */
143
+ onStoreError?: HubStoreErrorHook;
80
144
  };
81
145
  export declare function createHandlers(params: CreateHandlersParams): ProcedureHandlers<HubProtocol>;
package/lib/handlers.js CHANGED
@@ -1,12 +1,64 @@
1
1
  import { HandlerError } from '@enkaku/server';
2
- import { hubErrorCodeOf } from '@kumiai/hub-protocol';
2
+ import { HUB_ERROR_CODES, hubErrorCodeOf, InvalidPayloadError, KeyPackageFetchLimitError, keyPackageDigest } from '@kumiai/hub-protocol';
3
3
  import { fromB64, toB64 } from '@sozai/codec';
4
+ import { getReporter } from '@sozai/log';
4
5
  import { createRateLimiter } from './rateLimit.js';
5
6
  function normalizeAuthorizeDecision(decision) {
6
7
  return typeof decision === 'boolean' ? {
7
8
  allow: decision
8
9
  } : decision;
9
10
  }
11
+ /**
12
+ * What the hub did INSTEAD of failing, and what a permanent failure costs.
13
+ *
14
+ * Keyed by `method`, but the `fetchLastResortKeyPackage` text describes the top-up call site
15
+ * specifically (of the three places that method is called, only that one reports). The trigger to
16
+ * watch for is not another `method` arriving — `getSubscribers` already did, and the method-keyed
17
+ * union discriminated it correctly — it's a SECOND reporting call site of the SAME method: the
18
+ * moment either of the other two `fetchLastResortKeyPackage` calls starts reporting, it inherits
19
+ * this text even where it's wrong, and the fix is a site discriminator alongside `method`.
20
+ */ const STORE_ERROR_CONSEQUENCE = {
21
+ fetchLastResortKeyPackage: 'The fetch returned what the pool could serve, without the last-resort top-up. A read that ' + 'keeps failing means the availability floor the last-resort slot exists to provide is absent, ' + 'and joins fail downstream at the inviter with no signal here.',
22
+ ack: 'The frame stays pending and the client re-acks on the next round. An ack that keeps failing ' + 'redelivers every frame forever.',
23
+ purge: 'Retried on the next interval. A purge that keeps failing means the store grows without bound.',
24
+ getSubscribers: 'The frame is committed and queued, but the live push to connected subscribers was skipped: ' + 'each of them stops receiving new frames until it reconnects. A getSubscribers that keeps ' + 'failing means the hub has silently degraded from push to pull for every publish.'
25
+ };
26
+ const reportStoreError = getReporter([
27
+ 'kumiai',
28
+ 'hub-server'
29
+ ], '@kumiai/hub-server');
30
+ /** What the failed operation was about, for the default log line: a DID for the per-recipient
31
+ * methods, a topic for fan-out, nothing for a store-wide purge. */ function subjectOf(event) {
32
+ switch(event.method){
33
+ case 'purge':
34
+ return '';
35
+ case 'getSubscribers':
36
+ return ` on topic ${event.topicID}`;
37
+ case 'ack':
38
+ case 'fetchLastResortKeyPackage':
39
+ return ` for ${event.did}`;
40
+ }
41
+ }
42
+ /**
43
+ * Route a swallowed store failure to the host's hook, or to the reporter when it wired none.
44
+ *
45
+ * No throttling, deliberately: a permanently broken store emits per request, and logtape ships
46
+ * `getThrottlingFilter`, so rate control belongs in the app's sink config where an operator can
47
+ * tune it rather than hard-coded here.
48
+ */ export function createStoreErrorReporter(onStoreError) {
49
+ return (event)=>{
50
+ if (onStoreError == null) {
51
+ reportStoreError(`HubStore.${event.method} failed${subjectOf(event)}. ` + `${STORE_ERROR_CONSEQUENCE[event.method]} Wire \`onStoreError\` to handle this.`, event.error);
52
+ return;
53
+ }
54
+ try {
55
+ onStoreError(event);
56
+ } catch {
57
+ // A notice, not a dependency: a host whose own reporting throws must not fail the request
58
+ // that was being served correctly.
59
+ }
60
+ };
61
+ }
10
62
  export const DEFAULT_RATE_LIMITS = {
11
63
  perDID: {
12
64
  rate: 20,
@@ -20,8 +72,16 @@ export const DEFAULT_RATE_LIMITS = {
20
72
  export const DEFAULT_KEYPACKAGE_FETCH_LIMITS = {
21
73
  maxCount: 10,
22
74
  maxRequests: 30,
75
+ maxPerTargetConsumed: 60,
23
76
  windowMs: 60_000
24
77
  };
78
+ /**
79
+ * Max frames queued-but-unflushed on a single receive channel before it falls back to the store.
80
+ *
81
+ * Should be >= the drain's 50-frame fetch page size: the drain enqueues a whole backlog page
82
+ * before awaiting, so a limit below 50 would trip the cap on the very first page regardless of
83
+ * writer health. The default of 256 is safely above it.
84
+ */ export const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
25
85
  function getClientDID(ctx) {
26
86
  const iss = ctx.message.payload.iss;
27
87
  if (typeof iss !== 'string' || iss.length === 0) {
@@ -43,6 +103,19 @@ function getClientDID(ctx) {
43
103
  }
44
104
  throw error;
45
105
  }
106
+ function toReceiveFrame(message) {
107
+ return {
108
+ sequenceID: message.sequenceID,
109
+ senderDID: message.senderDID,
110
+ topicID: message.topicID,
111
+ payload: toB64(message.payload),
112
+ // Spread, not assignment — `logPosition: undefined` would serialize as a present key, defeating
113
+ // the field's absent-vs-present meaning.
114
+ ...message.logPosition != null ? {
115
+ logPosition: message.logPosition
116
+ } : {}
117
+ };
118
+ }
46
119
  export function createHandlers(params) {
47
120
  const { store, registry } = params;
48
121
  const authorize = params.authorize ?? (()=>true);
@@ -58,10 +131,14 @@ export function createHandlers(params) {
58
131
  };
59
132
  const didLimiter = createRateLimiter(rateLimits.perDID);
60
133
  const topicLimiter = createRateLimiter(rateLimits.perTopic);
134
+ const receiveBufferLimit = params.receiveBufferLimit ?? DEFAULT_RECEIVE_BUFFER_LIMIT;
135
+ const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
61
136
  const fetchLimits = {
62
137
  ...DEFAULT_KEYPACKAGE_FETCH_LIMITS,
63
138
  ...params.keyPackageFetchLimits
64
139
  };
140
+ // Bounded by throughput x windowMs (entries expire, size > 1024 sweep drops them), not an
141
+ // absolute cap — the absolute per-DID bound is the store's caps, not this map.
65
142
  const fetchWindows = new Map();
66
143
  function assertKeyPackageFetchAllowed(requesterDID) {
67
144
  const now = Date.now();
@@ -81,10 +158,36 @@ export function createHandlers(params) {
81
158
  return;
82
159
  }
83
160
  if (window.count >= fetchLimits.maxRequests) {
84
- throw new Error('Key package fetch rate limit exceeded');
161
+ throw new KeyPackageFetchLimitError('Key package fetch rate limit exceeded');
85
162
  }
86
163
  window.count++;
87
164
  }
165
+ // Bounded like fetchWindows: throughput x windowMs, not an absolute cap.
166
+ const targetWindows = new Map();
167
+ /** Charge `amount` packages against the target DID's consumption window. Throws when the
168
+ * window's budget is spent — this bounds how fast anyone, collectively, can drain a target. */ function assertTargetConsumptionAllowed(targetDID, amount) {
169
+ const now = Date.now();
170
+ if (targetWindows.size > 1024) {
171
+ for (const [did, window] of targetWindows){
172
+ if (window.resetAt <= now) targetWindows.delete(did);
173
+ }
174
+ }
175
+ const window = targetWindows.get(targetDID);
176
+ if (window == null || window.resetAt <= now) {
177
+ if (amount > fetchLimits.maxPerTargetConsumed) {
178
+ throw new KeyPackageFetchLimitError(`Key package consumption limit exceeded for target ${targetDID}`);
179
+ }
180
+ targetWindows.set(targetDID, {
181
+ count: amount,
182
+ resetAt: now + fetchLimits.windowMs
183
+ });
184
+ return;
185
+ }
186
+ if (window.count + amount > fetchLimits.maxPerTargetConsumed) {
187
+ throw new KeyPackageFetchLimitError(`Key package consumption limit exceeded for target ${targetDID}`);
188
+ }
189
+ window.count += amount;
190
+ }
88
191
  return {
89
192
  'hub/v1/publish': async (ctx)=>{
90
193
  const { topicID, payload } = ctx.param;
@@ -99,7 +202,12 @@ export function createHandlers(params) {
99
202
  // `retain` defaults to 'mailbox' here, matching the store's own default (`memoryStore.ts`) —
100
203
  // the hook must never see an absent retain, since `AuthorizeRequest`'s `publish` variant
101
204
  // declares it required.
102
- const payloadBytes = fromB64(payload);
205
+ let payloadBytes;
206
+ try {
207
+ payloadBytes = fromB64(payload);
208
+ } catch (error) {
209
+ rethrowAsHandlerError(new InvalidPayloadError(error instanceof Error ? error.message : 'Invalid base64 payload encoding'));
210
+ }
103
211
  const retain = ctx.param.retain ?? 'mailbox';
104
212
  const decision = normalizeAuthorizeDecision(await authorize({
105
213
  action: 'publish',
@@ -156,8 +264,24 @@ export function createHandlers(params) {
156
264
  const logPosition = ctx.param.retain === 'log' ? {
157
265
  logPosition: sequenceID
158
266
  } : {};
159
- // Live-deliver to currently-connected subscribers (minus the sender).
160
- const subscribers = await store.getSubscribers(topicID);
267
+ // Live-deliver to currently-connected subscribers (minus the sender). A failure here must
268
+ // not fail the request: `publish` committed the append and its delivery rows in one
269
+ // transaction, so the frame stays pending in the store and reaches each subscriber on its
270
+ // next reconnect. Failing would also make the miss permanent — the caller's `publishID`
271
+ // retry returns `deduped`, and the block below is gated on `!deduped`.
272
+ let subscribers;
273
+ try {
274
+ subscribers = await store.getSubscribers(topicID);
275
+ } catch (error) {
276
+ storeErrorReporter({
277
+ method: 'getSubscribers',
278
+ topicID,
279
+ error
280
+ });
281
+ return {
282
+ sequenceID
283
+ };
284
+ }
161
285
  for (const recipientDID of subscribers){
162
286
  if (recipientDID === senderDID) continue;
163
287
  const client = registry.getClient(recipientDID);
@@ -187,10 +311,16 @@ export function createHandlers(params) {
187
311
  }));
188
312
  if (!decision.allow) {
189
313
  throw new HandlerError({
190
- code: 'EK02',
314
+ code: HUB_ERROR_CODES.authorizationDenied,
191
315
  message: decision.reason ?? 'Not authorized to subscribe to topic'
192
316
  });
193
317
  }
318
+ if (!didLimiter.tryConsume(clientDID)) {
319
+ throw new HandlerError({
320
+ code: 'EK01',
321
+ message: 'Subscribe rate limit exceeded for DID'
322
+ });
323
+ }
194
324
  try {
195
325
  await store.subscribe({
196
326
  subscriberDID: clientDID,
@@ -209,6 +339,17 @@ export function createHandlers(params) {
209
339
  // subscriberDID is the authenticated caller (verified issuer), never a wire field — or any
210
340
  // member could read another's topic log by naming them.
211
341
  const subscriberDID = getClientDID(ctx);
342
+ const decision = normalizeAuthorizeDecision(await authorize({
343
+ action: 'topic/fetch',
344
+ did: subscriberDID,
345
+ topicID
346
+ }));
347
+ if (!decision.allow) {
348
+ throw new HandlerError({
349
+ code: HUB_ERROR_CODES.authorizationDenied,
350
+ message: decision.reason ?? 'Not authorized to fetch topic'
351
+ });
352
+ }
212
353
  try {
213
354
  const result = await store.fetchTopic({
214
355
  subscriberDID,
@@ -233,7 +374,17 @@ export function createHandlers(params) {
233
374
  'hub/v1/unsubscribe': async (ctx)=>{
234
375
  const { topicID } = ctx.param;
235
376
  const clientDID = getClientDID(ctx);
236
- await store.unsubscribe(clientDID, topicID);
377
+ if (!didLimiter.tryConsume(clientDID)) {
378
+ throw new HandlerError({
379
+ code: 'EK01',
380
+ message: 'Unsubscribe rate limit exceeded for DID'
381
+ });
382
+ }
383
+ try {
384
+ await store.unsubscribe(clientDID, topicID);
385
+ } catch (error) {
386
+ rethrowAsHandlerError(error);
387
+ }
237
388
  return {
238
389
  unsubscribed: true
239
390
  };
@@ -244,112 +395,377 @@ export function createHandlers(params) {
244
395
  registry.register(clientDID);
245
396
  const writer = ctx.writable.getWriter();
246
397
  const reader = ctx.readable.getReader();
247
- // Ends this channel when a newer one for the same DID takes the lane. Resolved, not thrown —
248
- // replacement isn't this channel's error, and it's the same client that replaced it.
249
398
  let endEvicted;
250
399
  const evictedHere = new Promise((resolve)=>{
251
400
  endEvicted = resolve;
252
401
  });
253
402
  let receiveToken;
403
+ // Delivery state machine (H1) + bounded write queue (H3).
404
+ // - DRAINING: live pushes buffer instead of writing; the drain writes the backlog in order.
405
+ // - after the drain: flush buffered frames with sequenceID > lastServed (the dedup), then LIVE.
406
+ // - LIVE: live pushes write directly.
407
+ let phase = 'draining';
408
+ const liveBuffer = [];
409
+ let lastServed = '' // highest sequenceID written; '' precedes every real (zero-padded) ID
410
+ ;
411
+ let pending = 0 // frames queued or mid-write but not yet flushed to the socket
412
+ ;
413
+ let tornDown = false;
414
+ let writeChain = Promise.resolve();
415
+ let resolveDone = ()=>{};
416
+ const done = new Promise((resolve)=>{
417
+ resolveDone = resolve;
418
+ });
419
+ // Idempotent teardown, shared by every failure and end path. Frames stay pending in the store
420
+ // and redeliver on the next connect (store-and-forward).
421
+ function finish() {
422
+ if (tornDown) return;
423
+ tornDown = true;
424
+ if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
425
+ registry.unregisterIfIdle(clientDID);
426
+ reader.cancel().catch(()=>{});
427
+ writer.abort(new Error('receive channel torn down')).catch(()=>{});
428
+ resolveDone();
429
+ }
430
+ // Serialize writes one at a time (preserves order). Over the cap or on a write rejection, tear
431
+ // the channel down rather than swallow the error or grow without bound.
432
+ function pushWrite(frame) {
433
+ if (tornDown) return;
434
+ pending++;
435
+ if (pending > receiveBufferLimit) {
436
+ finish();
437
+ return;
438
+ }
439
+ writeChain = writeChain.then(async ()=>{
440
+ if (tornDown) return;
441
+ try {
442
+ await writer.ready;
443
+ await writer.write(frame);
444
+ if (frame.sequenceID > lastServed) lastServed = frame.sequenceID;
445
+ } catch {
446
+ finish();
447
+ } finally{
448
+ pending--;
449
+ }
450
+ });
451
+ }
452
+ // The registry callback (publish fan-out). Buffers during the drain, writes once live.
453
+ // INVARIANT this and the flush's `> lastServed` dedup both depend on: the publish path must
454
+ // keep a FIXED await-depth between the store minting a sequenceID and its call to
455
+ // `sendMessage`, so concurrent publishes reach onLive in the same order their sequenceIDs
456
+ // were minted. Break that ordering and the dedup doesn't just reorder frames — it silently
457
+ // DROPS a lower-seq frame that arrives after a higher one already raised lastServed.
458
+ const onLive = (message)=>{
459
+ const frame = toReceiveFrame(message);
460
+ if (phase === 'draining') {
461
+ // Bound liveBuffer the same way pushWrite bounds the write queue: a stalled-but-still-
462
+ // connected reader can hang writeChain indefinitely, and buffered frames never reach
463
+ // pushWrite's own cap check. Without this, the drain never completes and every publish
464
+ // grows liveBuffer without bound. Drop the frame instead — it stays pending in the store
465
+ // and redelivers on reconnect (store-and-forward), same fallback pushWrite's cap uses.
466
+ if (liveBuffer.length >= receiveBufferLimit) {
467
+ finish();
468
+ return;
469
+ }
470
+ liveBuffer.push(frame);
471
+ } else {
472
+ pushWrite(frame);
473
+ }
474
+ };
254
475
  try {
255
476
  // Binding evicts whatever held the lane: a reconnect means the old connection broke and the
256
477
  // server learns last, so the stale writer must give way to the live one. See
257
478
  // `HubClientRegistry.bindReceiveWriter`.
258
- const { token, evicted } = registry.bindReceiveWriter(clientDID, (message)=>{
259
- writer.write({
260
- sequenceID: message.sequenceID,
261
- senderDID: message.senderDID,
262
- topicID: message.topicID,
263
- payload: toB64(message.payload),
264
- // Spread, not plain assignment — `logPosition: undefined` becomes a present key
265
- // once serialized, defeating the field's whole point (telling absent from present).
266
- ...message.logPosition != null ? {
267
- logPosition: message.logPosition
268
- } : {}
269
- }).catch(()=>{});
270
- }, ()=>endEvicted?.());
479
+ const { token, evicted } = registry.bindReceiveWriter(clientDID, onLive, ()=>endEvicted?.());
271
480
  receiveToken = token;
272
- // After the bind, so the lane is never unheld between the two — a frame arriving in that
273
- // gap would have nowhere to go, and mailbox frames drop when nobody's there.
481
+ // After the bind, so the lane is never unheld between the two.
274
482
  evicted?.();
483
+ // Drain the backlog. Await each page so lastServed is exact before the flush.
275
484
  let cursor = after;
276
- while(true){
485
+ while(!tornDown){
277
486
  const result = await store.fetch({
278
487
  recipientDID: clientDID,
279
488
  after: cursor ?? undefined,
280
489
  limit: 50
281
490
  });
282
491
  for (const msg of result.messages){
283
- await writer.write({
284
- sequenceID: msg.sequenceID,
285
- senderDID: msg.senderDID,
286
- topicID: msg.topicID,
287
- payload: toB64(msg.payload),
288
- ...msg.logPosition != null ? {
289
- logPosition: msg.logPosition
290
- } : {}
291
- });
492
+ pushWrite(toReceiveFrame(msg));
292
493
  }
494
+ await writeChain;
293
495
  cursor = result.cursor;
294
496
  if (!result.hasMore) break;
295
497
  }
498
+ // Flush frames that arrived live during the drain, deduped against what the drain already served,
499
+ // then go live. The flip happens only when the buffer is observed empty in the SAME synchronous
500
+ // step (no await between the empty check and the assignment), so a frame that arrived via onLive
501
+ // during a prior write is caught on the next iteration rather than stranded.
502
+ while(!tornDown){
503
+ if (liveBuffer.length === 0) {
504
+ phase = 'live';
505
+ break;
506
+ }
507
+ const batch = liveBuffer.splice(0);
508
+ for (const frame of batch){
509
+ if (frame.sequenceID > lastServed) pushWrite(frame);
510
+ }
511
+ await writeChain;
512
+ }
296
513
  } catch (error) {
514
+ // A synchronous drain error (e.g. store.fetch threw): clean up and reject the handler.
515
+ tornDown = true;
297
516
  if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
298
517
  registry.unregisterIfIdle(clientDID);
299
518
  reader.cancel().catch(()=>{});
300
519
  writer.abort(error).catch(()=>{});
301
520
  throw error;
302
521
  }
303
- const token = receiveToken;
522
+ // Ack loop (M1): a store.ack failure must not stop later acks; only a reader error closes.
304
523
  void (async ()=>{
305
- try {
306
- while(true){
307
- const { done, value } = await reader.read();
308
- if (done) break;
309
- if (value?.ack != null) {
524
+ while(true){
525
+ let result;
526
+ try {
527
+ result = await reader.read();
528
+ } catch {
529
+ break;
530
+ }
531
+ if (result.done) break;
532
+ const ack = result.value?.ack;
533
+ if (ack != null) {
534
+ try {
310
535
  await store.ack({
311
536
  recipientDID: clientDID,
312
- sequenceIDs: value.ack
537
+ sequenceIDs: ack
538
+ });
539
+ } catch (error) {
540
+ // Frame stays pending; the client re-acks next round. Do NOT break.
541
+ storeErrorReporter({
542
+ method: 'ack',
543
+ did: clientDID,
544
+ error
313
545
  });
314
546
  }
315
547
  }
316
- } catch {
317
- // Channel closed
318
548
  }
319
549
  })();
320
- return new Promise((resolve)=>{
321
- const finish = ()=>{
322
- // Token-scoped, so an already-evicted channel can't unbind the one that replaced it.
323
- if (token != null) registry.releaseReceiveWriter(clientDID, token);
324
- registry.unregisterIfIdle(clientDID);
325
- reader.cancel().catch(()=>{});
326
- writer.close().catch(()=>{});
327
- resolve(undefined);
328
- };
329
- // Evicted by a newer channel: end this one; `finish` releases only its own token, which is
330
- // no longer current.
331
- void evictedHere.then(finish);
332
- ctx.signal.addEventListener('abort', finish, {
333
- once: true
334
- });
550
+ // H2: an already-aborted signal never fires 'abort', so run teardown now.
551
+ if (ctx.signal.aborted) {
552
+ finish();
553
+ return done;
554
+ }
555
+ void evictedHere.then(finish);
556
+ ctx.signal.addEventListener('abort', finish, {
557
+ once: true
335
558
  });
559
+ return done;
336
560
  },
337
561
  'hub/v1/keypackage/upload': async (ctx)=>{
338
- const { keyPackages } = ctx.param;
562
+ const { keyPackages, lastResort, notAfter } = ctx.param;
339
563
  const clientDID = getClientDID(ctx);
340
- await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage(clientDID, kp)));
564
+ // The slot holds one package, so a flagged upload carries exactly one. JSON Schema cannot
565
+ // express a conditional on a sibling field, so the arity is enforced here.
566
+ let lastResortPackage = null;
567
+ if (lastResort === true) {
568
+ const [only, ...rest] = keyPackages;
569
+ if (only == null || rest.length > 0) {
570
+ throw new HandlerError({
571
+ code: HUB_ERROR_CODES.invalidPayload,
572
+ message: 'A last-resort upload must carry exactly one key package'
573
+ });
574
+ }
575
+ lastResortPackage = only;
576
+ }
577
+ if (lastResort === true && notAfter != null) {
578
+ throw new HandlerError({
579
+ code: HUB_ERROR_CODES.invalidPayload,
580
+ message: 'A last-resort upload carries no expiry: the slot is kept fresh by rotation'
581
+ });
582
+ }
583
+ const decision = normalizeAuthorizeDecision(await authorize({
584
+ action: 'keypackage/upload',
585
+ did: clientDID,
586
+ count: keyPackages.length,
587
+ // Spread, not assignment: `lastResort: undefined` would make an ordinary upload look
588
+ // like one that explicitly opted out.
589
+ ...lastResort === true ? {
590
+ lastResort: true
591
+ } : {}
592
+ }));
593
+ if (!decision.allow) {
594
+ throw new HandlerError({
595
+ code: HUB_ERROR_CODES.authorizationDenied,
596
+ message: decision.reason ?? 'Not authorized to upload key packages'
597
+ });
598
+ }
599
+ if (!didLimiter.tryConsume(clientDID)) {
600
+ throw new HandlerError({
601
+ code: 'EK01',
602
+ message: 'Key package upload rate limit exceeded for DID'
603
+ });
604
+ }
605
+ // A batch crossing the cap partially commits (the store enforces per-call, so the cap still
606
+ // holds) and rejects the rest with HUB_KEYPACKAGE_QUOTA; a retry finds the earlier ones stored.
607
+ // The last-resort path has no cap to cross: the slot is one entry, replaced in place.
608
+ try {
609
+ if (lastResortPackage != null) {
610
+ await store.storeLastResortKeyPackage(clientDID, lastResortPackage);
611
+ } else {
612
+ await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage(clientDID, kp, notAfter)));
613
+ }
614
+ } catch (error) {
615
+ rethrowAsHandlerError(error);
616
+ }
341
617
  return {
342
618
  stored: keyPackages.length
343
619
  };
344
620
  },
345
621
  'hub/v1/keypackage/fetch': async (ctx)=>{
346
622
  const requesterDID = getClientDID(ctx);
347
- assertKeyPackageFetchAllowed(requesterDID);
348
- const { did, count } = ctx.param;
623
+ const { did: targetDID, count } = ctx.param;
349
624
  const cappedCount = Math.min(Math.max(count ?? 1, 1), fetchLimits.maxCount);
350
- const keyPackages = await store.fetchKeyPackages(did, cappedCount);
625
+ const decision = normalizeAuthorizeDecision(await authorize({
626
+ action: 'keypackage/fetch',
627
+ did: requesterDID,
628
+ targetDID,
629
+ count: cappedCount
630
+ }));
631
+ if (!decision.allow) {
632
+ throw new HandlerError({
633
+ code: HUB_ERROR_CODES.authorizationDenied,
634
+ message: decision.reason ?? 'Not authorized to fetch key packages'
635
+ });
636
+ }
637
+ // Order matters: a refusal above never charged a window; per-requester before per-target so a
638
+ // throttled requester doesn't charge the target. rethrow maps KeyPackageFetchLimitError (no
639
+ // `code` of its own) to its wire code.
640
+ try {
641
+ assertKeyPackageFetchAllowed(requesterDID);
642
+ } catch (error) {
643
+ rethrowAsHandlerError(error);
644
+ }
645
+ let targetBudgetSpent = false;
646
+ try {
647
+ assertTargetConsumptionAllowed(targetDID, cappedCount);
648
+ } catch (error) {
649
+ if (!(error instanceof KeyPackageFetchLimitError)) rethrowAsHandlerError(error);
650
+ targetBudgetSpent = true;
651
+ }
652
+ if (targetBudgetSpent) {
653
+ // The drain budget bounds CONSUMPTION, and serving the slot consumes nothing — so a spent
654
+ // budget refuses the pool while the reusable floor still answers. A target with no slot is
655
+ // refused exactly as before.
656
+ //
657
+ // "Consumes nothing, so charges nothing" holds on THIS path only. The charge is levied on
658
+ // intent to consume, before either read: on the ordinary path below the target was already
659
+ // charged `cappedCount`, so a drained target's slot rides along on an already-charged
660
+ // request and does cost the shared per-target budget until the budget is spent. Harmless —
661
+ // the slot is served either way — but the invariant is not unconditional.
662
+ //
663
+ // A store failure HERE does not become the client's error: the request was already being
664
+ // refused, and the slot was the only thing that could have rescued it. Surfacing the
665
+ // store's error instead would turn a retryable coded refusal into an opaque one, changing
666
+ // what the caller does about a situation that has not changed. The store error rides along
667
+ // as `cause` so an operator can still see a broken store behind the refusal.
668
+ let lastResort = null;
669
+ let readFailure;
670
+ try {
671
+ lastResort = await store.fetchLastResortKeyPackage(targetDID);
672
+ } catch (error) {
673
+ readFailure = error;
674
+ }
675
+ if (lastResort == null) {
676
+ throw new HandlerError({
677
+ code: HUB_ERROR_CODES.keyPackageFetchLimit,
678
+ message: `Key package consumption limit exceeded for target ${targetDID}`,
679
+ ...readFailure != null ? {
680
+ cause: readFailure
681
+ } : {}
682
+ });
683
+ }
684
+ return {
685
+ keyPackages: [
686
+ lastResort
687
+ ]
688
+ };
689
+ }
690
+ // THE TWO READS BELOW ARE SPLIT DELIBERATELY, AND MUST STAY SPLIT. `fetchKeyPackages` is
691
+ // destructive: once it returns, those packages are spliced out of the store for good. A
692
+ // failure AFTER that point which propagates to the client destroys packages nobody ever
693
+ // received — and the client's retry burns the next batch, and the next. The likeliest trigger
694
+ // is exactly the third-party store this feature targets: one that has not implemented the
695
+ // slot yet throws `store.fetchLastResortKeyPackage is not a function` on the second read, so
696
+ // every fetch against it would silently drain the target — the drain the last-resort slot
697
+ // exists to close, reintroduced by the top-up read itself.
698
+ //
699
+ // So the pool read's failure IS the client's error and keeps its wire code (a named store
700
+ // error has to stay tellable from an unreachable hub), while the top-up read may only fail
701
+ // the request when nothing has been consumed and there is therefore nothing to lose.
702
+ let consumed;
703
+ try {
704
+ consumed = await store.fetchKeyPackages(targetDID, cappedCount);
705
+ } catch (error) {
706
+ rethrowAsHandlerError(error);
707
+ }
708
+ if (consumed.length >= cappedCount) return {
709
+ keyPackages: consumed
710
+ };
711
+ let lastResort = null;
712
+ try {
713
+ lastResort = await store.fetchLastResortKeyPackage(targetDID);
714
+ } catch (error) {
715
+ // Nothing consumed, so nothing is lost by surfacing it. Otherwise the top-up was a bonus
716
+ // the caller never paid for: hand back what the store has already given up.
717
+ if (consumed.length === 0) rethrowAsHandlerError(error);
718
+ storeErrorReporter({
719
+ method: 'fetchLastResortKeyPackage',
720
+ did: targetDID,
721
+ error
722
+ });
723
+ }
724
+ // Appended AT MOST ONCE, never padded out to `cappedCount`: handing one caller two copies
725
+ // of one init key is the reuse the whole design avoids.
726
+ return {
727
+ keyPackages: lastResort == null ? consumed : [
728
+ ...consumed,
729
+ lastResort
730
+ ]
731
+ };
732
+ },
733
+ 'hub/v1/keypackage/status': async (ctx)=>{
734
+ // No `did` parameter exists, so this answers only for the authenticated caller. That is the
735
+ // whole authorization story: a query that could name someone else would tell an attacker
736
+ // exactly when a drain against that DID had succeeded.
737
+ const clientDID = getClientDID(ctx);
738
+ const decision = normalizeAuthorizeDecision(await authorize({
739
+ action: 'keypackage/status',
740
+ did: clientDID
741
+ }));
742
+ if (!decision.allow) {
743
+ throw new HandlerError({
744
+ code: HUB_ERROR_CODES.authorizationDenied,
745
+ message: decision.reason ?? 'Not authorized to read key package status'
746
+ });
747
+ }
748
+ if (!didLimiter.tryConsume(clientDID)) {
749
+ throw new HandlerError({
750
+ code: 'EK01',
751
+ message: 'Key package status rate limit exceeded for DID'
752
+ });
753
+ }
754
+ let count;
755
+ let stored;
756
+ try {
757
+ // Two independent reads for one owner: concurrent, since neither informs the other.
758
+ ;
759
+ [count, stored] = await Promise.all([
760
+ store.countKeyPackages(clientDID),
761
+ store.fetchLastResortKeyPackage(clientDID)
762
+ ]);
763
+ } catch (error) {
764
+ rethrowAsHandlerError(error);
765
+ }
351
766
  return {
352
- keyPackages
767
+ count,
768
+ lastResort: stored == null ? null : keyPackageDigest(stored)
353
769
  };
354
770
  }
355
771
  };
package/lib/hub.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { ServerTransportOf } from '@enkaku/protocol';
2
2
  import type { AccessRules, ResourceLimits, Server } from '@enkaku/server';
3
3
  import type { Identity } from '@kokuin/token';
4
4
  import { type HubProtocol, type HubStore } from '@kumiai/hub-protocol';
5
- import { type AuthorizeHook, type HubRateLimits, type KeyPackageFetchLimits } from './handlers.js';
5
+ import { type AuthorizeHook, type HubRateLimits, type HubStoreErrorHook, type KeyPackageFetchLimits } from './handlers.js';
6
6
  import { HubClientRegistry } from './registry.js';
7
7
  /**
8
8
  * Default access rules: any authenticated DID may call hub procedures.
@@ -39,6 +39,11 @@ export type CreateHubParams = {
39
39
  * {@link DEFAULT_KEYPACKAGE_FETCH_LIMITS}.
40
40
  */
41
41
  keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
42
+ /**
43
+ * Called when a `HubStore` operation fails where the hub deliberately does not fail the
44
+ * request. Forwarded to {@link createHandlers} and used by the purge timer. Fire-and-forget.
45
+ */
46
+ onStoreError?: HubStoreErrorHook;
42
47
  /** Scheduled purge of expired stored messages. Set to `false` to disable. */
43
48
  purge?: HubPurgeOptions | false;
44
49
  /**
package/lib/hub.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { serve } from '@enkaku/server';
2
2
  import { hubProtocol } from '@kumiai/hub-protocol';
3
- import { createHandlers } from './handlers.js';
3
+ import { createHandlers, createStoreErrorReporter } from './handlers.js';
4
4
  import { HubClientRegistry } from './registry.js';
5
5
  /**
6
6
  * Default access rules: any authenticated DID may call hub procedures.
@@ -13,12 +13,17 @@ import { HubClientRegistry } from './registry.js';
13
13
  };
14
14
  export function createHub(params) {
15
15
  const registry = new HubClientRegistry();
16
+ // One reporter for the whole hub. Its type IS `HubStoreErrorHook`, so `createHandlers`' own
17
+ // wrapper delegates to this instance rather than building a second one from the same hook —
18
+ // which matters the moment a reporter holds state, as the throttling the README names would.
19
+ const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
16
20
  const handlers = createHandlers({
17
21
  registry,
18
22
  store: params.store,
19
23
  authorize: params.authorize,
20
24
  rateLimits: params.rateLimits,
21
- keyPackageFetchLimits: params.keyPackageFetchLimits
25
+ keyPackageFetchLimits: params.keyPackageFetchLimits,
26
+ onStoreError: storeErrorReporter
22
27
  });
23
28
  const limits = {
24
29
  ...params.limits,
@@ -43,8 +48,12 @@ export function createHub(params) {
43
48
  const purgeTimer = setInterval(()=>{
44
49
  params.store.purge({
45
50
  olderThan
46
- }).catch(()=>{
47
- // Purge failures are non-fatal; retried on the next interval
51
+ }).catch((error)=>{
52
+ // Purge failures are non-fatal; retried on the next interval
53
+ storeErrorReporter({
54
+ method: 'purge',
55
+ error
56
+ });
48
57
  });
49
58
  }, interval);
50
59
  server.disposed.then(()=>clearInterval(purgeTimer));
package/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { AuthorizeDecision, AuthorizeHook, AuthorizeRequest, CreateHandlersParams, HubRateLimits, KeyPackageFetchLimits, } from './handlers.js';
2
- export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS } from './handlers.js';
1
+ export type { AuthorizeDecision, AuthorizeHook, AuthorizeRequest, CreateHandlersParams, HubRateLimits, HubStoreErrorEvent, HubStoreErrorHook, KeyPackageFetchLimits, } from './handlers.js';
2
+ export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_BUFFER_LIMIT, } from './handlers.js';
3
3
  export type { CreateHubParams, HubInstance, HubPurgeOptions } from './hub.js';
4
4
  export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
5
5
  export { createMemoryStore, type MemoryStoreOptions } from './memoryStore.js';
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS } from './handlers.js';
1
+ export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_BUFFER_LIMIT } from './handlers.js';
2
2
  export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
3
3
  export { createMemoryStore } from './memoryStore.js';
4
4
  export { createRateLimiter } from './rateLimit.js';
@@ -14,6 +14,10 @@ export type MemoryStoreOptions = {
14
14
  /** Per-topic max retained entries; oldest are trimmed beyond this. Default 1000. */
15
15
  maxDepth?: number;
16
16
  retention?: MemoryStoreRetention;
17
+ /** Max key packages stored per owner DID before an upload is rejected. Default: 100. */
18
+ maxKeyPackagesPerDID?: number;
19
+ /** Max distinct topics one DID may subscribe to before a subscribe is rejected. Default: 1000. */
20
+ maxSubscriptionsPerDID?: number;
17
21
  };
18
22
  /**
19
23
  * In-memory HubStore for testing and development.
@@ -1,10 +1,13 @@
1
- import { HeadMismatchError, NotSubscribedError, RetentionExceededError } from '@kumiai/hub-protocol';
1
+ import { HeadMismatchError, KeyPackageQuotaExceededError, NotSubscribedError, RetentionExceededError, SubscriptionQuotaExceededError } from '@kumiai/hub-protocol';
2
2
  import { EventEmitter } from '@sozai/event';
3
3
  const DEFAULT_MAX_DEPTH = 1000;
4
4
  /** 30 days, in seconds. A finite default ceiling on requested retention. */ const DEFAULT_MAX_RETENTION = 2_592_000;
5
5
  function formatSequenceID(counter) {
6
6
  return String(counter).padStart(12, '0');
7
7
  }
8
+ function isLive(entry, nowSeconds) {
9
+ return entry.notAfter == null || entry.notAfter > nowSeconds;
10
+ }
8
11
  /**
9
12
  * The entry as a reader sees it, with the ONE piece of routing metadata a reader cannot recover for
10
13
  * itself: where the frame sits in its topic's log.
@@ -45,6 +48,8 @@ function formatSequenceID(counter) {
45
48
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
46
49
  const defaultRetention = options.retention?.default ?? 0;
47
50
  const maxRetention = options.retention?.max ?? DEFAULT_MAX_RETENTION;
51
+ const maxKeyPackagesPerDID = options.maxKeyPackagesPerDID ?? 100;
52
+ const maxSubscriptionsPerDID = options.maxSubscriptionsPerDID ?? 1000;
48
53
  let counter = 0;
49
54
  const entries = new Map();
50
55
  const topicLogs = new Map();
@@ -57,7 +62,12 @@ function formatSequenceID(counter) {
57
62
  * learn that a commit it never saw acknowledged had in fact landed.
58
63
  */ const publishRecords = new Map();
59
64
  /** Per topic: the subscribers, each with the retention it asked for. */ const subscriptions = new Map();
65
+ // Reverse index for an O(1) per-DID subscription count. Kept in lockstep with `subscriptions`.
66
+ const subsByDID = new Map();
60
67
  const keyPackages = new Map();
68
+ /** One reusable package per DID, replaced on re-upload. Deliberately not in `keyPackages`: a
69
+ * one-entry-per-DID map cannot grow, so it needs no quota, and charging it against
70
+ * `maxKeyPackagesPerDID` would let a full pool block the availability floor. */ const lastResortKeyPackages = new Map();
61
71
  const events = new EventEmitter();
62
72
  // The age bound for a topic: the longest retention any of its subscribers asked for, floored
63
73
  // by the hub's default and by the caller's own bound.
@@ -332,12 +342,23 @@ function formatSequenceID(counter) {
332
342
  if (requested > maxRetention) {
333
343
  throw new RetentionExceededError(`Requested retention of ${requested}s exceeds the maximum of ${maxRetention}s`);
334
344
  }
345
+ const held = subsByDID.get(params.subscriberDID);
346
+ const alreadyHeld = held?.has(params.topicID) ?? false;
347
+ if (!alreadyHeld && (held?.size ?? 0) >= maxSubscriptionsPerDID) {
348
+ throw new SubscriptionQuotaExceededError(`DID ${params.subscriberDID} exceeds the maximum of ${maxSubscriptionsPerDID} subscriptions`);
349
+ }
335
350
  let subscribers = subscriptions.get(params.topicID);
336
351
  if (subscribers == null) {
337
352
  subscribers = new Map();
338
353
  subscriptions.set(params.topicID, subscribers);
339
354
  }
340
355
  subscribers.set(params.subscriberDID, requested);
356
+ let ownTopics = subsByDID.get(params.subscriberDID);
357
+ if (ownTopics == null) {
358
+ ownTopics = new Set();
359
+ subsByDID.set(params.subscriberDID, ownTopics);
360
+ }
361
+ ownTopics.add(params.topicID);
341
362
  },
342
363
  async unsubscribe (subscriberDID, topicID) {
343
364
  const subscribers = subscriptions.get(topicID);
@@ -347,6 +368,11 @@ function formatSequenceID(counter) {
347
368
  subscriptions.delete(topicID);
348
369
  }
349
370
  }
371
+ const ownTopics = subsByDID.get(subscriberDID);
372
+ if (ownTopics != null) {
373
+ ownTopics.delete(topicID);
374
+ if (ownTopics.size === 0) subsByDID.delete(subscriberDID);
375
+ }
350
376
  // Drops this subscriber's pending deliveries for the topic — freeing a mailbox frame whose
351
377
  // last delivery this was, and leaving a log frame standing.
352
378
  const pending = deliveries.get(subscriberDID);
@@ -366,19 +392,59 @@ function formatSequenceID(counter) {
366
392
  ...subscribers.keys()
367
393
  ];
368
394
  },
369
- async storeKeyPackage (ownerDID, keyPackage) {
395
+ async storeKeyPackage (ownerDID, keyPackage, notAfter) {
370
396
  let packages = keyPackages.get(ownerDID);
371
397
  if (packages == null) {
372
398
  packages = [];
373
399
  keyPackages.set(ownerDID, packages);
374
400
  }
375
- packages.push(keyPackage);
401
+ // Drop the dead before charging the cap, or a pool that filled with expired entries could
402
+ // never be replenished: the cap rejects rather than evicts, by design, so nothing else would
403
+ // ever remove them.
404
+ const nowSeconds = Math.floor(Date.now() / 1000);
405
+ const live = packages.filter((entry)=>isLive(entry, nowSeconds));
406
+ if (live.length !== packages.length) {
407
+ packages.length = 0;
408
+ packages.push(...live);
409
+ }
410
+ if (packages.length >= maxKeyPackagesPerDID) {
411
+ throw new KeyPackageQuotaExceededError(`DID ${ownerDID} exceeds the maximum of ${maxKeyPackagesPerDID} stored key packages`);
412
+ }
413
+ packages.push({
414
+ keyPackage,
415
+ ...notAfter != null ? {
416
+ notAfter
417
+ } : {}
418
+ });
376
419
  },
377
420
  async fetchKeyPackages (ownerDID, count) {
378
421
  const packages = keyPackages.get(ownerDID);
379
422
  if (packages == null || packages.length === 0) return [];
423
+ const nowSeconds = Math.floor(Date.now() / 1000);
424
+ const served = [];
380
425
  const n = count ?? 1;
381
- return packages.splice(0, n);
426
+ // One pass, consuming as it goes: an expired entry is dropped rather than skipped, so FIFO
427
+ // stops handing out the nearest-expiry package first. Always shift index 0 — never advance a
428
+ // cursor — or this degenerates into the skip loop this task exists to remove.
429
+ while(packages.length > 0 && served.length < n){
430
+ const entry = packages.shift();
431
+ if (isLive(entry, nowSeconds)) served.push(entry.keyPackage);
432
+ }
433
+ return served;
434
+ },
435
+ async countKeyPackages (ownerDID) {
436
+ const packages = keyPackages.get(ownerDID);
437
+ if (packages == null) return 0;
438
+ const nowSeconds = Math.floor(Date.now() / 1000);
439
+ return packages.filter((entry)=>isLive(entry, nowSeconds)).length;
440
+ },
441
+ async storeLastResortKeyPackage (ownerDID, keyPackage) {
442
+ lastResortKeyPackages.set(ownerDID, keyPackage);
443
+ },
444
+ async fetchLastResortKeyPackage (ownerDID) {
445
+ // No splice, ever. Reuse is the point here — this package is marked last-resort in MLS, so
446
+ // serving it again is by design rather than the init-key reuse `fetchKeyPackages` avoids.
447
+ return lastResortKeyPackages.get(ownerDID) ?? null;
382
448
  }
383
449
  };
384
450
  }
@@ -3,10 +3,14 @@ export type RateLimitConfig = {
3
3
  rate: number;
4
4
  /** Maximum bucket capacity (burst). */
5
5
  burst: number;
6
+ /** Idle full buckets older than this (ms) are evicted opportunistically. Default: 300_000. */
7
+ ttlMs?: number;
6
8
  };
7
9
  export type RateLimiter = {
8
10
  /** Consumes a token if available, returning true; returns false otherwise. */
9
11
  tryConsume(key: string): boolean;
12
+ /** Current number of live buckets. Introspection for tests and metrics. */
13
+ size(): number;
10
14
  };
11
15
  /** Per-key token-bucket rate limiter. */
12
16
  export declare function createRateLimiter(config: RateLimitConfig): RateLimiter;
package/lib/rateLimit.js CHANGED
@@ -1,18 +1,36 @@
1
+ /** Threshold above which `tryConsume` sweeps for evictable buckets. */ const PRUNE_AT = 1024;
2
+ /** Tokens a bucket would hold at `now` under continuous refill, capped at burst. */ function refilledTokens(bucket, now, rate, burst) {
3
+ const elapsedSeconds = (now - bucket.lastRefill) / 1000;
4
+ return Math.min(burst, bucket.tokens + elapsedSeconds * rate);
5
+ }
1
6
  /** Per-key token-bucket rate limiter. */ export function createRateLimiter(config) {
2
7
  const buckets = new Map();
8
+ const ttlMs = config.ttlMs ?? 300_000;
9
+ // O(n), but only from the cold path past the size gate below, never on the hot path.
10
+ function prune(now) {
11
+ for (const [key, bucket] of buckets){
12
+ // Evict only full-and-idle buckets: recreating one on next use is identical, and a
13
+ // partially-spent one must never be dropped (that refunds its spent tokens). Fullness is
14
+ // recomputed from elapsed time, NOT read from stored `tokens` — every tryConsume decrements,
15
+ // so stored tokens is always < burst at rest and a stored-value check would never prune.
16
+ if (refilledTokens(bucket, now, config.rate, config.burst) >= config.burst && now - bucket.lastRefill >= ttlMs) {
17
+ buckets.delete(key);
18
+ }
19
+ }
20
+ }
3
21
  return {
4
22
  tryConsume (key) {
5
23
  const now = Date.now();
6
24
  let bucket = buckets.get(key);
7
25
  if (bucket == null) {
26
+ if (buckets.size >= PRUNE_AT) prune(now);
8
27
  bucket = {
9
28
  tokens: config.burst,
10
29
  lastRefill: now
11
30
  };
12
31
  buckets.set(key, bucket);
13
32
  } else {
14
- const elapsedSeconds = (now - bucket.lastRefill) / 1000;
15
- bucket.tokens = Math.min(config.burst, bucket.tokens + elapsedSeconds * config.rate);
33
+ bucket.tokens = refilledTokens(bucket, now, config.rate, config.burst);
16
34
  bucket.lastRefill = now;
17
35
  }
18
36
  if (bucket.tokens < 1) {
@@ -20,6 +38,9 @@
20
38
  }
21
39
  bucket.tokens -= 1;
22
40
  return true;
41
+ },
42
+ size () {
43
+ return buckets.size;
23
44
  }
24
45
  };
25
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-server",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Hub server for Enkaku E2EE group message routing",
5
5
  "keywords": [
6
6
  "hub",
@@ -25,17 +25,21 @@
25
25
  "lib/*"
26
26
  ],
27
27
  "dependencies": {
28
- "@enkaku/protocol": "^0.20.0",
29
- "@enkaku/server": "^0.20.0",
30
- "@kokuin/token": "^0.3.0",
31
- "@sozai/codec": "^0.2.0",
32
- "@sozai/event": "^0.1.1",
33
- "@kumiai/hub-protocol": "^0.4.1"
28
+ "@enkaku/protocol": "^0.21.0",
29
+ "@enkaku/server": "^0.21.0",
30
+ "@kokuin/token": "^0.4.0",
31
+ "@sozai/codec": "^0.4.0",
32
+ "@sozai/event": "^0.1.3",
33
+ "@sozai/log": "^0.3.1",
34
+ "@kumiai/hub-protocol": "^0.6.0"
34
35
  },
35
36
  "devDependencies": {
36
- "@enkaku/client": "^0.20.0",
37
- "@enkaku/transport": "^0.20.0",
38
- "@kumiai/hub-conformance": "^0.4.1"
37
+ "@enkaku/client": "^0.21.0",
38
+ "@enkaku/transport": "^0.21.0",
39
+ "@kumiai/hub-conformance": "^0.6.0"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
39
43
  },
40
44
  "scripts": {
41
45
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",