@kumiai/hub-server 0.10.1 → 0.10.3

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.js CHANGED
@@ -405,7 +405,8 @@ export function createHandlers(params) {
405
405
  payload: toB64(message.payload)
406
406
  })),
407
407
  head: result.head,
408
- oldest: result.oldest
408
+ oldest: result.oldest,
409
+ gap: result.gap
409
410
  };
410
411
  } catch (error) {
411
412
  rethrowAsHandlerError(error);
package/lib/hub.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ServerTransportOf } from '@enkaku/protocol';
2
2
  import type { AccessRules, ReplayOptions, ResourceLimits, Server } from '@enkaku/server';
3
+ import type { VerifyTokenHook } from '@kokuin/capability';
3
4
  import type { Identity } from '@kokuin/token';
4
5
  import type { HubProtocol, HubStore, WakeRegistry, WakeSender } from '@kumiai/hub-protocol';
5
6
  import { type AuthorizeHook, type HubRateLimits, type HubStoreErrorHook, type KeyPackageFetchLimits } from './handlers.js';
@@ -41,6 +42,12 @@ export type CreateHubParams = {
41
42
  * allow-any-authed.
42
43
  */
43
44
  authorize?: AuthorizeHook;
45
+ /**
46
+ * Called for each capability in a delegation chain after it verifies; throw to reject. This is
47
+ * where a host plugs in revocation. Forwarded to `serve()`, which only consults it for
48
+ * delegated capabilities.
49
+ */
50
+ verifyToken?: VerifyTokenHook;
44
51
  /** Publish rate limits. Merged over {@link DEFAULT_RATE_LIMITS}. */
45
52
  rateLimits?: Partial<HubRateLimits>;
46
53
  /**
@@ -81,5 +88,10 @@ export type CreateHubParams = {
81
88
  export type HubInstance = {
82
89
  registry: HubClientRegistry;
83
90
  server: Server<HubProtocol>;
91
+ /**
92
+ * Stops the purge timer, waits for an in-flight purge and in-flight wake sends, then disposes the
93
+ * server. Idempotent: every call returns the same promise.
94
+ */
95
+ dispose(): Promise<void>;
84
96
  };
85
97
  export declare function createHub(params: CreateHubParams): HubInstance;
package/lib/hub.js CHANGED
@@ -56,6 +56,7 @@ export function createHub(params) {
56
56
  transport: params.transport,
57
57
  identity: params.identity,
58
58
  accessRules: params.accessRules ?? DEFAULT_HUB_ACCESS_RULES,
59
+ verifyToken: params.verifyToken,
59
60
  replay: {
60
61
  ...params.replay,
61
62
  enabled: true,
@@ -63,27 +64,50 @@ export function createHub(params) {
63
64
  },
64
65
  limits
65
66
  });
