@bod.ee/db 0.9.0 → 0.10.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/cli.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  import { BodDB } from './index.ts';
3
3
  import { existsSync, mkdirSync } from 'fs';
4
4
  import { dirname, resolve } from 'path';
5
- import { cpus, totalmem } from 'os';
6
5
 
7
6
  const args = process.argv.slice(2);
8
7
  const flags: Record<string, string> = {};
@@ -31,11 +30,13 @@ for (let i = 0; i < args.length; i++) {
31
30
 
32
31
  // Load config file
33
32
  let options: Record<string, unknown> = {};
33
+ let setup: ((db: InstanceType<typeof BodDB>) => void | Promise<void>) | undefined;
34
34
  if (configPath) {
35
35
  const abs = resolve(configPath);
36
36
  if (configPath.endsWith('.ts') || configPath.endsWith('.js')) {
37
37
  const mod = await import(abs);
38
38
  options = { ...(mod.default ?? mod.config ?? mod) };
39
+ if (typeof mod.setup === 'function') setup = mod.setup;
39
40
  } else if (configPath.endsWith('.json')) {
40
41
  const text = require('fs').readFileSync(abs, 'utf-8');
41
42
  const parsed = JSON.parse(text);
@@ -56,7 +57,8 @@ if (dbPath !== ':memory:') {
56
57
  }
57
58
 
58
59
  const db = await BodDB.create(options as any);
59
- const server = db.serve();
60
+ if (setup) await setup(db);
61
+ const server = db.serve(options.transport as any);
60
62
  const port = server?.port ?? options.port ?? 4400;
61
63
 
62
64
  // Start replication if configured
@@ -84,47 +86,6 @@ console.log([
84
86
  '',
85
87
  ].join('\n'));
86
88
 
87
- // Admin stats (for admin UI stats bar)
88
- if (options.transport?.staticRoutes) {
89
- const { statSync } = require('fs');
90
- let lastCpu = process.cpuUsage();
91
- let lastTime = performance.now();
92
- let lastOsCpus = cpus();
93
- const systemCpuPercent = (): number => {
94
- const cur = cpus();
95
- let idleDelta = 0, totalDelta = 0;
96
- for (let i = 0; i < cur.length; i++) {
97
- const prev = lastOsCpus[i]?.times ?? cur[i].times;
98
- const c = cur[i].times;
99
- idleDelta += c.idle - prev.idle;
100
- totalDelta += (c.user + c.nice + c.sys + c.irq + c.idle) - (prev.user + prev.nice + prev.sys + prev.irq + prev.idle);
101
- }
102
- lastOsCpus = cur;
103
- return totalDelta > 0 ? +((1 - idleDelta / totalDelta) * 100).toFixed(1) : 0;
104
- };
105
- setInterval(() => {
106
- if (!db.subs.subscriberCount('_admin')) return;
107
- const now = performance.now();
108
- const cpu = process.cpuUsage();
109
- const elapsedUs = (now - lastTime) * 1000;
110
- const cpuPercent = +((cpu.user - lastCpu.user + cpu.system - lastCpu.system) / elapsedUs * 100).toFixed(1);
111
- lastCpu = cpu; lastTime = now;
112
- const mem = process.memoryUsage();
113
- const nodeCount = (db.storage.db.query('SELECT COUNT(*) as n FROM nodes WHERE mq_status IS NULL').get() as any).n;
114
- let dbSizeMb = 0;
115
- try { dbSizeMb = +(statSync(resolve(dbPath)).size / 1024 / 1024).toFixed(2); } catch {}
116
- db.set('_admin/stats', {
117
- process: { cpuPercent, heapUsedMb: +(mem.heapUsed / 1024 / 1024).toFixed(2), rssMb: +(mem.rss / 1024 / 1024).toFixed(2), uptimeSec: Math.floor(process.uptime()) },
118
- db: { nodeCount, sizeMb: dbSizeMb },
119
- system: { cpuCores: cpus().length, totalMemMb: +(totalmem() / 1024 / 1024).toFixed(0), cpuPercent: systemCpuPercent() },
120
- subs: db.subs.subscriberCount(),
121
- clients: db.transport?.clientCount ?? 0,
122
- repl: db.replication?.stats() ?? null,
123
- ts: Date.now(),
124
- });
125
- }, 1000);
126
- }
127
-
128
89
  // Graceful shutdown
129
90
  process.on('SIGINT', () => { db.close(); process.exit(0); });
130
91
  process.on('SIGTERM', () => { db.close(); process.exit(0); });
package/client.ts CHANGED
@@ -1,3 +1,5 @@
1
- export { BodClient, BodClientOptions, ValueSnapshot, ClientQueryBuilder, StreamReader, MQReader, MQMessageSnapshot, VFSClient } from './src/client/BodClient.ts';
2
- export type { ChildEvent, StreamEventSnapshot, FileStat } from './src/client/BodClient.ts';
3
- export { CachedClient, CachedClientOptions } from './src/client/CachedClient.ts';
1
+ export { BodClient, BodClientOptions, ValueSnapshot, ClientQueryBuilder, StreamReader, MQReader, MQMessageSnapshot, VFSClient, AuthFacade } from './src/client/BodClient.ts';
2
+ export type { ChildEvent, StreamEventSnapshot, FileStat, DeviceKeyStorage } from './src/client/BodClient.ts';
3
+ export { BodClientCached, BodClientCachedOptions } from './src/client/BodClientCached.ts';
4
+ export { generateKeyPair, sign, browserFingerprint, wrapPublicKey } from './src/shared/keyAuth.browser.ts';
5
+ export type { BrowserKeyPair } from './src/shared/keyAuth.browser.ts';
package/config.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import type { BodDBOptions } from './src/server/BodDB.ts';
2
2
 
3
+ // ── Configuration ─────────────────────────────────────────────────────────────
4
+ const PORT = 4444;
5
+ const DB_PATH = './.tmp/bod.db';
6
+ const VFS_ROOT = './.tmp/vfs';
7
+ // ──────────────────────────────────────────────────────────────────────────────
8
+
3
9
  export default {
4
- path: './.tmp/bod.db',
5
- port: 4400,
10
+ port: PORT,
11
+ path: DB_PATH,
6
12
  sweepInterval: 60000,
7
13
  rules: {
8
14
  '': { read: true, write: true },
@@ -14,7 +20,8 @@ export default {
14
20
  fts: {},
15
21
  vectors: { dimensions: 384 },
16
22
  mq: { visibilityTimeout: 30, maxDeliveries: 5 },
17
- vfs: { storageRoot: './.tmp/vfs' },
23
+ vfs: { storageRoot: VFS_ROOT },
24
+ keyAuth: {},
18
25
  compact: {
19
26
  'events/logs': { maxAge: 86400 },
20
27
  },
package/index.ts CHANGED
@@ -13,9 +13,14 @@ export { StreamEngine, StreamEngineOptions } from './src/server/StreamEngine.ts'
13
13
  export { MQEngine, MQEngineOptions } from './src/server/MQEngine.ts';
14
14
  export type { MQMessage } from './src/server/MQEngine.ts';
15
15
  export type { StreamEvent, CompactOptions, CompactResult, StreamSnapshot } from './src/server/StreamEngine.ts';
16
+ export { VFSEngine, VFSEngineOptions, DiskBackend, LocalBackend } from './src/server/VFSEngine.ts';
17
+ export type { VFSBackend } from './src/server/VFSEngine.ts';
16
18
  export { FileAdapter, FileAdapterOptions } from './src/server/FileAdapter.ts';
17
19
  export { ReplicationEngine, ReplicationOptions, ReplicationSource } from './src/server/ReplicationEngine.ts';
18
20
  export type { ReplEvent, WriteEvent } from './src/server/ReplicationEngine.ts';
21
+ export { KeyAuthEngine, KeyAuthEngineOptions } from './src/server/KeyAuthEngine.ts';
22
+ export type { KeyAuthAccount, KeyAuthDevice, KeyAuthRole, KeyAuthSession, KeyAuthContext, KeyAuthTokenPayload } from './src/shared/keyAuth.ts';
23
+ export { fingerprint, generateEd25519KeyPair, signData, verifySignature, encryptPrivateKey, decryptPrivateKey } from './src/shared/keyAuth.ts';
19
24
  export { compileRule } from './src/server/ExpressionRules.ts';
20
25
  export { increment, serverTimestamp, arrayUnion, arrayRemove, ref } from './src/shared/transforms.ts';
21
26
  export * from './src/shared/protocol.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bod.ee/db",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,10 @@
16
16
  "yaml": "^2.8.2"
17
17
  },
18
18
  "scripts": {
19
- "admin": "bun --watch run admin/server.ts",
19
+ "build": "echo 'no build step'",
20
+ "gen:models": "echo 'no gen:models step'",
21
+ "admin": "bun run admin/admin.ts",
22
+ "demo": "bun --watch run cli.ts admin/demo.config.ts",
20
23
  "admin:remote": "bun run admin/proxy.ts",
21
24
  "serve": "bun run cli.ts",
22
25
  "start": "bun run cli.ts config.ts",
@@ -34,5 +37,8 @@
34
37
  },
35
38
  "peerDependencies": {
36
39
  "typescript": "^5"
40
+ },
41
+ "dependencies": {
42
+ "@noble/ed25519": "^3.0.0"
37
43
  }
38
44
  }
@@ -4,11 +4,26 @@ import { pathKey } from '../shared/pathUtils.ts';
4
4
  export class BodClientOptions {
5
5
  url: string = 'ws://localhost:4400';
6
6
  auth?: () => string | Promise<string>;
7
+ /** Ed25519 key pair for KeyAuth challenge-response. signFn signs (nonce: string) => base64 signature. */
8
+ keyAuth?: { publicKey: string; signFn: (nonce: string) => string | Promise<string> };
9
+ /**
10
+ * Auto device auth: generate keypair, register on server, authenticate — all transparent.
11
+ * Requires `@noble/ed25519` (browser) or Node crypto. Set to `true` or pass options.
12
+ * Keypair stored in localStorage (browser) or in-memory (Node).
13
+ */
14
+ autoAuth?: boolean | { displayName?: string; storage?: DeviceKeyStorage };
7
15
  reconnect: boolean = true;
8
16
  reconnectInterval: number = 1000;
9
17
  maxReconnectInterval: number = 30000;
10
18
  }
11
19
 
20
+ /** Interface for persisting device keys. Default: localStorage (browser) or in-memory (Node). */
21
+ export interface DeviceKeyStorage {
22
+ get(key: string): string | null;
23
+ set(key: string, value: string): void;
24
+ remove(key: string): void;
25
+ }
26
+
12
27
  type PendingRequest = {
13
28
  resolve: (data: unknown) => void;
14
29
  reject: (err: Error) => void;
@@ -26,15 +41,28 @@ export class BodClient {
26
41
  private activeStreamSubs = new Set<string>(); // tracks 'path:groupId' keys
27
42
  private vfsDownloadCbs = new Map<string, Set<(chunk: { data: string; seq: number; done: boolean }) => void>>();
28
43
  private closed = false;
44
+ private _browserAuth: typeof import('../shared/keyAuth.browser.ts') | null = null;
29
45
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
30
46
  private reconnectDelay: number;
31
47
  private connectPromise: Promise<void> | null = null;
48
+ private _authToken: string | null = null;
49
+ private _deviceFingerprint: string | null = null;
50
+
51
+ /** Auth convenience methods */
52
+ readonly auth: AuthFacade;
32
53
 
33
54
  constructor(options?: Partial<BodClientOptions>) {
34
55
  this.options = { ...new BodClientOptions(), ...options };
35
56
  this.reconnectDelay = this.options.reconnectInterval;
57
+ this.auth = new AuthFacade(this);
36
58
  }
37
59
 
60
+ /** Device fingerprint (available after autoAuth or registerDevice) */
61
+ get deviceFingerprint(): string | null { return this._deviceFingerprint; }
62
+
63
+ /** Current auth token (available after autoAuth or manual authenticate) */
64
+ get authToken(): string | null { return this._authToken; }
65
+
38
66
  async connect(): Promise<void> {
39
67
  if (this.ws?.readyState === WebSocket.OPEN) return;
40
68
  if (this.connectPromise) return this.connectPromise;
@@ -48,8 +76,42 @@ export class BodClient {
48
76
  this.reconnectDelay = this.options.reconnectInterval;
49
77
 
50
78
  try {
51
- // Auth if configured
52
- if (this.options.auth) {
79
+ // AutoAuth: bootstrap device identity if configured
80
+ if (this.options.autoAuth && !this.options.keyAuth) {
81
+ const kp = await this._ensureDeviceKeys();
82
+ this.options.keyAuth = {
83
+ publicKey: kp.publicKey,
84
+ signFn: kp.signFn,
85
+ };
86
+ // Register device on server (idempotent)
87
+ await this.send('auth-register-device', {
88
+ publicKey: kp.publicKey,
89
+ displayName: typeof this.options.autoAuth === 'object' ? this.options.autoAuth.displayName : undefined,
90
+ });
91
+ }
92
+ // KeyAuth challenge-response
93
+ if (this.options.keyAuth) {
94
+ // Try cached token first
95
+ if (this._authToken) {
96
+ try {
97
+ await this.send('auth', { token: this._authToken });
98
+ } catch {
99
+ this._authToken = null;
100
+ }
101
+ }
102
+ if (!this._authToken) {
103
+ const challenge = await this.send('auth-challenge', {}) as { nonce: string; serverId: string };
104
+ const signature = await this.options.keyAuth.signFn(challenge.nonce);
105
+ const result = await this.send('auth-verify', {
106
+ publicKey: this.options.keyAuth.publicKey,
107
+ signature,
108
+ nonce: challenge.nonce,
109
+ }) as { token: string; expiresAt: number };
110
+ this._authToken = result.token;
111
+ }
112
+ }
113
+ // Auth if configured (legacy token-based)
114
+ else if (this.options.auth) {
53
115
  const token = await this.options.auth();
54
116
  await this.send('auth', { token });
55
117
  }
@@ -414,6 +476,162 @@ export class BodClient {
414
476
  if (this.vfsDownloadCbs.get(transferId)?.size === 0) this.vfsDownloadCbs.delete(transferId);
415
477
  };
416
478
  }
479
+
480
+ /** @internal — resolve device keys from storage or generate new ones */
481
+ async _ensureDeviceKeys(): Promise<{ publicKey: string; signFn: (nonce: string) => Promise<string> }> {
482
+ this._browserAuth ??= await import('../shared/keyAuth.browser.ts');
483
+ const browserAuth = this._browserAuth;
484
+ const storage = this._getStorage();
485
+ const existing = storage.get('boddb-device-pubkey');
486
+ if (existing) {
487
+ const privKey = storage.get('boddb-device-privkey')!;
488
+ this._deviceFingerprint = storage.get('boddb-device-fp') ?? null;
489
+ return {
490
+ publicKey: existing,
491
+ signFn: (nonce: string) => browserAuth.sign(nonce, privKey),
492
+ };
493
+ }
494
+ // Generate new keypair
495
+ const kp = await browserAuth.generateKeyPair();
496
+ storage.set('boddb-device-pubkey', kp.publicKeyBase64);
497
+ storage.set('boddb-device-privkey', kp.privateKeyBase64);
498
+ storage.set('boddb-device-fp', kp.fingerprint);
499
+ this._deviceFingerprint = kp.fingerprint;
500
+ return {
501
+ publicKey: kp.publicKeyBase64,
502
+ signFn: (nonce: string) => browserAuth.sign(nonce, kp.privateKeyBase64),
503
+ };
504
+ }
505
+
506
+ private _getStorage(): DeviceKeyStorage {
507
+ if (typeof this.options.autoAuth === 'object' && this.options.autoAuth.storage) {
508
+ return this.options.autoAuth.storage;
509
+ }
510
+ // Default: localStorage (browser) or in-memory (Node)
511
+ if (typeof localStorage !== 'undefined') {
512
+ return {
513
+ get: (k) => localStorage.getItem(k),
514
+ set: (k, v) => localStorage.setItem(k, v),
515
+ remove: (k) => localStorage.removeItem(k),
516
+ };
517
+ }
518
+ // In-memory fallback
519
+ if (!this._memStorage) this._memStorage = new Map();
520
+ return {
521
+ get: (k) => this._memStorage!.get(k) ?? null,
522
+ set: (k, v) => this._memStorage!.set(k, v),
523
+ remove: (k) => this._memStorage!.delete(k),
524
+ };
525
+ }
526
+ private _memStorage: Map<string, string> | null = null;
527
+ }
528
+
529
+ export class AuthFacade {
530
+ constructor(private client: BodClient) {}
531
+
532
+ /** Register a device-generated public key as an account (idempotent) */
533
+ async registerDevice(publicKey: string, displayName?: string): Promise<{ fingerprint: string }> {
534
+ return this.client._send('auth-register-device', { publicKey, displayName }) as Promise<{ fingerprint: string }>;
535
+ }
536
+
537
+ /** Challenge-response auth flow. Returns token + expiresAt. */
538
+ async authenticate(publicKey: string, signFn: (nonce: string) => string | Promise<string>): Promise<{ token: string; expiresAt: number }> {
539
+ const challenge = await this.client._send('auth-challenge', {}) as { nonce: string; serverId: string };
540
+ const signature = await signFn(challenge.nonce);
541
+ return this.client._send('auth-verify', { publicKey, signature, nonce: challenge.nonce }) as Promise<{ token: string; expiresAt: number }>;
542
+ }
543
+
544
+ /** Link a new device to an existing account */
545
+ async linkDevice(accountFingerprint: string, password: string, devicePublicKey: string, deviceName?: string): Promise<{ fingerprint: string }> {
546
+ return this.client._send('auth-link-device', { accountFingerprint, password, devicePublicKey, deviceName }) as Promise<{ fingerprint: string }>;
547
+ }
548
+
549
+ /** Revoke a session */
550
+ async revokeSession(sid: string): Promise<void> {
551
+ await this.client._send('auth-revoke-session', { sid });
552
+ }
553
+
554
+ /** Revoke a device */
555
+ async revokeDevice(accountFingerprint: string, deviceFingerprint: string): Promise<void> {
556
+ await this.client._send('auth-revoke-device', { accountFingerprint, deviceFingerprint });
557
+ }
558
+
559
+ /** Change account password */
560
+ async changePassword(fingerprint: string, oldPassword: string, newPassword: string): Promise<void> {
561
+ await this.client._send('auth-change-password', { fingerprint, oldPassword, newPassword });
562
+ }
563
+
564
+ /** Create a password-based account. First account auto-becomes root.
565
+ * Pass devicePublicKey to auto-link the calling device. */
566
+ async createAccount(password: string, roles?: string[], displayName?: string, devicePublicKey?: string): Promise<{ publicKey: string; fingerprint: string; isRoot: boolean; deviceFingerprint?: string }> {
567
+ return this.client._send('auth-create-account', { password, roles, displayName, devicePublicKey }) as Promise<{ publicKey: string; fingerprint: string; isRoot: boolean; deviceFingerprint?: string }>;
568
+ }
569
+
570
+ /** Create a role (requires root) */
571
+ async createRole(role: { id: string; name: string; permissions: Array<{ path: string; read?: boolean; write?: boolean }> }): Promise<void> {
572
+ await this.client._send('auth-create-role', { role });
573
+ }
574
+
575
+ /** Delete a role (requires root) */
576
+ async deleteRole(roleId: string): Promise<void> {
577
+ await this.client._send('auth-delete-role', { roleId });
578
+ }
579
+
580
+ /** Update account roles (requires root) */
581
+ async updateRoles(accountFingerprint: string, roles: string[]): Promise<void> {
582
+ await this.client._send('auth-update-roles', { accountFingerprint, roles });
583
+ }
584
+
585
+ /** List all accounts */
586
+ async listAccounts(): Promise<unknown[]> {
587
+ return this.client._send('auth-list-accounts', {}) as Promise<unknown[]>;
588
+ }
589
+
590
+ /** List all roles */
591
+ async listRoles(): Promise<unknown[]> {
592
+ return this.client._send('auth-list-roles', {}) as Promise<unknown[]>;
593
+ }
594
+
595
+ /** List devices linked to an account */
596
+ async listDevices(accountFingerprint: string): Promise<unknown[]> {
597
+ return this.client._send('auth-list-devices', { accountFingerprint }) as Promise<unknown[]>;
598
+ }
599
+
600
+ /** List sessions, optionally filtered by account */
601
+ async listSessions(accountFingerprint?: string): Promise<unknown[]> {
602
+ return this.client._send('auth-list-sessions', { accountFingerprint }) as Promise<unknown[]>;
603
+ }
604
+
605
+ /** Request QR cross-device approval (new device calls this) */
606
+ async requestApproval(publicKey: string): Promise<{ requestId: string }> {
607
+ return this.client._send('auth-request-approval', { publicKey }) as Promise<{ requestId: string }>;
608
+ }
609
+
610
+ /** Approve a pending device request (authenticated device calls this) */
611
+ async approveDevice(requestId: string): Promise<{ fingerprint: string; token: string; expiresAt: number }> {
612
+ return this.client._send('auth-approve-device', { requestId }) as Promise<{ fingerprint: string; token: string; expiresAt: number }>;
613
+ }
614
+
615
+ /** List account fingerprints + displayNames (no auth required, no secrets) */
616
+ async listAccountFingerprints(): Promise<{ fingerprint: string; displayName?: string }[]> {
617
+ return this.client._send('auth-list-account-fingerprints', {}) as Promise<{ fingerprint: string; displayName?: string }[]>;
618
+ }
619
+
620
+ /** Check if any accounts exist (no auth required) */
621
+ async hasAccounts(): Promise<boolean> {
622
+ const result = await this.client._send('auth-has-accounts', {}) as { hasAccounts: boolean };
623
+ return result.hasAccounts;
624
+ }
625
+
626
+ /** Get public account info (no secrets). Defaults to own account. */
627
+ async getAccountInfo(accountFingerprint?: string): Promise<{ fingerprint: string; displayName?: string; roles: string[]; isRoot: boolean; createdAt: number }> {
628
+ return this.client._send('auth-account-info', { accountFingerprint }) as Promise<{ fingerprint: string; displayName?: string; roles: string[]; isRoot: boolean; createdAt: number }>;
629
+ }
630
+
631
+ /** Poll approval status */
632
+ async pollApproval(requestId: string): Promise<{ status: string; token?: string; expiresAt?: number }> {
633
+ return this.client._send('auth-poll-approval', { requestId }) as Promise<{ status: string; token?: string; expiresAt?: number }>;
634
+ }
417
635
  }
418
636
 
419
637
  export class ValueSnapshot {
@@ -1,8 +1,9 @@
1
- import { BodClient, ValueSnapshot } from './BodClient.ts';
1
+ import { BodClient, ValueSnapshot, VFSClient } from './BodClient.ts';
2
2
  import type { ChildEvent } from './BodClient.ts';
3
+ import type { FileStat } from '../shared/protocol.ts';
3
4
  import { ancestors } from '../shared/pathUtils.ts';
4
5
 
5
- export class CachedClientOptions {
6
+ export class BodClientCachedOptions {
6
7
  maxAge: number = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
7
8
  maxMemoryEntries: number = 500;
8
9
  dbName: string = 'boddb-cache';
@@ -16,15 +17,17 @@ interface CacheEntry {
16
17
  cachedAt: number;
17
18
  }
18
19
 
19
- export class CachedClient {
20
- readonly options: CachedClientOptions;
20
+ export class BodClientCached {
21
+ readonly options: BodClientCachedOptions;
21
22
  readonly client: BodClient;
22
23
  private memory = new Map<string, CacheEntry>();
23
24
  private subscribedPaths = new Set<string>();
24
25
  private idb: IDBDatabase | null = null;
26
+ private _vfs: CachedVFSClient | null = null;
27
+ private _vfsUnsub: (() => void) | null = null;
25
28
 
26
- constructor(client: BodClient, options?: Partial<CachedClientOptions>) {
27
- this.options = { ...new CachedClientOptions(), ...options };
29
+ constructor(client: BodClient, options?: Partial<BodClientCachedOptions>) {
30
+ this.options = { ...new BodClientCachedOptions(), ...options };
28
31
  this.client = client;
29
32
  }
30
33
 
@@ -177,7 +180,21 @@ export class CachedClient {
177
180
  await Promise.all(promises);
178
181
  }
179
182
 
183
+ vfs(): CachedVFSClient {
184
+ if (!this._vfs) {
185
+ this._vfs = new CachedVFSClient(this.client.vfs(), this.options.maxAge);
186
+ this._vfsUnsub = this.client.onChild('_vfs', () => {
187
+ // _vfs events are coarse (top-level key only), clear all VFS caches
188
+ this._vfs?.clear();
189
+ });
190
+ }
191
+ return this._vfs;
192
+ }
193
+
180
194
  close(): void {
195
+ if (this._vfsUnsub) { this._vfsUnsub(); this._vfsUnsub = null; }
196
+ this._vfs?.clear();
197
+ this._vfs = null;
181
198
  this.memory.clear();
182
199
  this.subscribedPaths.clear();
183
200
  this.idb?.close();
@@ -226,3 +243,95 @@ export class CachedClient {
226
243
  } catch {}
227
244
  }
228
245
  }
246
+
247
+ export class CachedVFSClient {
248
+ private listCache = new Map<string, { data: FileStat[]; cachedAt: number }>();
249
+ private statCache = new Map<string, { data: FileStat | null; cachedAt: number }>();
250
+ private _stats = { hits: 0, misses: 0, invalidations: 0, pushClears: 0 };
251
+
252
+ constructor(
253
+ private raw: VFSClient,
254
+ private maxAge: number,
255
+ ) {}
256
+
257
+ async stat(path: string): Promise<FileStat | null> {
258
+ const cached = this.statCache.get(path);
259
+ if (cached && Date.now() - cached.cachedAt < this.maxAge) {
260
+ this._stats.hits++;
261
+ this.raw.stat(path).then(r => this.statCache.set(path, { data: r, cachedAt: Date.now() })).catch(() => {});
262
+ return cached.data;
263
+ }
264
+ this._stats.misses++;
265
+ const result = await this.raw.stat(path);
266
+ this.statCache.set(path, { data: result, cachedAt: Date.now() });
267
+ return result;
268
+ }
269
+
270
+ async list(path: string): Promise<FileStat[]> {
271
+ const cached = this.listCache.get(path);
272
+ if (cached && Date.now() - cached.cachedAt < this.maxAge) {
273
+ this._stats.hits++;
274
+ this.raw.list(path).then(r => this.listCache.set(path, { data: r, cachedAt: Date.now() })).catch(() => {});
275
+ return cached.data;
276
+ }
277
+ this._stats.misses++;
278
+ const result = await this.raw.list(path);
279
+ this.listCache.set(path, { data: result, cachedAt: Date.now() });
280
+ return result;
281
+ }
282
+
283
+ async upload(path: string, data: Uint8Array, mime?: string): Promise<FileStat> {
284
+ const r = await this.raw.upload(path, data, mime);
285
+ this.invalidatePath(path);
286
+ return r;
287
+ }
288
+
289
+ async delete(path: string): Promise<void> {
290
+ await this.raw.delete(path);
291
+ this.invalidatePath(path);
292
+ }
293
+
294
+ async mkdir(path: string): Promise<FileStat> {
295
+ const r = await this.raw.mkdir(path);
296
+ this.invalidatePath(path);
297
+ // Also invalidate the dir's own list (was empty/nonexistent before)
298
+ this.listCache.delete(path);
299
+ return r;
300
+ }
301
+
302
+ async move(src: string, dst: string): Promise<FileStat> {
303
+ const r = await this.raw.move(src, dst);
304
+ this.invalidatePath(src);
305
+ this.invalidatePath(dst);
306
+ return r;
307
+ }
308
+
309
+ async download(path: string): Promise<Uint8Array> {
310
+ return this.raw.download(path);
311
+ }
312
+
313
+ invalidatePath(filePath: string) {
314
+ this._stats.invalidations++;
315
+ this.statCache.delete(filePath);
316
+ const parent = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
317
+ this.listCache.delete(parent);
318
+ }
319
+
320
+ clear() {
321
+ this._stats.pushClears++;
322
+ this.listCache.clear();
323
+ this.statCache.clear();
324
+ }
325
+
326
+ /** Cache stats for diagnostics. Use in browser: `__bodCache.vfs().stats` */
327
+ get stats() {
328
+ const total = this._stats.hits + this._stats.misses;
329
+ return {
330
+ ...this._stats,
331
+ total,
332
+ hitRate: total ? Math.round(this._stats.hits / total * 100) + '%' : 'n/a',
333
+ listCacheSize: this.listCache.size,
334
+ statCacheSize: this.statCache.size,
335
+ };
336
+ }
337
+ }
@@ -9,6 +9,7 @@ import { StreamEngine, type CompactOptions } from './StreamEngine.ts';
9
9
  import { MQEngine, type MQEngineOptions } from './MQEngine.ts';
10
10
  import { ReplicationEngine, type ReplicationOptions, type WriteEvent } from './ReplicationEngine.ts';
11
11
  import { VFSEngine, type VFSEngineOptions } from './VFSEngine.ts';
12
+ import { KeyAuthEngine, type KeyAuthEngineOptions } from './KeyAuthEngine.ts';
12
13
  import { validatePath } from '../shared/pathUtils.ts';
13
14
 
14
15
  export interface TransactionProxy {
@@ -38,6 +39,10 @@ export class BodDBOptions {
38
39
  replication?: Partial<ReplicationOptions>;
39
40
  /** VFS config */
40
41
  vfs?: Partial<VFSEngineOptions>;
42
+ /** KeyAuth config */
43
+ keyAuth?: Partial<KeyAuthEngineOptions> & { rootPublicKey?: string };
44
+ /** When true, deny operations that don't match any rule (default: open) */
45
+ defaultDeny?: boolean;
41
46
  port?: number;
42
47
  auth?: TransportOptions['auth'];
43
48
  transport?: Partial<TransportOptions>;
@@ -52,6 +57,7 @@ export class BodDB {
52
57
  readonly fts: FTSEngine | null = null;
53
58
  readonly vectors: VectorEngine | null = null;
54
59
  readonly vfs: VFSEngine | null = null;
60
+ readonly keyAuth: KeyAuthEngine | null = null;
55
61
  readonly options: BodDBOptions;
56
62
  replication: ReplicationEngine | null = null;
57
63
  private _replaying = false;
@@ -81,7 +87,7 @@ export class BodDB {
81
87
  const resolvedRules = typeof rulesConfig === 'string'
82
88
  ? BodDB.loadRulesFileSync(rulesConfig)
83
89
  : (rulesConfig ?? {});
84
- this.rules = new RulesEngine({ rules: resolvedRules });
90
+ this.rules = new RulesEngine({ rules: resolvedRules, defaultDeny: this.options.defaultDeny });
85
91
 
86
92
  // Auto-create indexes from config
87
93
  if (this.options.indexes) {
@@ -107,6 +113,14 @@ export class BodDB {
107
113
  (this as { vfs: VFSEngine }).vfs = new VFSEngine(this, this.options.vfs);
108
114
  }
109
115
 
116
+ // Initialize KeyAuth if configured
117
+ if (this.options.keyAuth) {
118
+ (this as { keyAuth: KeyAuthEngine }).keyAuth = new KeyAuthEngine(this, this.options.keyAuth);
119
+ if (this.options.keyAuth.rootPublicKey) {
120
+ this.keyAuth!.initRoot(this.options.keyAuth.rootPublicKey);
121
+ }
122
+ }
123
+
110
124
  // Start TTL sweep
111
125
  if (this.options.sweepInterval > 0) {
112
126
  this.sweepTimer = setInterval(() => this.sweep(), this.options.sweepInterval);
@@ -347,7 +361,7 @@ export class BodDB {
347
361
  }
348
362
 
349
363
  /** Start publishing process/db stats to `_admin/stats` (only when subscribers exist). */
350
- private _startStatsPublisher(): void {
364
+ startStatsPublisher(): void {
351
365
  if (this._statsInterval) return;
352
366
  const { statSync } = require('fs');
353
367
  const { cpus, totalmem } = require('os');
@@ -386,6 +400,8 @@ export class BodDB {
386
400
  db: { nodeCount, sizeMb: dbSizeMb },
387
401
  system: { cpuCores: cur.length, totalMemMb: +(totalmem() / 1024 / 1024).toFixed(0), cpuPercent: sysCpu },
388
402
  subs: this.subs.subscriberCount(),
403
+ clients: this._transport?.clientCount ?? 0,
404
+ repl: this.replication?.stats() ?? null,
389
405
  ts: Date.now(),
390
406
  });
391
407
  }, 1000);
@@ -394,9 +410,9 @@ export class BodDB {
394
410
  /** Start standalone WebSocket + REST server on its own port */
395
411
  serve(options?: Partial<TransportOptions>) {
396
412
  const port = options?.port ?? this.options.port;
397
- const auth = options?.auth ?? this.options.auth;
398
- this._transport = new Transport(this, this.rules, { ...this.options.transport, ...options, port, auth });
399
- this._startStatsPublisher();
413
+ const auth = options?.auth ?? this.options.auth ?? (this.keyAuth ? this.keyAuth.authCallback() : undefined);
414
+ this._transport = new Transport(this, this.rules, { ...this.options.transport, ...options, port, auth, keyAuth: this.keyAuth ?? undefined });
415
+ this.startStatsPublisher();
400
416
  return this._transport.start();
401
417
  }
402
418
 
@@ -419,9 +435,9 @@ export class BodDB {
419
435
  * ```
420
436
  */
421
437
  getHandlers(options?: Partial<TransportOptions>) {
422
- const auth = options?.auth ?? this.options.auth;
423
- this._transport = new Transport(this, this.rules, { ...this.options.transport, ...options, auth });
424
- this._startStatsPublisher();
438
+ const auth = options?.auth ?? this.options.auth ?? (this.keyAuth ? this.keyAuth.authCallback() : undefined);
439
+ this._transport = new Transport(this, this.rules, { ...this.options.transport, ...options, auth, keyAuth: this.keyAuth ?? undefined });
440
+ this.startStatsPublisher();
425
441
  return {
426
442
  handleFetch: this._transport.handleFetch.bind(this._transport),
427
443
  websocketConfig: this._transport.websocketConfig,