@kumiai/hub-server 0.12.2 → 0.13.1

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
@@ -5,8 +5,8 @@ client registry for push fan-out, and an in-memory `HubStore` for development an
5
5
 
6
6
  ## Exports
7
7
 
8
- - `createHub({ transport, store, identity, ... })` — the whole server: handlers, registry, access
9
- rules, rate limits, and an optional scheduled purge. Returns `{ registry, server }`.
8
+ - `createHub({ transport, store, identity, ... })` -- handlers, registry, access rules, rate limits and an optional scheduled purge.
9
+ Returns `{ registry, server, dispose }`.
10
10
  - `createHandlers({ registry, store, ... })` — the handlers alone, for a host assembling its own
11
11
  server.
12
12
  - `createMemoryStore({ maxDepth?, retention? })` — an in-memory `HubStore`. For testing and
@@ -160,3 +160,19 @@ rather than passing silently. Pass an empty handler to silence it deliberately.
160
160
  There is no throttling: a permanently broken store reports per request. logtape ships
161
161
  `getThrottlingFilter`, so rate control belongs in the app's sink configuration where an operator
162
162
  can tune it.
163
+
164
+ ## Invocation hooks and attribution
165
+
166
+ `createHub` accepts optional `onInvocation`, forwarded to the Enkaku server for authenticated invocation context and delegation verification.
167
+ `createHub` and `createHandlers` accept optional `resolveAttribution({ message, senderDID })`.
168
+ The resolver can return `HubAttribution`, `undefined`, or a promise of either.
169
+
170
+ Without a resolver result, publishes use `{ account: senderDID, actor: senderDID, via: 'direct' }`.
171
+ The sender DID still comes from the verified signature.
172
+ A resolver supplies host accounting metadata rather than changing the authenticated sender or authorisation rules.
173
+ Only a verified delegation yields `via: 'delegated'`. A claimed subject alone does not prove delegation.
174
+ Hosts can use `onInvocation` to verify delegation before resolving attribution.
175
+
176
+ The store receives the resolved attribution with the publish.
177
+ The SQL stores persist it. The development memory store reports publish outcomes but does not implement occupancy sampling.
178
+ Unconfigured hooks preserve existing routing, retention and delivery behaviour.
package/lib/handlers.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ProcedureHandlers } from '@enkaku/server';
2
- import type { HubProtocol, HubStore, WakeRegistry } from '@kumiai/hub-protocol';
2
+ import type { HubAttribution, HubProtocol, HubStore, WakeRegistry } from '@kumiai/hub-protocol';
3
3
  import { type RateLimitConfig } from './rateLimit.js';
4
4
  import type { HubClientRegistry } from './registry.js';
5
5
  import type { WakeDispatcher } from './wake.js';
