@kumiai/hub-server 0.7.0 → 0.9.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 +19 -0
- package/lib/handlers.js +82 -9
- package/lib/hub.d.ts +11 -1
- package/lib/hub.js +1 -0
- package/lib/memoryStore.js +7 -7
- package/package.json +8 -8
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,
|
|
@@ -273,7 +286,9 @@ export function createHandlers(params) {
|
|
|
273
286
|
// retry returns `deduped`, and the block below is gated on `!deduped`.
|
|
274
287
|
let subscribers;
|
|
275
288
|
try {
|
|
276
|
-
subscribers = await store.getSubscribers(
|
|
289
|
+
subscribers = await store.getSubscribers({
|
|
290
|
+
topicID
|
|
291
|
+
});
|
|
277
292
|
} catch (error) {
|
|
278
293
|
storeErrorReporter({
|
|
279
294
|
method: 'getSubscribers',
|
|
@@ -406,7 +421,10 @@ export function createHandlers(params) {
|
|
|
406
421
|
});
|
|
407
422
|
}
|
|
408
423
|
try {
|
|
409
|
-
await store.unsubscribe(
|
|
424
|
+
await store.unsubscribe({
|
|
425
|
+
subscriberDID: clientDID,
|
|
426
|
+
topicID
|
|
427
|
+
});
|
|
410
428
|
} catch (error) {
|
|
411
429
|
rethrowAsHandlerError(error);
|
|
412
430
|
}
|
|
@@ -417,7 +435,43 @@ export function createHandlers(params) {
|
|
|
417
435
|
'hub/v1/receive': async (ctx)=>{
|
|
418
436
|
const clientDID = getClientDID(ctx);
|
|
419
437
|
const { after } = ctx.param ?? {};
|
|
438
|
+
const connectDecision = normalizeAuthorizeDecision(await authorize({
|
|
439
|
+
action: 'receive',
|
|
440
|
+
did: clientDID
|
|
441
|
+
}));
|
|
442
|
+
if (!connectDecision.allow) {
|
|
443
|
+
throw new HandlerError({
|
|
444
|
+
code: HUB_ERROR_CODES.authorizationDenied,
|
|
445
|
+
message: connectDecision.reason ?? 'Not authorized to receive'
|
|
446
|
+
});
|
|
447
|
+
}
|
|
420
448
|
registry.register(clientDID);
|
|
449
|
+
// Per-(did, topicID) delivery-authorization cache, local to this channel (torn down with it).
|
|
450
|
+
// Both allow AND deny are cached for the TTL, so re-grant is not immediate: a topic that was
|
|
451
|
+
// just re-authorized keeps being denied for up to `receiveAuthCacheTTL` ms, and a frame denied
|
|
452
|
+
// in-session redelivers on the recipient's next connect.
|
|
453
|
+
const authCache = new Map();
|
|
454
|
+
const gate = async (topicID)=>{
|
|
455
|
+
const key = `${clientDID}${topicID}`;
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
const cached = authCache.get(key);
|
|
458
|
+
if (cached != null && cached.expiresAt > now) return cached.allow;
|
|
459
|
+
const decision = normalizeAuthorizeDecision(await authorize({
|
|
460
|
+
action: 'receive/deliver',
|
|
461
|
+
did: clientDID,
|
|
462
|
+
topicID
|
|
463
|
+
}));
|
|
464
|
+
if (receiveAuthCacheTTL > 0) {
|
|
465
|
+
// Expiry is measured from when the decision is stored, AFTER the hook resolves — so the
|
|
466
|
+
// reuse window excludes the hook's own execution time. Re-read the clock here rather than
|
|
467
|
+
// reusing `now` (captured before the await).
|
|
468
|
+
authCache.set(key, {
|
|
469
|
+
allow: decision.allow,
|
|
470
|
+
expiresAt: Date.now() + receiveAuthCacheTTL
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return decision.allow;
|
|
474
|
+
};
|
|
421
475
|
const writer = ctx.writable.getWriter();
|
|
422
476
|
const reader = ctx.readable.getReader();
|
|
423
477
|
let endEvicted;
|
|
@@ -464,6 +518,7 @@ export function createHandlers(params) {
|
|
|
464
518
|
writeChain = writeChain.then(async ()=>{
|
|
465
519
|
if (tornDown) return;
|
|
466
520
|
try {
|
|
521
|
+
if (!await gate(frame.topicID)) return; // denied: skip, leave pending, no ack
|
|
467
522
|
await writer.ready;
|
|
468
523
|
await writer.write(frame);
|
|
469
524
|
if (frame.sequenceID > lastServed) lastServed = frame.sequenceID;
|
|
@@ -634,9 +689,16 @@ export function createHandlers(params) {
|
|
|
634
689
|
// The last-resort path has no cap to cross: the slot is one entry, replaced in place.
|
|
635
690
|
try {
|
|
636
691
|
if (lastResortPackage != null) {
|
|
637
|
-
await store.storeLastResortKeyPackage(
|
|
692
|
+
await store.storeLastResortKeyPackage({
|
|
693
|
+
ownerDID: clientDID,
|
|
694
|
+
keyPackage: lastResortPackage
|
|
695
|
+
});
|
|
638
696
|
} else {
|
|
639
|
-
await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage(
|
|
697
|
+
await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage({
|
|
698
|
+
ownerDID: clientDID,
|
|
699
|
+
keyPackage: kp,
|
|
700
|
+
notAfter
|
|
701
|
+
})));
|
|
640
702
|
}
|
|
641
703
|
} catch (error) {
|
|
642
704
|
rethrowAsHandlerError(error);
|
|
@@ -695,7 +757,9 @@ export function createHandlers(params) {
|
|
|
695
757
|
let lastResort = null;
|
|
696
758
|
let readFailure;
|
|
697
759
|
try {
|
|
698
|
-
lastResort = await store.fetchLastResortKeyPackage(
|
|
760
|
+
lastResort = await store.fetchLastResortKeyPackage({
|
|
761
|
+
ownerDID: targetDID
|
|
762
|
+
});
|
|
699
763
|
} catch (error) {
|
|
700
764
|
readFailure = error;
|
|
701
765
|
}
|
|
@@ -728,7 +792,10 @@ export function createHandlers(params) {
|
|
|
728
792
|
// the request when nothing has been consumed and there is therefore nothing to lose.
|
|
729
793
|
let consumed;
|
|
730
794
|
try {
|
|
731
|
-
consumed = await store.fetchKeyPackages(
|
|
795
|
+
consumed = await store.fetchKeyPackages({
|
|
796
|
+
ownerDID: targetDID,
|
|
797
|
+
count: cappedCount
|
|
798
|
+
});
|
|
732
799
|
} catch (error) {
|
|
733
800
|
rethrowAsHandlerError(error);
|
|
734
801
|
}
|
|
@@ -737,7 +804,9 @@ export function createHandlers(params) {
|
|
|
737
804
|
};
|
|
738
805
|
let lastResort = null;
|
|
739
806
|
try {
|
|
740
|
-
lastResort = await store.fetchLastResortKeyPackage(
|
|
807
|
+
lastResort = await store.fetchLastResortKeyPackage({
|
|
808
|
+
ownerDID: targetDID
|
|
809
|
+
});
|
|
741
810
|
} catch (error) {
|
|
742
811
|
// Nothing consumed, so nothing is lost by surfacing it. Otherwise the top-up was a bonus
|
|
743
812
|
// the caller never paid for: hand back what the store has already given up.
|
|
@@ -784,8 +853,12 @@ export function createHandlers(params) {
|
|
|
784
853
|
// Two independent reads for one owner: concurrent, since neither informs the other.
|
|
785
854
|
;
|
|
786
855
|
[count, stored] = await Promise.all([
|
|
787
|
-
store.countKeyPackages(
|
|
788
|
-
|
|
856
|
+
store.countKeyPackages({
|
|
857
|
+
ownerDID: clientDID
|
|
858
|
+
}),
|
|
859
|
+
store.fetchLastResortKeyPackage({
|
|
860
|
+
ownerDID: clientDID
|
|
861
|
+
})
|
|
789
862
|
]);
|
|
790
863
|
} catch (error) {
|
|
791
864
|
rethrowAsHandlerError(error);
|
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
|
-
/**
|
|
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/lib/memoryStore.js
CHANGED
|
@@ -360,7 +360,7 @@ function isLive(entry, nowSeconds) {
|
|
|
360
360
|
}
|
|
361
361
|
ownTopics.add(params.topicID);
|
|
362
362
|
},
|
|
363
|
-
async unsubscribe (subscriberDID, topicID) {
|
|
363
|
+
async unsubscribe ({ subscriberDID, topicID }) {
|
|
364
364
|
const subscribers = subscriptions.get(topicID);
|
|
365
365
|
if (subscribers != null) {
|
|
366
366
|
subscribers.delete(subscriberDID);
|
|
@@ -386,13 +386,13 @@ function isLive(entry, nowSeconds) {
|
|
|
386
386
|
}
|
|
387
387
|
}
|
|
388
388
|
},
|
|
389
|
-
async getSubscribers (topicID) {
|
|
389
|
+
async getSubscribers ({ topicID }) {
|
|
390
390
|
const subscribers = subscriptions.get(topicID);
|
|
391
391
|
return subscribers == null ? [] : [
|
|
392
392
|
...subscribers.keys()
|
|
393
393
|
];
|
|
394
394
|
},
|
|
395
|
-
async storeKeyPackage (ownerDID, keyPackage, notAfter) {
|
|
395
|
+
async storeKeyPackage ({ ownerDID, keyPackage, notAfter }) {
|
|
396
396
|
let packages = keyPackages.get(ownerDID);
|
|
397
397
|
if (packages == null) {
|
|
398
398
|
packages = [];
|
|
@@ -417,7 +417,7 @@ function isLive(entry, nowSeconds) {
|
|
|
417
417
|
} : {}
|
|
418
418
|
});
|
|
419
419
|
},
|
|
420
|
-
async fetchKeyPackages (ownerDID, count) {
|
|
420
|
+
async fetchKeyPackages ({ ownerDID, count }) {
|
|
421
421
|
const packages = keyPackages.get(ownerDID);
|
|
422
422
|
if (packages == null || packages.length === 0) return [];
|
|
423
423
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
@@ -432,16 +432,16 @@ function isLive(entry, nowSeconds) {
|
|
|
432
432
|
}
|
|
433
433
|
return served;
|
|
434
434
|
},
|
|
435
|
-
async countKeyPackages (ownerDID) {
|
|
435
|
+
async countKeyPackages ({ ownerDID }) {
|
|
436
436
|
const packages = keyPackages.get(ownerDID);
|
|
437
437
|
if (packages == null) return 0;
|
|
438
438
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
439
439
|
return packages.filter((entry)=>isLive(entry, nowSeconds)).length;
|
|
440
440
|
},
|
|
441
|
-
async storeLastResortKeyPackage (ownerDID, keyPackage) {
|
|
441
|
+
async storeLastResortKeyPackage ({ ownerDID, keyPackage }) {
|
|
442
442
|
lastResortKeyPackages.set(ownerDID, keyPackage);
|
|
443
443
|
},
|
|
444
|
-
async fetchLastResortKeyPackage (ownerDID) {
|
|
444
|
+
async fetchLastResortKeyPackage ({ ownerDID }) {
|
|
445
445
|
// No splice, ever. Reuse is the point here — this package is marked last-resort in MLS, so
|
|
446
446
|
// serving it again is by design rather than the init-key reuse `fetchKeyPackages` avoids.
|
|
447
447
|
return lastResortKeyPackages.get(ownerDID) ?? null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kumiai/hub-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Hub server for Enkaku E2EE group message routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hub",
|
|
@@ -25,19 +25,19 @@
|
|
|
25
25
|
"lib/*"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@enkaku/protocol": "^0.21.
|
|
29
|
-
"@enkaku/server": "^0.21.
|
|
28
|
+
"@enkaku/protocol": "^0.21.1",
|
|
29
|
+
"@enkaku/server": "^0.21.2",
|
|
30
30
|
"@kokuin/token": "^0.5.0",
|
|
31
|
+
"@kumiai/hub-protocol": "^0.9.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.
|
|
37
|
+
"@enkaku/client": "^0.21.2",
|
|
38
38
|
"@enkaku/transport": "^0.21.0",
|
|
39
|
-
"@kumiai/hub-conformance": "^0.
|
|
40
|
-
"@kumiai/hub-wake": "^0.
|
|
39
|
+
"@kumiai/hub-conformance": "^0.9.0",
|
|
40
|
+
"@kumiai/hub-wake": "^0.9.0"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public"
|