@kumiai/hub-server 0.6.0 → 0.8.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
@@ -31,10 +31,13 @@ another sender. The `identity` param is required for that reason.
31
31
 
32
32
  Authorization is two layers, and only the second one knows about topics: `accessRules` gate the
33
33
  procedures (the default lets any authenticated DID call them), and the optional
34
- `authorize(request)` hook decides publish and subscribe per topic. It takes one discriminated
35
- `AuthorizeRequest` — narrow on `request.action` — and returns `AuthorizeDecision`, either a boolean
36
- or `{ allow, reason?, code?, retryAfterMs? }`. `AuthorizeRequest` names seven actions, but only
37
- `publish` and `subscribe` are dispatched today. The default allows any authenticated DID.
34
+ `authorize(request)` hook decides the rest. It takes one discriminated `AuthorizeRequest` — narrow
35
+ on `request.action` — and returns `AuthorizeDecision`, either a boolean or
36
+ `{ allow, reason?, code?, retryAfterMs? }`. `AuthorizeRequest` names nine actions, of which only
37
+ `unsubscribe` is not dispatched today; the two `wake/*` actions gate push registration, which is the
38
+ one durable per-device identifier the hub stores. The default allows any authenticated DID, and a
39
+ hook should allow any action it does not recognise, so that a hook written before a variant shipped
40
+ does not start refusing a procedure that used to be ungated.
38
41
 
39
42
  ## Durable subscription, ephemeral connection
40
43
 
package/lib/handlers.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { type ProcedureHandlers } from '@enkaku/server';
2
- import type { HubProtocol, HubStore } from '@kumiai/hub-protocol';
2
+ import type { HubProtocol, HubStore, WakeRegistry } from '@kumiai/hub-protocol';
3
3
  import { type RateLimitConfig } from './rateLimit.js';
4
4
  import type { HubClientRegistry } from './registry.js';
5
+ import type { WakeDispatcher } from './wake.js';
5
6
  /**
6
- * A single request to authorize. All seven variants ship even though only `publish` and
7
- * `subscribe` are currently enforced: the union is the exhaustive-switch surface a host's own
8
- * `switch (req.action)` closes over.
7
+ * A single request to authorize. All nine variants ship even though `unsubscribe` is not yet
8
+ * dispatched: the union is the exhaustive-switch surface a host's own `switch (req.action)` closes
9
+ * over, and widening it after a release is a break for every host that closed over it.
9
10
  *
10
11
  * A host's `switch` need not handle every action today — an unrecognized action should default to
11
12
  * allow, so a hook written before a new variant shipped doesn't silently start refusing a
@@ -43,6 +44,32 @@ export type AuthorizeRequest = {
43
44
  } | {
44
45
  action: 'keypackage/status';
45
46
  did: string;
47
+ }
48
+ /**
49
+ * A device registering a push endpoint. `kind` (the opaque sender tag) and `expiresAt` (the
50
+ * caller-supplied registration lifetime cap, when given) are the only details a host can usefully
51
+ * gate on — `endpoint`, `publicKey` and `authSecret` are deliberately absent, since a hook that
52
+ * saw them would be a second place the endpoint gets read, and the hub's rule is that it never
53
+ * interprets one.
54
+ *
55
+ * This is the durable cross-group per-device identifier wake introduces, so it is the one a host
56
+ * is most likely to want a say over.
57
+ */
58
+ | {
59
+ action: 'wake/register';
60
+ did: string;
61
+ kind: string;
62
+ expiresAt?: number;
63
+ } | {
64
+ action: 'wake/unregister';
65
+ did: string;
66
+ } | {
67
+ action: 'receive';
68
+ did: string;
69
+ } | {
70
+ action: 'receive/deliver';
71
+ did: string;
72
+ topicID: string;
46
73
  };
47
74
  /**
48
75
  * A plain `boolean` is shorthand for `{ allow: boolean }`, with no reason, code, or retry hint.
@@ -89,6 +116,12 @@ export type HubStoreErrorEvent = {
89
116
  method: 'getSubscribers';
90
117
  topicID: string;
91
118
  error: unknown;
119
+ }
120
+ /** The DID whose wake dispatch (send, or the registry read/delete around it) failed. */
121
+ | {
122
+ method: 'wake';
123
+ did: string;
124
+ error: unknown;
92
125
  };
