@bod.ee/db 0.11.1 → 0.12.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.md CHANGED
@@ -72,6 +72,7 @@ config.ts — demo instance config (open rules, indexes, fts, v
72
72
  - **MCP**: `MCPAdapter` wraps a `BodClient` as a JSON-RPC MCP server (stdio + HTTP). Connects to a running BodDB instance over WebSocket — no embedded DB. Entry point: `mcp.ts`. Tools: CRUD (6), FTS (2), vectors (2), streams (4), MQ (7) = 21 tools. Use `--stdio` for Claude Code/Desktop, `--http` for remote agents.
73
73
  - **VFS (Virtual File System)**: `VFSEngine` — files stored outside SQLite via pluggable `VFSBackend` interface. `LocalBackend` stores at `<storageRoot>/<fileId>` using `Bun.file`/`Bun.write`. Metadata at `_vfs/<virtualPath>/` (size, mime, mtime, fileId, isDir) — gets subs/rules/replication for free. `fileId = pushId` so move/rename is metadata-only. REST: `POST/GET/DELETE /files/<path>`, `?stat=1`, `?list=1`, `?mkdir=1`, `PUT ?move=<dst>`. WS chunked fallback: base64-encoded `vfs-upload-init/chunk/done`, `vfs-download-init` → `vfs-download-chunk` push messages. Client: `VFSClient` via `client.vfs()` — `upload/download` (REST) + `uploadWS/downloadWS` (WS) + `stat/list/mkdir/delete/move`.
74
74
  - **Replication**: `ReplicationEngine` — single primary + N read replicas + multi-source feed subscriptions. Star topology. Primary emits write events to `_repl` stream via `onWrite` hooks. Replicas bootstrap via `streamMaterialize('_repl', { keepKey: 'path' })`, then subscribe for ongoing events. Write proxy: replica forwards writes to primary via BodClient, primary applies + emits, replica consumes. `_replaying` flag prevents re-emission loops. `_emitting` guard prevents recursion from `db.push('_repl')`. Updates flattened to per-path set events for correct compaction keying. Sweep delete events replicated. Excluded prefixes: `_repl`, `_streams`, `_mq`, `_auth`. **Sources**: `ReplicationSource[]` — subscribe to specific paths from multiple remote DBs. Each source is an independent BodClient that filters `_repl` events by path prefix, with optional `localPrefix` remapping (e.g. remote `users/u1` → local `db-a/users/u1`). Sources connect in parallel; individual failures don't block others. Sources are independent of role — a DB can be primary AND consume sources.
75
+ - **KeyAuth integration guide**: `docs/keyauth-integration.md` — flows for signup, signin, new device, autoAuth, IAM roles, common mistakes.
75
76
  - **KeyAuth**: `KeyAuthEngine` — portable Ed25519 identity & IAM. Identity hierarchy: Root (server-level, key on filesystem), Account (portable, password-encrypted private key in DB or device-generated), Device (delegate, linked via password unlock). Challenge-response auth: server sends nonce → client signs with Ed25519 → server verifies + creates session. Self-signed tokens (no JWT lib): `base64url(payload).base64url(Ed25519_sign)`. Data model at `_auth/` prefix (protected from external writes). Device reverse-index at `_auth/deviceIndex/{dfp}` for O(1) lookup. Password change is atomic (single `db.update()`). IAM: roles with path-based permissions, account role assignment. `_auth/` excluded from replication. Transport guards: `auth-link-device` and `auth-change-password` require authenticated session; non-root users can only change own password. **Device registration**: `registerDevice(publicKey)` — client-generated keypair, no password, idempotent; `allowOpenRegistration: false` requires authenticated session. **Browser crypto**: `keyAuth.browser.ts` uses `@noble/ed25519` with DER↔raw key bridge for server compatibility. **BodClient autoAuth**: `autoAuth: true` auto-generates keypair (localStorage), registers, authenticates — zero-config device identity. `client.auth.*` convenience methods for all auth ops. **IAM transport ops**: `auth-create-role`, `auth-delete-role`, `auth-update-roles` (root only), `auth-list-accounts`, `auth-list-roles`. Device accounts (no encrypted key) safely reject `linkDevice`/`changePassword`.
76
77
 
77
78
  ## MCP Server
@@ -0,0 +1,141 @@
1
+ # KeyAuth Integration Guide
2
+
3
+ BodDB's auth system has two identity layers — **accounts** (portable, password-protected) and **devices** (device-bound, no password). Understanding which to use is critical.
4
+
5
+ ---
6
+
7
+ ## Identity Model
8
+
9
+ | Type | Private key lives | Auth method | Portable? |
10
+ |------|------------------|-------------|-----------|
11
+ | **Account** | Encrypted in DB (password-protected) | Password (to unlock/link) | Yes — any device |
12
+ | **Device** | Client only (localStorage / memory) | Challenge-response (Ed25519) | No — tied to this client |
13
+
14
+ **Root** — first account ever created is auto-elevated to root. Server-level privileges.
15
+
16
+ ---
17
+
18
+ ## When to use what
19
+
20
+ **Use `autoAuth: true` (device-only)** when:
21
+ - You need zero-config anonymous identity (e.g. telemetry, guest sessions)
22
+ - Identity loss is acceptable (clearing localStorage = new identity)
23
+ - No cross-device portability needed
24
+
25
+ **Use account + linked device** when:
26
+ - Users have passwords and expect to sign in from multiple devices
27
+ - Identity must survive localStorage wipe
28
+ - You need IAM roles assigned to a persistent identity
29
+
30
+ ---
31
+
32
+ ## Flows
33
+
34
+ ### Signup (new user, first device)
35
+
36
+ ```ts
37
+ const client = new BodClient({ url: 'ws://localhost:4400' });
38
+ await client.connect();
39
+
40
+ // 1. Generate device keypair (client-side, stored in localStorage)
41
+ const kp = await generateDeviceKeypair(); // your key generation
42
+
43
+ // 2. Create account — auto-links this device
44
+ const account = await client.auth.createAccount(
45
+ password,
46
+ [], // roles (root assigns later)
47
+ 'alice', // displayName
48
+ kp.publicKey // auto-links device → no separate linkDevice call needed
49
+ );
50
+ // account.isRoot === true if this is the very first account on the server
51
+
52
+ // 3. Authenticate with the device keypair
53
+ const { token } = await client.auth.authenticate(kp.publicKey, kp.signFn);
54
+ ```
55
+
56
+ ### Sign in (same device, returning user)
57
+
58
+ ```ts
59
+ // Device keypair is already in localStorage — just authenticate
60
+ const { token } = await client.auth.authenticate(kp.publicKey, kp.signFn);
61
+ // Send token on subsequent requests (handled automatically by BodClient)
62
+ ```
63
+
64
+ ### Sign in from a new device
65
+
66
+ ```ts
67
+ // On the NEW device:
68
+ const newKp = await generateDeviceKeypair();
69
+
70
+ // Link it to the existing account (requires password)
71
+ await client.auth.linkDeviceByName('alice', password, newKp.publicKey, 'MacBook Pro');
72
+ // or by fingerprint:
73
+ await client.auth.linkDevice(accountFingerprint, password, newKp.publicKey, 'MacBook Pro');
74
+
75
+ // Now authenticate with the new device — no password needed again
76
+ const { token } = await client.auth.authenticate(newKp.publicKey, newKp.signFn);
77
+ ```
78
+
79
+ ### autoAuth (zero-config device identity)
80
+
81
+ ```ts
82
+ // Keypair auto-generated + stored in localStorage, registered + authenticated on connect
83
+ const client = new BodClient({ url: 'ws://localhost:4400', autoAuth: true });
84
+ await client.connect();
85
+ // client.deviceFingerprint and client.authToken are set automatically
86
+ ```
87
+
88
+ > ⚠️ `autoAuth` creates a device-only identity. There is no account behind it — no password, no cross-device portability. Clearing localStorage permanently loses this identity.
89
+
90
+ ---
91
+
92
+ ## Common mistakes
93
+
94
+ | Mistake | Problem | Fix |
95
+ |---------|---------|-----|
96
+ | Using `autoAuth` for user accounts | Identity lost on localStorage clear | Use `createAccount` + `linkDevice` |
97
+ | Calling `createAccount` without `devicePublicKey` | Have to call `linkDevice` separately | Pass `devicePublicKey` to `createAccount` |
98
+ | Not knowing account fingerprint on new device | Can't call `linkDevice` | Use `linkDeviceByName(displayName, ...)` instead |
99
+ | Assuming `autoAuth` device has an account | Device accounts reject `linkDevice`/`changePassword` | They're standalone — no account behind them |
100
+
101
+ ---
102
+
103
+ ## Server setup
104
+
105
+ ```ts
106
+ import { BodDB } from 'bod-db';
107
+ import { KeyAuthEngine } from 'bod-db/server';
108
+
109
+ const db = new BodDB({
110
+ keyAuth: {
111
+ allowOpenRegistration: false, // require auth session to register devices (recommended)
112
+ maxAuthFailures: 10,
113
+ authLockoutSeconds: 300,
114
+ }
115
+ });
116
+ ```
117
+
118
+ `allowOpenRegistration: true` (default) allows any client to register a device without being authenticated first — fine for open apps, restrict for closed ones.
119
+
120
+ ---
121
+
122
+ ## IAM roles
123
+
124
+ Roles are assigned to **accounts** (not devices). Devices inherit the account's roles via the linked account fingerprint.
125
+
126
+ ```ts
127
+ // Root only
128
+ await client.auth.createRole({
129
+ id: 'editor',
130
+ name: 'Editor',
131
+ permissions: [
132
+ { path: 'posts/$postId', read: true, write: true },
133
+ { path: 'users/$uid/profile', read: true },
134
+ ]
135
+ });
136
+
137
+ // Assign to account (root only)
138
+ await client.auth.updateRoles(accountFingerprint, ['editor']);
139
+ ```
140
+
141
+ Rules engine receives `auth.roles`, `auth.fingerprint`, `auth.accountFingerprint` in context.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bod.ee/db",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "exports": {
@@ -829,6 +829,10 @@ export class VFSClient {
829
829
  return await this.client._send('vfs-list', { path }) as FileStat[];
830
830
  }
831
831
 
832
+ async tree(path: string, opts?: { hiddenPaths?: string[]; hideDotfiles?: boolean }): Promise<any[]> {
833
+ return await this.client._send('vfs-tree', { path, ...opts }) as any[];
834
+ }
835
+
832
836
  async delete(path: string): Promise<void> {
833
837
  await this.client._send('vfs-delete', { path });
834
838
  }
@@ -182,7 +182,7 @@ export class BodClientCached {
182
182
 
183
183
  vfs(): CachedVFSClient {
184
184
  if (!this._vfs) {
185
- this._vfs = new CachedVFSClient(this.client.vfs(), this.options.maxAge);
185
+ this._vfs = new CachedVFSClient(this.client.vfs(), this.options.maxAge, this.idb);
186
186
  this._vfsUnsub = this.client.onChild('_vfs', () => {
187
187
  // _vfs events are coarse (top-level key only), clear all VFS caches
188
188
  this._vfs?.clear();
@@ -252,18 +252,34 @@ export class CachedVFSClient {
252
252
  constructor(
253
253
  private raw: VFSClient,
254
254
  private maxAge: number,
255
+ private idb: IDBDatabase | null = null,
255
256
  ) {}
256
257
 
257
258
  async stat(path: string): Promise<FileStat | null> {
258
259
  const cached = this.statCache.get(path);
259
260
  if (cached && Date.now() - cached.cachedAt < this.maxAge) {
260
261
  this._stats.hits++;
261
- this.raw.stat(path).then(r => this.statCache.set(path, { data: r, cachedAt: Date.now() })).catch(() => {});
262
+ this.raw.stat(path).then(r => {
263
+ this.statCache.set(path, { data: r, cachedAt: Date.now() });
264
+ this.idbSetVfs(`vfs:stat:${path}`, { data: r, cachedAt: Date.now() });
265
+ }).catch(() => {});
262
266
  return cached.data;
263
267
  }
268
+ // Check IDB before network
269
+ const idbEntry = await this.idbGetVfs(`vfs:stat:${path}`);
270
+ if (idbEntry && Date.now() - idbEntry.cachedAt < this.maxAge) {
271
+ this._stats.hits++;
272
+ this.statCache.set(path, idbEntry);
273
+ this.raw.stat(path).then(r => {
274
+ this.statCache.set(path, { data: r, cachedAt: Date.now() });
275
+ this.idbSetVfs(`vfs:stat:${path}`, { data: r, cachedAt: Date.now() });
276
+ }).catch(() => {});
277
+ return idbEntry.data as FileStat | null;
278
+ }
264
279
  this._stats.misses++;
265
280
  const result = await this.raw.stat(path);
266
281
  this.statCache.set(path, { data: result, cachedAt: Date.now() });
282
+ this.idbSetVfs(`vfs:stat:${path}`, { data: result, cachedAt: Date.now() });
267
283
  return result;
268
284
  }
269
285
 
@@ -271,15 +287,34 @@ export class CachedVFSClient {
271
287
  const cached = this.listCache.get(path);
272
288
  if (cached && Date.now() - cached.cachedAt < this.maxAge) {
273
289
  this._stats.hits++;
274
- this.raw.list(path).then(r => this.listCache.set(path, { data: r, cachedAt: Date.now() })).catch(() => {});
290
+ this.raw.list(path).then(r => {
291
+ this.listCache.set(path, { data: r, cachedAt: Date.now() });
292
+ this.idbSetVfs(`vfs:list:${path}`, { data: r, cachedAt: Date.now() });
293
+ }).catch(() => {});
275
294
  return cached.data;
276
295
  }
296
+ // Check IDB before network
297
+ const idbEntry = await this.idbGetVfs(`vfs:list:${path}`);
298
+ if (idbEntry && Date.now() - idbEntry.cachedAt < this.maxAge) {
299
+ this._stats.hits++;
300
+ this.listCache.set(path, idbEntry as { data: FileStat[]; cachedAt: number });
301
+ this.raw.list(path).then(r => {
302
+ this.listCache.set(path, { data: r, cachedAt: Date.now() });
303
+ this.idbSetVfs(`vfs:list:${path}`, { data: r, cachedAt: Date.now() });
304
+ }).catch(() => {});
305
+ return idbEntry.data as FileStat[];
306
+ }
277
307
  this._stats.misses++;
278
308
  const result = await this.raw.list(path);
279
309
  this.listCache.set(path, { data: result, cachedAt: Date.now() });
310
+ this.idbSetVfs(`vfs:list:${path}`, { data: result, cachedAt: Date.now() });
280
311
  return result;
281
312
  }
282
313
 
314
+ async tree(path: string, opts?: { hiddenPaths?: string[]; hideDotfiles?: boolean }): Promise<any[]> {
315
+ return this.raw.tree(path, opts);
316
+ }
317
+
283
318
  async upload(path: string, data: Uint8Array, mime?: string): Promise<FileStat> {
284
319
  const r = await this.raw.upload(path, data, mime);
285
320
  this.invalidatePath(path);
@@ -296,6 +331,7 @@ export class CachedVFSClient {
296
331
  this.invalidatePath(path);
297
332
  // Also invalidate the dir's own list (was empty/nonexistent before)
298
333
  this.listCache.delete(path);
334
+ this.idbDeleteVfs(`vfs:list:${path}`);
299
335
  return r;
300
336
  }
301
337
 
@@ -313,14 +349,17 @@ export class CachedVFSClient {
313
349
  invalidatePath(filePath: string) {
314
350
  this._stats.invalidations++;
315
351
  this.statCache.delete(filePath);
352
+ this.idbDeleteVfs(`vfs:stat:${filePath}`);
316
353
  const parent = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
317
354
  this.listCache.delete(parent);
355
+ this.idbDeleteVfs(`vfs:list:${parent}`);
318
356
  }
319
357
 
320
358
  clear() {
321
359
  this._stats.pushClears++;
322
360
  this.listCache.clear();
323
361
  this.statCache.clear();
362
+ this.idbClearVfs();
324
363
  }
325
364
 
326
365
  /** Cache stats for diagnostics. Use in browser: `__bodCache.vfs().stats` */
@@ -334,4 +373,53 @@ export class CachedVFSClient {
334
373
  statCacheSize: this.statCache.size,
335
374
  };
336
375
  }
376
+
377
+ // --- IDB helpers for VFS cache ---
378
+
379
+ private idbGetVfs(key: string): Promise<{ data: unknown; cachedAt: number } | null> {
380
+ if (!this.idb) return Promise.resolve(null);
381
+ try {
382
+ const tx = this.idb.transaction('entries', 'readonly');
383
+ const req = tx.objectStore('entries').get(key);
384
+ return new Promise(resolve => {
385
+ req.onsuccess = () => {
386
+ const entry = req.result;
387
+ resolve(entry ? { data: entry.data, cachedAt: entry.cachedAt } : null);
388
+ };
389
+ req.onerror = () => resolve(null);
390
+ });
391
+ } catch { return Promise.resolve(null); }
392
+ }
393
+
394
+ private idbSetVfs(key: string, value: { data: unknown; cachedAt: number }): void {
395
+ if (!this.idb) return;
396
+ try {
397
+ const tx = this.idb.transaction('entries', 'readwrite');
398
+ tx.objectStore('entries').put({ path: key, data: value.data, cachedAt: value.cachedAt, updatedAt: Date.now() });
399
+ } catch {}
400
+ }
401
+
402
+ private idbDeleteVfs(key: string): void {
403
+ if (!this.idb) return;
404
+ try {
405
+ const tx = this.idb.transaction('entries', 'readwrite');
406
+ tx.objectStore('entries').delete(key);
407
+ } catch {}
408
+ }
409
+
410
+ private idbClearVfs(): void {
411
+ if (!this.idb) return;
412
+ try {
413
+ // Delete all vfs: prefixed entries
414
+ const tx = this.idb.transaction('entries', 'readwrite');
415
+ const store = tx.objectStore('entries');
416
+ const req = store.openCursor();
417
+ req.onsuccess = () => {
418
+ const cursor = req.result;
419
+ if (!cursor) return;
420
+ if (typeof cursor.key === 'string' && cursor.key.startsWith('vfs:')) cursor.delete();
421
+ cursor.continue();
422
+ };
423
+ } catch {}
424
+ }
337
425
  }
@@ -753,6 +753,14 @@ export class Transport {
753
753
  }
754
754
  return reply(self.db.vfs.list(msg.path));
755
755
  }
756
+ case 'vfs-tree': {
757
+ if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
758
+ if (self.rules && !self.rules.check('read', `_vfs/${msg.path}`, ws.data.auth)) {
759
+ return error('Permission denied', Errors.PERMISSION_DENIED);
760
+ }
761
+ const hiddenPaths = msg.hiddenPaths ? new Set(msg.hiddenPaths) : undefined;
762
+ return reply(self.db.vfs.tree(msg.path, { hiddenPaths, hideDotfiles: msg.hideDotfiles }));
763
+ }
756
764
  case 'vfs-delete': {
757
765
  if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
758
766
  if (self.rules && !self.rules.check('write', `_vfs/${msg.path}`, ws.data.auth)) {
@@ -183,6 +183,33 @@ export class VFSEngine {
183
183
  return results;
184
184
  }
185
185
 
186
+ tree(virtualPath: string, opts?: { hiddenPaths?: Set<string>; hideDotfiles?: boolean }): any[] {
187
+ const hiddenPaths = opts?.hiddenPaths ?? new Set<string>();
188
+ const hideDotfiles = opts?.hideDotfiles ?? false;
189
+
190
+ const walk = (vPath: string): any[] => {
191
+ const stats = this.list(vPath);
192
+ stats.sort((a, b) => {
193
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
194
+ return a.name.localeCompare(b.name);
195
+ });
196
+ const result: any[] = [];
197
+ for (const s of stats) {
198
+ if (hiddenPaths.has(s.name)) continue;
199
+ if (hideDotfiles && s.name.startsWith('.')) continue;
200
+ if (s.isDir) {
201
+ const childPath = vPath ? `${vPath}/${s.name}` : s.name;
202
+ result.push({ name: s.name, type: 'directory', children: walk(childPath) });
203
+ } else {
204
+ result.push({ name: s.name, type: 'file' });
205
+ }
206
+ }
207
+ return result;
208
+ };
209
+
210
+ return walk(normalizePath(virtualPath));
211
+ }
212
+
186
213
  mkdir(virtualPath: string): FileStat {
187
214
  const vp = normalizePath(virtualPath);
188
215
  const name = vp.split('/').pop()!;
@@ -36,6 +36,7 @@ export type ClientMessage =
36
36
  | { id: string; op: 'vfs-download-init'; path: string }
37
37
  | { id: string; op: 'vfs-stat'; path: string }
38
38
  | { id: string; op: 'vfs-list'; path: string }
39
+ | { id: string; op: 'vfs-tree'; path: string; hiddenPaths?: string[]; hideDotfiles?: boolean }
39
40
  | { id: string; op: 'vfs-delete'; path: string }
40
41
  | { id: string; op: 'vfs-mkdir'; path: string }
41
42
  | { id: string; op: 'vfs-move'; path: string; dst: string }