@kumiai/hub-server 0.1.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.
@@ -0,0 +1,28 @@
1
+ import { type ProcedureHandlers } from '@enkaku/server';
2
+ import type { HubProtocol, HubStore } from '@kumiai/hub-protocol';
3
+ import { type RateLimitConfig } from './rateLimit.js';
4
+ import type { HubClientRegistry } from './registry.js';
5
+ export type AuthorizeAction = 'publish' | 'subscribe';
6
+ export type AuthorizeHook = (did: string, action: AuthorizeAction, topicID: string) => boolean | Promise<boolean>;
7
+ export type HubRateLimits = {
8
+ perDID: RateLimitConfig;
9
+ perTopic: RateLimitConfig;
10
+ };
11
+ export declare const DEFAULT_RATE_LIMITS: HubRateLimits;
12
+ export type KeyPackageFetchLimits = {
13
+ /** Maximum number of key packages returned per fetch. Default: 10 */
14
+ maxCount: number;
15
+ /** Maximum number of fetch requests per requester DID per window. Default: 30 */
16
+ maxRequests: number;
17
+ /** Rate-limit window duration in milliseconds. Default: 60000 (1 min) */
18
+ windowMs: number;
19
+ };
20
+ export declare const DEFAULT_KEYPACKAGE_FETCH_LIMITS: KeyPackageFetchLimits;
21
+ export type CreateHandlersParams = {
22
+ registry: HubClientRegistry;
23
+ store: HubStore;
24
+ authorize?: AuthorizeHook;
25
+ rateLimits?: Partial<HubRateLimits>;
26
+ keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
27
+ };
28
+ export declare function createHandlers(params: CreateHandlersParams): ProcedureHandlers<HubProtocol>;
@@ -0,0 +1,229 @@
1
+ import { HandlerError } from '@enkaku/server';
2
+ import { fromB64, toB64 } from '@sozai/codec';
3
+ import { createRateLimiter } from './rateLimit.js';
4
+ export const DEFAULT_RATE_LIMITS = {
5
+ perDID: {
6
+ rate: 20,
7
+ burst: 50
8
+ },
9
+ perTopic: {
10
+ rate: 100,
11
+ burst: 200
12
+ }
13
+ };
14
+ export const DEFAULT_KEYPACKAGE_FETCH_LIMITS = {
15
+ maxCount: 10,
16
+ maxRequests: 30,
17
+ windowMs: 60_000
18
+ };
19
+ function getClientDID(ctx) {
20
+ const iss = ctx.message.payload.iss;
21
+ if (typeof iss !== 'string' || iss.length === 0) {
22
+ throw new Error('Unauthenticated message: missing verified issuer DID');
23
+ }
24
+ return iss;
25
+ }
26
+ export function createHandlers(params) {
27
+ const { store, registry } = params;
28
+ const authorize = params.authorize ?? (()=>true);
29
+ const rateLimits = {
30
+ perDID: {
31
+ ...DEFAULT_RATE_LIMITS.perDID,
32
+ ...params.rateLimits?.perDID
33
+ },
34
+ perTopic: {
35
+ ...DEFAULT_RATE_LIMITS.perTopic,
36
+ ...params.rateLimits?.perTopic
37
+ }
38
+ };
39
+ const didLimiter = createRateLimiter(rateLimits.perDID);
40
+ const topicLimiter = createRateLimiter(rateLimits.perTopic);
41
+ const fetchLimits = {
42
+ ...DEFAULT_KEYPACKAGE_FETCH_LIMITS,
43
+ ...params.keyPackageFetchLimits
44
+ };
45
+ const fetchWindows = new Map();
46
+ function assertKeyPackageFetchAllowed(requesterDID) {
47
+ const now = Date.now();
48
+ if (fetchWindows.size > 1024) {
49
+ for (const [did, window] of fetchWindows){
50
+ if (window.resetAt <= now) {
51
+ fetchWindows.delete(did);
52
+ }
53
+ }
54
+ }
55
+ const window = fetchWindows.get(requesterDID);
56
+ if (window == null || window.resetAt <= now) {
57
+ fetchWindows.set(requesterDID, {
58
+ count: 1,
59
+ resetAt: now + fetchLimits.windowMs
60
+ });
61
+ return;
62
+ }
63
+ if (window.count >= fetchLimits.maxRequests) {
64
+ throw new Error('Key package fetch rate limit exceeded');
65
+ }
66
+ window.count++;
67
+ }
68
+ return {
69
+ 'hub/publish': async (ctx)=>{
70
+ const { topicID, payload } = ctx.param;
71
+ const senderDID = getClientDID(ctx);
72
+ if (!await authorize(senderDID, 'publish', topicID)) {
73
+ throw new HandlerError({
74
+ code: 'EK02',
75
+ message: 'Not authorized to publish to topic'
76
+ });
77
+ }
78
+ if (!didLimiter.tryConsume(senderDID)) {
79
+ throw new HandlerError({
80
+ code: 'EK01',
81
+ message: 'Publish rate limit exceeded for DID'
82
+ });
83
+ }
84
+ if (!topicLimiter.tryConsume(topicID)) {
85
+ throw new HandlerError({
86
+ code: 'EK01',
87
+ message: 'Publish rate limit exceeded for topic'
88
+ });
89
+ }
90
+ const payloadBytes = fromB64(payload);
91
+ const sequenceID = await store.publish({
92
+ senderDID,
93
+ topicID,
94
+ payload: payloadBytes
95
+ });
96
+ // Live-deliver to currently-connected subscribers (minus the sender).
97
+ const subscribers = await store.getSubscribers(topicID);
98
+ for (const recipientDID of subscribers){
99
+ if (recipientDID === senderDID) continue;
100
+ const client = registry.getClient(recipientDID);
101
+ if (client?.sendMessage != null) {
102
+ client.sendMessage({
103
+ sequenceID,
104
+ senderDID,
105
+ topicID,
106
+ payload: payloadBytes
107
+ });
108
+ }
109
+ }
110
+ return {
111
+ sequenceID
112
+ };
113
+ },
114
+ 'hub/subscribe': async (ctx)=>{
115
+ const { topicID } = ctx.param;
116
+ const clientDID = getClientDID(ctx);
117
+ if (!await authorize(clientDID, 'subscribe', topicID)) {
118
+ throw new HandlerError({
119
+ code: 'EK02',
120
+ message: 'Not authorized to subscribe to topic'
121
+ });
122
+ }
123
+ await store.subscribe(clientDID, topicID);
124
+ return {
125
+ subscribed: true
126
+ };
127
+ },
128
+ 'hub/unsubscribe': async (ctx)=>{
129
+ const { topicID } = ctx.param;
130
+ const clientDID = getClientDID(ctx);
131
+ await store.unsubscribe(clientDID, topicID);
132
+ return {
133
+ unsubscribed: true
134
+ };
135
+ },
136
+ 'hub/receive': async (ctx)=>{
137
+ const clientDID = getClientDID(ctx);
138
+ const { after } = ctx.param ?? {};
139
+ registry.register(clientDID);
140
+ if (registry.isWriterBound(clientDID)) {
141
+ throw new HandlerError({
142
+ code: 'EK01',
143
+ message: `receive writer already bound for DID ${clientDID}`
144
+ });
145
+ }
146
+ const writer = ctx.writable.getWriter();
147
+ const reader = ctx.readable.getReader();
148
+ try {
149
+ registry.setReceiveWriter(clientDID, (message)=>{
150
+ writer.write({
151
+ sequenceID: message.sequenceID,
152
+ senderDID: message.senderDID,
153
+ topicID: message.topicID,
154
+ payload: toB64(message.payload)
155
+ }).catch(()=>{});
156
+ });
157
+ let cursor = after;
158
+ while(true){
159
+ const result = await store.fetch({
160
+ recipientDID: clientDID,
161
+ after: cursor ?? undefined,
162
+ limit: 50
163
+ });
164
+ for (const msg of result.messages){
165
+ await writer.write({
166
+ sequenceID: msg.sequenceID,
167
+ senderDID: msg.senderDID,
168
+ topicID: msg.topicID,
169
+ payload: toB64(msg.payload)
170
+ });
171
+ }
172
+ cursor = result.cursor;
173
+ if (!result.hasMore) break;
174
+ }
175
+ } catch (error) {
176
+ registry.clearReceiveWriter(clientDID);
177
+ registry.unregisterIfIdle(clientDID);
178
+ reader.cancel().catch(()=>{});
179
+ writer.abort(error).catch(()=>{});
180
+ throw error;
181
+ }
182
+ void (async ()=>{
183
+ try {
184
+ while(true){
185
+ const { done, value } = await reader.read();
186
+ if (done) break;
187
+ if (value?.ack != null) {
188
+ await store.ack({
189
+ recipientDID: clientDID,
190
+ sequenceIDs: value.ack
191
+ });
192
+ }
193
+ }
194
+ } catch {
195
+ // Channel closed
196
+ }
197
+ })();
198
+ return new Promise((resolve)=>{
199
+ ctx.signal.addEventListener('abort', ()=>{
200
+ registry.clearReceiveWriter(clientDID);
201
+ registry.unregisterIfIdle(clientDID);
202
+ reader.cancel().catch(()=>{});
203
+ writer.close().catch(()=>{});
204
+ resolve(undefined);
205
+ }, {
206
+ once: true
207
+ });
208
+ });
209
+ },
210
+ 'hub/keypackage/upload': async (ctx)=>{
211
+ const { keyPackages } = ctx.param;
212
+ const clientDID = getClientDID(ctx);
213
+ await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage(clientDID, kp)));
214
+ return {
215
+ stored: keyPackages.length
216
+ };
217
+ },
218
+ 'hub/keypackage/fetch': async (ctx)=>{
219
+ const requesterDID = getClientDID(ctx);
220
+ assertKeyPackageFetchAllowed(requesterDID);
221
+ const { did, count } = ctx.param;
222
+ const cappedCount = Math.min(Math.max(count ?? 1, 1), fetchLimits.maxCount);
223
+ const keyPackages = await store.fetchKeyPackages(did, cappedCount);
224
+ return {
225
+ keyPackages
226
+ };
227
+ }
228
+ };
229
+ }
package/lib/hub.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { ServerTransportOf } from '@enkaku/protocol';
2
+ import type { AccessRules, ResourceLimits, Server } from '@enkaku/server';
3
+ import type { Identity } from '@kokuin/token';
4
+ import { type HubProtocol, type HubStore } from '@kumiai/hub-protocol';
5
+ import { type AuthorizeHook, type HubRateLimits, type KeyPackageFetchLimits } from './handlers.js';
6
+ import { HubClientRegistry } from './registry.js';
7
+ /**
8
+ * Default access rules: any authenticated DID may call hub procedures.
9
+ * The hub is a blind relay — per-procedure authorization (the `authorize`
10
+ * hook) happens in the handlers.
11
+ */
12
+ export declare const DEFAULT_HUB_ACCESS_RULES: AccessRules;
13
+ export type HubPurgeOptions = {
14
+ /** Interval between purge runs in milliseconds. Default: 3600000 (1 hour) */
15
+ interval?: number;
16
+ /** Age in seconds after which unacked stored messages are purged. Default: 604800 (7 days) */
17
+ olderThan?: number;
18
+ };
19
+ export type CreateHubParams = {
20
+ transport: ServerTransportOf<HubProtocol>;
21
+ store: HubStore;
22
+ /**
23
+ * Hub server identity. Required: all hub procedures derive the client DID
24
+ * from the verified `iss` of signed messages.
25
+ */
26
+ identity: Identity;
27
+ /** Access rules enforced by the server. Defaults to {@link DEFAULT_HUB_ACCESS_RULES}. */
28
+ accessRules?: AccessRules;
29
+ /** Per-procedure publish/subscribe authorization. Defaults to allow-any-authed. */
30
+ authorize?: AuthorizeHook;
31
+ /** Publish rate limits. Merged over {@link DEFAULT_RATE_LIMITS}. */
32
+ rateLimits?: Partial<HubRateLimits>;
33
+ /** Quotas applied to hub/keypackage/fetch. Merged over {@link DEFAULT_KEYPACKAGE_FETCH_LIMITS}. */
34
+ keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
35
+ /** Scheduled purge of expired stored messages. Set to `false` to disable. */
36
+ purge?: HubPurgeOptions | false;
37
+ /**
38
+ * Server resource limits. `hub/receive` is always added to
39
+ * `longLivedProcedures` so open mailbox channels are exempt from
40
+ * `controllerTimeoutMs` and from the `maxConcurrentHandlers` cap.
41
+ */
42
+ limits?: Partial<ResourceLimits>;
43
+ };
44
+ export type HubInstance = {
45
+ registry: HubClientRegistry;
46
+ server: Server<HubProtocol>;
47
+ };
48
+ export declare function createHub(params: CreateHubParams): HubInstance;
package/lib/hub.js ADDED
@@ -0,0 +1,56 @@
1
+ import { serve } from '@enkaku/server';
2
+ import { hubProtocol } from '@kumiai/hub-protocol';
3
+ import { createHandlers } from './handlers.js';
4
+ import { HubClientRegistry } from './registry.js';
5
+ /**
6
+ * Default access rules: any authenticated DID may call hub procedures.
7
+ * The hub is a blind relay — per-procedure authorization (the `authorize`
8
+ * hook) happens in the handlers.
9
+ */ export const DEFAULT_HUB_ACCESS_RULES = {
10
+ 'hub/*': {
11
+ allow: true
12
+ }
13
+ };
14
+ export function createHub(params) {
15
+ const registry = new HubClientRegistry();
16
+ const handlers = createHandlers({
17
+ registry,
18
+ store: params.store,
19
+ authorize: params.authorize,
20
+ rateLimits: params.rateLimits,
21
+ keyPackageFetchLimits: params.keyPackageFetchLimits
22
+ });
23
+ const limits = {
24
+ ...params.limits,
25
+ longLivedProcedures: [
26
+ ...new Set([
27
+ ...params.limits?.longLivedProcedures ?? [],
28
+ 'hub/receive'
29
+ ])
30
+ ]
31
+ };
32
+ const server = serve({
33
+ handlers,
34
+ protocol: hubProtocol,
35
+ transport: params.transport,
36
+ identity: params.identity,
37
+ accessRules: params.accessRules ?? DEFAULT_HUB_ACCESS_RULES,
38
+ limits
39
+ });
40
+ if (params.purge !== false) {
41
+ const interval = params.purge?.interval ?? 3_600_000;
42
+ const olderThan = params.purge?.olderThan ?? 604_800;
43
+ const purgeTimer = setInterval(()=>{
44
+ params.store.purge({
45
+ olderThan
46
+ }).catch(()=>{
47
+ // Purge failures are non-fatal; retried on the next interval
48
+ });
49
+ }, interval);
50
+ server.disposed.then(()=>clearInterval(purgeTimer));
51
+ }
52
+ return {
53
+ registry,
54
+ server
55
+ };
56
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export type { AuthorizeAction, AuthorizeHook, CreateHandlersParams, HubRateLimits, KeyPackageFetchLimits, } from './handlers.js';
2
+ export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS } from './handlers.js';
3
+ export type { CreateHubParams, HubInstance, HubPurgeOptions } from './hub.js';
4
+ export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
5
+ export { createMemoryStore, type MemoryStoreOptions } from './memoryStore.js';
6
+ export { createRateLimiter, type RateLimitConfig, type RateLimiter } from './rateLimit.js';
7
+ export type { ClientEntry } from './registry.js';
8
+ export { HubClientRegistry } from './registry.js';
package/lib/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS } from './handlers.js';
2
+ export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
3
+ export { createMemoryStore } from './memoryStore.js';
4
+ export { createRateLimiter } from './rateLimit.js';
5
+ export { HubClientRegistry } from './registry.js';
@@ -0,0 +1,12 @@
1
+ import type { HubStore } from '@kumiai/hub-protocol';
2
+ export type MemoryStoreOptions = {
3
+ /** Per-topic max retained messages; oldest are trimmed beyond this. Default 1000. */
4
+ maxDepth?: number;
5
+ };
6
+ /**
7
+ * In-memory implementation of HubStore for testing and development.
8
+ *
9
+ * Single message copy + per-subscriber delivery index + refcount GC. Recipients
10
+ * are resolved from the subscription table at publish time, never passed in.
11
+ */
12
+ export declare function createMemoryStore(options?: MemoryStoreOptions): HubStore;
@@ -0,0 +1,230 @@
1
+ import { EventEmitter } from '@sozai/event';
2
+ const DEFAULT_MAX_DEPTH = 1000;
3
+ function formatSequenceID(counter) {
4
+ return String(counter).padStart(12, '0');
5
+ }
6
+ /**
7
+ * In-memory implementation of HubStore for testing and development.
8
+ *
9
+ * Single message copy + per-subscriber delivery index + refcount GC. Recipients
10
+ * are resolved from the subscription table at publish time, never passed in.
11
+ */ export function createMemoryStore(options = {}) {
12
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
13
+ let counter = 0;
14
+ const messages = new Map();
15
+ const deliveries = new Map();
16
+ const subscriptions = new Map();
17
+ const topicMessages = new Map();
18
+ const keyPackages = new Map();
19
+ const events = new EventEmitter();
20
+ // Remove a message entirely: every recipient delivery list, the topic log,
21
+ // and the message record.
22
+ function deleteMessage(sequenceID) {
23
+ const record = messages.get(sequenceID);
24
+ if (record == null) return;
25
+ for (const recipient of record.recipients){
26
+ const list = deliveries.get(recipient);
27
+ if (list != null) {
28
+ const index = list.indexOf(sequenceID);
29
+ if (index !== -1) list.splice(index, 1);
30
+ }
31
+ }
32
+ const topicLog = topicMessages.get(record.topicID);
33
+ if (topicLog != null) {
34
+ const index = topicLog.indexOf(sequenceID);
35
+ if (index !== -1) topicLog.splice(index, 1);
36
+ if (topicLog.length === 0) topicMessages.delete(record.topicID);
37
+ }
38
+ messages.delete(sequenceID);
39
+ }
40
+ // Drop one subscriber's delivery of a message; GC the message when its last
41
+ // recipient is gone (refcount → 0).
42
+ function removeDelivery(recipientDID, sequenceID) {
43
+ const list = deliveries.get(recipientDID);
44
+ if (list != null) {
45
+ const index = list.indexOf(sequenceID);
46
+ if (index !== -1) list.splice(index, 1);
47
+ }
48
+ const record = messages.get(sequenceID);
49
+ if (record != null) {
50
+ record.recipients.delete(recipientDID);
51
+ if (record.recipients.size === 0) {
52
+ deleteMessage(sequenceID);
53
+ }
54
+ }
55
+ }
56
+ return {
57
+ events,
58
+ async publish (params) {
59
+ counter++;
60
+ const sequenceID = formatSequenceID(counter);
61
+ // Recipients = current subscribers minus the sender. Zero recipients
62
+ // (no subscribers, or only the sender) → drop immediately, store nothing.
63
+ const subscribers = subscriptions.get(params.topicID);
64
+ const recipients = new Set();
65
+ if (subscribers != null) {
66
+ for (const did of subscribers){
67
+ if (did !== params.senderDID) recipients.add(did);
68
+ }
69
+ }
70
+ if (recipients.size === 0) {
71
+ return sequenceID;
72
+ }
73
+ const record = {
74
+ sequenceID,
75
+ senderDID: params.senderDID,
76
+ topicID: params.topicID,
77
+ payload: params.payload,
78
+ recipients,
79
+ storedAt: Date.now()
80
+ };
81
+ messages.set(sequenceID, record);
82
+ for (const recipient of recipients){
83
+ let list = deliveries.get(recipient);
84
+ if (list == null) {
85
+ list = [];
86
+ deliveries.set(recipient, list);
87
+ }
88
+ list.push(sequenceID);
89
+ }
90
+ let topicLog = topicMessages.get(params.topicID);
91
+ if (topicLog == null) {
92
+ topicLog = [];
93
+ topicMessages.set(params.topicID, topicLog);
94
+ }
95
+ topicLog.push(sequenceID);
96
+ // Per-topic max-depth trim: drop oldest beyond the bound.
97
+ while(topicLog.length > maxDepth){
98
+ deleteMessage(topicLog[0]);
99
+ }
100
+ return sequenceID;
101
+ },
102
+ async fetch (params) {
103
+ if (params.ack != null && params.ack.length > 0) {
104
+ for (const sequenceID of params.ack){
105
+ removeDelivery(params.recipientDID, sequenceID);
106
+ }
107
+ }
108
+ const recipientDeliveries = deliveries.get(params.recipientDID);
109
+ if (recipientDeliveries == null || recipientDeliveries.length === 0) {
110
+ return {
111
+ messages: [],
112
+ cursor: null
113
+ };
114
+ }
115
+ let startIndex = 0;
116
+ if (params.after != null) {
117
+ const afterIndex = recipientDeliveries.indexOf(params.after);
118
+ if (afterIndex !== -1) {
119
+ startIndex = afterIndex + 1;
120
+ }
121
+ }
122
+ const available = recipientDeliveries.slice(startIndex);
123
+ const limit = params.limit ?? available.length;
124
+ const selected = available.slice(0, limit);
125
+ const hasMore = available.length > limit;
126
+ const resultMessages = [];
127
+ for (const sequenceID of selected){
128
+ const record = messages.get(sequenceID);
129
+ if (record != null) {
130
+ resultMessages.push({
131
+ sequenceID: record.sequenceID,
132
+ senderDID: record.senderDID,
133
+ topicID: record.topicID,
134
+ payload: record.payload
135
+ });
136
+ }
137
+ }
138
+ const cursor = resultMessages.length > 0 ? resultMessages[resultMessages.length - 1].sequenceID : null;
139
+ const result = {
140
+ messages: resultMessages,
141
+ cursor
142
+ };
143
+ if (hasMore) {
144
+ result.hasMore = true;
145
+ }
146
+ return result;
147
+ },
148
+ async ack (params) {
149
+ for (const sequenceID of params.sequenceIDs){
150
+ removeDelivery(params.recipientDID, sequenceID);
151
+ }
152
+ },
153
+ async purge (params) {
154
+ const threshold = Date.now() - params.olderThan * 1000;
155
+ const purgedIDs = [];
156
+ for (const [sequenceID, record] of messages){
157
+ if (record.storedAt <= threshold) {
158
+ purgedIDs.push(sequenceID);
159
+ deleteMessage(sequenceID);
160
+ }
161
+ }
162
+ if (purgedIDs.length > 0) {
163
+ await events.emit('purge', {
164
+ sequenceIDs: purgedIDs
165
+ });
166
+ }
167
+ return purgedIDs;
168
+ },
169
+ async subscribe (subscriberDID, topicID) {
170
+ let subs = subscriptions.get(topicID);
171
+ if (subs == null) {
172
+ subs = new Set();
173
+ subscriptions.set(topicID, subs);
174
+ }
175
+ subs.add(subscriberDID);
176
+ },
177
+ async unsubscribe (subscriberDID, topicID) {
178
+ const subs = subscriptions.get(topicID);
179
+ if (subs != null) {
180
+ subs.delete(subscriberDID);
181
+ if (subs.size === 0) {
182
+ subscriptions.delete(topicID);
183
+ }
184
+ }
185
+ // Drop this subscriber's pending deliveries for the topic.
186
+ const list = deliveries.get(subscriberDID);
187
+ if (list != null) {
188
+ for (const sequenceID of [
189
+ ...list
190
+ ]){
191
+ const record = messages.get(sequenceID);
192
+ if (record != null && record.topicID === topicID) {
193
+ removeDelivery(subscriberDID, sequenceID);
194
+ }
195
+ }
196
+ }
197
+ // Last subscriber gone → drop the whole topic log immediately.
198
+ if (!subscriptions.has(topicID)) {
199
+ const topicLog = topicMessages.get(topicID);
200
+ if (topicLog != null) {
201
+ for (const sequenceID of [
202
+ ...topicLog
203
+ ]){
204
+ deleteMessage(sequenceID);
205
+ }
206
+ }
207
+ }
208
+ },
209
+ async getSubscribers (topicID) {
210
+ const subs = subscriptions.get(topicID);
211
+ return subs == null ? [] : [
212
+ ...subs
213
+ ];
214
+ },
215
+ async storeKeyPackage (ownerDID, keyPackage) {
216
+ let packages = keyPackages.get(ownerDID);
217
+ if (packages == null) {
218
+ packages = [];
219
+ keyPackages.set(ownerDID, packages);
220
+ }
221
+ packages.push(keyPackage);
222
+ },
223
+ async fetchKeyPackages (ownerDID, count) {
224
+ const packages = keyPackages.get(ownerDID);
225
+ if (packages == null || packages.length === 0) return [];
226
+ const n = count ?? 1;
227
+ return packages.splice(0, n);
228
+ }
229
+ };
230
+ }
@@ -0,0 +1,12 @@
1
+ export type RateLimitConfig = {
2
+ /** Sustained refill rate in tokens per second. */
3
+ rate: number;
4
+ /** Maximum bucket capacity (burst). */
5
+ burst: number;
6
+ };
7
+ export type RateLimiter = {
8
+ /** Consumes a token if available, returning true; returns false otherwise. */
9
+ tryConsume(key: string): boolean;
10
+ };
11
+ /** Per-key token-bucket rate limiter. */
12
+ export declare function createRateLimiter(config: RateLimitConfig): RateLimiter;
@@ -0,0 +1,25 @@
1
+ /** Per-key token-bucket rate limiter. */ export function createRateLimiter(config) {
2
+ const buckets = new Map();
3
+ return {
4
+ tryConsume (key) {
5
+ const now = Date.now();
6
+ let bucket = buckets.get(key);
7
+ if (bucket == null) {
8
+ bucket = {
9
+ tokens: config.burst,
10
+ lastRefill: now
11
+ };
12
+ buckets.set(key, bucket);
13
+ } else {
14
+ const elapsedSeconds = (now - bucket.lastRefill) / 1000;
15
+ bucket.tokens = Math.min(config.burst, bucket.tokens + elapsedSeconds * config.rate);
16
+ bucket.lastRefill = now;
17
+ }
18
+ if (bucket.tokens < 1) {
19
+ return false;
20
+ }
21
+ bucket.tokens -= 1;
22
+ return true;
23
+ }
24
+ };
25
+ }
@@ -0,0 +1,22 @@
1
+ import type { StoredMessage } from '@kumiai/hub-protocol';
2
+ export type ClientEntry = {
3
+ did: string;
4
+ sendMessage: ((message: StoredMessage) => void) | null;
5
+ };
6
+ /**
7
+ * Tracks online clients and their live receive-channel writers. Subscription
8
+ * state is durable in the store, not here — the registry only routes live
9
+ * fan-out to currently-connected subscribers.
10
+ */
11
+ export declare class HubClientRegistry {
12
+ #private;
13
+ register(did: string): ClientEntry;
14
+ unregister(did: string): void;
15
+ /** Removes the entry only when no receive writer is bound. */
16
+ unregisterIfIdle(did: string): void;
17
+ setReceiveWriter(did: string, writer: (message: StoredMessage) => void): void;
18
+ clearReceiveWriter(did: string): void;
19
+ getClient(did: string): ClientEntry | undefined;
20
+ isOnline(did: string): boolean;
21
+ isWriterBound(did: string): boolean;
22
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tracks online clients and their live receive-channel writers. Subscription
3
+ * state is durable in the store, not here — the registry only routes live
4
+ * fan-out to currently-connected subscribers.
5
+ */ export class HubClientRegistry {
6
+ #clients = new Map();
7
+ register(did) {
8
+ const existing = this.#clients.get(did);
9
+ if (existing != null) {
10
+ return existing;
11
+ }
12
+ const entry = {
13
+ did,
14
+ sendMessage: null
15
+ };
16
+ this.#clients.set(did, entry);
17
+ return entry;
18
+ }
19
+ unregister(did) {
20
+ this.#clients.delete(did);
21
+ }
22
+ /** Removes the entry only when no receive writer is bound. */ unregisterIfIdle(did) {
23
+ const entry = this.#clients.get(did);
24
+ if (entry != null && entry.sendMessage == null) {
25
+ this.#clients.delete(did);
26
+ }
27
+ }
28
+ setReceiveWriter(did, writer) {
29
+ const entry = this.#clients.get(did);
30
+ if (entry == null) return;
31
+ if (entry.sendMessage != null) {
32
+ throw new Error(`receive writer already bound for DID ${did}`);
33
+ }
34
+ entry.sendMessage = writer;
35
+ }
36
+ clearReceiveWriter(did) {
37
+ const entry = this.#clients.get(did);
38
+ if (entry != null) {
39
+ entry.sendMessage = null;
40
+ }
41
+ }
42
+ getClient(did) {
43
+ return this.#clients.get(did);
44
+ }
45
+ isOnline(did) {
46
+ return this.#clients.get(did)?.sendMessage != null;
47
+ }
48
+ isWriterBound(did) {
49
+ return this.#clients.get(did)?.sendMessage != null;
50
+ }
51
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@kumiai/hub-server",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "Hub server for Enkaku E2EE group message routing",
6
+ "keywords": [
7
+ "hub",
8
+ "server",
9
+ "e2ee",
10
+ "routing"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/TairuFramework/kumiai",
15
+ "directory": "packages/hub-server"
16
+ },
17
+ "type": "module",
18
+ "main": "lib/index.js",
19
+ "types": "lib/index.d.ts",
20
+ "exports": {
21
+ ".": "./lib/index.js"
22
+ },
23
+ "files": [
24
+ "lib/*"
25
+ ],
26
+ "sideEffects": false,
27
+ "dependencies": {
28
+ "@sozai/codec": "^0.1.0",
29
+ "@sozai/event": "^0.1.0",
30
+ "@enkaku/protocol": "^0.18.0",
31
+ "@enkaku/server": "^0.18.0",
32
+ "@sozai/stream": "^0.1.0",
33
+ "@kokuin/token": "^0.1.0",
34
+ "@kumiai/mls": "^0.1.0",
35
+ "@kumiai/hub-protocol": "^0.1.0"
36
+ },
37
+ "devDependencies": {
38
+ "@enkaku/client": "^0.18.0",
39
+ "@enkaku/transport": "^0.18.0"
40
+ },
41
+ "scripts": {
42
+ "build:clean": "del lib",
43
+ "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
44
+ "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
45
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
46
+ "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
47
+ "test:unit": "vitest run",
48
+ "test": "pnpm run test:types && pnpm run test:unit"
49
+ }
50
+ }