@@ -198,6 +198,10 @@ export type CreateHandlersParams = {
198
198
  registry: HubClientRegistry;
199
199
  store: HubStore;
200
200
  authorize?: AuthorizeHook;
201
+ resolveAttribution?: (params: {
202
+ message: unknown;
203
+ senderDID: string;
204
+ }) => HubAttribution | undefined | Promise<HubAttribution | undefined>;
201
205
  rateLimits?: Partial<HubRateLimits>;
202
206
  keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
203
207
  /** Max frames queued-but-unflushed on a receive channel. See {@link DEFAULT_RECEIVE_BUFFER_LIMIT}
package/lib/handlers.js CHANGED
@@ -287,8 +287,17 @@ export function createHandlers(params) {
287
287
  let sequenceID;
288
288
  let deduped;
289
289
  try {
290
+ const attribution = await params.resolveAttribution?.({
291
+ message: ctx.message,
292
+ senderDID
293
+ }) ?? {
294
+ account: senderDID,
295
+ actor: senderDID,
296
+ via: 'direct'
297
+ };
290
298
  const result = await store.publish({
291
299
  senderDID,
300
+ attribution,
292
301
  topicID,
293
302
  payload: payloadBytes,
294
303
  retain: ctx.param.retain,
@@ -624,7 +633,10 @@ export function createHandlers(params) {
624
633
  logUnackedFrames();
625
634
  tornDown = true;
626
635
  notifyWindow();
627
- if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
636
+ if (receiveToken != null) registry.releaseReceiveWriter({
637
+ did: clientDID,
638
+ token: receiveToken
639
+ });
628
640
  registry.unregisterIfIdle(clientDID);
629
641
  reader.cancel().catch(()=>{});
630
642
  writer.abort(new Error('receive channel torn down')).catch(()=>{});
@@ -718,7 +730,11 @@ export function createHandlers(params) {
718
730
  // Binding evicts whatever held the lane: a reconnect means the old connection broke and the
719
731
  // server learns last, so the stale writer must give way to the live one. See
720
732
  // `HubClientRegistry.bindReceiveWriter`.
721
- const { token, evicted } = registry.bindReceiveWriter(clientDID, onLive, ()=>endEvicted?.());
733
+ const { token, evicted } = registry.bindReceiveWriter({
734
+ did: clientDID,
735
+ writer: onLive,
736
+ endReceive: ()=>endEvicted?.()
737
+ });
722
738
  receiveToken = token;
723
739
  // After the bind, so the lane is never unheld between the two.
724
740
  evicted?.();
@@ -770,7 +786,10 @@ export function createHandlers(params) {
770
786
  if (!tornDown) logUnackedFrames();
771
787
  tornDown = true;
772
788
  notifyWindow();
773
- if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
789
+ if (receiveToken != null) registry.releaseReceiveWriter({
790
+ did: clientDID,
791
+ token: receiveToken
792
+ });
774
793
  registry.unregisterIfIdle(clientDID);
775
794
  reader.cancel().catch(()=>{});
776
795
  writer.abort(error).catch(()=>{});
package/lib/hub.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { ServerTransportOf } from '@enkaku/protocol';
2
- import type { AccessRules, ReplayOptions, ResourceLimits, Server } from '@enkaku/server';
2
+ import type { AccessRules, InvocationHook, ReplayOptions, ResourceLimits, Server } from '@enkaku/server';
3
3
  import type { VerifyTokenHook } from '@kokuin/capability';
4
- import type { Identity } from '@kokuin/token';
4
+ import type { CryptoOptions, Identity } from '@kokuin/token';
5
5
  import type { HubProtocol, HubStore, WakeRegistry, WakeSender } from '@kumiai/hub-protocol';
6
- import { type AuthorizeHook, type HubRateLimits, type HubStoreErrorHook, type KeyPackageFetchLimits } from './handlers.js';
6
+ import type { AuthorizeHook, CreateHandlersParams, HubRateLimits, HubStoreErrorHook, KeyPackageFetchLimits } from './handlers.js';
7
7
  import { HubClientRegistry } from './registry.js';
8
8
  /**
9
9
  * Default access rules: any authenticated DID may call hub procedures.
@@ -28,7 +28,7 @@ export type HubPurgeOptions = {
28
28
  };
29
29
  /** Replay settings a host may tune. The hub always enables replay checks and rejects stale tokens. */
30
30
  export type HubReplayOptions = Omit<ReplayOptions, 'enabled' | 'rejectStale'>;
31
- export type CreateHubParams = {
31
+ export type CreateHubParams = CryptoOptions & {
32
32
  transport: ServerTransportOf<HubProtocol>;
33
33
  store: HubStore;
34
34
  /**
@@ -53,6 +53,9 @@ export type CreateHubParams = {
53
53
  * delegated capabilities.
54
54
  */
55
55
  verifyToken?: VerifyTokenHook;
56
+ onInvocation?: InvocationHook;
57
+ /** An absent result attributes directly to the sender. */
58
+ resolveAttribution?: CreateHandlersParams['resolveAttribution'];
56
59
  /** Publish rate limits. Merged over {@link DEFAULT_RATE_LIMITS}. */
57
60
  rateLimits?: Partial<HubRateLimits>;
58
61
  /**
package/lib/hub.js CHANGED
@@ -32,6 +32,7 @@ export function createHub(params) {
32
32
  registry,
33
33
  store: params.store,
34
34
  authorize: params.authorize,
35
+ resolveAttribution: params.resolveAttribution,
35
36
  rateLimits: params.rateLimits,
36
37
  keyPackageFetchLimits: params.keyPackageFetchLimits,
37
38
  receiveAuthCacheTTL: params.receiveAuthCacheTTL,
@@ -55,8 +56,10 @@ export function createHub(params) {
55
56
  protocol: hubProtocol,
56
57
  transport: params.transport,
57
58
  identity: params.identity,
59
+ crypto: params.crypto,
58
60
  accessRules: params.accessRules ?? DEFAULT_HUB_ACCESS_RULES,
59
61
  verifyToken: params.verifyToken,
62
+ onInvocation: params.onInvocation,
60
63
  replay: {
61
64
  ...params.replay,
62
65
  enabled: true,
package/lib/index.d.ts CHANGED
@@ -4,6 +4,6 @@ export type { CreateHubParams, HubInstance, HubPurgeOptions, HubReplayOptions }
4
4
  export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
5
5
  export { createMemoryStore, type MemoryStoreOptions } from './memoryStore.js';
6
6
  export { createRateLimiter, type RateLimitConfig, type RateLimiter } from './rateLimit.js';
7
- export type { ClientEntry } from './registry.js';
7
+ export type { ClientEntry, HubClientRegistryBindReceiveWriterParams, HubClientRegistryReleaseReceiveWriterParams, } from './registry.js';
8
8
  export { HubClientRegistry } from './registry.js';
9
9
  export { createWakeDispatcher, type WakeDispatcher, type WakeDispatcherParams, type WakeNotifyParams, } from './wake.js';
@@ -137,7 +137,9 @@ function isLive(entry, nowSeconds) {
137
137
  if (accepted !== undefined) {
138
138
  return {
139
139
  sequenceID: accepted,
140
- deduped: true
140
+ deduped: true,
141
+ outcome: 'deduplicated',
142
+ storedBytes: 0
141
143
  };
142
144
  }
143
145
  }
@@ -174,7 +176,9 @@ function isLive(entry, nowSeconds) {
174
176
  if (retain === 'mailbox' && recipients.size === 0) {
175
177
  return {
176
178
  sequenceID,
177
- deduped: false
179
+ deduped: false,
180
+ outcome: 'no-recipients',
181
+ storedBytes: 0
178
182
  };
179
183
  }
180
184
  const entry = {
@@ -219,7 +223,9 @@ function isLive(entry, nowSeconds) {
219
223
  }
220
224
  return {
221
225
  sequenceID,
222
- deduped: false
226
+ deduped: false,
227
+ outcome: 'inserted',
228
+ storedBytes: params.payload.byteLength
223
229
  };
224
230
  },
225
231
  async fetch (params) {
package/lib/registry.d.ts CHANGED
@@ -7,6 +7,15 @@ export type ClientEntry = {
7
7
  /** Identifies the binding, so a channel can only ever release its OWN. */
8
8
  token: symbol | null;
9
9
  };
10
+ export type HubClientRegistryBindReceiveWriterParams = {
11
+ did: string;
12
+ writer: (message: StoredMessage) => void;
13
+ endReceive: () => void;
14
+ };
15
+ export type HubClientRegistryReleaseReceiveWriterParams = {
16
+ did: string;
17
+ token: symbol;
18
+ };
10
19
  /**
11
20
  * Tracks online clients and their live receive-channel writers. Subscription
12
21
  * state is durable in the store, not here — the registry only routes live
@@ -39,12 +48,12 @@ export declare class HubClientRegistry {
39
48
  * it. So a release names the binding it is releasing, and one that is no longer current is a
40
49
  * no-op.
41
50
  */
42
- bindReceiveWriter(did: string, writer: (message: StoredMessage) => void, endReceive: () => void): {
51
+ bindReceiveWriter({ did, writer, endReceive }: HubClientRegistryBindReceiveWriterParams): {
43
52
  token: symbol;
44
53
  evicted: (() => void) | null;
45
54
  };
46
55
  /** Clear the binding this token names. A token that is no longer current is a no-op. */
47
- releaseReceiveWriter(did: string, token: symbol): void;
56
+ releaseReceiveWriter({ did, token }: HubClientRegistryReleaseReceiveWriterParams): void;
48
57
  getClient(did: string): ClientEntry | undefined;
49
58
  isOnline(did: string): boolean;
50
59
  isWriterBound(did: string): boolean;
package/lib/registry.js CHANGED
@@ -47,7 +47,7 @@
47
47
  * null the writer the new channel has just bound, leaving the DID online with nothing behind
48
48
  * it. So a release names the binding it is releasing, and one that is no longer current is a
49
49
  * no-op.
50
- */ bindReceiveWriter(did, writer, endReceive) {
50
+ */ bindReceiveWriter({ did, writer, endReceive }) {
51
51
  const entry = this.#clients.get(did);
52
52
  if (entry == null) return {
53
53
  token: Symbol('unbound'),
@@ -63,7 +63,7 @@
63
63
  evicted
64
64
  };
65
65
  }
66
- /** Clear the binding this token names. A token that is no longer current is a no-op. */ releaseReceiveWriter(did, token) {
66
+ /** Clear the binding this token names. A token that is no longer current is a no-op. */ releaseReceiveWriter({ did, token }) {
67
67
  const entry = this.#clients.get(did);
68
68
  if (entry == null || entry.token !== token) return;
69
69
  entry.sendMessage = null;
package/lib/wake.js CHANGED
@@ -31,12 +31,15 @@ const DEFAULT_DEBOUNCE_MS = 10_000;
31
31
  const registration = await params.registry.get(did);
32
32
  if (registration == null) return;
33
33
  const body = sealWakeHint({
34
- topicID,
35
- sequenceID,
36
- count
37
- }, {
38
- publicKey: fromB64U(registration.publicKey),
39
- authSecret: fromB64U(registration.authSecret)
34
+ hint: {
35
+ topicID,
36
+ sequenceID,
37
+ count
38
+ },
39
+ recipient: {
40
+ publicKey: fromB64U(registration.publicKey),
41
+ authSecret: fromB64U(registration.authSecret)
42
+ }
40
43
  });
41
44
  const verdict = await params.sender.send({
42
45
  registration,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-server",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
4
4
  "description": "Hub server for Enkaku E2EE group message routing",
5
5
  "keywords": [
6
6
  "hub",
@@ -25,20 +25,21 @@
25
25
  "lib/*"
26
26
  ],
27
27
  "dependencies": {
28
- "@enkaku/protocol": "^0.22.5",
29
- "@enkaku/server": "^0.22.5",
30
- "@kokuin/capability": "^0.5.2",
31
- "@kokuin/token": "^0.6.4",
32
- "@kumiai/hub-protocol": "^0.12.0",
28
+ "@enkaku/protocol": "^0.23.1",
29
+ "@enkaku/server": "^0.23.1",
30
+ "@kokuin/capability": "^0.7.0",
31
+ "@kokuin/token": "^0.7.0",
32
+ "@kumiai/hub-protocol": "^0.13.1",
33
33
  "@sozai/codec": "^0.4.4",
34
34
  "@sozai/event": "^0.1.6",
35
35
  "@sozai/log": "^0.4.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@enkaku/client": "^0.22.5",
39
- "@enkaku/transport": "^0.22.5",
40
- "@kumiai/hub-conformance": "^0.12.0",
41
- "@kumiai/hub-wake": "^0.12.0"
38
+ "@enkaku/client": "^0.23.1",
39
+ "@enkaku/transport": "^0.23.1",
40
+ "@kokuin/crypto": "^0.7.1",
41
+ "@kumiai/hub-conformance": "^0.13.0",
42
+ "@kumiai/hub-wake": "^0.13.0"
42
43
  },
43
44
  "publishConfig": {
44
45
  "access": "public"