@kumiai/hub-server 0.3.0 → 0.4.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 +84 -0
- package/lib/handlers.d.ts +55 -2
- package/lib/handlers.js +92 -35
- package/lib/hub.d.ts +5 -2
- package/lib/hub.js +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/memoryStore.js +23 -12
- package/lib/registry.d.ts +31 -2
- package/lib/registry.js +41 -10
- package/package.json +15 -17
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @kumiai/hub-server
|
|
2
|
+
|
|
3
|
+
The hub server: `@kumiai/hub-protocol`'s procedure handlers wired to an `@enkaku` server, a live
|
|
4
|
+
client registry for push fan-out, and an in-memory `HubStore` for development and tests.
|
|
5
|
+
|
|
6
|
+
## Exports
|
|
7
|
+
|
|
8
|
+
- `createHub({ transport, store, identity, ... })` — the whole server: handlers, registry, access
|
|
9
|
+
rules, rate limits, and an optional scheduled purge. Returns `{ registry, server }`.
|
|
10
|
+
- `createHandlers({ registry, store, ... })` — the handlers alone, for a host assembling its own
|
|
11
|
+
server.
|
|
12
|
+
- `createMemoryStore({ maxDepth?, retention? })` — an in-memory `HubStore`. For testing and
|
|
13
|
+
development; it is also what runs the `@kumiai/hub-conformance` suites in this repo.
|
|
14
|
+
- `HubClientRegistry` — the live-connection table.
|
|
15
|
+
- `createRateLimiter`, `DEFAULT_RATE_LIMITS`, `DEFAULT_KEYPACKAGE_FETCH_LIMITS`,
|
|
16
|
+
`DEFAULT_HUB_ACCESS_RULES`.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { createHub, createMemoryStore } from '@kumiai/hub-server'
|
|
20
|
+
|
|
21
|
+
const { server } = createHub({ transport, identity, store: createMemoryStore() })
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## The hub is blind, and identity comes only from the signature
|
|
25
|
+
|
|
26
|
+
Topic IDs are opaque and payloads are ciphertext the hub never opens. Every handler takes the caller
|
|
27
|
+
DID from the **verified issuer** of the signed message, never from a wire field — so
|
|
28
|
+
`hub/v1/topic/fetch` cannot be pointed at someone else's subscription, and a publish cannot claim
|
|
29
|
+
another sender. The `identity` param is required for that reason.
|
|
30
|
+
|
|
31
|
+
Authorization is two layers, and only the second one knows about topics: `accessRules` gate the
|
|
32
|
+
procedures (the default lets any authenticated DID call them), and the optional
|
|
33
|
+
`authorize(request)` hook decides publish and subscribe per topic. It takes one discriminated
|
|
34
|
+
`AuthorizeRequest` — narrow on `request.action` — and returns `AuthorizeDecision`, either a boolean
|
|
35
|
+
or `{ allow, reason?, code?, retryAfterMs? }`. `AuthorizeRequest` names six actions, but only
|
|
36
|
+
`publish` and `subscribe` are dispatched today. The default allows any authenticated DID.
|
|
37
|
+
|
|
38
|
+
## Durable subscription, ephemeral connection
|
|
39
|
+
|
|
40
|
+
Subscription state lives in the store. `HubClientRegistry` holds only currently-connected clients and
|
|
41
|
+
their live `hub/v1/receive` writers, so it routes push fan-out and nothing else — a restart loses no
|
|
42
|
+
subscription.
|
|
43
|
+
|
|
44
|
+
Binding a receive channel **evicts** whatever held the lane for that DID. A reconnect happens because
|
|
45
|
+
the old connection broke and the server learns that last, so the stale writer must give way to the
|
|
46
|
+
live one, not the other way round. The evicted channel is resolved rather than thrown: being replaced
|
|
47
|
+
is not its error, and the client that replaced it is the same client.
|
|
48
|
+
|
|
49
|
+
`hub/v1/receive` is always added to the server's `longLivedProcedures`, so open mailbox channels are
|
|
50
|
+
exempt from `controllerTimeoutMs` and from the `maxConcurrentHandlers` cap. A host passing its own
|
|
51
|
+
`limits` does not need to remember this.
|
|
52
|
+
|
|
53
|
+
## What live fan-out does and does not push
|
|
54
|
+
|
|
55
|
+
A **deduped** publish fans out to nobody. It appended nothing — the frame was already accepted and
|
|
56
|
+
already delivered to whoever was subscribed then, and its sequenceID may since have been acked and
|
|
57
|
+
its delivery row removed — so re-running the loop would push a frame every current subscriber has
|
|
58
|
+
already applied, named by a dead sequenceID. Fan-out is for a genuine append only.
|
|
59
|
+
|
|
60
|
+
The sender is excluded from its own fan-out. `hub/v1/topic/fetch` makes no such exclusion — a topic's
|
|
61
|
+
log holds every log-class frame including the caller's own — so a reader that must not see its own
|
|
62
|
+
messages twice filters them itself, as `@kumiai/rpc`'s drain does.
|
|
63
|
+
|
|
64
|
+
A pushed frame carries `logPosition` **only** when it is log-class, and the key is spread in rather
|
|
65
|
+
than assigned, because `logPosition: undefined` becomes a present key with a falsy value once it is
|
|
66
|
+
off the wire — and the entire point of the field is that a reader can tell "no place in any log" from
|
|
67
|
+
a place.
|
|
68
|
+
|
|
69
|
+
## Retention: the store refuses, the hub schedules
|
|
70
|
+
|
|
71
|
+
Two different knobs, easily confused. `createHub`'s `purge.olderThan` (default 7 days) is the **age
|
|
72
|
+
bound** the scheduled sweep applies to a topic no subscriber asked to keep longer; the sweep runs on
|
|
73
|
+
`purge.interval` (default 1 hour) and stops with the server. The **ceiling** on what a subscriber may
|
|
74
|
+
request belongs to the store, because the store is what refuses the subscribe —
|
|
75
|
+
`createMemoryStore`'s is 30 days, finite by design, since an unbounded ceiling lets any subscriber
|
|
76
|
+
pin a topic's frames forever with `subscribe({ retention: 2 ** 31 })`. A subscribe above the ceiling
|
|
77
|
+
is refused with `RetentionExceededError`, never clamped.
|
|
78
|
+
|
|
79
|
+
`createMemoryStore`'s `maxDepth` (default 1000) evicts the oldest **log** frames beyond that count.
|
|
80
|
+
Mailbox frames on the same topic are not counted, so a member cannot evict the log with a mailbox
|
|
81
|
+
flood.
|
|
82
|
+
|
|
83
|
+
Publish rate limits are per DID (20/s, burst 50) and per topic (100/s, burst 200), merged over
|
|
84
|
+
`DEFAULT_RATE_LIMITS`; `hub/v1/keypackage/fetch` has its own request quota per requester DID.
|
package/lib/handlers.d.ts
CHANGED
|
@@ -2,8 +2,61 @@ import { type ProcedureHandlers } from '@enkaku/server';
|
|
|
2
2
|
import type { HubProtocol, HubStore } from '@kumiai/hub-protocol';
|
|
3
3
|
import { type RateLimitConfig } from './rateLimit.js';
|
|
4
4
|
import type { HubClientRegistry } from './registry.js';
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* A single request to authorize. All six variants ship even though only `publish` and
|
|
7
|
+
* `subscribe` are currently enforced: the union is the exhaustive-switch surface a host's own
|
|
8
|
+
* `switch (req.action)` closes over, so adding a seventh variant later is the compatibility break
|
|
9
|
+
* this type exists to avoid.
|
|
10
|
+
*
|
|
11
|
+
* A host's `switch` need not handle every action today — an unrecognized action should default to
|
|
12
|
+
* allow, so a hook written before a new variant shipped doesn't silently start refusing a
|
|
13
|
+
* procedure that was previously ungated.
|
|
14
|
+
*/
|
|
15
|
+
export type AuthorizeRequest = {
|
|
16
|
+
action: 'publish';
|
|
17
|
+
did: string;
|
|
18
|
+
topicID: string;
|
|
19
|
+
retain: 'log' | 'mailbox';
|
|
20
|
+
payloadSize: number;
|
|
21
|
+
} | {
|
|
22
|
+
action: 'subscribe';
|
|
23
|
+
did: string;
|
|
24
|
+
topicID: string;
|
|
25
|
+
retention?: number;
|
|
26
|
+
} | {
|
|
27
|
+
action: 'unsubscribe';
|
|
28
|
+
did: string;
|
|
29
|
+
topicID: string;
|
|
30
|
+
} | {
|
|
31
|
+
action: 'topic/fetch';
|
|
32
|
+
did: string;
|
|
33
|
+
topicID: string;
|
|
34
|
+
} | {
|
|
35
|
+
action: 'keypackage/upload';
|
|
36
|
+
did: string;
|
|
37
|
+
count: number;
|
|
38
|
+
} | {
|
|
39
|
+
action: 'keypackage/fetch';
|
|
40
|
+
did: string;
|
|
41
|
+
targetDID: string;
|
|
42
|
+
count: number;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A plain `boolean` is shorthand for `{ allow: boolean }`, with no reason, code, or retry hint.
|
|
46
|
+
*
|
|
47
|
+
* **`reason` is the only field a caller sees today.** `code` and `retryAfterMs` are reserved for
|
|
48
|
+
* enforcement that hasn't been built yet — a hook may return them, but both are silently dropped
|
|
49
|
+
* before the refusal reaches the client. They ship now because widening this union later is the
|
|
50
|
+
* break the surface exists to avoid; until enforcement lands, don't depend on either reaching a
|
|
51
|
+
* caller.
|
|
52
|
+
*/
|
|
53
|
+
export type AuthorizeDecision = boolean | {
|
|
54
|
+
allow: boolean;
|
|
55
|
+
reason?: string;
|
|
56
|
+
code?: string;
|
|
57
|
+
retryAfterMs?: number;
|
|
58
|
+
};
|
|
59
|
+
export type AuthorizeHook = (req: AuthorizeRequest) => AuthorizeDecision | Promise<AuthorizeDecision>;
|
|
7
60
|
export type HubRateLimits = {
|
|
8
61
|
perDID: RateLimitConfig;
|
|
9
62
|
perTopic: RateLimitConfig;
|
package/lib/handlers.js
CHANGED
|
@@ -2,6 +2,11 @@ import { HandlerError } from '@enkaku/server';
|
|
|
2
2
|
import { hubErrorCodeOf } from '@kumiai/hub-protocol';
|
|
3
3
|
import { fromB64, toB64 } from '@sozai/codec';
|
|
4
4
|
import { createRateLimiter } from './rateLimit.js';
|
|
5
|
+
function normalizeAuthorizeDecision(decision) {
|
|
6
|
+
return typeof decision === 'boolean' ? {
|
|
7
|
+
allow: decision
|
|
8
|
+
} : decision;
|
|
9
|
+
}
|
|
5
10
|
export const DEFAULT_RATE_LIMITS = {
|
|
6
11
|
perDID: {
|
|
7
12
|
rate: 20,
|
|
@@ -81,13 +86,32 @@ export function createHandlers(params) {
|
|
|
81
86
|
window.count++;
|
|
82
87
|
}
|
|
83
88
|
return {
|
|
84
|
-
'hub/publish': async (ctx)=>{
|
|
89
|
+
'hub/v1/publish': async (ctx)=>{
|
|
85
90
|
const { topicID, payload } = ctx.param;
|
|
86
91
|
const senderDID = getClientDID(ctx);
|
|
87
|
-
|
|
92
|
+
// Decoded before authorize (not after) so the hook can see the real size. Two consequences
|
|
93
|
+
// are deliberate: an unauthorized caller sending malformed base64 gets a decode error instead
|
|
94
|
+
// of EK02, and decoding runs before both rate limiters, so oversized payloads are decoded
|
|
95
|
+
// before being throttled (bounded by the transport's own message limit). Deriving the size
|
|
96
|
+
// arithmetically from the base64 length instead was rejected — it drifts from
|
|
97
|
+
// `@sozai/codec`'s actual decoding and can hand the hook a wrong number.
|
|
98
|
+
//
|
|
99
|
+
// `retain` defaults to 'mailbox' here, matching the store's own default (`memoryStore.ts`) —
|
|
100
|
+
// the hook must never see an absent retain, since `AuthorizeRequest`'s `publish` variant
|
|
101
|
+
// declares it required.
|
|
102
|
+
const payloadBytes = fromB64(payload);
|
|
103
|
+
const retain = ctx.param.retain ?? 'mailbox';
|
|
104
|
+
const decision = normalizeAuthorizeDecision(await authorize({
|
|
105
|
+
action: 'publish',
|
|
106
|
+
did: senderDID,
|
|
107
|
+
topicID,
|
|
108
|
+
retain,
|
|
109
|
+
payloadSize: payloadBytes.length
|
|
110
|
+
}));
|
|
111
|
+
if (!decision.allow) {
|
|
88
112
|
throw new HandlerError({
|
|
89
113
|
code: 'EK02',
|
|
90
|
-
message: 'Not authorized to publish to topic'
|
|
114
|
+
message: decision.reason ?? 'Not authorized to publish to topic'
|
|
91
115
|
});
|
|
92
116
|
}
|
|
93
117
|
if (!didLimiter.tryConsume(senderDID)) {
|
|
@@ -102,7 +126,6 @@ export function createHandlers(params) {
|
|
|
102
126
|
message: 'Publish rate limit exceeded for topic'
|
|
103
127
|
});
|
|
104
128
|
}
|
|
105
|
-
const payloadBytes = fromB64(payload);
|
|
106
129
|
let sequenceID;
|
|
107
130
|
let deduped;
|
|
108
131
|
try {
|
|
@@ -123,11 +146,16 @@ export function createHandlers(params) {
|
|
|
123
146
|
} catch (error) {
|
|
124
147
|
rethrowAsHandlerError(error);
|
|
125
148
|
}
|
|
126
|
-
// A deduped publish appended nothing:
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// applied, named by a dead sequenceID. Live fan-out is for a genuine append only.
|
|
149
|
+
// A deduped publish appended nothing: already fanned out to whoever was subscribed then, and
|
|
150
|
+
// its sequenceID may since be acked and its delivery row gone. Re-running would push a frame
|
|
151
|
+
// every current subscriber already has, named by a dead sequenceID.
|
|
130
152
|
if (!deduped) {
|
|
153
|
+
// Only a log-class publish carries a logPosition. This sequenceID IS the log position (the
|
|
154
|
+
// store just minted it for this accepted append); a mailbox publish has none, since its
|
|
155
|
+
// recipient's own delivery-queue position isn't usable as a durable log cursor.
|
|
156
|
+
const logPosition = ctx.param.retain === 'log' ? {
|
|
157
|
+
logPosition: sequenceID
|
|
158
|
+
} : {};
|
|
131
159
|
// Live-deliver to currently-connected subscribers (minus the sender).
|
|
132
160
|
const subscribers = await store.getSubscribers(topicID);
|
|
133
161
|
for (const recipientDID of subscribers){
|
|
@@ -138,7 +166,8 @@ export function createHandlers(params) {
|
|
|
138
166
|
sequenceID,
|
|
139
167
|
senderDID,
|
|
140
168
|
topicID,
|
|
141
|
-
payload: payloadBytes
|
|
169
|
+
payload: payloadBytes,
|
|
170
|
+
...logPosition
|
|
142
171
|
});
|
|
143
172
|
}
|
|
144
173
|
}
|
|
@@ -147,20 +176,26 @@ export function createHandlers(params) {
|
|
|
147
176
|
sequenceID
|
|
148
177
|
};
|
|
149
178
|
},
|
|
150
|
-
'hub/subscribe': async (ctx)=>{
|
|
151
|
-
const { topicID } = ctx.param;
|
|
179
|
+
'hub/v1/subscribe': async (ctx)=>{
|
|
180
|
+
const { topicID, retention } = ctx.param;
|
|
152
181
|
const clientDID = getClientDID(ctx);
|
|
153
|
-
|
|
182
|
+
const decision = normalizeAuthorizeDecision(await authorize({
|
|
183
|
+
action: 'subscribe',
|
|
184
|
+
did: clientDID,
|
|
185
|
+
topicID,
|
|
186
|
+
retention
|
|
187
|
+
}));
|
|
188
|
+
if (!decision.allow) {
|
|
154
189
|
throw new HandlerError({
|
|
155
190
|
code: 'EK02',
|
|
156
|
-
message: 'Not authorized to subscribe to topic'
|
|
191
|
+
message: decision.reason ?? 'Not authorized to subscribe to topic'
|
|
157
192
|
});
|
|
158
193
|
}
|
|
159
194
|
try {
|
|
160
195
|
await store.subscribe({
|
|
161
196
|
subscriberDID: clientDID,
|
|
162
197
|
topicID,
|
|
163
|
-
retention
|
|
198
|
+
retention
|
|
164
199
|
});
|
|
165
200
|
} catch (error) {
|
|
166
201
|
rethrowAsHandlerError(error);
|
|
@@ -169,10 +204,10 @@ export function createHandlers(params) {
|
|
|
169
204
|
subscribed: true
|
|
170
205
|
};
|
|
171
206
|
},
|
|
172
|
-
'hub/topic/fetch': async (ctx)=>{
|
|
207
|
+
'hub/v1/topic/fetch': async (ctx)=>{
|
|
173
208
|
const { topicID, after, limit } = ctx.param;
|
|
174
|
-
//
|
|
175
|
-
//
|
|
209
|
+
// subscriberDID is the authenticated caller (verified issuer), never a wire field — or any
|
|
210
|
+
// member could read another's topic log by naming them.
|
|
176
211
|
const subscriberDID = getClientDID(ctx);
|
|
177
212
|
try {
|
|
178
213
|
const result = await store.fetchTopic({
|
|
@@ -195,7 +230,7 @@ export function createHandlers(params) {
|
|
|
195
230
|
rethrowAsHandlerError(error);
|
|
196
231
|
}
|
|
197
232
|
},
|
|
198
|
-
'hub/unsubscribe': async (ctx)=>{
|
|
233
|
+
'hub/v1/unsubscribe': async (ctx)=>{
|
|
199
234
|
const { topicID } = ctx.param;
|
|
200
235
|
const clientDID = getClientDID(ctx);
|
|
201
236
|
await store.unsubscribe(clientDID, topicID);
|
|
@@ -203,27 +238,40 @@ export function createHandlers(params) {
|
|
|
203
238
|
unsubscribed: true
|
|
204
239
|
};
|
|
205
240
|
},
|
|
206
|
-
'hub/receive': async (ctx)=>{
|
|
241
|
+
'hub/v1/receive': async (ctx)=>{
|
|
207
242
|
const clientDID = getClientDID(ctx);
|
|
208
243
|
const { after } = ctx.param ?? {};
|
|
209
244
|
registry.register(clientDID);
|
|
210
|
-
if (registry.isWriterBound(clientDID)) {
|
|
211
|
-
throw new HandlerError({
|
|
212
|
-
code: 'EK01',
|
|
213
|
-
message: `receive writer already bound for DID ${clientDID}`
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
245
|
const writer = ctx.writable.getWriter();
|
|
217
246
|
const reader = ctx.readable.getReader();
|
|
247
|
+
// Ends this channel when a newer one for the same DID takes the lane. Resolved, not thrown —
|
|
248
|
+
// replacement isn't this channel's error, and it's the same client that replaced it.
|
|
249
|
+
let endEvicted;
|
|
250
|
+
const evictedHere = new Promise((resolve)=>{
|
|
251
|
+
endEvicted = resolve;
|
|
252
|
+
});
|
|
253
|
+
let receiveToken;
|
|
218
254
|
try {
|
|
219
|
-
|
|
255
|
+
// Binding evicts whatever held the lane: a reconnect means the old connection broke and the
|
|
256
|
+
// server learns last, so the stale writer must give way to the live one. See
|
|
257
|
+
// `HubClientRegistry.bindReceiveWriter`.
|
|
258
|
+
const { token, evicted } = registry.bindReceiveWriter(clientDID, (message)=>{
|
|
220
259
|
writer.write({
|
|
221
260
|
sequenceID: message.sequenceID,
|
|
222
261
|
senderDID: message.senderDID,
|
|
223
262
|
topicID: message.topicID,
|
|
224
|
-
payload: toB64(message.payload)
|
|
263
|
+
payload: toB64(message.payload),
|
|
264
|
+
// Spread, not plain assignment — `logPosition: undefined` becomes a present key
|
|
265
|
+
// once serialized, defeating the field's whole point (telling absent from present).
|
|
266
|
+
...message.logPosition != null ? {
|
|
267
|
+
logPosition: message.logPosition
|
|
268
|
+
} : {}
|
|
225
269
|
}).catch(()=>{});
|
|
226
|
-
});
|
|
270
|
+
}, ()=>endEvicted?.());
|
|
271
|
+
receiveToken = token;
|
|
272
|
+
// After the bind, so the lane is never unheld between the two — a frame arriving in that
|
|
273
|
+
// gap would have nowhere to go, and mailbox frames drop when nobody's there.
|
|
274
|
+
evicted?.();
|
|
227
275
|
let cursor = after;
|
|
228
276
|
while(true){
|
|
229
277
|
const result = await store.fetch({
|
|
@@ -236,19 +284,23 @@ export function createHandlers(params) {
|
|
|
236
284
|
sequenceID: msg.sequenceID,
|
|
237
285
|
senderDID: msg.senderDID,
|
|
238
286
|
topicID: msg.topicID,
|
|
239
|
-
payload: toB64(msg.payload)
|
|
287
|
+
payload: toB64(msg.payload),
|
|
288
|
+
...msg.logPosition != null ? {
|
|
289
|
+
logPosition: msg.logPosition
|
|
290
|
+
} : {}
|
|
240
291
|
});
|
|
241
292
|
}
|
|
242
293
|
cursor = result.cursor;
|
|
243
294
|
if (!result.hasMore) break;
|
|
244
295
|
}
|
|
245
296
|
} catch (error) {
|
|
246
|
-
registry.
|
|
297
|
+
if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
|
|
247
298
|
registry.unregisterIfIdle(clientDID);
|
|
248
299
|
reader.cancel().catch(()=>{});
|
|
249
300
|
writer.abort(error).catch(()=>{});
|
|
250
301
|
throw error;
|
|
251
302
|
}
|
|
303
|
+
const token = receiveToken;
|
|
252
304
|
void (async ()=>{
|
|
253
305
|
try {
|
|
254
306
|
while(true){
|
|
@@ -266,18 +318,23 @@ export function createHandlers(params) {
|
|
|
266
318
|
}
|
|
267
319
|
})();
|
|
268
320
|
return new Promise((resolve)=>{
|
|
269
|
-
|
|
270
|
-
|
|
321
|
+
const finish = ()=>{
|
|
322
|
+
// Token-scoped, so an already-evicted channel can't unbind the one that replaced it.
|
|
323
|
+
if (token != null) registry.releaseReceiveWriter(clientDID, token);
|
|
271
324
|
registry.unregisterIfIdle(clientDID);
|
|
272
325
|
reader.cancel().catch(()=>{});
|
|
273
326
|
writer.close().catch(()=>{});
|
|
274
327
|
resolve(undefined);
|
|
275
|
-
}
|
|
328
|
+
};
|
|
329
|
+
// Evicted by a newer channel: end this one; `finish` releases only its own token, which is
|
|
330
|
+
// no longer current.
|
|
331
|
+
void evictedHere.then(finish);
|
|
332
|
+
ctx.signal.addEventListener('abort', finish, {
|
|
276
333
|
once: true
|
|
277
334
|
});
|
|
278
335
|
});
|
|
279
336
|
},
|
|
280
|
-
'hub/keypackage/upload': async (ctx)=>{
|
|
337
|
+
'hub/v1/keypackage/upload': async (ctx)=>{
|
|
281
338
|
const { keyPackages } = ctx.param;
|
|
282
339
|
const clientDID = getClientDID(ctx);
|
|
283
340
|
await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage(clientDID, kp)));
|
|
@@ -285,7 +342,7 @@ export function createHandlers(params) {
|
|
|
285
342
|
stored: keyPackages.length
|
|
286
343
|
};
|
|
287
344
|
},
|
|
288
|
-
'hub/keypackage/fetch': async (ctx)=>{
|
|
345
|
+
'hub/v1/keypackage/fetch': async (ctx)=>{
|
|
289
346
|
const requesterDID = getClientDID(ctx);
|
|
290
347
|
assertKeyPackageFetchAllowed(requesterDID);
|
|
291
348
|
const { did, count } = ctx.param;
|
package/lib/hub.d.ts
CHANGED
|
@@ -34,12 +34,15 @@ export type CreateHubParams = {
|
|
|
34
34
|
authorize?: AuthorizeHook;
|
|
35
35
|
/** Publish rate limits. Merged over {@link DEFAULT_RATE_LIMITS}. */
|
|
36
36
|
rateLimits?: Partial<HubRateLimits>;
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Quotas applied to hub/v1/keypackage/fetch. Merged over
|
|
39
|
+
* {@link DEFAULT_KEYPACKAGE_FETCH_LIMITS}.
|
|
40
|
+
*/
|
|
38
41
|
keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
|
|
39
42
|
/** Scheduled purge of expired stored messages. Set to `false` to disable. */
|
|
40
43
|
purge?: HubPurgeOptions | false;
|
|
41
44
|
/**
|
|
42
|
-
* Server resource limits. `hub/receive` is always added to
|
|
45
|
+
* Server resource limits. `hub/v1/receive` is always added to
|
|
43
46
|
* `longLivedProcedures` so open mailbox channels are exempt from
|
|
44
47
|
* `controllerTimeoutMs` and from the `maxConcurrentHandlers` cap.
|
|
45
48
|
*/
|
package/lib/hub.js
CHANGED
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type {
|
|
1
|
+
export type { AuthorizeDecision, AuthorizeHook, AuthorizeRequest, CreateHandlersParams, HubRateLimits, KeyPackageFetchLimits, } from './handlers.js';
|
|
2
2
|
export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS } from './handlers.js';
|
|
3
3
|
export type { CreateHubParams, HubInstance, HubPurgeOptions } from './hub.js';
|
|
4
4
|
export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
|
package/lib/memoryStore.js
CHANGED
|
@@ -5,6 +5,27 @@ const DEFAULT_MAX_DEPTH = 1000;
|
|
|
5
5
|
function formatSequenceID(counter) {
|
|
6
6
|
return String(counter).padStart(12, '0');
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* The entry as a reader sees it, with the ONE piece of routing metadata a reader cannot recover for
|
|
10
|
+
* itself: where the frame sits in its topic's log.
|
|
11
|
+
*
|
|
12
|
+
* Present iff the entry is log-class, and the key is left off entirely otherwise — a mailbox frame
|
|
13
|
+
* has no place in a log, and a falsy placeholder is a position a reader's cursor would move to,
|
|
14
|
+
* skipping every log frame below it. This store mints one sequence for both, so the log position of
|
|
15
|
+
* a log-class frame IS its sequenceID here; a store that mints a separate per-recipient delivery
|
|
16
|
+
* sequence reports its log's own value, and the conformance suite pins the equality that matters —
|
|
17
|
+
* a pushed log frame's `logPosition` is the position `fetchTopic` serves that same frame at.
|
|
18
|
+
*/ function asStoredMessage(entry) {
|
|
19
|
+
return {
|
|
20
|
+
sequenceID: entry.sequenceID,
|
|
21
|
+
senderDID: entry.senderDID,
|
|
22
|
+
topicID: entry.topicID,
|
|
23
|
+
payload: entry.payload,
|
|
24
|
+
...entry.retain === 'log' ? {
|
|
25
|
+
logPosition: entry.sequenceID
|
|
26
|
+
} : {}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
8
29
|
/**
|
|
9
30
|
* In-memory HubStore for testing and development.
|
|
10
31
|
*
|
|
@@ -224,12 +245,7 @@ function formatSequenceID(counter) {
|
|
|
224
245
|
for (const sequenceID of selected){
|
|
225
246
|
const entry = entries.get(sequenceID);
|
|
226
247
|
if (entry != null) {
|
|
227
|
-
resultMessages.push(
|
|
228
|
-
sequenceID: entry.sequenceID,
|
|
229
|
-
senderDID: entry.senderDID,
|
|
230
|
-
topicID: entry.topicID,
|
|
231
|
-
payload: entry.payload
|
|
232
|
-
});
|
|
248
|
+
resultMessages.push(asStoredMessage(entry));
|
|
233
249
|
}
|
|
234
250
|
}
|
|
235
251
|
const cursor = resultMessages.length > 0 ? resultMessages[resultMessages.length - 1].sequenceID : null;
|
|
@@ -262,12 +278,7 @@ function formatSequenceID(counter) {
|
|
|
262
278
|
for (const sequenceID of limited){
|
|
263
279
|
const entry = entries.get(sequenceID);
|
|
264
280
|
if (entry != null) {
|
|
265
|
-
messages.push(
|
|
266
|
-
sequenceID: entry.sequenceID,
|
|
267
|
-
senderDID: entry.senderDID,
|
|
268
|
-
topicID: entry.topicID,
|
|
269
|
-
payload: entry.payload
|
|
270
|
-
});
|
|
281
|
+
messages.push(asStoredMessage(entry));
|
|
271
282
|
}
|
|
272
283
|
}
|
|
273
284
|
return {
|
package/lib/registry.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { StoredMessage } from '@kumiai/hub-protocol';
|
|
|
2
2
|
export type ClientEntry = {
|
|
3
3
|
did: string;
|
|
4
4
|
sendMessage: ((message: StoredMessage) => void) | null;
|
|
5
|
+
/** Ends the currently bound receive channel. Set with the writer and cleared with it. */
|
|
6
|
+
endReceive: (() => void) | null;
|
|
7
|
+
/** Identifies the binding, so a channel can only ever release its OWN. */
|
|
8
|
+
token: symbol | null;
|
|
5
9
|
};
|
|
6
10
|
/**
|
|
7
11
|
* Tracks online clients and their live receive-channel writers. Subscription
|
|
@@ -14,8 +18,33 @@ export declare class HubClientRegistry {
|
|
|
14
18
|
unregister(did: string): void;
|
|
15
19
|
/** Removes the entry only when no receive writer is bound. */
|
|
16
20
|
unregisterIfIdle(did: string): void;
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Bind this DID's receive writer, EVICTING whatever was bound before it.
|
|
23
|
+
*
|
|
24
|
+
* One writer per DID, and the newest one wins. The alternative — refuse the second — reads as
|
|
25
|
+
* the safer rule and is not: a client whose socket died has a writer the server still believes
|
|
26
|
+
* in, because nothing has told it otherwise yet, and that belief outlives the socket by however
|
|
27
|
+
* long the transport takes to notice. Refusing on it means the reconnect that the client makes
|
|
28
|
+
* precisely BECAUSE its connection dropped is the one that gets turned away, and the member has
|
|
29
|
+
* no push lane until a timeout it cannot see or influence expires. Last-writer-wins costs a
|
|
30
|
+
* live connection only when the same authenticated DID opens a second channel, which is the
|
|
31
|
+
* same client and its own business.
|
|
32
|
+
*
|
|
33
|
+
* The eviction is handed back rather than run here: ending a channel is the handler's to do,
|
|
34
|
+
* and a registry that closed streams would be doing two jobs.
|
|
35
|
+
*
|
|
36
|
+
* The TOKEN is what keeps the eviction from unbinding its successor. The evicted handler runs
|
|
37
|
+
* its own cleanup afterwards — it does not know why it ended — and a `clear(did)` there would
|
|
38
|
+
* null the writer the new channel has just bound, leaving the DID online with nothing behind
|
|
39
|
+
* it. So a release names the binding it is releasing, and one that is no longer current is a
|
|
40
|
+
* no-op.
|
|
41
|
+
*/
|
|
42
|
+
bindReceiveWriter(did: string, writer: (message: StoredMessage) => void, endReceive: () => void): {
|
|
43
|
+
token: symbol;
|
|
44
|
+
evicted: (() => void) | null;
|
|
45
|
+
};
|
|
46
|
+
/** Clear the binding this token names. A token that is no longer current is a no-op. */
|
|
47
|
+
releaseReceiveWriter(did: string, token: symbol): void;
|
|
19
48
|
getClient(did: string): ClientEntry | undefined;
|
|
20
49
|
isOnline(did: string): boolean;
|
|
21
50
|
isWriterBound(did: string): boolean;
|
package/lib/registry.js
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
}
|
|
12
12
|
const entry = {
|
|
13
13
|
did,
|
|
14
|
-
sendMessage: null
|
|
14
|
+
sendMessage: null,
|
|
15
|
+
endReceive: null,
|
|
16
|
+
token: null
|
|
15
17
|
};
|
|
16
18
|
this.#clients.set(did, entry);
|
|
17
19
|
return entry;
|
|
@@ -25,19 +27,48 @@
|
|
|
25
27
|
this.#clients.delete(did);
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Bind this DID's receive writer, EVICTING whatever was bound before it.
|
|
32
|
+
*
|
|
33
|
+
* One writer per DID, and the newest one wins. The alternative — refuse the second — reads as
|
|
34
|
+
* the safer rule and is not: a client whose socket died has a writer the server still believes
|
|
35
|
+
* in, because nothing has told it otherwise yet, and that belief outlives the socket by however
|
|
36
|
+
* long the transport takes to notice. Refusing on it means the reconnect that the client makes
|
|
37
|
+
* precisely BECAUSE its connection dropped is the one that gets turned away, and the member has
|
|
38
|
+
* no push lane until a timeout it cannot see or influence expires. Last-writer-wins costs a
|
|
39
|
+
* live connection only when the same authenticated DID opens a second channel, which is the
|
|
40
|
+
* same client and its own business.
|
|
41
|
+
*
|
|
42
|
+
* The eviction is handed back rather than run here: ending a channel is the handler's to do,
|
|
43
|
+
* and a registry that closed streams would be doing two jobs.
|
|
44
|
+
*
|
|
45
|
+
* The TOKEN is what keeps the eviction from unbinding its successor. The evicted handler runs
|
|
46
|
+
* its own cleanup afterwards — it does not know why it ended — and a `clear(did)` there would
|
|
47
|
+
* null the writer the new channel has just bound, leaving the DID online with nothing behind
|
|
48
|
+
* it. So a release names the binding it is releasing, and one that is no longer current is a
|
|
49
|
+
* no-op.
|
|
50
|
+
*/ bindReceiveWriter(did, writer, endReceive) {
|
|
29
51
|
const entry = this.#clients.get(did);
|
|
30
|
-
if (entry == null) return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
52
|
+
if (entry == null) return {
|
|
53
|
+
token: Symbol('unbound'),
|
|
54
|
+
evicted: null
|
|
55
|
+
};
|
|
56
|
+
const evicted = entry.endReceive;
|
|
57
|
+
const token = Symbol('receive');
|
|
34
58
|
entry.sendMessage = writer;
|
|
59
|
+
entry.endReceive = endReceive;
|
|
60
|
+
entry.token = token;
|
|
61
|
+
return {
|
|
62
|
+
token,
|
|
63
|
+
evicted
|
|
64
|
+
};
|
|
35
65
|
}
|
|
36
|
-
|
|
66
|
+
/** Clear the binding this token names. A token that is no longer current is a no-op. */ releaseReceiveWriter(did, token) {
|
|
37
67
|
const entry = this.#clients.get(did);
|
|
38
|
-
if (entry
|
|
39
|
-
|
|
40
|
-
|
|
68
|
+
if (entry == null || entry.token !== token) return;
|
|
69
|
+
entry.sendMessage = null;
|
|
70
|
+
entry.endReceive = null;
|
|
71
|
+
entry.token = null;
|
|
41
72
|
}
|
|
42
73
|
getClient(did) {
|
|
43
74
|
return this.#clients.get(did);
|
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kumiai/hub-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"license": "MIT",
|
|
3
|
+
"version": "0.4.1",
|
|
5
4
|
"description": "Hub server for Enkaku E2EE group message routing",
|
|
6
5
|
"keywords": [
|
|
7
6
|
"hub",
|
|
@@ -14,38 +13,37 @@
|
|
|
14
13
|
"url": "https://github.com/TairuFramework/kumiai",
|
|
15
14
|
"directory": "packages/hub-server"
|
|
16
15
|
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"sideEffects": false,
|
|
17
18
|
"type": "module",
|
|
18
|
-
"main": "lib/index.js",
|
|
19
|
-
"types": "lib/index.d.ts",
|
|
20
19
|
"exports": {
|
|
21
20
|
".": "./lib/index.js"
|
|
22
21
|
},
|
|
22
|
+
"main": "lib/index.js",
|
|
23
|
+
"types": "lib/index.d.ts",
|
|
23
24
|
"files": [
|
|
24
25
|
"lib/*"
|
|
25
26
|
],
|
|
26
|
-
"sideEffects": false,
|
|
27
27
|
"dependencies": {
|
|
28
|
+
"@enkaku/protocol": "^0.20.0",
|
|
29
|
+
"@enkaku/server": "^0.20.0",
|
|
30
|
+
"@kokuin/token": "^0.3.0",
|
|
28
31
|
"@sozai/codec": "^0.2.0",
|
|
29
32
|
"@sozai/event": "^0.1.1",
|
|
30
|
-
"@
|
|
31
|
-
"@enkaku/server": "^0.19.0",
|
|
32
|
-
"@sozai/stream": "^0.2.0",
|
|
33
|
-
"@kokuin/token": "^0.2.0",
|
|
34
|
-
"@kumiai/hub-protocol": "^0.3.0",
|
|
35
|
-
"@kumiai/mls": "^0.3.0"
|
|
33
|
+
"@kumiai/hub-protocol": "^0.4.1"
|
|
36
34
|
},
|
|
37
35
|
"devDependencies": {
|
|
38
|
-
"@enkaku/client": "^0.
|
|
39
|
-
"@enkaku/transport": "^0.
|
|
40
|
-
"@kumiai/hub-conformance": "^0.
|
|
36
|
+
"@enkaku/client": "^0.20.0",
|
|
37
|
+
"@enkaku/transport": "^0.20.0",
|
|
38
|
+
"@kumiai/hub-conformance": "^0.4.1"
|
|
41
39
|
},
|
|
42
40
|
"scripts": {
|
|
41
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
43
42
|
"build:clean": "del lib",
|
|
44
43
|
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
45
44
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
46
|
-
"
|
|
45
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
47
46
|
"test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
|
|
48
|
-
"test:unit": "vitest run"
|
|
49
|
-
"test": "pnpm run test:types && pnpm run test:unit"
|
|
47
|
+
"test:unit": "vitest run"
|
|
50
48
|
}
|
|
51
49
|
}
|