@kumiai/hub-server 0.7.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/lib/handlers.d.ts CHANGED
@@ -63,6 +63,13 @@ export type AuthorizeRequest = {
63
63
  } | {
64
64
  action: 'wake/unregister';
65
65
  did: string;
66
+ } | {
67
+ action: 'receive';
68
+ did: string;
69
+ } | {
70
+ action: 'receive/deliver';
71
+ did: string;
72
+ topicID: string;
66
73
  };
67
74
  /**
68
75
  * A plain `boolean` is shorthand for `{ allow: boolean }`, with no reason, code, or retry hint.
@@ -150,6 +157,12 @@ export declare const DEFAULT_KEYPACKAGE_FETCH_LIMITS: KeyPackageFetchLimits;
150
157
  * writer health. The default of 256 is safely above it.
151
158
  */
152
159
  export declare const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
160
+ /**
161
+ * Default TTL (ms) for a receive channel's per-(did, topicID) delivery-authorization decision.
162
+ * Bounds how long an already-resolved allow is reused before the hook is re-consulted — i.e. the
163
+ * maximum window a removed member can keep draining a topic. See the receive handler's `gate`.
164
+ */
165
+ export declare const DEFAULT_RECEIVE_AUTH_CACHE_TTL = 5000;
153
166
  export type CreateHandlersParams = {
154
167
  registry: HubClientRegistry;
155
168
  store: HubStore;
@@ -159,6 +172,12 @@ export type CreateHandlersParams = {
159
172
  /** Max frames queued-but-unflushed on a receive channel. See {@link DEFAULT_RECEIVE_BUFFER_LIMIT}
160
173
  * for the >= 50-frame-page floor this should respect. Default: 256 */
161
174
  receiveBufferLimit?: number;
175
+ /**
176
+ * TTL (ms) for the per-(did, topicID) receive delivery-authorization cache. `0` disables reuse
177
+ * (the hook is consulted for every frame). Non-finite or negative values fall back to
178
+ * {@link DEFAULT_RECEIVE_AUTH_CACHE_TTL}. Default: 5000.
179
+ */
180
+ receiveAuthCacheTTL?: number;
162
181
  /**
163
182
  * Called when a `HubStore` operation fails at a point where the hub deliberately does not fail
164
183
  * the request. Fire-and-forget; a throw here is swallowed.
package/lib/handlers.js CHANGED
@@ -84,6 +84,11 @@ export const DEFAULT_KEYPACKAGE_FETCH_LIMITS = {
84
84
  * before awaiting, so a limit below 50 would trip the cap on the very first page regardless of
85
85
  * writer health. The default of 256 is safely above it.
86
86
  */ export const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
87
+ /**
88
+ * Default TTL (ms) for a receive channel's per-(did, topicID) delivery-authorization decision.
89
+ * Bounds how long an already-resolved allow is reused before the hook is re-consulted — i.e. the
90
+ * maximum window a removed member can keep draining a topic. See the receive handler's `gate`.
91
+ */ export const DEFAULT_RECEIVE_AUTH_CACHE_TTL = 5000;
87
92
  function getClientDID(ctx) {
88
93
  const iss = ctx.message.payload.iss;
89
94
  if (typeof iss !== 'string' || iss.length === 0) {
@@ -105,6 +110,13 @@ function getClientDID(ctx) {
105
110
  }
106
111
  throw error;
107
112
  }
113
+ function resolveReceiveAuthCacheTTL(value) {
114
+ if (value === undefined || !Number.isFinite(value) || value < 0) {
115
+ return DEFAULT_RECEIVE_AUTH_CACHE_TTL;
116
+ }
117
+ return value // 0 allowed: no reuse
118
+ ;
119
+ }
108
120
  function toReceiveFrame(message) {
109
121
  return {
110
122
  sequenceID: message.sequenceID,
@@ -134,6 +146,7 @@ export function createHandlers(params) {
134
146
  const didLimiter = createRateLimiter(rateLimits.perDID);
135
147
  const topicLimiter = createRateLimiter(rateLimits.perTopic);
136
148
  const receiveBufferLimit = params.receiveBufferLimit ?? DEFAULT_RECEIVE_BUFFER_LIMIT;
149
+ const receiveAuthCacheTTL = resolveReceiveAuthCacheTTL(params.receiveAuthCacheTTL);
137
150
  const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
138
151
  const fetchLimits = {
139
152
  ...DEFAULT_KEYPACKAGE_FETCH_LIMITS,
@@ -417,7 +430,43 @@ export function createHandlers(params) {
417
430
  'hub/v1/receive': async (ctx)=>{
418
431
  const clientDID = getClientDID(ctx);
419
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
+ }
420
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
+ };
421
470
  const writer = ctx.writable.getWriter();
422
471
  const reader = ctx.readable.getReader();
423
472
  let endEvicted;
@@ -464,6 +513,7 @@ export function createHandlers(params) {
464
513
  writeChain = writeChain.then(async ()=>{
465
514
  if (tornDown) return;
466
515
  try {
516
+ if (!await gate(frame.topicID)) return; // denied: skip, leave pending, no ack
467
517
  await writer.ready;
468
518
  await writer.write(frame);
469
519
  if (frame.sequenceID > lastServed) lastServed = frame.sequenceID;
package/lib/hub.d.ts CHANGED
@@ -30,7 +30,12 @@ export type CreateHubParams = {
30
30
  identity: Identity;
31
31
  /** Access rules enforced by the server. Defaults to {@link DEFAULT_HUB_ACCESS_RULES}. */
32
32
  accessRules?: AccessRules;
33
- /** Per-procedure publish/subscribe authorization. Defaults to allow-any-authed. */
33
+ /**
34
+ * Per-action authorization hook. Consulted for publish, subscribe, topic/fetch, keypackage/*,
35
+ * wake/*, and receive — the coarse `receive` gate at channel open plus a per-frame
36
+ * `receive/deliver` gate by topic (see {@link CreateHubParams.receiveAuthCacheTTL}). Defaults to
37
+ * allow-any-authed.
38
+ */
34
39
  authorize?: AuthorizeHook;
35
40
  /** Publish rate limits. Merged over {@link DEFAULT_RATE_LIMITS}. */
36
41
  rateLimits?: Partial<HubRateLimits>;
@@ -39,6 +44,11 @@ export type CreateHubParams = {
39
44
  * {@link DEFAULT_KEYPACKAGE_FETCH_LIMITS}.
40
45
  */
41
46
  keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
47
+ /**
48
+ * TTL (ms) for the per-(did, topicID) receive delivery-authorization cache. `0` consults the
49
+ * hook for every frame. Forwarded to {@link createHandlers}. Default: 5000.
50
+ */
51
+ receiveAuthCacheTTL?: number;
42
52
  /**
43
53
  * Called when a `HubStore` operation fails where the hub deliberately does not fail the
44
54
  * request. Forwarded to {@link createHandlers} and used by the purge timer. Fire-and-forget.
package/lib/hub.js CHANGED
@@ -34,6 +34,7 @@ export function createHub(params) {
34
34
  authorize: params.authorize,
35
35
  rateLimits: params.rateLimits,
36
36
  keyPackageFetchLimits: params.keyPackageFetchLimits,
37
+ receiveAuthCacheTTL: params.receiveAuthCacheTTL,
37
38
  onStoreError: storeErrorReporter,
38
39
  wake: params.wake == null ? undefined : {
39
40
  registry: params.wake.registry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-server",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Hub server for Enkaku E2EE group message routing",
5
5
  "keywords": [
6
6
  "hub",
@@ -26,18 +26,18 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "@enkaku/protocol": "^0.21.0",
29
- "@enkaku/server": "^0.21.1",
29
+ "@enkaku/server": "^0.21.2",
30
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.7.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.7.0",
40
- "@kumiai/hub-wake": "^0.7.0"
39
+ "@kumiai/hub-conformance": "^0.8.0",
40
+ "@kumiai/hub-wake": "^0.8.0"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"