@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 +7 -4
- package/lib/handlers.d.ts +57 -4
- package/lib/handlers.js +184 -2
- package/lib/hub.d.ts +23 -2
- package/lib/hub.js +20 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/wake.d.ts +33 -0
- package/lib/wake.js +112 -0
- package/package.json +8 -7
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
`
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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.
|
|
30
|
-
"@kokuin/token": "^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.
|
|
37
|
+
"@enkaku/client": "^0.21.1",
|
|
38
38
|
"@enkaku/transport": "^0.21.0",
|
|
39
|
-
"@kumiai/hub-conformance": "^0.
|
|
39
|
+
"@kumiai/hub-conformance": "^0.8.0",
|
|
40
|
+
"@kumiai/hub-wake": "^0.8.0"
|
|
40
41
|
},
|
|
41
42
|
"publishConfig": {
|
|
42
43
|
"access": "public"
|