@kumiai/hub-server 0.4.1 → 0.5.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 +38 -1
- package/lib/handlers.d.ts +67 -3
- package/lib/handlers.js +480 -64
- package/lib/hub.d.ts +6 -1
- package/lib/hub.js +13 -4
- package/lib/index.d.ts +2 -2
- package/lib/index.js +1 -1
- package/lib/memoryStore.d.ts +4 -0
- package/lib/memoryStore.js +70 -4
- package/lib/rateLimit.d.ts +4 -0
- package/lib/rateLimit.js +23 -2
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ client registry for push fan-out, and an in-memory `HubStore` for development an
|
|
|
14
14
|
- `HubClientRegistry` — the live-connection table.
|
|
15
15
|
- `createRateLimiter`, `DEFAULT_RATE_LIMITS`, `DEFAULT_KEYPACKAGE_FETCH_LIMITS`,
|
|
16
16
|
`DEFAULT_HUB_ACCESS_RULES`.
|
|
17
|
+
- `HubStoreErrorEvent`, `HubStoreErrorHook` — the `onStoreError` event and hook types.
|
|
17
18
|
|
|
18
19
|
```ts
|
|
19
20
|
import { createHub, createMemoryStore } from '@kumiai/hub-server'
|
|
@@ -32,7 +33,7 @@ Authorization is two layers, and only the second one knows about topics: `access
|
|
|
32
33
|
procedures (the default lets any authenticated DID call them), and the optional
|
|
33
34
|
`authorize(request)` hook decides publish and subscribe per topic. It takes one discriminated
|
|
34
35
|
`AuthorizeRequest` — narrow on `request.action` — and returns `AuthorizeDecision`, either a boolean
|
|
35
|
-
or `{ allow, reason?, code?, retryAfterMs? }`. `AuthorizeRequest` names
|
|
36
|
+
or `{ allow, reason?, code?, retryAfterMs? }`. `AuthorizeRequest` names seven actions, but only
|
|
36
37
|
`publish` and `subscribe` are dispatched today. The default allows any authenticated DID.
|
|
37
38
|
|
|
38
39
|
## Durable subscription, ephemeral connection
|
|
@@ -82,3 +83,39 @@ flood.
|
|
|
82
83
|
|
|
83
84
|
Publish rate limits are per DID (20/s, burst 50) and per topic (100/s, burst 200), merged over
|
|
84
85
|
`DEFAULT_RATE_LIMITS`; `hub/v1/keypackage/fetch` has its own request quota per requester DID.
|
|
86
|
+
|
|
87
|
+
## A store failure that is not a request failure
|
|
88
|
+
|
|
89
|
+
Four store operations are deliberately not allowed to fail the request they happen in:
|
|
90
|
+
|
|
91
|
+
- the **last-resort key-package top-up**, read after `fetchKeyPackages` has already consumed
|
|
92
|
+
destructively — surfacing it would destroy packages nobody received, and the client's retry
|
|
93
|
+
would burn the next batch
|
|
94
|
+
- an **ack**, where the frame simply stays pending and the client re-acks next round
|
|
95
|
+
- a scheduled **purge**, retried on the next interval
|
|
96
|
+
- the **subscriber read for live fan-out**, which runs after the publish committed its append and
|
|
97
|
+
delivery rows — every subscriber still receives the frame by pulling, and failing the request
|
|
98
|
+
would instead lose the live push for good, since the caller's `publishID` retry dedups and skips
|
|
99
|
+
fan-out
|
|
100
|
+
|
|
101
|
+
All four are correct, and all four were silent. `createHandlers` and `createHub` take
|
|
102
|
+
`onStoreError`, called with an event discriminated on `method` — the `HubStore` method that threw —
|
|
103
|
+
carrying the subject that method has: `did` for `ack` and `fetchLastResortKeyPackage`, `topicID` for
|
|
104
|
+
`getSubscribers`, neither for `purge`. Wire it to whatever an operator watches:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const { server } = createHub({
|
|
108
|
+
transport,
|
|
109
|
+
store,
|
|
110
|
+
identity,
|
|
111
|
+
onStoreError: (event) => metrics.storeFailure(event),
|
|
112
|
+
})
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Fire-and-forget — a throw from the hook is swallowed rather than allowed to fail the request.
|
|
116
|
+
Unwired, the failure is reported through `@sozai/log` under `['kumiai', 'hub-server']` at `error`
|
|
117
|
+
rather than passing silently. Pass an empty handler to silence it deliberately.
|
|
118
|
+
|
|
119
|
+
There is no throttling: a permanently broken store reports per request. logtape ships
|
|
120
|
+
`getThrottlingFilter`, so rate control belongs in the app's sink configuration where an operator
|
|
121
|
+
can tune it.
|
package/lib/handlers.d.ts
CHANGED
|
@@ -3,10 +3,9 @@ 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
5
|
/**
|
|
6
|
-
* A single request to authorize. All
|
|
6
|
+
* A single request to authorize. All seven variants ship even though only `publish` and
|
|
7
7
|
* `subscribe` are currently enforced: the union is the exhaustive-switch surface a host's own
|
|
8
|
-
* `switch (req.action)` closes over
|
|
9
|
-
* this type exists to avoid.
|
|
8
|
+
* `switch (req.action)` closes over.
|
|
10
9
|
*
|
|
11
10
|
* A host's `switch` need not handle every action today — an unrecognized action should default to
|
|
12
11
|
* allow, so a hook written before a new variant shipped doesn't silently start refusing a
|
|
@@ -35,11 +34,15 @@ export type AuthorizeRequest = {
|
|
|
35
34
|
action: 'keypackage/upload';
|
|
36
35
|
did: string;
|
|
37
36
|
count: number;
|
|
37
|
+
lastResort?: boolean;
|
|
38
38
|
} | {
|
|
39
39
|
action: 'keypackage/fetch';
|
|
40
40
|
did: string;
|
|
41
41
|
targetDID: string;
|
|
42
42
|
count: number;
|
|
43
|
+
} | {
|
|
44
|
+
action: 'keypackage/status';
|
|
45
|
+
did: string;
|
|
43
46
|
};
|
|
44
47
|
/**
|
|
45
48
|
* A plain `boolean` is shorthand for `{ allow: boolean }`, with no reason, code, or retry hint.
|
|
@@ -57,6 +60,45 @@ export type AuthorizeDecision = boolean | {
|
|
|
57
60
|
retryAfterMs?: number;
|
|
58
61
|
};
|
|
59
62
|
export type AuthorizeHook = (req: AuthorizeRequest) => AuthorizeDecision | Promise<AuthorizeDecision>;
|
|
63
|
+
/**
|
|
64
|
+
* A `HubStore` operation that failed at a point where the hub deliberately does NOT fail the
|
|
65
|
+
* request. Each of these swallows is correct — see the call sites for why — and each was, until
|
|
66
|
+
* this hook existed, completely silent.
|
|
67
|
+
*
|
|
68
|
+
* A union rather than a flat record: each variant carries the subject its own site has, so a site
|
|
69
|
+
* cannot forget one and a topic-keyed site cannot be described with a DID.
|
|
70
|
+
*/
|
|
71
|
+
export type HubStoreErrorEvent = {
|
|
72
|
+
method: 'purge';
|
|
73
|
+
error: unknown;
|
|
74
|
+
}
|
|
75
|
+
/** The DID whose ack the store refused. */
|
|
76
|
+
| {
|
|
77
|
+
method: 'ack';
|
|
78
|
+
did: string;
|
|
79
|
+
error: unknown;
|
|
80
|
+
}
|
|
81
|
+
/** The DID whose last-resort slot was being read. */
|
|
82
|
+
| {
|
|
83
|
+
method: 'fetchLastResortKeyPackage';
|
|
84
|
+
did: string;
|
|
85
|
+
error: unknown;
|
|
86
|
+
}
|
|
87
|
+
/** The topic whose subscriber list could not be read for live fan-out. */
|
|
88
|
+
| {
|
|
89
|
+
method: 'getSubscribers';
|
|
90
|
+
topicID: string;
|
|
91
|
+
error: unknown;
|
|
92
|
+
};
|
|
93
|
+
export type HubStoreErrorHook = (event: HubStoreErrorEvent) => void;
|
|
94
|
+
/**
|
|
95
|
+
* Route a swallowed store failure to the host's hook, or to the reporter when it wired none.
|
|
96
|
+
*
|
|
97
|
+
* No throttling, deliberately: a permanently broken store emits per request, and logtape ships
|
|
98
|
+
* `getThrottlingFilter`, so rate control belongs in the app's sink config where an operator can
|
|
99
|
+
* tune it rather than hard-coded here.
|
|
100
|
+
*/
|
|
101
|
+
export declare function createStoreErrorReporter(onStoreError?: HubStoreErrorHook): (event: HubStoreErrorEvent) => void;
|
|
60
102
|
export type HubRateLimits = {
|
|
61
103
|
perDID: RateLimitConfig;
|
|
62
104
|
perTopic: RateLimitConfig;
|
|
@@ -67,15 +109,37 @@ export type KeyPackageFetchLimits = {
|
|
|
67
109
|
maxCount: number;
|
|
68
110
|
/** Maximum number of fetch requests per requester DID per window. Default: 30 */
|
|
69
111
|
maxRequests: number;
|
|
112
|
+
/** Maximum number of key packages that may be consumed from ONE target DID per window,
|
|
113
|
+
* summed across all requesters. Bounds collective drain. Default: 60 */
|
|
114
|
+
maxPerTargetConsumed: number;
|
|
70
115
|
/** Rate-limit window duration in milliseconds. Default: 60000 (1 min) */
|
|
71
116
|
windowMs: number;
|
|
72
117
|
};
|
|
73
118
|
export declare const DEFAULT_KEYPACKAGE_FETCH_LIMITS: KeyPackageFetchLimits;
|
|
119
|
+
/**
|
|
120
|
+
* Max frames queued-but-unflushed on a single receive channel before it falls back to the store.
|
|
121
|
+
*
|
|
122
|
+
* Should be >= the drain's 50-frame fetch page size: the drain enqueues a whole backlog page
|
|
123
|
+
* before awaiting, so a limit below 50 would trip the cap on the very first page regardless of
|
|
124
|
+
* writer health. The default of 256 is safely above it.
|
|
125
|
+
*/
|
|
126
|
+
export declare const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
|
|
74
127
|
export type CreateHandlersParams = {
|
|
75
128
|
registry: HubClientRegistry;
|
|
76
129
|
store: HubStore;
|
|
77
130
|
authorize?: AuthorizeHook;
|
|
78
131
|
rateLimits?: Partial<HubRateLimits>;
|
|
79
132
|
keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
|
|
133
|
+
/** Max frames queued-but-unflushed on a receive channel. See {@link DEFAULT_RECEIVE_BUFFER_LIMIT}
|
|
134
|
+
* for the >= 50-frame-page floor this should respect. Default: 256 */
|
|
135
|
+
receiveBufferLimit?: number;
|
|
136
|
+
/**
|
|
137
|
+
* Called when a `HubStore` operation fails at a point where the hub deliberately does not fail
|
|
138
|
+
* the request. Fire-and-forget; a throw here is swallowed.
|
|
139
|
+
*
|
|
140
|
+
* Omitted, the failure is reported through `@sozai/log` instead of passing silently. Pass an
|
|
141
|
+
* empty handler to silence it deliberately.
|
|
142
|
+
*/
|
|
143
|
+
onStoreError?: HubStoreErrorHook;
|
|
80
144
|
};
|
|
81
145
|
export declare function createHandlers(params: CreateHandlersParams): ProcedureHandlers<HubProtocol>;
|
package/lib/handlers.js
CHANGED
|
@@ -1,12 +1,64 @@
|
|
|
1
1
|
import { HandlerError } from '@enkaku/server';
|
|
2
|
-
import { hubErrorCodeOf } from '@kumiai/hub-protocol';
|
|
2
|
+
import { HUB_ERROR_CODES, hubErrorCodeOf, InvalidPayloadError, KeyPackageFetchLimitError, keyPackageDigest } from '@kumiai/hub-protocol';
|
|
3
3
|
import { fromB64, toB64 } from '@sozai/codec';
|
|
4
|
+
import { getReporter } from '@sozai/log';
|
|
4
5
|
import { createRateLimiter } from './rateLimit.js';
|
|
5
6
|
function normalizeAuthorizeDecision(decision) {
|
|
6
7
|
return typeof decision === 'boolean' ? {
|
|
7
8
|
allow: decision
|
|
8
9
|
} : decision;
|
|
9
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* What the hub did INSTEAD of failing, and what a permanent failure costs.
|
|
13
|
+
*
|
|
14
|
+
* Keyed by `method`, but the `fetchLastResortKeyPackage` text describes the top-up call site
|
|
15
|
+
* specifically (of the three places that method is called, only that one reports). The trigger to
|
|
16
|
+
* watch for is not another `method` arriving — `getSubscribers` already did, and the method-keyed
|
|
17
|
+
* union discriminated it correctly — it's a SECOND reporting call site of the SAME method: the
|
|
18
|
+
* moment either of the other two `fetchLastResortKeyPackage` calls starts reporting, it inherits
|
|
19
|
+
* this text even where it's wrong, and the fix is a site discriminator alongside `method`.
|
|
20
|
+
*/ const STORE_ERROR_CONSEQUENCE = {
|
|
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
|
+
ack: 'The frame stays pending and the client re-acks on the next round. An ack that keeps failing ' + 'redelivers every frame forever.',
|
|
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.'
|
|
25
|
+
};
|
|
26
|
+
const reportStoreError = getReporter([
|
|
27
|
+
'kumiai',
|
|
28
|
+
'hub-server'
|
|
29
|
+
], '@kumiai/hub-server');
|
|
30
|
+
/** What the failed operation was about, for the default log line: a DID for the per-recipient
|
|
31
|
+
* methods, a topic for fan-out, nothing for a store-wide purge. */ function subjectOf(event) {
|
|
32
|
+
switch(event.method){
|
|
33
|
+
case 'purge':
|
|
34
|
+
return '';
|
|
35
|
+
case 'getSubscribers':
|
|
36
|
+
return ` on topic ${event.topicID}`;
|
|
37
|
+
case 'ack':
|
|
38
|
+
case 'fetchLastResortKeyPackage':
|
|
39
|
+
return ` for ${event.did}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Route a swallowed store failure to the host's hook, or to the reporter when it wired none.
|
|
44
|
+
*
|
|
45
|
+
* No throttling, deliberately: a permanently broken store emits per request, and logtape ships
|
|
46
|
+
* `getThrottlingFilter`, so rate control belongs in the app's sink config where an operator can
|
|
47
|
+
* tune it rather than hard-coded here.
|
|
48
|
+
*/ export function createStoreErrorReporter(onStoreError) {
|
|
49
|
+
return (event)=>{
|
|
50
|
+
if (onStoreError == null) {
|
|
51
|
+
reportStoreError(`HubStore.${event.method} failed${subjectOf(event)}. ` + `${STORE_ERROR_CONSEQUENCE[event.method]} Wire \`onStoreError\` to handle this.`, event.error);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
onStoreError(event);
|
|
56
|
+
} catch {
|
|
57
|
+
// A notice, not a dependency: a host whose own reporting throws must not fail the request
|
|
58
|
+
// that was being served correctly.
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
10
62
|
export const DEFAULT_RATE_LIMITS = {
|
|
11
63
|
perDID: {
|
|
12
64
|
rate: 20,
|
|
@@ -20,8 +72,16 @@ export const DEFAULT_RATE_LIMITS = {
|
|
|
20
72
|
export const DEFAULT_KEYPACKAGE_FETCH_LIMITS = {
|
|
21
73
|
maxCount: 10,
|
|
22
74
|
maxRequests: 30,
|
|
75
|
+
maxPerTargetConsumed: 60,
|
|
23
76
|
windowMs: 60_000
|
|
24
77
|
};
|
|
78
|
+
/**
|
|
79
|
+
* Max frames queued-but-unflushed on a single receive channel before it falls back to the store.
|
|
80
|
+
*
|
|
81
|
+
* Should be >= the drain's 50-frame fetch page size: the drain enqueues a whole backlog page
|
|
82
|
+
* before awaiting, so a limit below 50 would trip the cap on the very first page regardless of
|
|
83
|
+
* writer health. The default of 256 is safely above it.
|
|
84
|
+
*/ export const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
|
|
25
85
|
function getClientDID(ctx) {
|
|
26
86
|
const iss = ctx.message.payload.iss;
|
|
27
87
|
if (typeof iss !== 'string' || iss.length === 0) {
|
|
@@ -43,6 +103,19 @@ function getClientDID(ctx) {
|
|
|
43
103
|
}
|
|
44
104
|
throw error;
|
|
45
105
|
}
|
|
106
|
+
function toReceiveFrame(message) {
|
|
107
|
+
return {
|
|
108
|
+
sequenceID: message.sequenceID,
|
|
109
|
+
senderDID: message.senderDID,
|
|
110
|
+
topicID: message.topicID,
|
|
111
|
+
payload: toB64(message.payload),
|
|
112
|
+
// Spread, not assignment — `logPosition: undefined` would serialize as a present key, defeating
|
|
113
|
+
// the field's absent-vs-present meaning.
|
|
114
|
+
...message.logPosition != null ? {
|
|
115
|
+
logPosition: message.logPosition
|
|
116
|
+
} : {}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
46
119
|
export function createHandlers(params) {
|
|
47
120
|
const { store, registry } = params;
|
|
48
121
|
const authorize = params.authorize ?? (()=>true);
|
|
@@ -58,10 +131,14 @@ export function createHandlers(params) {
|
|
|
58
131
|
};
|
|
59
132
|
const didLimiter = createRateLimiter(rateLimits.perDID);
|
|
60
133
|
const topicLimiter = createRateLimiter(rateLimits.perTopic);
|
|
134
|
+
const receiveBufferLimit = params.receiveBufferLimit ?? DEFAULT_RECEIVE_BUFFER_LIMIT;
|
|
135
|
+
const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
|
|
61
136
|
const fetchLimits = {
|
|
62
137
|
...DEFAULT_KEYPACKAGE_FETCH_LIMITS,
|
|
63
138
|
...params.keyPackageFetchLimits
|
|
64
139
|
};
|
|
140
|
+
// Bounded by throughput x windowMs (entries expire, size > 1024 sweep drops them), not an
|
|
141
|
+
// absolute cap — the absolute per-DID bound is the store's caps, not this map.
|
|
65
142
|
const fetchWindows = new Map();
|
|
66
143
|
function assertKeyPackageFetchAllowed(requesterDID) {
|
|
67
144
|
const now = Date.now();
|
|
@@ -81,10 +158,36 @@ export function createHandlers(params) {
|
|
|
81
158
|
return;
|
|
82
159
|
}
|
|
83
160
|
if (window.count >= fetchLimits.maxRequests) {
|
|
84
|
-
throw new
|
|
161
|
+
throw new KeyPackageFetchLimitError('Key package fetch rate limit exceeded');
|
|
85
162
|
}
|
|
86
163
|
window.count++;
|
|
87
164
|
}
|
|
165
|
+
// Bounded like fetchWindows: throughput x windowMs, not an absolute cap.
|
|
166
|
+
const targetWindows = new Map();
|
|
167
|
+
/** Charge `amount` packages against the target DID's consumption window. Throws when the
|
|
168
|
+
* window's budget is spent — this bounds how fast anyone, collectively, can drain a target. */ function assertTargetConsumptionAllowed(targetDID, amount) {
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
if (targetWindows.size > 1024) {
|
|
171
|
+
for (const [did, window] of targetWindows){
|
|
172
|
+
if (window.resetAt <= now) targetWindows.delete(did);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const window = targetWindows.get(targetDID);
|
|
176
|
+
if (window == null || window.resetAt <= now) {
|
|
177
|
+
if (amount > fetchLimits.maxPerTargetConsumed) {
|
|
178
|
+
throw new KeyPackageFetchLimitError(`Key package consumption limit exceeded for target ${targetDID}`);
|
|
179
|
+
}
|
|
180
|
+
targetWindows.set(targetDID, {
|
|
181
|
+
count: amount,
|
|
182
|
+
resetAt: now + fetchLimits.windowMs
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (window.count + amount > fetchLimits.maxPerTargetConsumed) {
|
|
187
|
+
throw new KeyPackageFetchLimitError(`Key package consumption limit exceeded for target ${targetDID}`);
|
|
188
|
+
}
|
|
189
|
+
window.count += amount;
|
|
190
|
+
}
|
|
88
191
|
return {
|
|
89
192
|
'hub/v1/publish': async (ctx)=>{
|
|
90
193
|
const { topicID, payload } = ctx.param;
|
|
@@ -99,7 +202,12 @@ export function createHandlers(params) {
|
|
|
99
202
|
// `retain` defaults to 'mailbox' here, matching the store's own default (`memoryStore.ts`) —
|
|
100
203
|
// the hook must never see an absent retain, since `AuthorizeRequest`'s `publish` variant
|
|
101
204
|
// declares it required.
|
|
102
|
-
|
|
205
|
+
let payloadBytes;
|
|
206
|
+
try {
|
|
207
|
+
payloadBytes = fromB64(payload);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
rethrowAsHandlerError(new InvalidPayloadError(error instanceof Error ? error.message : 'Invalid base64 payload encoding'));
|
|
210
|
+
}
|
|
103
211
|
const retain = ctx.param.retain ?? 'mailbox';
|
|
104
212
|
const decision = normalizeAuthorizeDecision(await authorize({
|
|
105
213
|
action: 'publish',
|
|
@@ -156,8 +264,24 @@ export function createHandlers(params) {
|
|
|
156
264
|
const logPosition = ctx.param.retain === 'log' ? {
|
|
157
265
|
logPosition: sequenceID
|
|
158
266
|
} : {};
|
|
159
|
-
// Live-deliver to currently-connected subscribers (minus the sender).
|
|
160
|
-
|
|
267
|
+
// Live-deliver to currently-connected subscribers (minus the sender). A failure here must
|
|
268
|
+
// not fail the request: `publish` committed the append and its delivery rows in one
|
|
269
|
+
// transaction, so the frame stays pending in the store and reaches each subscriber on its
|
|
270
|
+
// next reconnect. Failing would also make the miss permanent — the caller's `publishID`
|
|
271
|
+
// retry returns `deduped`, and the block below is gated on `!deduped`.
|
|
272
|
+
let subscribers;
|
|
273
|
+
try {
|
|
274
|
+
subscribers = await store.getSubscribers(topicID);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
storeErrorReporter({
|
|
277
|
+
method: 'getSubscribers',
|
|
278
|
+
topicID,
|
|
279
|
+
error
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
sequenceID
|
|
283
|
+
};
|
|
284
|
+
}
|
|
161
285
|
for (const recipientDID of subscribers){
|
|
162
286
|
if (recipientDID === senderDID) continue;
|
|
163
287
|
const client = registry.getClient(recipientDID);
|
|
@@ -187,10 +311,16 @@ export function createHandlers(params) {
|
|
|
187
311
|
}));
|
|
188
312
|
if (!decision.allow) {
|
|
189
313
|
throw new HandlerError({
|
|
190
|
-
code:
|
|
314
|
+
code: HUB_ERROR_CODES.authorizationDenied,
|
|
191
315
|
message: decision.reason ?? 'Not authorized to subscribe to topic'
|
|
192
316
|
});
|
|
193
317
|
}
|
|
318
|
+
if (!didLimiter.tryConsume(clientDID)) {
|
|
319
|
+
throw new HandlerError({
|
|
320
|
+
code: 'EK01',
|
|
321
|
+
message: 'Subscribe rate limit exceeded for DID'
|
|
322
|
+
});
|
|
323
|
+
}
|
|
194
324
|
try {
|
|
195
325
|
await store.subscribe({
|
|
196
326
|
subscriberDID: clientDID,
|
|
@@ -209,6 +339,17 @@ export function createHandlers(params) {
|
|
|
209
339
|
// subscriberDID is the authenticated caller (verified issuer), never a wire field — or any
|
|
210
340
|
// member could read another's topic log by naming them.
|
|
211
341
|
const subscriberDID = getClientDID(ctx);
|
|
342
|
+
const decision = normalizeAuthorizeDecision(await authorize({
|
|
343
|
+
action: 'topic/fetch',
|
|
344
|
+
did: subscriberDID,
|
|
345
|
+
topicID
|
|
346
|
+
}));
|
|
347
|
+
if (!decision.allow) {
|
|
348
|
+
throw new HandlerError({
|
|
349
|
+
code: HUB_ERROR_CODES.authorizationDenied,
|
|
350
|
+
message: decision.reason ?? 'Not authorized to fetch topic'
|
|
351
|
+
});
|
|
352
|
+
}
|
|
212
353
|
try {
|
|
213
354
|
const result = await store.fetchTopic({
|
|
214
355
|
subscriberDID,
|
|
@@ -233,7 +374,17 @@ export function createHandlers(params) {
|
|
|
233
374
|
'hub/v1/unsubscribe': async (ctx)=>{
|
|
234
375
|
const { topicID } = ctx.param;
|
|
235
376
|
const clientDID = getClientDID(ctx);
|
|
236
|
-
|
|
377
|
+
if (!didLimiter.tryConsume(clientDID)) {
|
|
378
|
+
throw new HandlerError({
|
|
379
|
+
code: 'EK01',
|
|
380
|
+
message: 'Unsubscribe rate limit exceeded for DID'
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
await store.unsubscribe(clientDID, topicID);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
rethrowAsHandlerError(error);
|
|
387
|
+
}
|
|
237
388
|
return {
|
|
238
389
|
unsubscribed: true
|
|
239
390
|
};
|
|
@@ -244,112 +395,377 @@ export function createHandlers(params) {
|
|
|
244
395
|
registry.register(clientDID);
|
|
245
396
|
const writer = ctx.writable.getWriter();
|
|
246
397
|
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
398
|
let endEvicted;
|
|
250
399
|
const evictedHere = new Promise((resolve)=>{
|
|
251
400
|
endEvicted = resolve;
|
|
252
401
|
});
|
|
253
402
|
let receiveToken;
|
|
403
|
+
// Delivery state machine (H1) + bounded write queue (H3).
|
|
404
|
+
// - DRAINING: live pushes buffer instead of writing; the drain writes the backlog in order.
|
|
405
|
+
// - after the drain: flush buffered frames with sequenceID > lastServed (the dedup), then LIVE.
|
|
406
|
+
// - LIVE: live pushes write directly.
|
|
407
|
+
let phase = 'draining';
|
|
408
|
+
const liveBuffer = [];
|
|
409
|
+
let lastServed = '' // highest sequenceID written; '' precedes every real (zero-padded) ID
|
|
410
|
+
;
|
|
411
|
+
let pending = 0 // frames queued or mid-write but not yet flushed to the socket
|
|
412
|
+
;
|
|
413
|
+
let tornDown = false;
|
|
414
|
+
let writeChain = Promise.resolve();
|
|
415
|
+
let resolveDone = ()=>{};
|
|
416
|
+
const done = new Promise((resolve)=>{
|
|
417
|
+
resolveDone = resolve;
|
|
418
|
+
});
|
|
419
|
+
// Idempotent teardown, shared by every failure and end path. Frames stay pending in the store
|
|
420
|
+
// and redeliver on the next connect (store-and-forward).
|
|
421
|
+
function finish() {
|
|
422
|
+
if (tornDown) return;
|
|
423
|
+
tornDown = true;
|
|
424
|
+
if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
|
|
425
|
+
registry.unregisterIfIdle(clientDID);
|
|
426
|
+
reader.cancel().catch(()=>{});
|
|
427
|
+
writer.abort(new Error('receive channel torn down')).catch(()=>{});
|
|
428
|
+
resolveDone();
|
|
429
|
+
}
|
|
430
|
+
// Serialize writes one at a time (preserves order). Over the cap or on a write rejection, tear
|
|
431
|
+
// the channel down rather than swallow the error or grow without bound.
|
|
432
|
+
function pushWrite(frame) {
|
|
433
|
+
if (tornDown) return;
|
|
434
|
+
pending++;
|
|
435
|
+
if (pending > receiveBufferLimit) {
|
|
436
|
+
finish();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
writeChain = writeChain.then(async ()=>{
|
|
440
|
+
if (tornDown) return;
|
|
441
|
+
try {
|
|
442
|
+
await writer.ready;
|
|
443
|
+
await writer.write(frame);
|
|
444
|
+
if (frame.sequenceID > lastServed) lastServed = frame.sequenceID;
|
|
445
|
+
} catch {
|
|
446
|
+
finish();
|
|
447
|
+
} finally{
|
|
448
|
+
pending--;
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
// The registry callback (publish fan-out). Buffers during the drain, writes once live.
|
|
453
|
+
// INVARIANT this and the flush's `> lastServed` dedup both depend on: the publish path must
|
|
454
|
+
// keep a FIXED await-depth between the store minting a sequenceID and its call to
|
|
455
|
+
// `sendMessage`, so concurrent publishes reach onLive in the same order their sequenceIDs
|
|
456
|
+
// were minted. Break that ordering and the dedup doesn't just reorder frames — it silently
|
|
457
|
+
// DROPS a lower-seq frame that arrives after a higher one already raised lastServed.
|
|
458
|
+
const onLive = (message)=>{
|
|
459
|
+
const frame = toReceiveFrame(message);
|
|
460
|
+
if (phase === 'draining') {
|
|
461
|
+
// Bound liveBuffer the same way pushWrite bounds the write queue: a stalled-but-still-
|
|
462
|
+
// connected reader can hang writeChain indefinitely, and buffered frames never reach
|
|
463
|
+
// pushWrite's own cap check. Without this, the drain never completes and every publish
|
|
464
|
+
// grows liveBuffer without bound. Drop the frame instead — it stays pending in the store
|
|
465
|
+
// and redelivers on reconnect (store-and-forward), same fallback pushWrite's cap uses.
|
|
466
|
+
if (liveBuffer.length >= receiveBufferLimit) {
|
|
467
|
+
finish();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
liveBuffer.push(frame);
|
|
471
|
+
} else {
|
|
472
|
+
pushWrite(frame);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
254
475
|
try {
|
|
255
476
|
// Binding evicts whatever held the lane: a reconnect means the old connection broke and the
|
|
256
477
|
// server learns last, so the stale writer must give way to the live one. See
|
|
257
478
|
// `HubClientRegistry.bindReceiveWriter`.
|
|
258
|
-
const { token, evicted } = registry.bindReceiveWriter(clientDID, (
|
|
259
|
-
writer.write({
|
|
260
|
-
sequenceID: message.sequenceID,
|
|
261
|
-
senderDID: message.senderDID,
|
|
262
|
-
topicID: message.topicID,
|
|
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
|
-
} : {}
|
|
269
|
-
}).catch(()=>{});
|
|
270
|
-
}, ()=>endEvicted?.());
|
|
479
|
+
const { token, evicted } = registry.bindReceiveWriter(clientDID, onLive, ()=>endEvicted?.());
|
|
271
480
|
receiveToken = token;
|
|
272
|
-
// After the bind, so the lane is never unheld between the two
|
|
273
|
-
// gap would have nowhere to go, and mailbox frames drop when nobody's there.
|
|
481
|
+
// After the bind, so the lane is never unheld between the two.
|
|
274
482
|
evicted?.();
|
|
483
|
+
// Drain the backlog. Await each page so lastServed is exact before the flush.
|
|
275
484
|
let cursor = after;
|
|
276
|
-
while(
|
|
485
|
+
while(!tornDown){
|
|
277
486
|
const result = await store.fetch({
|
|
278
487
|
recipientDID: clientDID,
|
|
279
488
|
after: cursor ?? undefined,
|
|
280
489
|
limit: 50
|
|
281
490
|
});
|
|
282
491
|
for (const msg of result.messages){
|
|
283
|
-
|
|
284
|
-
sequenceID: msg.sequenceID,
|
|
285
|
-
senderDID: msg.senderDID,
|
|
286
|
-
topicID: msg.topicID,
|
|
287
|
-
payload: toB64(msg.payload),
|
|
288
|
-
...msg.logPosition != null ? {
|
|
289
|
-
logPosition: msg.logPosition
|
|
290
|
-
} : {}
|
|
291
|
-
});
|
|
492
|
+
pushWrite(toReceiveFrame(msg));
|
|
292
493
|
}
|
|
494
|
+
await writeChain;
|
|
293
495
|
cursor = result.cursor;
|
|
294
496
|
if (!result.hasMore) break;
|
|
295
497
|
}
|
|
498
|
+
// Flush frames that arrived live during the drain, deduped against what the drain already served,
|
|
499
|
+
// then go live. The flip happens only when the buffer is observed empty in the SAME synchronous
|
|
500
|
+
// step (no await between the empty check and the assignment), so a frame that arrived via onLive
|
|
501
|
+
// during a prior write is caught on the next iteration rather than stranded.
|
|
502
|
+
while(!tornDown){
|
|
503
|
+
if (liveBuffer.length === 0) {
|
|
504
|
+
phase = 'live';
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
const batch = liveBuffer.splice(0);
|
|
508
|
+
for (const frame of batch){
|
|
509
|
+
if (frame.sequenceID > lastServed) pushWrite(frame);
|
|
510
|
+
}
|
|
511
|
+
await writeChain;
|
|
512
|
+
}
|
|
296
513
|
} catch (error) {
|
|
514
|
+
// A synchronous drain error (e.g. store.fetch threw): clean up and reject the handler.
|
|
515
|
+
tornDown = true;
|
|
297
516
|
if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
|
|
298
517
|
registry.unregisterIfIdle(clientDID);
|
|
299
518
|
reader.cancel().catch(()=>{});
|
|
300
519
|
writer.abort(error).catch(()=>{});
|
|
301
520
|
throw error;
|
|
302
521
|
}
|
|
303
|
-
|
|
522
|
+
// Ack loop (M1): a store.ack failure must not stop later acks; only a reader error closes.
|
|
304
523
|
void (async ()=>{
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
524
|
+
while(true){
|
|
525
|
+
let result;
|
|
526
|
+
try {
|
|
527
|
+
result = await reader.read();
|
|
528
|
+
} catch {
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
if (result.done) break;
|
|
532
|
+
const ack = result.value?.ack;
|
|
533
|
+
if (ack != null) {
|
|
534
|
+
try {
|
|
310
535
|
await store.ack({
|
|
311
536
|
recipientDID: clientDID,
|
|
312
|
-
sequenceIDs:
|
|
537
|
+
sequenceIDs: ack
|
|
538
|
+
});
|
|
539
|
+
} catch (error) {
|
|
540
|
+
// Frame stays pending; the client re-acks next round. Do NOT break.
|
|
541
|
+
storeErrorReporter({
|
|
542
|
+
method: 'ack',
|
|
543
|
+
did: clientDID,
|
|
544
|
+
error
|
|
313
545
|
});
|
|
314
546
|
}
|
|
315
547
|
}
|
|
316
|
-
} catch {
|
|
317
|
-
// Channel closed
|
|
318
548
|
}
|
|
319
549
|
})();
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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, {
|
|
333
|
-
once: true
|
|
334
|
-
});
|
|
550
|
+
// H2: an already-aborted signal never fires 'abort', so run teardown now.
|
|
551
|
+
if (ctx.signal.aborted) {
|
|
552
|
+
finish();
|
|
553
|
+
return done;
|
|
554
|
+
}
|
|
555
|
+
void evictedHere.then(finish);
|
|
556
|
+
ctx.signal.addEventListener('abort', finish, {
|
|
557
|
+
once: true
|
|
335
558
|
});
|
|
559
|
+
return done;
|
|
336
560
|
},
|
|
337
561
|
'hub/v1/keypackage/upload': async (ctx)=>{
|
|
338
|
-
const { keyPackages } = ctx.param;
|
|
562
|
+
const { keyPackages, lastResort, notAfter } = ctx.param;
|
|
339
563
|
const clientDID = getClientDID(ctx);
|
|
340
|
-
|
|
564
|
+
// The slot holds one package, so a flagged upload carries exactly one. JSON Schema cannot
|
|
565
|
+
// express a conditional on a sibling field, so the arity is enforced here.
|
|
566
|
+
let lastResortPackage = null;
|
|
567
|
+
if (lastResort === true) {
|
|
568
|
+
const [only, ...rest] = keyPackages;
|
|
569
|
+
if (only == null || rest.length > 0) {
|
|
570
|
+
throw new HandlerError({
|
|
571
|
+
code: HUB_ERROR_CODES.invalidPayload,
|
|
572
|
+
message: 'A last-resort upload must carry exactly one key package'
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
lastResortPackage = only;
|
|
576
|
+
}
|
|
577
|
+
if (lastResort === true && notAfter != null) {
|
|
578
|
+
throw new HandlerError({
|
|
579
|
+
code: HUB_ERROR_CODES.invalidPayload,
|
|
580
|
+
message: 'A last-resort upload carries no expiry: the slot is kept fresh by rotation'
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
const decision = normalizeAuthorizeDecision(await authorize({
|
|
584
|
+
action: 'keypackage/upload',
|
|
585
|
+
did: clientDID,
|
|
586
|
+
count: keyPackages.length,
|
|
587
|
+
// Spread, not assignment: `lastResort: undefined` would make an ordinary upload look
|
|
588
|
+
// like one that explicitly opted out.
|
|
589
|
+
...lastResort === true ? {
|
|
590
|
+
lastResort: true
|
|
591
|
+
} : {}
|
|
592
|
+
}));
|
|
593
|
+
if (!decision.allow) {
|
|
594
|
+
throw new HandlerError({
|
|
595
|
+
code: HUB_ERROR_CODES.authorizationDenied,
|
|
596
|
+
message: decision.reason ?? 'Not authorized to upload key packages'
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
if (!didLimiter.tryConsume(clientDID)) {
|
|
600
|
+
throw new HandlerError({
|
|
601
|
+
code: 'EK01',
|
|
602
|
+
message: 'Key package upload rate limit exceeded for DID'
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
// A batch crossing the cap partially commits (the store enforces per-call, so the cap still
|
|
606
|
+
// holds) and rejects the rest with HUB_KEYPACKAGE_QUOTA; a retry finds the earlier ones stored.
|
|
607
|
+
// The last-resort path has no cap to cross: the slot is one entry, replaced in place.
|
|
608
|
+
try {
|
|
609
|
+
if (lastResortPackage != null) {
|
|
610
|
+
await store.storeLastResortKeyPackage(clientDID, lastResortPackage);
|
|
611
|
+
} else {
|
|
612
|
+
await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage(clientDID, kp, notAfter)));
|
|
613
|
+
}
|
|
614
|
+
} catch (error) {
|
|
615
|
+
rethrowAsHandlerError(error);
|
|
616
|
+
}
|
|
341
617
|
return {
|
|
342
618
|
stored: keyPackages.length
|
|
343
619
|
};
|
|
344
620
|
},
|
|
345
621
|
'hub/v1/keypackage/fetch': async (ctx)=>{
|
|
346
622
|
const requesterDID = getClientDID(ctx);
|
|
347
|
-
|
|
348
|
-
const { did, count } = ctx.param;
|
|
623
|
+
const { did: targetDID, count } = ctx.param;
|
|
349
624
|
const cappedCount = Math.min(Math.max(count ?? 1, 1), fetchLimits.maxCount);
|
|
350
|
-
const
|
|
625
|
+
const decision = normalizeAuthorizeDecision(await authorize({
|
|
626
|
+
action: 'keypackage/fetch',
|
|
627
|
+
did: requesterDID,
|
|
628
|
+
targetDID,
|
|
629
|
+
count: cappedCount
|
|
630
|
+
}));
|
|
631
|
+
if (!decision.allow) {
|
|
632
|
+
throw new HandlerError({
|
|
633
|
+
code: HUB_ERROR_CODES.authorizationDenied,
|
|
634
|
+
message: decision.reason ?? 'Not authorized to fetch key packages'
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
// Order matters: a refusal above never charged a window; per-requester before per-target so a
|
|
638
|
+
// throttled requester doesn't charge the target. rethrow maps KeyPackageFetchLimitError (no
|
|
639
|
+
// `code` of its own) to its wire code.
|
|
640
|
+
try {
|
|
641
|
+
assertKeyPackageFetchAllowed(requesterDID);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
rethrowAsHandlerError(error);
|
|
644
|
+
}
|
|
645
|
+
let targetBudgetSpent = false;
|
|
646
|
+
try {
|
|
647
|
+
assertTargetConsumptionAllowed(targetDID, cappedCount);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (!(error instanceof KeyPackageFetchLimitError)) rethrowAsHandlerError(error);
|
|
650
|
+
targetBudgetSpent = true;
|
|
651
|
+
}
|
|
652
|
+
if (targetBudgetSpent) {
|
|
653
|
+
// The drain budget bounds CONSUMPTION, and serving the slot consumes nothing — so a spent
|
|
654
|
+
// budget refuses the pool while the reusable floor still answers. A target with no slot is
|
|
655
|
+
// refused exactly as before.
|
|
656
|
+
//
|
|
657
|
+
// "Consumes nothing, so charges nothing" holds on THIS path only. The charge is levied on
|
|
658
|
+
// intent to consume, before either read: on the ordinary path below the target was already
|
|
659
|
+
// charged `cappedCount`, so a drained target's slot rides along on an already-charged
|
|
660
|
+
// request and does cost the shared per-target budget until the budget is spent. Harmless —
|
|
661
|
+
// the slot is served either way — but the invariant is not unconditional.
|
|
662
|
+
//
|
|
663
|
+
// A store failure HERE does not become the client's error: the request was already being
|
|
664
|
+
// refused, and the slot was the only thing that could have rescued it. Surfacing the
|
|
665
|
+
// store's error instead would turn a retryable coded refusal into an opaque one, changing
|
|
666
|
+
// what the caller does about a situation that has not changed. The store error rides along
|
|
667
|
+
// as `cause` so an operator can still see a broken store behind the refusal.
|
|
668
|
+
let lastResort = null;
|
|
669
|
+
let readFailure;
|
|
670
|
+
try {
|
|
671
|
+
lastResort = await store.fetchLastResortKeyPackage(targetDID);
|
|
672
|
+
} catch (error) {
|
|
673
|
+
readFailure = error;
|
|
674
|
+
}
|
|
675
|
+
if (lastResort == null) {
|
|
676
|
+
throw new HandlerError({
|
|
677
|
+
code: HUB_ERROR_CODES.keyPackageFetchLimit,
|
|
678
|
+
message: `Key package consumption limit exceeded for target ${targetDID}`,
|
|
679
|
+
...readFailure != null ? {
|
|
680
|
+
cause: readFailure
|
|
681
|
+
} : {}
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
return {
|
|
685
|
+
keyPackages: [
|
|
686
|
+
lastResort
|
|
687
|
+
]
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
// THE TWO READS BELOW ARE SPLIT DELIBERATELY, AND MUST STAY SPLIT. `fetchKeyPackages` is
|
|
691
|
+
// destructive: once it returns, those packages are spliced out of the store for good. A
|
|
692
|
+
// failure AFTER that point which propagates to the client destroys packages nobody ever
|
|
693
|
+
// received — and the client's retry burns the next batch, and the next. The likeliest trigger
|
|
694
|
+
// is exactly the third-party store this feature targets: one that has not implemented the
|
|
695
|
+
// slot yet throws `store.fetchLastResortKeyPackage is not a function` on the second read, so
|
|
696
|
+
// every fetch against it would silently drain the target — the drain the last-resort slot
|
|
697
|
+
// exists to close, reintroduced by the top-up read itself.
|
|
698
|
+
//
|
|
699
|
+
// So the pool read's failure IS the client's error and keeps its wire code (a named store
|
|
700
|
+
// error has to stay tellable from an unreachable hub), while the top-up read may only fail
|
|
701
|
+
// the request when nothing has been consumed and there is therefore nothing to lose.
|
|
702
|
+
let consumed;
|
|
703
|
+
try {
|
|
704
|
+
consumed = await store.fetchKeyPackages(targetDID, cappedCount);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
rethrowAsHandlerError(error);
|
|
707
|
+
}
|
|
708
|
+
if (consumed.length >= cappedCount) return {
|
|
709
|
+
keyPackages: consumed
|
|
710
|
+
};
|
|
711
|
+
let lastResort = null;
|
|
712
|
+
try {
|
|
713
|
+
lastResort = await store.fetchLastResortKeyPackage(targetDID);
|
|
714
|
+
} catch (error) {
|
|
715
|
+
// Nothing consumed, so nothing is lost by surfacing it. Otherwise the top-up was a bonus
|
|
716
|
+
// the caller never paid for: hand back what the store has already given up.
|
|
717
|
+
if (consumed.length === 0) rethrowAsHandlerError(error);
|
|
718
|
+
storeErrorReporter({
|
|
719
|
+
method: 'fetchLastResortKeyPackage',
|
|
720
|
+
did: targetDID,
|
|
721
|
+
error
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
// Appended AT MOST ONCE, never padded out to `cappedCount`: handing one caller two copies
|
|
725
|
+
// of one init key is the reuse the whole design avoids.
|
|
726
|
+
return {
|
|
727
|
+
keyPackages: lastResort == null ? consumed : [
|
|
728
|
+
...consumed,
|
|
729
|
+
lastResort
|
|
730
|
+
]
|
|
731
|
+
};
|
|
732
|
+
},
|
|
733
|
+
'hub/v1/keypackage/status': async (ctx)=>{
|
|
734
|
+
// No `did` parameter exists, so this answers only for the authenticated caller. That is the
|
|
735
|
+
// whole authorization story: a query that could name someone else would tell an attacker
|
|
736
|
+
// exactly when a drain against that DID had succeeded.
|
|
737
|
+
const clientDID = getClientDID(ctx);
|
|
738
|
+
const decision = normalizeAuthorizeDecision(await authorize({
|
|
739
|
+
action: 'keypackage/status',
|
|
740
|
+
did: clientDID
|
|
741
|
+
}));
|
|
742
|
+
if (!decision.allow) {
|
|
743
|
+
throw new HandlerError({
|
|
744
|
+
code: HUB_ERROR_CODES.authorizationDenied,
|
|
745
|
+
message: decision.reason ?? 'Not authorized to read key package status'
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
if (!didLimiter.tryConsume(clientDID)) {
|
|
749
|
+
throw new HandlerError({
|
|
750
|
+
code: 'EK01',
|
|
751
|
+
message: 'Key package status rate limit exceeded for DID'
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
let count;
|
|
755
|
+
let stored;
|
|
756
|
+
try {
|
|
757
|
+
// Two independent reads for one owner: concurrent, since neither informs the other.
|
|
758
|
+
;
|
|
759
|
+
[count, stored] = await Promise.all([
|
|
760
|
+
store.countKeyPackages(clientDID),
|
|
761
|
+
store.fetchLastResortKeyPackage(clientDID)
|
|
762
|
+
]);
|
|
763
|
+
} catch (error) {
|
|
764
|
+
rethrowAsHandlerError(error);
|
|
765
|
+
}
|
|
351
766
|
return {
|
|
352
|
-
|
|
767
|
+
count,
|
|
768
|
+
lastResort: stored == null ? null : keyPackageDigest(stored)
|
|
353
769
|
};
|
|
354
770
|
}
|
|
355
771
|
};
|
package/lib/hub.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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
4
|
import { type HubProtocol, type HubStore } from '@kumiai/hub-protocol';
|
|
5
|
-
import { type AuthorizeHook, type HubRateLimits, type KeyPackageFetchLimits } from './handlers.js';
|
|
5
|
+
import { type AuthorizeHook, type HubRateLimits, type HubStoreErrorHook, type KeyPackageFetchLimits } from './handlers.js';
|
|
6
6
|
import { HubClientRegistry } from './registry.js';
|
|
7
7
|
/**
|
|
8
8
|
* Default access rules: any authenticated DID may call hub procedures.
|
|
@@ -39,6 +39,11 @@ export type CreateHubParams = {
|
|
|
39
39
|
* {@link DEFAULT_KEYPACKAGE_FETCH_LIMITS}.
|
|
40
40
|
*/
|
|
41
41
|
keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
|
|
42
|
+
/**
|
|
43
|
+
* Called when a `HubStore` operation fails where the hub deliberately does not fail the
|
|
44
|
+
* request. Forwarded to {@link createHandlers} and used by the purge timer. Fire-and-forget.
|
|
45
|
+
*/
|
|
46
|
+
onStoreError?: HubStoreErrorHook;
|
|
42
47
|
/** Scheduled purge of expired stored messages. Set to `false` to disable. */
|
|
43
48
|
purge?: HubPurgeOptions | false;
|
|
44
49
|
/**
|
package/lib/hub.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { serve } from '@enkaku/server';
|
|
2
2
|
import { hubProtocol } from '@kumiai/hub-protocol';
|
|
3
|
-
import { createHandlers } from './handlers.js';
|
|
3
|
+
import { createHandlers, createStoreErrorReporter } from './handlers.js';
|
|
4
4
|
import { HubClientRegistry } from './registry.js';
|
|
5
5
|
/**
|
|
6
6
|
* Default access rules: any authenticated DID may call hub procedures.
|
|
@@ -13,12 +13,17 @@ import { HubClientRegistry } from './registry.js';
|
|
|
13
13
|
};
|
|
14
14
|
export function createHub(params) {
|
|
15
15
|
const registry = new HubClientRegistry();
|
|
16
|
+
// One reporter for the whole hub. Its type IS `HubStoreErrorHook`, so `createHandlers`' own
|
|
17
|
+
// wrapper delegates to this instance rather than building a second one from the same hook —
|
|
18
|
+
// which matters the moment a reporter holds state, as the throttling the README names would.
|
|
19
|
+
const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
|
|
16
20
|
const handlers = createHandlers({
|
|
17
21
|
registry,
|
|
18
22
|
store: params.store,
|
|
19
23
|
authorize: params.authorize,
|
|
20
24
|
rateLimits: params.rateLimits,
|
|
21
|
-
keyPackageFetchLimits: params.keyPackageFetchLimits
|
|
25
|
+
keyPackageFetchLimits: params.keyPackageFetchLimits,
|
|
26
|
+
onStoreError: storeErrorReporter
|
|
22
27
|
});
|
|
23
28
|
const limits = {
|
|
24
29
|
...params.limits,
|
|
@@ -43,8 +48,12 @@ export function createHub(params) {
|
|
|
43
48
|
const purgeTimer = setInterval(()=>{
|
|
44
49
|
params.store.purge({
|
|
45
50
|
olderThan
|
|
46
|
-
}).catch(()=>{
|
|
47
|
-
|
|
51
|
+
}).catch((error)=>{
|
|
52
|
+
// Purge failures are non-fatal; retried on the next interval
|
|
53
|
+
storeErrorReporter({
|
|
54
|
+
method: 'purge',
|
|
55
|
+
error
|
|
56
|
+
});
|
|
48
57
|
});
|
|
49
58
|
}, interval);
|
|
50
59
|
server.disposed.then(()=>clearInterval(purgeTimer));
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type { AuthorizeDecision, AuthorizeHook, AuthorizeRequest, CreateHandlersParams, HubRateLimits, KeyPackageFetchLimits, } from './handlers.js';
|
|
2
|
-
export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS } from './handlers.js';
|
|
1
|
+
export type { AuthorizeDecision, AuthorizeHook, AuthorizeRequest, CreateHandlersParams, HubRateLimits, HubStoreErrorEvent, HubStoreErrorHook, KeyPackageFetchLimits, } from './handlers.js';
|
|
2
|
+
export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_BUFFER_LIMIT, } from './handlers.js';
|
|
3
3
|
export type { CreateHubParams, HubInstance, HubPurgeOptions } from './hub.js';
|
|
4
4
|
export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
|
|
5
5
|
export { createMemoryStore, type MemoryStoreOptions } from './memoryStore.js';
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS } from './handlers.js';
|
|
1
|
+
export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_BUFFER_LIMIT } from './handlers.js';
|
|
2
2
|
export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
|
|
3
3
|
export { createMemoryStore } from './memoryStore.js';
|
|
4
4
|
export { createRateLimiter } from './rateLimit.js';
|
package/lib/memoryStore.d.ts
CHANGED
|
@@ -14,6 +14,10 @@ export type MemoryStoreOptions = {
|
|
|
14
14
|
/** Per-topic max retained entries; oldest are trimmed beyond this. Default 1000. */
|
|
15
15
|
maxDepth?: number;
|
|
16
16
|
retention?: MemoryStoreRetention;
|
|
17
|
+
/** Max key packages stored per owner DID before an upload is rejected. Default: 100. */
|
|
18
|
+
maxKeyPackagesPerDID?: number;
|
|
19
|
+
/** Max distinct topics one DID may subscribe to before a subscribe is rejected. Default: 1000. */
|
|
20
|
+
maxSubscriptionsPerDID?: number;
|
|
17
21
|
};
|
|
18
22
|
/**
|
|
19
23
|
* In-memory HubStore for testing and development.
|
package/lib/memoryStore.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { HeadMismatchError, NotSubscribedError, RetentionExceededError } from '@kumiai/hub-protocol';
|
|
1
|
+
import { HeadMismatchError, KeyPackageQuotaExceededError, NotSubscribedError, RetentionExceededError, SubscriptionQuotaExceededError } from '@kumiai/hub-protocol';
|
|
2
2
|
import { EventEmitter } from '@sozai/event';
|
|
3
3
|
const DEFAULT_MAX_DEPTH = 1000;
|
|
4
4
|
/** 30 days, in seconds. A finite default ceiling on requested retention. */ const DEFAULT_MAX_RETENTION = 2_592_000;
|
|
5
5
|
function formatSequenceID(counter) {
|
|
6
6
|
return String(counter).padStart(12, '0');
|
|
7
7
|
}
|
|
8
|
+
function isLive(entry, nowSeconds) {
|
|
9
|
+
return entry.notAfter == null || entry.notAfter > nowSeconds;
|
|
10
|
+
}
|
|
8
11
|
/**
|
|
9
12
|
* The entry as a reader sees it, with the ONE piece of routing metadata a reader cannot recover for
|
|
10
13
|
* itself: where the frame sits in its topic's log.
|
|
@@ -45,6 +48,8 @@ function formatSequenceID(counter) {
|
|
|
45
48
|
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
46
49
|
const defaultRetention = options.retention?.default ?? 0;
|
|
47
50
|
const maxRetention = options.retention?.max ?? DEFAULT_MAX_RETENTION;
|
|
51
|
+
const maxKeyPackagesPerDID = options.maxKeyPackagesPerDID ?? 100;
|
|
52
|
+
const maxSubscriptionsPerDID = options.maxSubscriptionsPerDID ?? 1000;
|
|
48
53
|
let counter = 0;
|
|
49
54
|
const entries = new Map();
|
|
50
55
|
const topicLogs = new Map();
|
|
@@ -57,7 +62,12 @@ function formatSequenceID(counter) {
|
|
|
57
62
|
* learn that a commit it never saw acknowledged had in fact landed.
|
|
58
63
|
*/ const publishRecords = new Map();
|
|
59
64
|
/** Per topic: the subscribers, each with the retention it asked for. */ const subscriptions = new Map();
|
|
65
|
+
// Reverse index for an O(1) per-DID subscription count. Kept in lockstep with `subscriptions`.
|
|
66
|
+
const subsByDID = new Map();
|
|
60
67
|
const keyPackages = new Map();
|
|
68
|
+
/** One reusable package per DID, replaced on re-upload. Deliberately not in `keyPackages`: a
|
|
69
|
+
* one-entry-per-DID map cannot grow, so it needs no quota, and charging it against
|
|
70
|
+
* `maxKeyPackagesPerDID` would let a full pool block the availability floor. */ const lastResortKeyPackages = new Map();
|
|
61
71
|
const events = new EventEmitter();
|
|
62
72
|
// The age bound for a topic: the longest retention any of its subscribers asked for, floored
|
|
63
73
|
// by the hub's default and by the caller's own bound.
|
|
@@ -332,12 +342,23 @@ function formatSequenceID(counter) {
|
|
|
332
342
|
if (requested > maxRetention) {
|
|
333
343
|
throw new RetentionExceededError(`Requested retention of ${requested}s exceeds the maximum of ${maxRetention}s`);
|
|
334
344
|
}
|
|
345
|
+
const held = subsByDID.get(params.subscriberDID);
|
|
346
|
+
const alreadyHeld = held?.has(params.topicID) ?? false;
|
|
347
|
+
if (!alreadyHeld && (held?.size ?? 0) >= maxSubscriptionsPerDID) {
|
|
348
|
+
throw new SubscriptionQuotaExceededError(`DID ${params.subscriberDID} exceeds the maximum of ${maxSubscriptionsPerDID} subscriptions`);
|
|
349
|
+
}
|
|
335
350
|
let subscribers = subscriptions.get(params.topicID);
|
|
336
351
|
if (subscribers == null) {
|
|
337
352
|
subscribers = new Map();
|
|
338
353
|
subscriptions.set(params.topicID, subscribers);
|
|
339
354
|
}
|
|
340
355
|
subscribers.set(params.subscriberDID, requested);
|
|
356
|
+
let ownTopics = subsByDID.get(params.subscriberDID);
|
|
357
|
+
if (ownTopics == null) {
|
|
358
|
+
ownTopics = new Set();
|
|
359
|
+
subsByDID.set(params.subscriberDID, ownTopics);
|
|
360
|
+
}
|
|
361
|
+
ownTopics.add(params.topicID);
|
|
341
362
|
},
|
|
342
363
|
async unsubscribe (subscriberDID, topicID) {
|
|
343
364
|
const subscribers = subscriptions.get(topicID);
|
|
@@ -347,6 +368,11 @@ function formatSequenceID(counter) {
|
|
|
347
368
|
subscriptions.delete(topicID);
|
|
348
369
|
}
|
|
349
370
|
}
|
|
371
|
+
const ownTopics = subsByDID.get(subscriberDID);
|
|
372
|
+
if (ownTopics != null) {
|
|
373
|
+
ownTopics.delete(topicID);
|
|
374
|
+
if (ownTopics.size === 0) subsByDID.delete(subscriberDID);
|
|
375
|
+
}
|
|
350
376
|
// Drops this subscriber's pending deliveries for the topic — freeing a mailbox frame whose
|
|
351
377
|
// last delivery this was, and leaving a log frame standing.
|
|
352
378
|
const pending = deliveries.get(subscriberDID);
|
|
@@ -366,19 +392,59 @@ function formatSequenceID(counter) {
|
|
|
366
392
|
...subscribers.keys()
|
|
367
393
|
];
|
|
368
394
|
},
|
|
369
|
-
async storeKeyPackage (ownerDID, keyPackage) {
|
|
395
|
+
async storeKeyPackage (ownerDID, keyPackage, notAfter) {
|
|
370
396
|
let packages = keyPackages.get(ownerDID);
|
|
371
397
|
if (packages == null) {
|
|
372
398
|
packages = [];
|
|
373
399
|
keyPackages.set(ownerDID, packages);
|
|
374
400
|
}
|
|
375
|
-
|
|
401
|
+
// Drop the dead before charging the cap, or a pool that filled with expired entries could
|
|
402
|
+
// never be replenished: the cap rejects rather than evicts, by design, so nothing else would
|
|
403
|
+
// ever remove them.
|
|
404
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
405
|
+
const live = packages.filter((entry)=>isLive(entry, nowSeconds));
|
|
406
|
+
if (live.length !== packages.length) {
|
|
407
|
+
packages.length = 0;
|
|
408
|
+
packages.push(...live);
|
|
409
|
+
}
|
|
410
|
+
if (packages.length >= maxKeyPackagesPerDID) {
|
|
411
|
+
throw new KeyPackageQuotaExceededError(`DID ${ownerDID} exceeds the maximum of ${maxKeyPackagesPerDID} stored key packages`);
|
|
412
|
+
}
|
|
413
|
+
packages.push({
|
|
414
|
+
keyPackage,
|
|
415
|
+
...notAfter != null ? {
|
|
416
|
+
notAfter
|
|
417
|
+
} : {}
|
|
418
|
+
});
|
|
376
419
|
},
|
|
377
420
|
async fetchKeyPackages (ownerDID, count) {
|
|
378
421
|
const packages = keyPackages.get(ownerDID);
|
|
379
422
|
if (packages == null || packages.length === 0) return [];
|
|
423
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
424
|
+
const served = [];
|
|
380
425
|
const n = count ?? 1;
|
|
381
|
-
|
|
426
|
+
// One pass, consuming as it goes: an expired entry is dropped rather than skipped, so FIFO
|
|
427
|
+
// stops handing out the nearest-expiry package first. Always shift index 0 — never advance a
|
|
428
|
+
// cursor — or this degenerates into the skip loop this task exists to remove.
|
|
429
|
+
while(packages.length > 0 && served.length < n){
|
|
430
|
+
const entry = packages.shift();
|
|
431
|
+
if (isLive(entry, nowSeconds)) served.push(entry.keyPackage);
|
|
432
|
+
}
|
|
433
|
+
return served;
|
|
434
|
+
},
|
|
435
|
+
async countKeyPackages (ownerDID) {
|
|
436
|
+
const packages = keyPackages.get(ownerDID);
|
|
437
|
+
if (packages == null) return 0;
|
|
438
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
439
|
+
return packages.filter((entry)=>isLive(entry, nowSeconds)).length;
|
|
440
|
+
},
|
|
441
|
+
async storeLastResortKeyPackage (ownerDID, keyPackage) {
|
|
442
|
+
lastResortKeyPackages.set(ownerDID, keyPackage);
|
|
443
|
+
},
|
|
444
|
+
async fetchLastResortKeyPackage (ownerDID) {
|
|
445
|
+
// No splice, ever. Reuse is the point here — this package is marked last-resort in MLS, so
|
|
446
|
+
// serving it again is by design rather than the init-key reuse `fetchKeyPackages` avoids.
|
|
447
|
+
return lastResortKeyPackages.get(ownerDID) ?? null;
|
|
382
448
|
}
|
|
383
449
|
};
|
|
384
450
|
}
|
package/lib/rateLimit.d.ts
CHANGED
|
@@ -3,10 +3,14 @@ export type RateLimitConfig = {
|
|
|
3
3
|
rate: number;
|
|
4
4
|
/** Maximum bucket capacity (burst). */
|
|
5
5
|
burst: number;
|
|
6
|
+
/** Idle full buckets older than this (ms) are evicted opportunistically. Default: 300_000. */
|
|
7
|
+
ttlMs?: number;
|
|
6
8
|
};
|
|
7
9
|
export type RateLimiter = {
|
|
8
10
|
/** Consumes a token if available, returning true; returns false otherwise. */
|
|
9
11
|
tryConsume(key: string): boolean;
|
|
12
|
+
/** Current number of live buckets. Introspection for tests and metrics. */
|
|
13
|
+
size(): number;
|
|
10
14
|
};
|
|
11
15
|
/** Per-key token-bucket rate limiter. */
|
|
12
16
|
export declare function createRateLimiter(config: RateLimitConfig): RateLimiter;
|
package/lib/rateLimit.js
CHANGED
|
@@ -1,18 +1,36 @@
|
|
|
1
|
+
/** Threshold above which `tryConsume` sweeps for evictable buckets. */ const PRUNE_AT = 1024;
|
|
2
|
+
/** Tokens a bucket would hold at `now` under continuous refill, capped at burst. */ function refilledTokens(bucket, now, rate, burst) {
|
|
3
|
+
const elapsedSeconds = (now - bucket.lastRefill) / 1000;
|
|
4
|
+
return Math.min(burst, bucket.tokens + elapsedSeconds * rate);
|
|
5
|
+
}
|
|
1
6
|
/** Per-key token-bucket rate limiter. */ export function createRateLimiter(config) {
|
|
2
7
|
const buckets = new Map();
|
|
8
|
+
const ttlMs = config.ttlMs ?? 300_000;
|
|
9
|
+
// O(n), but only from the cold path past the size gate below, never on the hot path.
|
|
10
|
+
function prune(now) {
|
|
11
|
+
for (const [key, bucket] of buckets){
|
|
12
|
+
// Evict only full-and-idle buckets: recreating one on next use is identical, and a
|
|
13
|
+
// partially-spent one must never be dropped (that refunds its spent tokens). Fullness is
|
|
14
|
+
// recomputed from elapsed time, NOT read from stored `tokens` — every tryConsume decrements,
|
|
15
|
+
// so stored tokens is always < burst at rest and a stored-value check would never prune.
|
|
16
|
+
if (refilledTokens(bucket, now, config.rate, config.burst) >= config.burst && now - bucket.lastRefill >= ttlMs) {
|
|
17
|
+
buckets.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
3
21
|
return {
|
|
4
22
|
tryConsume (key) {
|
|
5
23
|
const now = Date.now();
|
|
6
24
|
let bucket = buckets.get(key);
|
|
7
25
|
if (bucket == null) {
|
|
26
|
+
if (buckets.size >= PRUNE_AT) prune(now);
|
|
8
27
|
bucket = {
|
|
9
28
|
tokens: config.burst,
|
|
10
29
|
lastRefill: now
|
|
11
30
|
};
|
|
12
31
|
buckets.set(key, bucket);
|
|
13
32
|
} else {
|
|
14
|
-
|
|
15
|
-
bucket.tokens = Math.min(config.burst, bucket.tokens + elapsedSeconds * config.rate);
|
|
33
|
+
bucket.tokens = refilledTokens(bucket, now, config.rate, config.burst);
|
|
16
34
|
bucket.lastRefill = now;
|
|
17
35
|
}
|
|
18
36
|
if (bucket.tokens < 1) {
|
|
@@ -20,6 +38,9 @@
|
|
|
20
38
|
}
|
|
21
39
|
bucket.tokens -= 1;
|
|
22
40
|
return true;
|
|
41
|
+
},
|
|
42
|
+
size () {
|
|
43
|
+
return buckets.size;
|
|
23
44
|
}
|
|
24
45
|
};
|
|
25
46
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kumiai/hub-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Hub server for Enkaku E2EE group message routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hub",
|
|
@@ -28,14 +28,18 @@
|
|
|
28
28
|
"@enkaku/protocol": "^0.20.0",
|
|
29
29
|
"@enkaku/server": "^0.20.0",
|
|
30
30
|
"@kokuin/token": "^0.3.0",
|
|
31
|
-
"@sozai/codec": "^0.
|
|
32
|
-
"@sozai/event": "^0.1.
|
|
33
|
-
"@
|
|
31
|
+
"@sozai/codec": "^0.3.0",
|
|
32
|
+
"@sozai/event": "^0.1.3",
|
|
33
|
+
"@sozai/log": "^0.3.0",
|
|
34
|
+
"@kumiai/hub-protocol": "^0.5.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@enkaku/client": "^0.20.0",
|
|
37
38
|
"@enkaku/transport": "^0.20.0",
|
|
38
|
-
"@kumiai/hub-conformance": "^0.
|
|
39
|
+
"@kumiai/hub-conformance": "^0.5.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
39
43
|
},
|
|
40
44
|
"scripts": {
|
|
41
45
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|