@bod.ee/db 0.10.1 → 0.11.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/.claude/settings.local.json +5 -1
- package/.claude/skills/config-file.md +3 -1
- package/.claude/skills/developing-bod-db.md +10 -1
- package/.claude/skills/using-bod-db.md +18 -1
- package/CLAUDE.md +11 -3
- package/admin/admin.ts +9 -0
- package/admin/demo.config.ts +1 -0
- package/admin/ui.html +67 -37
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/client/BodClient.ts +31 -6
- package/src/server/BodDB.ts +113 -42
- package/src/server/KeyAuthEngine.ts +32 -7
- package/src/server/ReplicationEngine.ts +31 -1
- package/src/server/StorageEngine.ts +117 -6
- package/src/server/StreamEngine.ts +20 -2
- package/src/server/SubscriptionEngine.ts +122 -35
- package/src/server/Transport.ts +131 -19
- package/src/shared/keyAuth.ts +4 -4
- package/src/shared/logger.ts +68 -0
- package/src/shared/protocol.ts +3 -1
- package/tests/keyauth.test.ts +28 -1
- package/tests/optimization.test.ts +392 -0
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import { ancestors } from '../shared/pathUtils.ts';
|
|
2
2
|
|
|
3
|
-
export type SubscriptionCallback = (snapshot: { path: string; val: () => unknown }) => void;
|
|
3
|
+
export type SubscriptionCallback = (snapshot: { path: string; val: () => unknown; updatedAt?: number }) => void;
|
|
4
4
|
export type ChildCallback = (event: { type: 'added' | 'changed' | 'removed'; key: string; path: string; val: () => unknown }) => void;
|
|
5
5
|
|
|
6
|
+
export class SubscriptionEngineOptions {
|
|
7
|
+
/** Maximum total subscriptions (default 50000) */
|
|
8
|
+
maxSubscriptions: number = 50000;
|
|
9
|
+
/** Enable async batched notifications (default false) */
|
|
10
|
+
asyncNotify: boolean = false;
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export class SubscriptionEngine {
|
|
14
|
+
readonly options: SubscriptionEngineOptions;
|
|
7
15
|
private valueSubs = new Map<string, Set<SubscriptionCallback>>();
|
|
8
16
|
private childSubs = new Map<string, Set<ChildCallback>>();
|
|
17
|
+
private _pendingNotify: Set<string> | null = null;
|
|
18
|
+
private _pendingGetFn: ((path: string) => unknown) | null = null;
|
|
19
|
+
private _pendingExisted: Set<string> | null = null;
|
|
20
|
+
private _flushScheduled = false;
|
|
21
|
+
|
|
22
|
+
constructor(options?: Partial<SubscriptionEngineOptions>) {
|
|
23
|
+
this.options = { ...new SubscriptionEngineOptions(), ...options };
|
|
24
|
+
}
|
|
9
25
|
|
|
10
26
|
get hasSubscriptions(): boolean {
|
|
11
27
|
return this.valueSubs.size > 0 || this.childSubs.size > 0;
|
|
@@ -31,6 +47,7 @@ export class SubscriptionEngine {
|
|
|
31
47
|
|
|
32
48
|
/** Subscribe to value changes at a path (fires for exact + descendant writes) */
|
|
33
49
|
onValue(path: string, cb: SubscriptionCallback): () => void {
|
|
50
|
+
if (this.size >= this.options.maxSubscriptions) throw new Error('Subscription limit reached');
|
|
34
51
|
if (!this.valueSubs.has(path)) this.valueSubs.set(path, new Set());
|
|
35
52
|
this.valueSubs.get(path)!.add(cb);
|
|
36
53
|
return () => {
|
|
@@ -41,6 +58,7 @@ export class SubscriptionEngine {
|
|
|
41
58
|
|
|
42
59
|
/** Subscribe to child events at a path */
|
|
43
60
|
onChild(path: string, cb: ChildCallback): () => void {
|
|
61
|
+
if (this.size >= this.options.maxSubscriptions) throw new Error('Subscription limit reached');
|
|
44
62
|
if (!this.childSubs.has(path)) this.childSubs.set(path, new Set());
|
|
45
63
|
this.childSubs.get(path)!.add(cb);
|
|
46
64
|
return () => {
|
|
@@ -49,6 +67,28 @@ export class SubscriptionEngine {
|
|
|
49
67
|
};
|
|
50
68
|
}
|
|
51
69
|
|
|
70
|
+
/** Push a value directly to subscribers of a specific path + its ancestors. No DB involved. */
|
|
71
|
+
pushValue(path: string, value: unknown): void {
|
|
72
|
+
const valFn = () => value;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const subs = this.valueSubs.get(path);
|
|
75
|
+
if (subs?.size) {
|
|
76
|
+
const snapshot = { path, val: valFn, updatedAt: now };
|
|
77
|
+
for (const cb of subs) { try { cb(snapshot); } catch {} }
|
|
78
|
+
}
|
|
79
|
+
// Walk ancestors by finding '/' separators (avoids split/slice/join allocations)
|
|
80
|
+
let idx = path.lastIndexOf('/');
|
|
81
|
+
while (idx > 0) {
|
|
82
|
+
const ancestor = path.substring(0, idx);
|
|
83
|
+
const asubs = this.valueSubs.get(ancestor);
|
|
84
|
+
if (asubs?.size) {
|
|
85
|
+
const snap = { path: ancestor, val: valFn, updatedAt: now };
|
|
86
|
+
for (const cb of asubs) { try { cb(snap); } catch {} }
|
|
87
|
+
}
|
|
88
|
+
idx = path.lastIndexOf('/', idx - 1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
/**
|
|
53
93
|
* Notify after a write.
|
|
54
94
|
* @param changedPaths - all leaf paths that were written
|
|
@@ -56,56 +96,103 @@ export class SubscriptionEngine {
|
|
|
56
96
|
* @param existedBefore - set of paths that existed before the write (for added/changed detection)
|
|
57
97
|
*/
|
|
58
98
|
notify(changedPaths: string[], getFn: (path: string) => unknown, existedBefore?: Set<string>) {
|
|
59
|
-
|
|
99
|
+
// Value subscriptions — walk each leaf + ancestors using indexOf (no array alloc)
|
|
100
|
+
const notified = changedPaths.length > 1 ? new Set<string>() : null;
|
|
60
101
|
|
|
61
102
|
for (const leafPath of changedPaths) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
103
|
+
// Notify exact path
|
|
104
|
+
if (!notified || !notified.has(leafPath)) {
|
|
105
|
+
notified?.add(leafPath);
|
|
106
|
+
const subs = this.valueSubs.get(leafPath);
|
|
107
|
+
if (subs?.size) {
|
|
108
|
+
const snapshot = { path: leafPath, val: () => getFn(leafPath) };
|
|
109
|
+
for (const cb of subs) { try { cb(snapshot); } catch {} }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Walk ancestors via lastIndexOf (no split/slice/join)
|
|
113
|
+
let idx = leafPath.lastIndexOf('/');
|
|
114
|
+
while (idx > 0) {
|
|
115
|
+
const ancestor = leafPath.substring(0, idx);
|
|
116
|
+
if (notified && notified.has(ancestor)) { idx = leafPath.lastIndexOf('/', idx - 1); continue; }
|
|
117
|
+
notified?.add(ancestor);
|
|
118
|
+
const subs = this.valueSubs.get(ancestor);
|
|
69
119
|
if (subs?.size) {
|
|
70
|
-
const snapshot = { path:
|
|
71
|
-
for (const cb of subs) cb(snapshot);
|
|
120
|
+
const snapshot = { path: ancestor, val: () => getFn(ancestor) };
|
|
121
|
+
for (const cb of subs) { try { cb(snapshot); } catch {} }
|
|
72
122
|
}
|
|
123
|
+
idx = leafPath.lastIndexOf('/', idx - 1);
|
|
73
124
|
}
|
|
74
125
|
}
|
|
75
126
|
|
|
76
|
-
// Child events
|
|
77
|
-
|
|
78
|
-
for (const leafPath of changedPaths) {
|
|
79
|
-
const parts = leafPath.split('/');
|
|
80
|
-
if (parts.length < 2) continue;
|
|
127
|
+
// Child events — skip entirely when no child subs
|
|
128
|
+
if (!this.childSubs.size) return;
|
|
81
129
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
130
|
+
const childNotified = changedPaths.length > 1 ? new Set<string>() : null;
|
|
131
|
+
for (const leafPath of changedPaths) {
|
|
132
|
+
// Walk parent/child pairs via indexOf
|
|
133
|
+
let start = 0;
|
|
134
|
+
let slashIdx = leafPath.indexOf('/', start);
|
|
135
|
+
while (slashIdx !== -1) {
|
|
136
|
+
const parentPath = leafPath.substring(0, slashIdx);
|
|
137
|
+
const nextSlash = leafPath.indexOf('/', slashIdx + 1);
|
|
138
|
+
const childKey = nextSlash === -1 ? leafPath.substring(slashIdx + 1) : leafPath.substring(slashIdx + 1, nextSlash);
|
|
85
139
|
const dedupKey = `${parentPath}/${childKey}`;
|
|
86
140
|
|
|
87
|
-
if (childNotified.has(dedupKey))
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
141
|
+
if (!childNotified || !childNotified.has(dedupKey)) {
|
|
142
|
+
childNotified?.add(dedupKey);
|
|
143
|
+
const subs = this.childSubs.get(parentPath);
|
|
144
|
+
if (subs?.size) {
|
|
145
|
+
const childPath = dedupKey;
|
|
146
|
+
const val = getFn(childPath);
|
|
147
|
+
let type: 'added' | 'changed' | 'removed';
|
|
148
|
+
if (val === null) {
|
|
149
|
+
type = 'removed';
|
|
150
|
+
} else if (existedBefore && !existedBefore.has(childPath)) {
|
|
151
|
+
type = 'added';
|
|
152
|
+
} else {
|
|
153
|
+
type = 'changed';
|
|
154
|
+
}
|
|
155
|
+
const event = { type, key: childKey, path: childPath, val: () => val };
|
|
156
|
+
for (const cb of subs) { try { cb(event); } catch {} }
|
|
101
157
|
}
|
|
102
|
-
const event = { type, key: childKey, path: childPath, val: () => val };
|
|
103
|
-
for (const cb of subs) cb(event);
|
|
104
158
|
}
|
|
159
|
+
start = slashIdx + 1;
|
|
160
|
+
slashIdx = leafPath.indexOf('/', start);
|
|
105
161
|
}
|
|
106
162
|
}
|
|
107
163
|
}
|
|
108
164
|
|
|
165
|
+
/** Queue notifications for async batched delivery. Coalesces multiple writes in same tick. */
|
|
166
|
+
queueNotify(changedPaths: string[], getFn: (path: string) => unknown, existedBefore?: Set<string>) {
|
|
167
|
+
if (!this.options.asyncNotify) {
|
|
168
|
+
return this.notify(changedPaths, getFn, existedBefore);
|
|
169
|
+
}
|
|
170
|
+
if (!this._pendingNotify) this._pendingNotify = new Set();
|
|
171
|
+
for (const p of changedPaths) this._pendingNotify.add(p);
|
|
172
|
+
this._pendingGetFn = getFn;
|
|
173
|
+
if (existedBefore) {
|
|
174
|
+
if (!this._pendingExisted) this._pendingExisted = new Set();
|
|
175
|
+
for (const p of existedBefore) this._pendingExisted.add(p);
|
|
176
|
+
}
|
|
177
|
+
if (!this._flushScheduled) {
|
|
178
|
+
this._flushScheduled = true;
|
|
179
|
+
queueMicrotask(() => this._flushNotify());
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private _flushNotify() {
|
|
184
|
+
this._flushScheduled = false;
|
|
185
|
+
const paths = this._pendingNotify;
|
|
186
|
+
const getFn = this._pendingGetFn;
|
|
187
|
+
const existed = this._pendingExisted;
|
|
188
|
+
this._pendingNotify = null;
|
|
189
|
+
this._pendingGetFn = null;
|
|
190
|
+
this._pendingExisted = null;
|
|
191
|
+
if (paths && getFn) {
|
|
192
|
+
this.notify([...paths], getFn, existed ?? undefined);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
109
196
|
get size(): number {
|
|
110
197
|
let count = 0;
|
|
111
198
|
for (const s of this.valueSubs.values()) count += s.size;
|
package/src/server/Transport.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { AUTH_PREFIX } from './KeyAuthEngine.ts';
|
|
|
6
6
|
import type { ClientMessage } from '../shared/protocol.ts';
|
|
7
7
|
import { Errors } from '../shared/errors.ts';
|
|
8
8
|
import { normalizePath, pathKey } from '../shared/pathUtils.ts';
|
|
9
|
+
import type { ComponentLogger } from '../shared/logger.ts';
|
|
9
10
|
import { increment, serverTimestamp, arrayUnion, arrayRemove } from '../shared/transforms.ts';
|
|
10
11
|
|
|
11
12
|
interface VfsTransfer {
|
|
@@ -31,20 +32,81 @@ export class TransportOptions {
|
|
|
31
32
|
staticRoutes?: Record<string, string>;
|
|
32
33
|
/** Custom REST routes checked before 404. Return null to fall through. */
|
|
33
34
|
extraRoutes?: Record<string, (req: Request, url: URL) => Response | Promise<Response> | null>;
|
|
35
|
+
/** Maximum concurrent WebSocket connections (default 10000) */
|
|
36
|
+
maxConnections: number = 10000;
|
|
37
|
+
/** Maximum incoming WebSocket message size in bytes (default 1MB) */
|
|
38
|
+
maxMessageSize: number = 1_048_576;
|
|
39
|
+
/** Maximum subscriptions per WebSocket client (default 500) */
|
|
40
|
+
maxSubscriptionsPerClient: number = 500;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export class Transport {
|
|
37
44
|
readonly options: TransportOptions;
|
|
38
45
|
private server: Server | null = null;
|
|
39
46
|
private _clients = new Set<ServerWebSocket<WsData>>();
|
|
47
|
+
/** Broadcast groups: path → Set of WS clients */
|
|
48
|
+
private _valueGroups = new Map<string, Set<ServerWebSocket<WsData>>>();
|
|
49
|
+
private _childGroups = new Map<string, Set<ServerWebSocket<WsData>>>();
|
|
50
|
+
/** DB unsub functions for broadcast groups */
|
|
51
|
+
private _groupUnsubs = new Map<string, () => void>();
|
|
52
|
+
private _log: ComponentLogger;
|
|
40
53
|
get clientCount(): number { return this._clients.size; }
|
|
41
54
|
|
|
55
|
+
/** Send pre-serialized stats directly to subscribed WS clients, bypassing SubscriptionEngine. */
|
|
56
|
+
broadcastStats(data: unknown, updatedAt: number): void {
|
|
57
|
+
// Check both exact path and ancestor subscribers
|
|
58
|
+
const group = this._valueGroups.get('_admin/stats');
|
|
59
|
+
const adminGroup = this._valueGroups.get('_admin');
|
|
60
|
+
if (!group?.size && !adminGroup?.size) return;
|
|
61
|
+
const payload = JSON.stringify({ type: 'value', path: '_admin/stats', data, updatedAt });
|
|
62
|
+
if (group) for (const c of group) { try { c.send(payload); } catch {} }
|
|
63
|
+
if (adminGroup) for (const c of adminGroup) { try { c.send(payload); } catch {} }
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
constructor(
|
|
43
67
|
private db: BodDB,
|
|
44
68
|
private rules: RulesEngine | null,
|
|
45
69
|
options?: Partial<TransportOptions>,
|
|
46
70
|
) {
|
|
47
71
|
this.options = { ...new TransportOptions(), ...options };
|
|
72
|
+
this._log = db.log.forComponent('transport');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Add a WS client to a broadcast group. Returns the per-client unsub function. */
|
|
76
|
+
private _addBroadcastSub(ws: ServerWebSocket<WsData>, path: string, event: 'value' | 'child'): () => void {
|
|
77
|
+
const groups = event === 'value' ? this._valueGroups : this._childGroups;
|
|
78
|
+
const unsubKey = `${event}:${path}`;
|
|
79
|
+
|
|
80
|
+
if (!groups.has(path)) {
|
|
81
|
+
groups.set(path, new Set());
|
|
82
|
+
const dbOff = event === 'value'
|
|
83
|
+
? this.db.on(path, (snap) => {
|
|
84
|
+
const group = groups.get(path);
|
|
85
|
+
if (!group?.size) return;
|
|
86
|
+
const data = snap.val();
|
|
87
|
+
const updatedAt = snap.updatedAt ?? this.db.storage.getWithMeta(snap.path)?.updatedAt;
|
|
88
|
+
const payload = JSON.stringify({ type: 'value', path: snap.path, data, updatedAt });
|
|
89
|
+
for (const c of group) { try { c.send(payload); } catch {} }
|
|
90
|
+
})
|
|
91
|
+
: this.db.onChild(path, (ev) => {
|
|
92
|
+
const group = groups.get(path);
|
|
93
|
+
if (!group?.size) return;
|
|
94
|
+
const payload = JSON.stringify({ type: 'child', path, key: ev.key, data: ev.val(), event: ev.type });
|
|
95
|
+
for (const c of group) { try { c.send(payload); } catch {} }
|
|
96
|
+
});
|
|
97
|
+
this._groupUnsubs.set(unsubKey, dbOff);
|
|
98
|
+
}
|
|
99
|
+
groups.get(path)!.add(ws);
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
const group = groups.get(path);
|
|
103
|
+
group?.delete(ws);
|
|
104
|
+
if (group?.size === 0) {
|
|
105
|
+
groups.delete(path);
|
|
106
|
+
this._groupUnsubs.get(unsubKey)?.();
|
|
107
|
+
this._groupUnsubs.delete(unsubKey);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
48
110
|
}
|
|
49
111
|
|
|
50
112
|
private async extractAuth(req: Request): Promise<Record<string, unknown> | null> {
|
|
@@ -75,6 +137,8 @@ export class Transport {
|
|
|
75
137
|
}
|
|
76
138
|
}
|
|
77
139
|
|
|
140
|
+
if (this._log.isDebug) this._log.debug(`${req.method} ${url.pathname}`);
|
|
141
|
+
|
|
78
142
|
// REST: GET /db/<path> → get
|
|
79
143
|
if (req.method === 'GET' && url.pathname.startsWith('/db/')) {
|
|
80
144
|
return (async () => {
|
|
@@ -255,7 +319,7 @@ export class Transport {
|
|
|
255
319
|
return Response.json({ ok: true });
|
|
256
320
|
}
|
|
257
321
|
|
|
258
|
-
return
|
|
322
|
+
return Response.json({ ok: false, error: 'Method not allowed', code: Errors.INTERNAL }, { status: 405 });
|
|
259
323
|
})();
|
|
260
324
|
}
|
|
261
325
|
|
|
@@ -275,16 +339,25 @@ export class Transport {
|
|
|
275
339
|
}
|
|
276
340
|
}
|
|
277
341
|
|
|
278
|
-
return
|
|
342
|
+
return Response.json({ ok: false, error: 'Not found', code: Errors.NOT_FOUND }, { status: 404 });
|
|
279
343
|
}
|
|
280
344
|
|
|
281
345
|
/** WebSocket handler config for Bun.serve() */
|
|
282
346
|
get websocketConfig() {
|
|
283
347
|
const self = this;
|
|
284
348
|
return {
|
|
285
|
-
open(ws: ServerWebSocket<WsData>) {
|
|
349
|
+
open(ws: ServerWebSocket<WsData>) {
|
|
350
|
+
if (self._clients.size >= self.options.maxConnections) {
|
|
351
|
+
ws.close(1008, 'At capacity');
|
|
352
|
+
self._log.warn(`Connection rejected: at capacity (${self.options.maxConnections})`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
self._clients.add(ws);
|
|
356
|
+
self._log.debug(`Client connected (${self._clients.size} total)`);
|
|
357
|
+
},
|
|
286
358
|
close(ws: ServerWebSocket<WsData>) {
|
|
287
359
|
self._clients.delete(ws);
|
|
360
|
+
self._log.debug(`Client disconnected (${self._clients.size} total)`);
|
|
288
361
|
for (const off of ws.data.valueSubs.values()) off();
|
|
289
362
|
for (const off of ws.data.childSubs.values()) off();
|
|
290
363
|
for (const off of ws.data.streamSubs.values()) off();
|
|
@@ -293,6 +366,11 @@ export class Transport {
|
|
|
293
366
|
ws.data.streamSubs.clear();
|
|
294
367
|
},
|
|
295
368
|
async message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
369
|
+
const rawLen = typeof raw === 'string' ? Buffer.byteLength(raw) : raw.byteLength;
|
|
370
|
+
if (rawLen > self.options.maxMessageSize) {
|
|
371
|
+
ws.send(JSON.stringify({ id: '?', ok: false, error: 'Message too large', code: Errors.INTERNAL }));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
296
374
|
let msg: ClientMessage;
|
|
297
375
|
try {
|
|
298
376
|
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
@@ -303,7 +381,10 @@ export class Transport {
|
|
|
303
381
|
|
|
304
382
|
const { id } = msg;
|
|
305
383
|
const reply = (data: unknown, extra?: Record<string, unknown>) => ws.send(JSON.stringify({ id, ok: true, data, ...extra }));
|
|
306
|
-
const error = (err: string, code: string) =>
|
|
384
|
+
const error = (err: string, code: string) => {
|
|
385
|
+
self._log.warn(`WS op=${msg.op} path=${(msg as any).path ?? '—'} → ${err}`);
|
|
386
|
+
ws.send(JSON.stringify({ id, ok: false, error: err, code }));
|
|
387
|
+
};
|
|
307
388
|
// Block external writes to _auth/ prefix (only KeyAuthEngine may write)
|
|
308
389
|
const guardAuthPrefix = (path: string) => {
|
|
309
390
|
if (self.options.keyAuth && normalizePath(path).startsWith(AUTH_PREFIX)) {
|
|
@@ -394,21 +475,16 @@ export class Transport {
|
|
|
394
475
|
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
395
476
|
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
396
477
|
}
|
|
478
|
+
if (ws.data.valueSubs.size + ws.data.childSubs.size >= self.options.maxSubscriptionsPerClient) {
|
|
479
|
+
return error('Per-client subscription limit reached', Errors.INTERNAL);
|
|
480
|
+
}
|
|
397
481
|
const subKey = `${msg.event}:${msg.path}`;
|
|
398
482
|
if (msg.event === 'value') {
|
|
399
483
|
if (ws.data.valueSubs.has(subKey)) return reply(null);
|
|
400
|
-
|
|
401
|
-
const meta = self.db.storage.getWithMeta(snap.path);
|
|
402
|
-
ws.send(JSON.stringify({ type: 'value', path: snap.path, data: snap.val(), updatedAt: meta?.updatedAt }));
|
|
403
|
-
});
|
|
404
|
-
ws.data.valueSubs.set(subKey, off);
|
|
484
|
+
ws.data.valueSubs.set(subKey, self._addBroadcastSub(ws, msg.path, 'value'));
|
|
405
485
|
} else if (msg.event === 'child') {
|
|
406
486
|
if (ws.data.childSubs.has(subKey)) return reply(null);
|
|
407
|
-
|
|
408
|
-
const off = self.db.onChild(subPath, (event) => {
|
|
409
|
-
ws.send(JSON.stringify({ type: 'child', path: subPath, key: event.key, data: event.val(), event: event.type }));
|
|
410
|
-
});
|
|
411
|
-
ws.data.childSubs.set(subKey, off);
|
|
487
|
+
ws.data.childSubs.set(subKey, self._addBroadcastSub(ws, msg.path, 'child'));
|
|
412
488
|
}
|
|
413
489
|
return reply(null);
|
|
414
490
|
}
|
|
@@ -708,16 +784,30 @@ export class Transport {
|
|
|
708
784
|
case 'auth-verify': {
|
|
709
785
|
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
710
786
|
const result = self.options.keyAuth.verify(msg.publicKey, msg.signature, msg.nonce);
|
|
711
|
-
if (!result)
|
|
787
|
+
if (!result) {
|
|
788
|
+
self._log.warn(`Auth failed for key ${msg.publicKey?.slice(0, 16)}…`);
|
|
789
|
+
return error('Authentication failed', Errors.AUTH_REQUIRED);
|
|
790
|
+
}
|
|
712
791
|
// Also set ws auth context
|
|
713
792
|
const ctx = self.options.keyAuth.validateToken(result.token);
|
|
714
793
|
if (ctx) ws.data.auth = ctx as unknown as Record<string, unknown>;
|
|
794
|
+
self._log.info(`Auth succeeded: ${(ctx as any)?.fingerprint?.slice(0, 12) ?? 'unknown'}…`);
|
|
715
795
|
return reply(result);
|
|
716
796
|
}
|
|
717
797
|
case 'auth-link-device': {
|
|
718
798
|
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
799
|
+
// Resolve account: by fingerprint or displayName
|
|
800
|
+
let accountFp = msg.accountFingerprint;
|
|
801
|
+
if (!accountFp && msg.displayName) {
|
|
802
|
+
const match = self.options.keyAuth.listAccounts().find(
|
|
803
|
+
(a: any) => a.displayName?.toLowerCase() === msg.displayName.toLowerCase()
|
|
804
|
+
);
|
|
805
|
+
if (!match) return error('Account not found', Errors.AUTH_REQUIRED);
|
|
806
|
+
accountFp = match.fingerprint;
|
|
807
|
+
}
|
|
808
|
+
if (!accountFp) return error('accountFingerprint or displayName required', Errors.INVALID);
|
|
719
809
|
// Password is the auth — engine.linkDevice verifies it
|
|
720
|
-
const linked = self.options.keyAuth.linkDevice(
|
|
810
|
+
const linked = self.options.keyAuth.linkDevice(accountFp, msg.password, msg.devicePublicKey, msg.deviceName);
|
|
721
811
|
if (!linked) return error('Link failed (wrong password or account not found)', Errors.AUTH_REQUIRED);
|
|
722
812
|
return reply(linked);
|
|
723
813
|
}
|
|
@@ -737,14 +827,14 @@ export class Transport {
|
|
|
737
827
|
}
|
|
738
828
|
case 'auth-create-account': {
|
|
739
829
|
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
740
|
-
// Root-only, or allowed when zero accounts exist (bootstrap)
|
|
830
|
+
// Root-only, or allowed when zero accounts exist (bootstrap), or open registration
|
|
741
831
|
if (ws.data.auth) {
|
|
742
832
|
const ctx = ws.data.auth as { isRoot?: boolean };
|
|
743
833
|
if (!ctx.isRoot) return error('Root only', Errors.PERMISSION_DENIED);
|
|
744
834
|
} else {
|
|
745
|
-
// Only allow unauthenticated if no accounts exist yet
|
|
746
835
|
const accounts = self.options.keyAuth.listAccounts();
|
|
747
|
-
|
|
836
|
+
const allowOpen = self.options.keyAuth.options.allowOpenRegistration;
|
|
837
|
+
if (accounts.length > 0 && !allowOpen) return error('Authentication required', Errors.AUTH_REQUIRED);
|
|
748
838
|
}
|
|
749
839
|
const result = self.options.keyAuth.createAccount(msg.password, msg.roles, msg.displayName, msg.devicePublicKey);
|
|
750
840
|
return reply(result);
|
|
@@ -846,6 +936,20 @@ export class Transport {
|
|
|
846
936
|
return reply(info);
|
|
847
937
|
}
|
|
848
938
|
|
|
939
|
+
case 'batch-sub': {
|
|
940
|
+
let count = 0;
|
|
941
|
+
for (const sub of msg.subscriptions) {
|
|
942
|
+
if (self.rules && !self.rules.check('read', sub.path, ws.data.auth)) continue;
|
|
943
|
+
if (ws.data.valueSubs.size + ws.data.childSubs.size >= self.options.maxSubscriptionsPerClient) break;
|
|
944
|
+
const bSubKey = `${sub.event}:${sub.path}`;
|
|
945
|
+
const subsMap = sub.event === 'value' ? ws.data.valueSubs : ws.data.childSubs;
|
|
946
|
+
if (subsMap.has(bSubKey)) { count++; continue; }
|
|
947
|
+
subsMap.set(bSubKey, self._addBroadcastSub(ws, sub.path, sub.event as 'value' | 'child'));
|
|
948
|
+
count++;
|
|
949
|
+
}
|
|
950
|
+
return reply({ subscribed: count });
|
|
951
|
+
}
|
|
952
|
+
|
|
849
953
|
case 'transform': {
|
|
850
954
|
const { path: tPath, type, value: tVal } = msg as any;
|
|
851
955
|
let sentinel: unknown;
|
|
@@ -877,11 +981,19 @@ export class Transport {
|
|
|
877
981
|
return reply(summary);
|
|
878
982
|
}
|
|
879
983
|
|
|
984
|
+
case 'admin-stats-toggle': {
|
|
985
|
+
const enable = msg.enabled;
|
|
986
|
+
if (enable) self.db.startStatsPublisher();
|
|
987
|
+
else self.db.stopStatsPublisher();
|
|
988
|
+
return reply({ enabled: self.db.statsEnabled });
|
|
989
|
+
}
|
|
990
|
+
|
|
880
991
|
default:
|
|
881
992
|
return error('Unknown operation', Errors.INTERNAL);
|
|
882
993
|
}
|
|
883
994
|
} catch (e: unknown) {
|
|
884
995
|
const message = e instanceof Error ? e.message : 'Internal error';
|
|
996
|
+
self._log.error(`op=${msg.op} path=${msg.path ?? '—'} error: ${message}`);
|
|
885
997
|
return error(message, Errors.INTERNAL);
|
|
886
998
|
}
|
|
887
999
|
},
|
package/src/shared/keyAuth.ts
CHANGED
|
@@ -119,10 +119,10 @@ const PBKDF2_ITERATIONS = 100_000;
|
|
|
119
119
|
const PBKDF2_KEYLEN = 32; // 256 bits
|
|
120
120
|
const PBKDF2_DIGEST = 'sha256';
|
|
121
121
|
|
|
122
|
-
export function encryptPrivateKey(privateKeyDer: Uint8Array, password: string): { encrypted: string; salt: string; iv: string; authTag: string } {
|
|
122
|
+
export function encryptPrivateKey(privateKeyDer: Uint8Array, password: string, iterations?: number): { encrypted: string; salt: string; iv: string; authTag: string } {
|
|
123
123
|
const salt = randomBytes(16);
|
|
124
124
|
const iv = randomBytes(12); // 96-bit for GCM
|
|
125
|
-
const derivedKey = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
125
|
+
const derivedKey = pbkdf2Sync(password, salt, iterations ?? PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
126
126
|
const cipher = createCipheriv('aes-256-gcm', derivedKey, iv);
|
|
127
127
|
const encrypted = Buffer.concat([cipher.update(privateKeyDer), cipher.final()]);
|
|
128
128
|
const authTag = cipher.getAuthTag();
|
|
@@ -134,8 +134,8 @@ export function encryptPrivateKey(privateKeyDer: Uint8Array, password: string):
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
export function decryptPrivateKey(encrypted: string, salt: string, iv: string, authTag: string, password: string): Uint8Array {
|
|
138
|
-
const derivedKey = pbkdf2Sync(password, Buffer.from(salt, 'base64'), PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
137
|
+
export function decryptPrivateKey(encrypted: string, salt: string, iv: string, authTag: string, password: string, iterations?: number): Uint8Array {
|
|
138
|
+
const derivedKey = pbkdf2Sync(password, Buffer.from(salt, 'base64'), iterations ?? PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
139
139
|
const decipher = createDecipheriv('aes-256-gcm', derivedKey, Buffer.from(iv, 'base64'));
|
|
140
140
|
decipher.setAuthTag(Buffer.from(authTag, 'base64'));
|
|
141
141
|
const decrypted = Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64')), decipher.final()]);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BodDB internal logger — config-driven, zero-dependency.
|
|
3
|
+
* Disabled by default. Enable via `log` option in BodDBOptions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
7
|
+
|
|
8
|
+
export interface LogConfig {
|
|
9
|
+
/** Enable logging (default: false) */
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
/** Minimum log level (default: 'info') */
|
|
12
|
+
level?: LogLevel;
|
|
13
|
+
/** Enable specific components: ['storage', 'transport', 'subs', 'replication', 'stats', 'keyauth'] or '*' for all */
|
|
14
|
+
components?: string[] | '*';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
18
|
+
|
|
19
|
+
export class Logger {
|
|
20
|
+
private enabled: boolean;
|
|
21
|
+
private minLevel: number;
|
|
22
|
+
private components: Set<string> | '*';
|
|
23
|
+
private _cache = new Map<string, ComponentLogger>();
|
|
24
|
+
|
|
25
|
+
constructor(config?: LogConfig) {
|
|
26
|
+
this.enabled = config?.enabled ?? false;
|
|
27
|
+
this.minLevel = LEVELS[config?.level ?? 'info'];
|
|
28
|
+
this.components = config?.components === '*' ? '*' : new Set(config?.components ?? []);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
forComponent(name: string): ComponentLogger {
|
|
32
|
+
let cl = this._cache.get(name);
|
|
33
|
+
if (!cl) { cl = new ComponentLogger(this, name); this._cache.set(name, cl); }
|
|
34
|
+
return cl;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
shouldLog(level: LogLevel, component: string): boolean {
|
|
38
|
+
if (!this.enabled) return false;
|
|
39
|
+
if (LEVELS[level] < this.minLevel) return false;
|
|
40
|
+
if (this.components !== '*' && !this.components.has(component)) return false;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
log(level: LogLevel, component: string, msg: string, data?: unknown): void {
|
|
45
|
+
if (!this.shouldLog(level, component)) return;
|
|
46
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
47
|
+
const prefix = `[${ts}] [${level.toUpperCase()}] [${component}]`;
|
|
48
|
+
if (data !== undefined) {
|
|
49
|
+
console[level === 'debug' ? 'log' : level](prefix, msg, data);
|
|
50
|
+
} else {
|
|
51
|
+
console[level === 'debug' ? 'log' : level](prefix, msg);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class ComponentLogger {
|
|
57
|
+
readonly isDebug: boolean;
|
|
58
|
+
constructor(private logger: Logger, private component: string) {
|
|
59
|
+
this.isDebug = logger.shouldLog('debug', component);
|
|
60
|
+
}
|
|
61
|
+
debug(msg: string, data?: unknown) { this.logger.log('debug', this.component, msg, data); }
|
|
62
|
+
info(msg: string, data?: unknown) { this.logger.log('info', this.component, msg, data); }
|
|
63
|
+
warn(msg: string, data?: unknown) { this.logger.log('warn', this.component, msg, data); }
|
|
64
|
+
error(msg: string, data?: unknown) { this.logger.log('error', this.component, msg, data); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Global no-op logger (used when logging disabled) */
|
|
68
|
+
export const noopLogger = new Logger();
|
package/src/shared/protocol.ts
CHANGED
|
@@ -65,7 +65,9 @@ export type ClientMessage =
|
|
|
65
65
|
| { id: string; op: 'transform'; path: string; type: string; value?: unknown }
|
|
66
66
|
| { id: string; op: 'set-ttl'; path: string; value: unknown; ttl: number }
|
|
67
67
|
| { id: string; op: 'sweep' }
|
|
68
|
-
| { id: string; op: 'get-rules' }
|
|
68
|
+
| { id: string; op: 'get-rules' }
|
|
69
|
+
| { id: string; op: 'batch-sub'; subscriptions: Array<{ path: string; event: SubEvent }> }
|
|
70
|
+
| { id: string; op: 'admin-stats-toggle'; enabled: boolean };
|
|
69
71
|
|
|
70
72
|
// Server → Client messages
|
|
71
73
|
export type ServerMessage =
|
package/tests/keyauth.test.ts
CHANGED
|
@@ -796,13 +796,40 @@ describe('KeyAuth', () => {
|
|
|
796
796
|
expect(result.data.isRoot).toBe(true);
|
|
797
797
|
expect(result.data.deviceFingerprint).toBeTruthy();
|
|
798
798
|
|
|
799
|
-
//
|
|
799
|
+
// With open registration (default), second create is allowed
|
|
800
800
|
const ws2 = await openWs();
|
|
801
|
+
const second = await wsSend(ws2, { op: 'auth-create-account', password: 'user2' });
|
|
802
|
+
expect(second.ok).toBe(true);
|
|
803
|
+
expect(second.data.isRoot).toBe(false);
|
|
804
|
+
ws.close();
|
|
805
|
+
ws2.close();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('auth-create-account closed registration: second account rejected', async () => {
|
|
809
|
+
// Use a separate DB with closed registration
|
|
810
|
+
const closedPort = 14400 + Math.floor(Math.random() * 1000);
|
|
811
|
+
const closedDb = new BodDB({ path: ':memory:', sweepInterval: 0, keyAuth: { allowOpenRegistration: false } });
|
|
812
|
+
closedDb.serve({ port: closedPort });
|
|
813
|
+
const openClosedWs = () => new Promise<WebSocket>((res, rej) => {
|
|
814
|
+
const w = new WebSocket(`ws://localhost:${closedPort}`);
|
|
815
|
+
w.addEventListener('open', () => res(w));
|
|
816
|
+
w.addEventListener('error', rej);
|
|
817
|
+
});
|
|
818
|
+
const deviceKp = await browserGenerateKeyPair();
|
|
819
|
+
const ws = await openClosedWs();
|
|
820
|
+
const result = await wsSend(ws, {
|
|
821
|
+
op: 'auth-create-account', password: 'root-pw', displayName: 'Root',
|
|
822
|
+
devicePublicKey: deviceKp.publicKeyBase64,
|
|
823
|
+
});
|
|
824
|
+
expect(result.ok).toBe(true);
|
|
825
|
+
|
|
826
|
+
const ws2 = await openClosedWs();
|
|
801
827
|
const reject = await wsSend(ws2, { op: 'auth-create-account', password: 'hacker' });
|
|
802
828
|
expect(reject.ok).toBe(false);
|
|
803
829
|
expect(reject.code).toBe('AUTH_REQUIRED');
|
|
804
830
|
ws.close();
|
|
805
831
|
ws2.close();
|
|
832
|
+
closedDb.close();
|
|
806
833
|
});
|
|
807
834
|
|
|
808
835
|
it('auth-register-device via WS', async () => {
|