67
+ let purgeTimer;
68
+ const purgesInFlight = new Set();
66
69
  if (params.purge !== false) {
67
70
  const interval = params.purge?.interval ?? 3_600_000;
68
71
  const olderThan = params.purge?.olderThan ?? 604_800;
69
- const purgeTimer = setInterval(()=>{
70
- params.store.purge({
72
+ purgeTimer = setInterval(()=>{
73
+ const run = params.store.purge({
71
74
  olderThan
72
- }).catch((error)=>{
75
+ }).then(()=>{}).catch((error)=>{
73
76
  // Purge failures are non-fatal; retried on the next interval
74
77
  storeErrorReporter({
75
78
  method: 'purge',
76
79
  error
77
80
  });
78
81
  });
82
+ purgesInFlight.add(run);
83
+ void run.finally(()=>{
84
+ purgesInFlight.delete(run);
85
+ });
79
86
  }, interval);
80
- server.disposed.then(()=>clearInterval(purgeTimer));
81
87
  }
82
- if (wakeDispatcher != null) {
83
- server.disposed.then(()=>wakeDispatcher.dispose());
88
+ // Stop background work first so nothing new starts, then drain what already did, then the server.
89
+ let teardown;
90
+ function stopBackground() {
91
+ teardown ??= (async ()=>{
92
+ if (purgeTimer != null) clearInterval(purgeTimer);
93
+ await Promise.all(purgesInFlight);
94
+ await wakeDispatcher?.dispose();
95
+ })();
96
+ return teardown;
97
+ }
98
+ let disposing;
99
+ function dispose() {
100
+ disposing ??= (async ()=>{
101
+ await stopBackground();
102
+ await server.dispose();
103
+ })();
104
+ return disposing;
84
105
  }
106
+ // Disposing the server directly must still stop purge and wake.
107
+ void server.disposed.then(stopBackground);
85
108
  return {
86
109
  registry,
87
- server
110
+ server,
111
+ dispose
88
112
  };
89
113
  }
@@ -54,6 +54,7 @@ function isLive(entry, nowSeconds) {
54
54
  const entries = new Map();
55
55
  const topicLogs = new Map();
56
56
  const heads = new Map();
57
+ const removedThrough = new Map();
57
58
  const deliveries = new Map();
58
59
  /**
59
60
  * publishID -> the sequenceID it was accepted as. Not a log entry and not reachable from one: no
@@ -87,6 +88,10 @@ function isLive(entry, nowSeconds) {
87
88
  function removeEntry(sequenceID) {
88
89
  const entry = entries.get(sequenceID);
89
90
  if (entry == null) return;
91
+ if (entry.retain === 'log') {
92
+ const removed = removedThrough.get(entry.topicID);
93
+ if (removed == null || sequenceID > removed) removedThrough.set(entry.topicID, sequenceID);
94
+ }
90
95
  for (const recipientDID of entry.pendingFor){
91
96
  const list = deliveries.get(recipientDID);
92
97
  if (list != null) {
@@ -295,7 +300,8 @@ function isLive(entry, nowSeconds) {
295
300
  return {
296
301
  messages,
297
302
  head: heads.get(params.topicID) ?? null,
298
- oldest: log[0] ?? null
303
+ oldest: log[0] ?? null,
304
+ gap: removedThrough.has(params.topicID) && (after == null || removedThrough.get(params.topicID) > after)
299
305
  };
300
306
  },
301
307
  async trim (params) {
@@ -318,14 +324,17 @@ function isLive(entry, nowSeconds) {
318
324
  }
319
325
  },
320
326
  async purge (params) {
321
- // The age bound, for both classes: the same removal path, the same invariants — head is
322
- // untouched.
323
327
  const now = Date.now();
324
328
  const purgedIDs = [];
329
+ const retainedTopics = new Set();
330
+ // Insertion order is log-position order, even if the clock moves backwards.
325
331
  for (const [sequenceID, entry] of entries){
332
+ if (entry.retain === 'log' && retainedTopics.has(entry.topicID)) continue;
326
333
  const retention = retentionOf(entry.topicID, params.olderThan);
327
334
  if (entry.storedAt <= now - retention * 1000) {
328
335
  purgedIDs.push(sequenceID);
336
+ } else if (entry.retain === 'log') {
337
+ retainedTopics.add(entry.topicID);
329
338
  }
330
339
  }
331
340
  for (const sequenceID of purgedIDs){
package/lib/wake.d.ts CHANGED
@@ -20,7 +20,8 @@ export type WakeDispatcher = {
20
20
  notify(params: WakeNotifyParams): void;
21
21
  /** The device bound a receive channel: drop any pending trailing ping. */
22
22
  online(did: string): void;
23
- dispose(): void;
23
+ /** Stops accepting frames, drops pending windows, and waits for every in-flight send to settle. */
24
+ dispose(): Promise<void>;
24
25
  };
25
26
  /**
26
27
  * Coalesces wake pings per DID on a LEADING edge: the first frame pings immediately, then a window
package/lib/wake.js CHANGED
@@ -11,6 +11,7 @@ const DEFAULT_DEBOUNCE_MS = 10_000;
11
11
  */ export function createWakeDispatcher(params) {
12
12
  const debounceMs = params.debounceMs ?? DEFAULT_DEBOUNCE_MS;
13
13
  const pending = new Map();
14
+ const inFlight = new Set();
14
15
  let disposed = false;
15
16
  function report(did, error) {
16
17
  try {
@@ -25,7 +26,7 @@ const DEFAULT_DEBOUNCE_MS = 10_000;
25
26
  function send(did, topicID, sequenceID, count) {
26
27
  // Deliberately not awaited: a slow or hanging provider must never delay the publish fan-out
27
28
  // this is called from.
28
- void (async ()=>{
29
+ const task = (async ()=>{
29
30
  try {
30
31
  const registration = await params.registry.get(did);
31
32
  if (registration == null) return;
@@ -45,7 +46,8 @@ const DEFAULT_DEBOUNCE_MS = 10_000;
45
46
  case 'delivered':
46
47
  break;
47
48
  case 'gone':
48
- await params.registry.delete(did);
49
+ // A hub being torn down must not write to a registry its host may already be closing.
50
+ if (!disposed) await params.registry.delete(did);
49
51
  break;
50
52
  case 'retry':
51
53
  report(did, new Error('Wake send failed transiently'));
@@ -59,6 +61,8 @@ const DEFAULT_DEBOUNCE_MS = 10_000;
59
61
  report(did, error);
60
62
  }
61
63
  })();
64
+ inFlight.add(task);
65
+ void task.finally(()=>inFlight.delete(task));
62
66
  }
63
67
  function openWindow(did) {
64
68
  const entry = {
@@ -103,10 +107,11 @@ const DEFAULT_DEBOUNCE_MS = 10_000;
103
107
  clearTimeout(entry.timer);
104
108
  pending.delete(did);
105
109
  },
106
- dispose () {
110
+ async dispose () {
107
111
  disposed = true;
108
112
  for (const entry of pending.values())clearTimeout(entry.timer);
109
113
  pending.clear();
114
+ await Promise.all(inFlight);
110
115
  }
111
116
  };
112
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-server",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "Hub server for Enkaku E2EE group message routing",
5
5
  "keywords": [
6
6
  "hub",
@@ -25,18 +25,19 @@
25
25
  "lib/*"
26
26
  ],
27
27
  "dependencies": {
28
- "@enkaku/protocol": "^0.21.3",
29
- "@enkaku/server": "^0.21.3",
30
- "@kokuin/token": "^0.5.0",
31
- "@kumiai/hub-protocol": "^0.10.0",
32
- "@sozai/codec": "^0.4.0",
33
- "@sozai/event": "^0.1.3",
34
- "@sozai/log": "^0.3.1"
28
+ "@enkaku/protocol": "^0.21.4",
29
+ "@enkaku/server": "^0.21.4",
30
+ "@kokuin/capability": "^0.4.1",
31
+ "@kokuin/token": "^0.5.1",
32
+ "@kumiai/hub-protocol": "^0.10.1",
33
+ "@sozai/codec": "^0.4.2",
34
+ "@sozai/event": "^0.1.5",
35
+ "@sozai/log": "^0.4.0"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@enkaku/client": "^0.21.3",
38
39
  "@enkaku/transport": "^0.21.3",
39
- "@kumiai/hub-conformance": "^0.10.0",
40
+ "@kumiai/hub-conformance": "^0.10.1",
40
41
  "@kumiai/hub-wake": "^0.10.0"
41
42
  },
42
43
  "publishConfig": {