@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.
@@ -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
- const notified = new Set<string>();
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
- const pathsToNotify = [leafPath, ...ancestors(leafPath)];
63
-
64
- for (const p of pathsToNotify) {
65
- if (notified.has(p)) continue;
66
- notified.add(p);
67
-
68
- const subs = this.valueSubs.get(p);
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: p, val: () => getFn(p) };
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
- const childNotified = new Set<string>();
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
- for (let i = 1; i < parts.length; i++) {
83
- const parentPath = parts.slice(0, i).join('/');
84
- const childKey = parts[i];
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)) continue;
88
- childNotified.add(dedupKey);
89
-
90
- const subs = this.childSubs.get(parentPath);
91
- if (subs?.size) {
92
- const childPath = `${parentPath}/${childKey}`;
93
- const val = getFn(childPath);
94
- let type: 'added' | 'changed' | 'removed';
95
- if (val === null) {
96
- type = 'removed';
97
- } else if (existedBefore && !existedBefore.has(childPath)) {
98
- type = 'added';
99
- } else {
100
- type = 'changed';
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;
@@ -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 new Response('Method not allowed', { status: 405 });
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 new Response('Not Found', { status: 404 });
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>) { self._clients.add(ws); },
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) => ws.send(JSON.stringify({ id, ok: false, error: err, code }));
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
- const off = self.db.on(msg.path, (snap) => {
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
- const subPath = msg.path;
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) return error('Authentication failed', Errors.AUTH_REQUIRED);
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(msg.accountFingerprint, msg.password, msg.devicePublicKey, msg.deviceName);
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
- if (accounts.length > 0) return error('Authentication required', Errors.AUTH_REQUIRED);
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
  },
@@ -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();
@@ -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 =
@@ -796,13 +796,40 @@ describe('KeyAuth', () => {
796
796
  expect(result.data.isRoot).toBe(true);
797
797
  expect(result.data.deviceFingerprint).toBeTruthy();
798
798
 
799
- // Second unauthenticated create should be rejected
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 () => {