93
126
  export type HubStoreErrorHook = (event: HubStoreErrorEvent) => void;
94
127
  /**
@@ -124,6 +157,12 @@ export declare const DEFAULT_KEYPACKAGE_FETCH_LIMITS: KeyPackageFetchLimits;
124
157
  * writer health. The default of 256 is safely above it.
125
158
  */
126
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;
127
166
  export type CreateHandlersParams = {
128
167
  registry: HubClientRegistry;
129
168
  store: HubStore;
@@ -133,6 +172,12 @@ export type CreateHandlersParams = {
133
172
  /** Max frames queued-but-unflushed on a receive channel. See {@link DEFAULT_RECEIVE_BUFFER_LIMIT}
134
173
  * for the >= 50-frame-page floor this should respect. Default: 256 */
135
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;
136
181
  /**
137
182
  * Called when a `HubStore` operation fails at a point where the hub deliberately does not fail
138
183
  * the request. Fire-and-forget; a throw here is swallowed.
@@ -141,5 +186,13 @@ export type CreateHandlersParams = {
141
186
  * empty handler to silence it deliberately.
142
187
  */
143
188
  onStoreError?: HubStoreErrorHook;
189
+ /**
190
+ * Wake support. Absent: both wake procedures refuse. The registry is separate from the
191
+ * dispatcher because the handlers only ever store and remove; sending is the dispatcher's.
192
+ */
193
+ wake?: {
194
+ registry: WakeRegistry;
195
+ dispatcher?: WakeDispatcher;
196
+ };
144
197
  };
145
198
  export declare function createHandlers(params: CreateHandlersParams): ProcedureHandlers<HubProtocol>;
package/lib/handlers.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { HandlerError } from '@enkaku/server';
2
- import { HUB_ERROR_CODES, hubErrorCodeOf, InvalidPayloadError, KeyPackageFetchLimitError, keyPackageDigest } from '@kumiai/hub-protocol';
2
+ import { HUB_ERROR_CODES, hubErrorCodeOf, InvalidPayloadError, KeyPackageFetchLimitError, keyPackageDigest, wakeRecipientKeyProblem } from '@kumiai/hub-protocol';
3
3
  import { fromB64, toB64 } from '@sozai/codec';
4
4
  import { getReporter } from '@sozai/log';
5
5
  import { createRateLimiter } from './rateLimit.js';
@@ -21,7 +21,8 @@ function normalizeAuthorizeDecision(decision) {
21
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
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
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.'
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
+ wake: 'The offline device was not pinged for this frame. It still redelivers on the next reconnect ' + '(store-and-forward), so nothing is lost — but a wake that keeps failing means the device ' + 'stays asleep until something else wakes it.'
25
26
  };
26
27
  const reportStoreError = getReporter([
27
28
  'kumiai',
@@ -36,6 +37,7 @@ const reportStoreError = getReporter([
36
37
  return ` on topic ${event.topicID}`;
37
38
  case 'ack':
38
39
  case 'fetchLastResortKeyPackage':
40
+ case 'wake':
39
41
  return ` for ${event.did}`;
40
42
  }
41
43
  }
@@ -82,6 +84,11 @@ export const DEFAULT_KEYPACKAGE_FETCH_LIMITS = {
82
84
  * before awaiting, so a limit below 50 would trip the cap on the very first page regardless of
83
85
  * writer health. The default of 256 is safely above it.
84
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;
85
92
  function getClientDID(ctx) {
86
93
  const iss = ctx.message.payload.iss;
87
94
  if (typeof iss !== 'string' || iss.length === 0) {
@@ -103,6 +110,13 @@ function getClientDID(ctx) {
103
110
  }
104
111
  throw error;
105
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
+ }
106
120
  function toReceiveFrame(message) {
107
121
  return {
108
122
  sequenceID: message.sequenceID,
@@ -132,6 +146,7 @@ export function createHandlers(params) {
132
146
  const didLimiter = createRateLimiter(rateLimits.perDID);
133
147
  const topicLimiter = createRateLimiter(rateLimits.perTopic);
134
148
  const receiveBufferLimit = params.receiveBufferLimit ?? DEFAULT_RECEIVE_BUFFER_LIMIT;
149
+ const receiveAuthCacheTTL = resolveReceiveAuthCacheTTL(params.receiveAuthCacheTTL);
135
150
  const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
136
151
  const fetchLimits = {
137
152
  ...DEFAULT_KEYPACKAGE_FETCH_LIMITS,
@@ -293,6 +308,29 @@ export function createHandlers(params) {
293
308
  payload: payloadBytes,
294
309
  ...logPosition
295
310
  });
311
+ } else {
312
+ // No live channel: the frame is durably queued, so all that is missing is a nudge to
313
+ // come and get it. Both retention classes wake — a commit-lane frame is log-class, and
314
+ // a membership change is exactly what a sleeping device must learn.
315
+ //
316
+ // Guarded even though `notify` is documented never to throw: `dispatcher` is
317
+ // caller-injectable (`CreateHandlersParams.wake.dispatcher`), and a misbehaving
318
+ // third-party implementation must not abort this loop mid-way — that would cost live
319
+ // delivery to every subscriber still to come, for a publish whose append already
320
+ // committed.
321
+ try {
322
+ params.wake?.dispatcher?.notify({
323
+ did: recipientDID,
324
+ topicID,
325
+ sequenceID
326
+ });
327
+ } catch (error) {
328
+ storeErrorReporter({
329
+ method: 'wake',
330
+ did: recipientDID,
331
+ error
332
+ });
333
+ }
296
334
  }
297
335
  }
298
336
  }
@@ -392,7 +430,43 @@ export function createHandlers(params) {
392
430
  'hub/v1/receive': async (ctx)=>{
393
431
  const clientDID = getClientDID(ctx);
394
432
  const { after } = ctx.param ?? {};
433
+ const connectDecision = normalizeAuthorizeDecision(await authorize({
434
+ action: 'receive',
435
+ did: clientDID
436
+ }));
437
+ if (!connectDecision.allow) {
438
+ throw new HandlerError({
439
+ code: HUB_ERROR_CODES.authorizationDenied,
440
+ message: connectDecision.reason ?? 'Not authorized to receive'
441
+ });
442
+ }
395
443
  registry.register(clientDID);
444
+ // Per-(did, topicID) delivery-authorization cache, local to this channel (torn down with it).
445
+ // Both allow AND deny are cached for the TTL, so re-grant is not immediate: a topic that was
446
+ // just re-authorized keeps being denied for up to `receiveAuthCacheTTL` ms, and a frame denied
447
+ // in-session redelivers on the recipient's next connect.
448
+ const authCache = new Map();
449
+ const gate = async (topicID)=>{
450
+ const key = `${clientDID}${topicID}`;
451
+ const now = Date.now();
452
+ const cached = authCache.get(key);
453
+ if (cached != null && cached.expiresAt > now) return cached.allow;
454
+ const decision = normalizeAuthorizeDecision(await authorize({
455
+ action: 'receive/deliver',
456
+ did: clientDID,
457
+ topicID
458
+ }));
459
+ if (receiveAuthCacheTTL > 0) {
460
+ // Expiry is measured from when the decision is stored, AFTER the hook resolves — so the
461
+ // reuse window excludes the hook's own execution time. Re-read the clock here rather than
462
+ // reusing `now` (captured before the await).
463
+ authCache.set(key, {
464
+ allow: decision.allow,
465
+ expiresAt: Date.now() + receiveAuthCacheTTL
466
+ });
467
+ }
468
+ return decision.allow;
469
+ };
396
470
  const writer = ctx.writable.getWriter();
397
471
  const reader = ctx.readable.getReader();
398
472
  let endEvicted;
@@ -439,6 +513,7 @@ export function createHandlers(params) {
439
513
  writeChain = writeChain.then(async ()=>{
440
514
  if (tornDown) return;
441
515
  try {
516
+ if (!await gate(frame.topicID)) return; // denied: skip, leave pending, no ack
442
517
  await writer.ready;
443
518
  await writer.write(frame);
444
519
  if (frame.sequenceID > lastServed) lastServed = frame.sequenceID;
@@ -480,6 +555,8 @@ export function createHandlers(params) {
480
555
  receiveToken = token;
481
556
  // After the bind, so the lane is never unheld between the two.
482
557
  evicted?.();
558
+ // The device is draining; a trailing summary would be noise it has already outrun.
559
+ params.wake?.dispatcher?.online(clientDID);
483
560
  // Drain the backlog. Await each page so lastServed is exact before the flush.
484
561
  let cursor = after;
485
562
  while(!tornDown){
@@ -767,6 +844,111 @@ export function createHandlers(params) {
767
844
  count,
768
845
  lastResort: stored == null ? null : keyPackageDigest(stored)
769
846
  };
847
+ },
848
+ 'hub/v1/wake/register': async (ctx)=>{
849
+ const clientDID = getClientDID(ctx);
850
+ const wake = params.wake;
851
+ if (wake == null) {
852
+ throw new HandlerError({
853
+ code: HUB_ERROR_CODES.wakeNotSupported,
854
+ message: 'This hub does not support wake notifications'
855
+ });
856
+ }
857
+ // After the not-supported refusal, before the rate limit — the order every enforced variant
858
+ // uses. Configuration is answered first because a hub with no wake support has nothing for a
859
+ // host to have an opinion about.
860
+ const decision = normalizeAuthorizeDecision(await authorize({
861
+ action: 'wake/register',
862
+ did: clientDID,
863
+ kind: ctx.param.kind,
864
+ expiresAt: ctx.param.expiresAt
865
+ }));
866
+ if (!decision.allow) {
867
+ throw new HandlerError({
868
+ code: HUB_ERROR_CODES.authorizationDenied,
869
+ message: decision.reason ?? 'Not authorized to register a wake endpoint'
870
+ });
871
+ }
872
+ if (!didLimiter.tryConsume(clientDID)) {
873
+ throw new HandlerError({
874
+ code: 'EK01',
875
+ message: 'Wake rate limit exceeded for DID'
876
+ });
877
+ }
878
+ // The param schema bounds the length (`minLength: 1`, `maxLength: 128`/`64`) but not the
879
+ // shape, which accepts key material the hub can never seal to. Storing it answers
880
+ // `registered: true` and then fails inside every single seal — one error per frame, forever,
881
+ // since a seal failure is not a `gone` verdict and nothing removes the entry. The device
882
+ // believes it is reachable and is never woken.
883
+ // The rule itself lives in hub-protocol next to RFC 8291, so the hub needs no curve of its
884
+ // own to know that a 65-byte value off the curve fails exactly as a 33-byte one does.
885
+ const keyProblem = wakeRecipientKeyProblem({
886
+ publicKey: ctx.param.publicKey,
887
+ authSecret: ctx.param.authSecret
888
+ });
889
+ if (keyProblem != null) {
890
+ throw new HandlerError({
891
+ code: HUB_ERROR_CODES.invalidPayload,
892
+ message: `Wake ${keyProblem}`
893
+ });
894
+ }
895
+ try {
896
+ await wake.registry.put({
897
+ // The DID is the verified issuer, never a wire field — otherwise any member could
898
+ // redirect another member's wakes to an endpoint they control.
899
+ did: clientDID,
900
+ kind: ctx.param.kind,
901
+ endpoint: ctx.param.endpoint,
902
+ publicKey: ctx.param.publicKey,
903
+ authSecret: ctx.param.authSecret,
904
+ ...ctx.param.expiresAt != null ? {
905
+ expiresAt: ctx.param.expiresAt
906
+ } : {}
907
+ });
908
+ } catch (error) {
909
+ rethrowAsHandlerError(error);
910
+ }
911
+ return {
912
+ registered: true
913
+ };
914
+ },
915
+ 'hub/v1/wake/unregister': async (ctx)=>{
916
+ const clientDID = getClientDID(ctx);
917
+ const wake = params.wake;
918
+ if (wake == null) {
919
+ throw new HandlerError({
920
+ code: HUB_ERROR_CODES.wakeNotSupported,
921
+ message: 'This hub does not support wake notifications'
922
+ });
923
+ }
924
+ const decision = normalizeAuthorizeDecision(await authorize({
925
+ action: 'wake/unregister',
926
+ did: clientDID
927
+ }));
928
+ if (!decision.allow) {
929
+ throw new HandlerError({
930
+ code: HUB_ERROR_CODES.authorizationDenied,
931
+ message: decision.reason ?? 'Not authorized to unregister a wake endpoint'
932
+ });
933
+ }
934
+ // Matches `register` above and the subscribe/unsubscribe pair: both halves of a pair cost
935
+ // the same, or the uncounted half is the one an abusive caller uses.
936
+ if (!didLimiter.tryConsume(clientDID)) {
937
+ throw new HandlerError({
938
+ code: 'EK01',
939
+ message: 'Wake rate limit exceeded for DID'
940
+ });
941
+ }
942
+ let existed;
943
+ try {
944
+ existed = await wake.registry.get(clientDID) != null;
945
+ await wake.registry.delete(clientDID);
946
+ } catch (error) {
947
+ rethrowAsHandlerError(error);
948
+ }
949
+ return {
950
+ unregistered: existed
951
+ };
770
952
  }
771
953
  };
772
954
  }
package/lib/hub.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  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
- import { type HubProtocol, type HubStore } from '@kumiai/hub-protocol';
4
+ import type { HubProtocol, HubStore, WakeRegistry, WakeSender } from '@kumiai/hub-protocol';
5
5
  import { type AuthorizeHook, type HubRateLimits, type HubStoreErrorHook, type KeyPackageFetchLimits } from './handlers.js';
6
6
  import { HubClientRegistry } from './registry.js';
7
7
  /**
@@ -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.
@@ -52,6 +62,17 @@ export type CreateHubParams = {
52
62
  * `controllerTimeoutMs` and from the `maxConcurrentHandlers` cap.
53
63
  */
54
64
  limits?: Partial<ResourceLimits>;
65
+ /**
66
+ * Wake notifications. Absent: `hub/v1/wake/*` refuse with `WakeNotSupportedError` — refusing is
67
+ * the only honest answer, since accepting a registration the hub will never act on leaves the
68
+ * device believing it is reachable.
69
+ */
70
+ wake?: {
71
+ registry: WakeRegistry;
72
+ sender: WakeSender;
73
+ /** Coalescing window in milliseconds. Default: 10 000. */
74
+ debounceMs?: number;
75
+ };
55
76
  };
56
77
  export type HubInstance = {
57
78
  registry: HubClientRegistry;
package/lib/hub.js CHANGED
@@ -2,6 +2,7 @@ import { serve } from '@enkaku/server';
2
2
  import { hubProtocol } from '@kumiai/hub-protocol';
3
3
  import { createHandlers, createStoreErrorReporter } from './handlers.js';
4
4
  import { HubClientRegistry } from './registry.js';
5
+ import { createWakeDispatcher } from './wake.js';
5
6
  /**
6
7
  * Default access rules: any authenticated DID may call hub procedures.
7
8
  * The hub is a blind relay — per-procedure authorization (the `authorize`
@@ -17,13 +18,28 @@ export function createHub(params) {
17
18
  // wrapper delegates to this instance rather than building a second one from the same hook —
18
19
  // which matters the moment a reporter holds state, as the throttling the README names would.
19
20
  const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
21
+ const wakeDispatcher = params.wake == null ? undefined : createWakeDispatcher({
22
+ registry: params.wake.registry,
23
+ sender: params.wake.sender,
24
+ debounceMs: params.wake.debounceMs,
25
+ onError: ({ did, error })=>storeErrorReporter({
26
+ method: 'wake',
27
+ did,
28
+ error
29
+ })
30
+ });
20
31
  const handlers = createHandlers({
21
32
  registry,
22
33
  store: params.store,
23
34
  authorize: params.authorize,
24
35
  rateLimits: params.rateLimits,
25
36
  keyPackageFetchLimits: params.keyPackageFetchLimits,
26
- onStoreError: storeErrorReporter
37
+ receiveAuthCacheTTL: params.receiveAuthCacheTTL,
38
+ onStoreError: storeErrorReporter,
39
+ wake: params.wake == null ? undefined : {
40
+ registry: params.wake.registry,
41
+ dispatcher: wakeDispatcher
42
+ }
27
43
  });
28
44
  const limits = {
29
45
  ...params.limits,
@@ -58,6 +74,9 @@ export function createHub(params) {
58
74
  }, interval);
59
75
  server.disposed.then(()=>clearInterval(purgeTimer));
60
76
  }
77
+ if (wakeDispatcher != null) {
78
+ server.disposed.then(()=>wakeDispatcher.dispose());
79
+ }
61
80
  return {
62
81
  registry,
63
82
  server
package/lib/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export { createMemoryStore, type MemoryStoreOptions } from './memoryStore.js';
6
6
  export { createRateLimiter, type RateLimitConfig, type RateLimiter } from './rateLimit.js';
7
7
  export type { ClientEntry } from './registry.js';
8
8
  export { HubClientRegistry } from './registry.js';
9
+ export { createWakeDispatcher, type WakeDispatcher, type WakeDispatcherParams, type WakeNotifyParams, } from './wake.js';
package/lib/index.js CHANGED
@@ -3,3 +3,4 @@ export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
3
3
  export { createMemoryStore } from './memoryStore.js';
4
4
  export { createRateLimiter } from './rateLimit.js';
5
5
  export { HubClientRegistry } from './registry.js';
6
+ export { createWakeDispatcher } from './wake.js';
package/lib/wake.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { type WakeRegistry, type WakeSender } from '@kumiai/hub-protocol';
2
+ export type WakeDispatcherParams = {
3
+ registry: WakeRegistry;
4
+ sender: WakeSender;
5
+ /** Coalescing window in milliseconds. Default: 10 000. */
6
+ debounceMs?: number;
7
+ /** Reports a transient send failure or a throwing sender. Fire-and-forget. */
8
+ onError?: (params: {
9
+ did: string;
10
+ error: unknown;
11
+ }) => void;
12
+ };
13
+ export type WakeNotifyParams = {
14
+ did: string;
15
+ topicID: string;
16
+ sequenceID: string;
17
+ };
18
+ export type WakeDispatcher = {
19
+ /** A frame was queued for an OFFLINE subscriber. Never throws, never awaited by the caller. */
20
+ notify(params: WakeNotifyParams): void;
21
+ /** The device bound a receive channel: drop any pending trailing ping. */
22
+ online(did: string): void;
23
+ dispose(): void;
24
+ };
25
+ /**
26
+ * Coalesces wake pings per DID on a LEADING edge: the first frame pings immediately, then a window
27
+ * opens and everything inside it collapses into one trailing summary.
28
+ *
29
+ * Leading rather than trailing because the timer map is in-process. A hub restart drops pending
30
+ * windows; on a leading edge that loses at most a summary, whereas trailing-only would lose the
31
+ * notification itself every time a restart landed inside a window.
32
+ */
33
+ export declare function createWakeDispatcher(params: WakeDispatcherParams): WakeDispatcher;
package/lib/wake.js ADDED
@@ -0,0 +1,112 @@
1
+ import { sealWakeHint } from '@kumiai/hub-protocol';
2
+ import { fromB64U } from '@sozai/codec';
3
+ const DEFAULT_DEBOUNCE_MS = 10_000;
4
+ /**
5
+ * Coalesces wake pings per DID on a LEADING edge: the first frame pings immediately, then a window
6
+ * opens and everything inside it collapses into one trailing summary.
7
+ *
8
+ * Leading rather than trailing because the timer map is in-process. A hub restart drops pending
9
+ * windows; on a leading edge that loses at most a summary, whereas trailing-only would lose the
10
+ * notification itself every time a restart landed inside a window.
11
+ */ export function createWakeDispatcher(params) {
12
+ const debounceMs = params.debounceMs ?? DEFAULT_DEBOUNCE_MS;
13
+ const pending = new Map();
14
+ let disposed = false;
15
+ function report(did, error) {
16
+ try {
17
+ params.onError?.({
18
+ did,
19
+ error
20
+ });
21
+ } catch {
22
+ // A reporter that throws must not take the dispatcher with it.
23
+ }
24
+ }
25
+ function send(did, topicID, sequenceID, count) {
26
+ // Deliberately not awaited: a slow or hanging provider must never delay the publish fan-out
27
+ // this is called from.
28
+ void (async ()=>{
29
+ try {
30
+ const registration = await params.registry.get(did);
31
+ if (registration == null) return;
32
+ const body = sealWakeHint({
33
+ topicID,
34
+ sequenceID,
35
+ count
36
+ }, {
37
+ publicKey: fromB64U(registration.publicKey),
38
+ authSecret: fromB64U(registration.authSecret)
39
+ });
40
+ const verdict = await params.sender.send({
41
+ registration,
42
+ body
43
+ });
44
+ switch(verdict){
45
+ case 'delivered':
46
+ break;
47
+ case 'gone':
48
+ await params.registry.delete(did);
49
+ break;
50
+ case 'retry':
51
+ report(did, new Error('Wake send failed transiently'));
52
+ break;
53
+ default:
54
+ // Exhaustive over WakeVerdict: a future verdict must be handled explicitly here rather
55
+ // than silently falling through as 'delivered'.
56
+ verdict;
57
+ }
58
+ } catch (error) {
59
+ report(did, error);
60
+ }
61
+ })();
62
+ }
63
+ function openWindow(did) {
64
+ const entry = {
65
+ timer: setTimeout(()=>{
66
+ const current = pending.get(did);
67
+ pending.delete(did);
68
+ if (current?.latest == null) return;
69
+ send(did, current.latest.topicID, current.latest.sequenceID, current.count);
70
+ // Traffic is still flowing, so a fresh window opens behind the summary rather than letting
71
+ // the next frame ping immediately and undo the coalescing.
72
+ pending.set(did, openWindow(did));
73
+ }, debounceMs),
74
+ latest: null,
75
+ count: 0
76
+ };
77
+ // A pending wake must never hold a process open by itself. `.unref` is a Node timer method
78
+ // absent from the DOM `Timeout` type; it only typechecks here because this package's
79
+ // tsconfig.json (unlike its siblings') sets `"types": ["node"]`. If hub-server is ever
80
+ // aligned to drop that, this line needs `(entry.timer as NodeJS.Timeout).unref?.()` or an
81
+ // equivalent cast.
82
+ entry.timer.unref?.();
83
+ return entry;
84
+ }
85
+ return {
86
+ notify ({ did, topicID, sequenceID }) {
87
+ if (disposed) return;
88
+ const entry = pending.get(did);
89
+ if (entry == null) {
90
+ pending.set(did, openWindow(did));
91
+ send(did, topicID, sequenceID, 1);
92
+ return;
93
+ }
94
+ entry.latest = {
95
+ topicID,
96
+ sequenceID
97
+ };
98
+ entry.count += 1;
99
+ },
100
+ online (did) {
101
+ const entry = pending.get(did);
102
+ if (entry == null) return;
103
+ clearTimeout(entry.timer);
104
+ pending.delete(did);
105
+ },
106
+ dispose () {
107
+ disposed = true;
108
+ for (const entry of pending.values())clearTimeout(entry.timer);
109
+ pending.clear();
110
+ }
111
+ };
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-server",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Hub server for Enkaku E2EE group message routing",
5
5
  "keywords": [
6
6
  "hub",
@@ -26,17 +26,18 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "@enkaku/protocol": "^0.21.0",
29
- "@enkaku/server": "^0.21.0",
30
- "@kokuin/token": "^0.4.0",
29
+ "@enkaku/server": "^0.21.2",
30
+ "@kokuin/token": "^0.5.0",
31
+ "@kumiai/hub-protocol": "^0.8.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.6.0"
34
+ "@sozai/log": "^0.3.1"
35
35
  },
36
36
  "devDependencies": {
37
- "@enkaku/client": "^0.21.0",
37
+ "@enkaku/client": "^0.21.1",
38
38
  "@enkaku/transport": "^0.21.0",
39
- "@kumiai/hub-conformance": "^0.6.0"
39
+ "@kumiai/hub-conformance": "^0.8.0",
40
+ "@kumiai/hub-wake": "^0.8.0"
40
41
  },
41
42
  "publishConfig": {
42
43
  "access": "public"