@bod.ee/db 0.10.2 → 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.
@@ -29,7 +29,11 @@
29
29
  "WebFetch(domain:caniuse.com)",
30
30
  "WebFetch(domain:github.com)",
31
31
  "WebFetch(domain:api.github.com)",
32
- "WebFetch(domain:raw.githubusercontent.com)"
32
+ "WebFetch(domain:raw.githubusercontent.com)",
33
+ "Bash(lsof:*)",
34
+ "Bash(ps:*)",
35
+ "Bash(sleep 1:*)",
36
+ "WebFetch(domain:bun.com)"
33
37
  ]
34
38
  }
35
39
  }
@@ -42,7 +42,9 @@ export default {
42
42
  mq: { visibilityTimeout: 30, maxDeliveries: 5 },
43
43
  compact: { 'events/logs': { maxAge: 86400 } },
44
44
  vfs: { storageRoot: './files' },
45
- keyAuth: { sessionTtl: 86400, nonceTtl: 60, rootPublicKey: '<base64>' },
45
+ keyAuth: { sessionTtl: 86400, nonceTtl: 60, rootPublicKey: '<base64>', pbkdf2Iterations: 100000, maxAuthFailures: 5, authLockoutSeconds: 60 },
46
+ transport: { maxConnections: 10000, maxMessageSize: 1_048_576, maxSubscriptionsPerClient: 500 },
47
+ log: { enabled: true, level: 'info', components: ['transport', 'stats', 'storage'] }, // or components: '*' for all
46
48
  replication: {
47
49
  role: 'primary',
48
50
  sources: [
@@ -99,6 +99,14 @@ Push paths are append-only logs. `StreamEngine` adds consumer group offsets (`_s
99
99
  ### KeyAuth (Ed25519 Identity & IAM)
100
100
  `KeyAuthEngine` — self-sovereign auth using Ed25519 key pairs. Identity hierarchy: Root (filesystem key, god mode) → Account (password-encrypted private key in DB, portable) → Device (delegate, linked via password unlock). Challenge-response auth: server issues nonce (TTL + one-time), client signs with device/account key, server verifies + creates session. Self-signed tokens: `base64url(payload).base64url(Ed25519_sign)`. Device reverse-index at `_auth/deviceIndex/{dfp}` for O(1) lookup. `_auth/` prefix protected from external writes in Transport. Password change = re-encrypt same key pair (fingerprint stable). IAM: roles with path-based permissions.
101
101
 
102
+ ### Optimization Patterns
103
+ - **SQL push-down**: `StorageEngine.query()` auto-detects push rows (single JSON, not flattened) and uses `json_extract()` in SQL WHERE/ORDER/LIMIT. Falls back to JS for flattened data. Op whitelist prevents SQL injection.
104
+ - **Broadcast groups**: `Transport._addBroadcastSub()` — one DB subscription per path shared across all WS clients. Serialize once, send buffer to all.
105
+ - **Batch re-subscribe**: `batch-sub` wire op registers N subs in one message. Client uses on reconnect with fallback.
106
+ - **Async notifications**: `SubscriptionEngine.queueNotify()` coalesces via `queueMicrotask`. Opt-in `asyncNotify: true`.
107
+ - **existsMany**: Single SQL query with OR conditions to batch-check path existence.
108
+ - **Replication batching**: `beginBatch()/flushBatch()` buffers events during transactions, emits after commit.
109
+
102
110
  ### FileAdapter
103
111
  Scans directory recursively on `start()`. Optional `fs.watch` for live sync. Stores metadata (size, mtime, mime) at `basePath/<relPath>`. Content read/write-through methods.
104
112
 
@@ -132,10 +140,11 @@ Path patterns with `$wildcard` capture. Most specific match wins. Supports boole
132
140
  - **Phase 10** (DONE): VFS — VFSEngine, LocalBackend, REST + WS transport, VFSClient SDK
133
141
  - **Phase 11** (DONE): BodClientCached — two-tier cache (memory + IndexedDB), stale-while-revalidate, updatedAt protocol
134
142
  - **Phase 12** (DONE): KeyAuth — Ed25519 identity & IAM, challenge-response, accounts, devices, sessions, roles
143
+ - **Phase 13** (DONE): Optimization — request timeouts, connection/sub caps, SQL query push-down, broadcast groups, async batched notifications, batch re-subscribe, cursor-based materialize, replication event batching, auth rate limiting
135
144
 
136
145
  ## Testing
137
146
 
138
- - `bun test` — 266 tests across 23 files
147
+ - `bun test` — 334 tests across 24 files
139
148
  - Each engine/feature gets its own test file in `tests/`
140
149
  - Test happy path, edge cases, error cases
141
150
  - Use `{ sweepInterval: 0 }` in tests to disable background sweep
@@ -183,6 +183,7 @@ const client = new BodClient({
183
183
  url: 'ws://localhost:4400',
184
184
  auth: () => 'my-token',
185
185
  reconnect: true,
186
+ requestTimeout: 30000, // ms, rejects pending requests after timeout (default: 30s)
186
187
  });
187
188
 
188
189
  await client.connect();
@@ -290,8 +291,15 @@ ws.send(JSON.stringify({ id: '17', op: 'fts-index', path: 'posts/p1', fields: ['
290
291
  ws.send(JSON.stringify({ id: '18', op: 'vector-search', query: [0.1, 0.2], path: 'docs', limit: 5 }));
291
292
  ws.send(JSON.stringify({ id: '19', op: 'vector-store', path: 'docs/d1', embedding: [0.1, 0.2] }));
292
293
 
294
+ // Batch re-subscribe (reconnect optimization — all subs in one message)
295
+ ws.send(JSON.stringify({ id: '20', op: 'batch-sub', subscriptions: [
296
+ { path: 'users/u1', event: 'value' },
297
+ { path: 'posts', event: 'child' },
298
+ ]}));
299
+ // → { id: '20', ok: true, data: { subscribed: 2 } }
300
+
293
301
  // Stream extended ops
294
- ws.send(JSON.stringify({ id: '20', op: 'stream-snapshot', path: 'events/orders' }));
302
+ ws.send(JSON.stringify({ id: '21', op: 'stream-snapshot', path: 'events/orders' }));
295
303
  ws.send(JSON.stringify({ id: '21', op: 'stream-materialize', path: 'events/orders', keepKey: 'orderId' }));
296
304
  ws.send(JSON.stringify({ id: '22', op: 'stream-compact', path: 'events/orders', maxAge: 86400 }));
297
305
  ws.send(JSON.stringify({ id: '23', op: 'stream-reset', path: 'events/orders' }));
@@ -393,6 +401,15 @@ const snap = db.stream.snapshot('events/orders');
393
401
  const view = db.stream.materialize('events/orders', { keepKey: 'orderId' });
394
402
  // view = { o1: {orderId:'o1', status:'completed'}, o2: {...}, ... }
395
403
 
404
+ // Cursor-based materialize (for large topics — paginated)
405
+ let allData: Record<string, unknown> = {};
406
+ let cursor: string | undefined;
407
+ do {
408
+ const r = db.stream.materialize('events/orders', { keepKey: 'orderId', batchSize: 1000, cursor });
409
+ Object.assign(allData, r.data);
410
+ cursor = r.nextCursor;
411
+ } while (cursor);
412
+
396
413
  // Safety: compaction never folds events beyond the minimum consumer group offset
397
414
  ```
398
415
 
package/CLAUDE.md CHANGED
@@ -58,13 +58,21 @@ config.ts — demo instance config (open rules, indexes, fts, v
58
58
  - **MQ (Message Queue)**: SQS-style work queue. `db.mq.push(queue, value)` / `.fetch(queue, count)` / `.ack(queue, key)` / `.nack(queue, key)`. Each message claimed by exactly one worker (visibility timeout). Columns: `mq_status` (pending/inflight/dead), `mq_inflight_until`, `mq_delivery_count`. `sweep()` reclaims expired inflight; exhausted messages (>maxDeliveries) move to DLQ at `<queue>/_dlq/<key>` with `mq_status='dead'`. Ack = DELETE (keeps queue lean). `purge(queue)` deletes pending only; `purge(queue, { all: true })` deletes all (pending + inflight + DLQ). Per-queue options via `queues` config (longest prefix match). Client: `MQReader` / `MQMessageSnapshot`.
59
59
  - **FileAdapter**: scans directory, syncs metadata to DB, optional fs.watch, content read/write-through.
60
60
  - **SSE fallback**: `GET /sse/<path>?event=value|child` returns `text/event-stream`. Initial `: ok` comment flushes the stream connection.
61
- - **Perf**: `snapshotExisting` and `notify` are skipped when no subscriptions are active. `exists()` uses `LIMIT 1`.
62
- - **Transport**: WS messages follow `protocol.ts` types. REST at `/db/<path>`. Auth via `op:'auth'` message. Subs cleaned up on disconnect.
63
- - **BodClient**: id-correlated request/response over WS. Auto-reconnect with exponential backoff. Re-subscribes all active subs on reconnect. `ValueSnapshot` with `.val()`, `.key`, `.path`, `.exists()`, `.updatedAt`. `getSnapshot(path)` returns ValueSnapshot with `updatedAt` from server.
61
+ - **Perf**: `snapshotExisting` uses `existsMany()` (single SQL query with OR conditions). `notify` skipped when no subscriptions active. `exists()` uses `LIMIT 1`. Subscription callbacks wrapped in try-catch (one bad callback can't break others).
62
+ - **Query SQL push-down**: For push rows (single JSON values), queries use `json_extract(value, '$.field')` in SQL WHERE/ORDER/LIMIT. Auto-detected via sampling first row. Flattened rows fall back to JS-based filtering.
63
+ - **Broadcast groups**: Transport shares one DB subscription per path across all WS clients. `JSON.stringify()` once, `ws.send(buffer)` for all clients. Reduces serialization from O(N) to O(1) per notification.
64
+ - **Async batched notifications**: `SubscriptionEngine.queueNotify()` coalesces multiple writes in same tick via `queueMicrotask()`. Opt-in via `asyncNotify: true` in `SubscriptionEngineOptions`.
65
+ - **Batch re-subscribe**: `{ op: 'batch-sub', subscriptions: [{path, event}] }` — single message to register N subscriptions. Client uses this on reconnect. Fallback to individual subs if server returns error.
66
+ - **Cursor-based materialize**: `stream.materialize(topic, { batchSize, cursor })` returns `{ data, nextCursor? }` for paginated materialization of large streams.
67
+ - **Transport**: WS messages follow `protocol.ts` types. REST at `/db/<path>`. Auth via `op:'auth'` message. Subs cleaned up on disconnect. Connection cap (`maxConnections: 10000`), message size limit (`maxMessageSize: 1MB`), per-client sub cap (`maxSubscriptionsPerClient: 500`). Configurable via `TransportOptions`.
68
+ - **Replication batching**: Transaction writes are buffered via `ReplicationEngine.beginBatch()/flushBatch()` — emits all repl events after transaction commit instead of per-write.
69
+ - **Auth rate limiting**: `KeyAuthEngine` supports `maxAuthFailures` + `authLockoutSeconds` — failed auth attempts tracked at `_auth/rateLimit/{fp}` with TTL. Cleared on success. `pbkdf2Iterations` configurable (default 100000).
70
+ - **BodClient**: id-correlated request/response over WS. Auto-reconnect with exponential backoff. Re-subscribes all active subs on reconnect via `batch-sub` (single message). `ValueSnapshot` with `.val()`, `.key`, `.path`, `.exists()`, `.updatedAt`. `getSnapshot(path)` returns ValueSnapshot with `updatedAt` from server. `requestTimeout` (default 30s) auto-rejects pending requests.
64
71
  - **BodClientCached**: two-tier cache wrapper around BodClient. Memory (Map, LRU eviction) + IndexedDB persistence. Stale-while-revalidate: subscribed paths always fresh, unsubscribed return stale + background refetch. Writes (`set/update/delete`) invalidate path + ancestors. `init()` opens IDB + sweeps expired. `warmup(paths[])` bulk-loads from IDB. Passthrough for `push/batch/query/search/mq/stream/vfs` via `cachedClient.client`.
65
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.
66
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`.
67
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.
68
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`.
69
77
 
70
78
  ## MCP Server
@@ -122,3 +130,4 @@ bun test tests/storage.test.ts # single file
122
130
  - [x] Phase 10: VFS — Virtual File System (VFSEngine, LocalBackend, REST + WS transport, VFSClient)
123
131
  - [x] Phase 11: BodClientCached — two-tier cache (memory + IndexedDB), stale-while-revalidate, updatedAt protocol
124
132
  - [x] Phase 12: KeyAuth — Ed25519 identity & IAM (KeyAuthEngine, accounts, devices, challenge-response, sessions, roles, self-signed tokens)
133
+ - [x] Phase 13: Optimization — request timeouts, connection/sub caps, SQL query push-down, broadcast groups, async batched notifications, batch re-subscribe, cursor-based materialize, replication event batching, auth rate limiting, configurable PBKDF2
package/admin/admin.ts CHANGED
@@ -21,11 +21,20 @@ const UI_PATH = join(import.meta.dir, 'ui.html');
21
21
  export function startAdminUI(options?: { port?: number; serverUrl?: string }) {
22
22
  const uiPort = options?.port ?? DEFAULT_UI_PORT;
23
23
  const serverUrl = options?.serverUrl ?? DEFAULT_SERVER_URL;
24
+ // Derive HTTP base URL from WS URL (ws:// → http://, wss:// → https://)
25
+ const httpBase = serverUrl.replace(/^ws(s?):\/\//, 'http$1://');
24
26
 
25
27
  const server = Bun.serve({
26
28
  port: uiPort,
27
29
  async fetch(req) {
28
30
  const url = new URL(req.url);
31
+
32
+ // Proxy API calls to the BodDB server
33
+ if (url.pathname.startsWith('/db/') || url.pathname.startsWith('/files/') || url.pathname.startsWith('/sse/')) {
34
+ const target = httpBase + url.pathname + url.search;
35
+ return fetch(target, { method: req.method, headers: req.headers, body: req.body });
36
+ }
37
+
29
38
  if (url.pathname === '/' || url.pathname === '/ui.html') {
30
39
  let html = await Bun.file(UI_PATH).text();
31
40
  html = html.replace(
@@ -23,6 +23,7 @@ export default {
23
23
  vectors: { dimensions: 384 },
24
24
  vfs: { storageRoot: VFS_ROOT },
25
25
  keyAuth: {},
26
+ log: { enabled: true, level: 'info', components: '*' },
26
27
  replication: {
27
28
  role: 'primary',
28
29
  sources: [{
package/admin/ui.html CHANGED
@@ -45,7 +45,8 @@
45
45
 
46
46
  /* Right pane */
47
47
  #right-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
48
- .tabs { display: flex; background: #161616; border-bottom: 1px solid #2a2a2a; overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; }
48
+ .tabs { display: flex; background: #161616; border-bottom: 1px solid #2a2a2a; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
49
+ .tabs::-webkit-scrollbar { display: none; }
49
50
  .tab { padding: 7px 16px; cursor: pointer; border-bottom: 2px solid transparent; color: #666; font-size: 12px; white-space: nowrap; flex-shrink: 0; }
50
51
  .tab.active { color: #fff; border-bottom-color: #569cd6; }
51
52
  .panel { display: none; flex: 1; overflow-y: auto; padding: 12px; flex-direction: column; gap: 10px; }
@@ -134,6 +135,7 @@
134
135
  <div class="metric-top"><span class="metric-label">Uptime</span><span class="metric-value dim" id="s-uptime">—</span></div>
135
136
  <div style="margin-top:4px;font-size:10px;color:#555" id="s-ts">—</div>
136
137
  <div style="margin-top:4px"><span class="metric-label">WS<span id="ws-dot"></span></span> <span style="font-size:10px;color:#555"><span id="s-clients">0</span> clients · <span id="s-subs">0</span> subs</span></div>
138
+ <div style="margin-top:6px"><button id="stats-toggle" class="sm" onclick="toggleStats()" title="Toggle server stats collection">Stats: ON</button></div>
137
139
  </div>
138
140
  </div>
139
141
 
@@ -1210,6 +1212,21 @@ async function measurePing() {
1210
1212
  }
1211
1213
  setInterval(measurePing, 2000);
1212
1214
 
1215
+ let _statsEnabled = true;
1216
+ async function toggleStats() {
1217
+ try {
1218
+ const result = await db._send('admin-stats-toggle', { enabled: !_statsEnabled });
1219
+ _statsEnabled = result.enabled;
1220
+ const btn = document.getElementById('stats-toggle');
1221
+ btn.textContent = 'Stats: ' + (_statsEnabled ? 'ON' : 'OFF');
1222
+ btn.style.color = _statsEnabled ? '#4ec9b0' : '#f48771';
1223
+ if (!_statsEnabled) {
1224
+ // Clear displays
1225
+ for (const id of ['s-cpu','s-heap','s-rss','s-nodes']) document.getElementById(id).textContent = '—';
1226
+ }
1227
+ } catch (e) { console.warn('Stats toggle failed:', e); }
1228
+ }
1229
+
1213
1230
  db.on('_admin/stats', (snap) => {
1214
1231
  const s = snap.val();
1215
1232
  if (!s) return;
@@ -1437,7 +1454,7 @@ function renderChildren(children, parentPath) {
1437
1454
  html += `<div class="tree-leaf" data-path="${escHtml(path)}" onclick="selectPath('${path.replace(/'/g, "\\'")}')"><span class="tree-key">${escHtml(ch.key)}</span>${ttlBadge}<span class="tree-val">${escHtml(String(display ?? ''))}</span></div>`;
1438
1455
  } else {
1439
1456
  const isOpen = _restoredOpenPaths.has(path);
1440
- html += `<details data-path="${escHtml(path)}"${isOpen ? ' open' : ''}><summary onclick="selectPath('${path.replace(/'/g, "\\'")}')">${escHtml(ch.key)}${ttlBadge}</summary><div class="tree-children" data-parent="${escHtml(path)}"></div></details>`;
1457
+ html += `<details data-path="${escHtml(path)}"${isOpen ? ' open' : ''}><summary><span onclick="selectPath('${path.replace(/'/g, "\\'")}')">${escHtml(ch.key)}${ttlBadge}</span></summary><div class="tree-children" data-parent="${escHtml(path)}"></div></details>`;
1441
1458
  }
1442
1459
  }
1443
1460
  return html;
@@ -2598,6 +2615,19 @@ async function doMqDemo() {
2598
2615
  }
2599
2616
 
2600
2617
  // ── Helpers ────────────────────────────────────────────────────────────────────
2618
+
2619
+ /** Fetch that always returns { ok, error?, data?, status } — never throws on non-2xx or non-JSON */
2620
+ async function apiFetch(url, opts) {
2621
+ let res;
2622
+ try { res = await fetch(url, opts); } catch (e) { return { ok: false, error: e.message }; }
2623
+ const ct = res.headers.get('content-type') || '';
2624
+ if (ct.includes('application/json')) {
2625
+ try { return await res.json(); } catch (e) { return { ok: false, error: 'Invalid JSON: ' + e.message }; }
2626
+ }
2627
+ const text = await res.text().catch(() => res.statusText);
2628
+ return { ok: false, error: res.status + ' ' + (text || res.statusText) };
2629
+ }
2630
+
2601
2631
  function showStatus(id, msg, ok, ms) {
2602
2632
  const el = document.getElementById(id);
2603
2633
  el.className = 'status ' + (ok ? 'ok' : 'err');
@@ -2704,8 +2734,7 @@ async function vfsNavigate(path) {
2704
2734
  const tbl = document.getElementById('vfs-table');
2705
2735
  const t0 = performance.now();
2706
2736
  try {
2707
- const res = await fetch('/files/' + (vfsPath || '') + '?list=1');
2708
- const json = await res.json();
2737
+ const json = await apiFetch('/files/' + (vfsPath || '') + '?list=1');
2709
2738
  const ms = performance.now() - t0;
2710
2739
  if (!json.ok) { tbl.innerHTML = '<div style="padding:12px;color:#f44">Error: ' + (json.error || 'Failed') + '</div>'; return; }
2711
2740
  const items = json.data || [];
@@ -2799,8 +2828,7 @@ async function vfsUploadHere() {
2799
2828
  const path = (vfsPath ? vfsPath + '/' : '') + file.name;
2800
2829
  const t0 = performance.now();
2801
2830
  try {
2802
- const res = await fetch('/files/' + path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
2803
- const json = await res.json();
2831
+ const json = await apiFetch('/files/' + path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
2804
2832
  showStatus('vfs-explorer-status', json.ok ? 'Uploaded ' + file.name : (json.error || 'Failed'), json.ok, performance.now() - t0);
2805
2833
  } catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
2806
2834
  fileInput.value = '';
@@ -2836,11 +2864,10 @@ async function vfsSyncFolder() {
2836
2864
  const batch = files.slice(i, i + 5);
2837
2865
  const results = await Promise.allSettled(batch.map(async (f) => {
2838
2866
  const file = await f.handle.getFile();
2839
- const res = await fetch('/files/' + basePath + '/' + f.path, {
2867
+ const json = await apiFetch('/files/' + basePath + '/' + f.path, {
2840
2868
  method: 'POST', body: file,
2841
2869
  headers: { 'Content-Type': file.type || 'application/octet-stream' }
2842
2870
  });
2843
- const json = await res.json();
2844
2871
  if (!json.ok) throw new Error(json.error);
2845
2872
  }));
2846
2873
  for (const r of results) r.status === 'fulfilled' ? ok++ : fail++;
@@ -2857,8 +2884,7 @@ async function vfsMkdirHere() {
2857
2884
  const path = (vfsPath ? vfsPath + '/' : '') + name;
2858
2885
  const t0 = performance.now();
2859
2886
  try {
2860
- const res = await fetch('/files/' + path + '?mkdir=1', { method: 'POST' });
2861
- const json = await res.json();
2887
+ const json = await apiFetch('/files/' + path + '?mkdir=1', { method: 'POST' });
2862
2888
  showStatus('vfs-explorer-status', json.ok ? 'Created ' + name : (json.error || 'Failed'), json.ok, performance.now() - t0);
2863
2889
  } catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
2864
2890
  vfsNavigate(vfsPath);
@@ -2885,8 +2911,7 @@ async function vfsRename() {
2885
2911
  const dst = (vfsPath ? vfsPath + '/' : '') + newName;
2886
2912
  const t0 = performance.now();
2887
2913
  try {
2888
- const res = await fetch('/files/' + vfsSelected.fullPath + '?move=' + encodeURIComponent(dst), { method: 'PUT' });
2889
- const json = await res.json();
2914
+ const json = await apiFetch('/files/' + vfsSelected.fullPath + '?move=' + encodeURIComponent(dst), { method: 'PUT' });
2890
2915
  showStatus('vfs-explorer-status', json.ok ? 'Renamed → ' + newName : (json.error || 'Failed'), json.ok, performance.now() - t0);
2891
2916
  } catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
2892
2917
  vfsNavigate(vfsPath);
@@ -2896,8 +2921,7 @@ async function vfsDeleteSel() {
2896
2921
  if (!vfsSelected) return;
2897
2922
  const t0 = performance.now();
2898
2923
  try {
2899
- const res = await fetch('/files/' + vfsSelected.fullPath, { method: 'DELETE' });
2900
- const json = await res.json();
2924
+ const json = await apiFetch('/files/' + vfsSelected.fullPath, { method: 'DELETE' });
2901
2925
  showStatus('vfs-explorer-status', json.ok ? 'Deleted ' + vfsSelected.name : (json.error || 'Failed'), json.ok, performance.now() - t0);
2902
2926
  } catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
2903
2927
  vfsSelected = null;
@@ -2914,8 +2938,7 @@ async function doVfsUpload() {
2914
2938
  const file = fileInput.files[0];
2915
2939
  const t0 = performance.now();
2916
2940
  try {
2917
- const res = await fetch('/files/' + path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
2918
- const json = await res.json();
2941
+ const json = await apiFetch('/files/' + path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
2919
2942
  const ms = performance.now() - t0;
2920
2943
  if (json.ok) {
2921
2944
  for (const id of ['vfs-download-path','vfs-stat-path','vfs-delete-path','vfs-move-src']) document.getElementById(id).value = path;
@@ -2948,8 +2971,7 @@ async function doVfsStat() {
2948
2971
  if (!path) return showStatus('vfs-stat-status', 'Path required', false);
2949
2972
  const t0 = performance.now();
2950
2973
  try {
2951
- const res = await fetch('/files/' + path + '?stat=1');
2952
- const json = await res.json();
2974
+ const json = await apiFetch('/files/' + path + '?stat=1');
2953
2975
  const ms = performance.now() - t0;
2954
2976
  const el = document.getElementById('vfs-stat-result');
2955
2977
  if (json.ok) { el.style.display = 'block'; el.textContent = JSON.stringify(json.data, null, 2); }
@@ -2963,8 +2985,7 @@ async function doVfsList() {
2963
2985
  if (!path) return showStatus('vfs-list-status', 'Path required', false);
2964
2986
  const t0 = performance.now();
2965
2987
  try {
2966
- const res = await fetch('/files/' + path + '?list=1');
2967
- const json = await res.json();
2988
+ const json = await apiFetch('/files/' + path + '?list=1');
2968
2989
  const ms = performance.now() - t0;
2969
2990
  const el = document.getElementById('vfs-list-result');
2970
2991
  if (json.ok) { el.style.display = 'block'; el.textContent = JSON.stringify(json.data, null, 2); }
@@ -2978,8 +2999,7 @@ async function doVfsMkdir() {
2978
2999
  if (!path) return showStatus('vfs-mkdir-status', 'Path required', false);
2979
3000
  const t0 = performance.now();
2980
3001
  try {
2981
- const res = await fetch('/files/' + path + '?mkdir=1', { method: 'POST' });
2982
- const json = await res.json();
3002
+ const json = await apiFetch('/files/' + path + '?mkdir=1', { method: 'POST' });
2983
3003
  showStatus('vfs-mkdir-status', json.ok ? `Created: ${path}` : (json.error || 'Failed'), json.ok, performance.now() - t0);
2984
3004
  } catch (e) { showStatus('vfs-mkdir-status', e.message, false, performance.now() - t0); }
2985
3005
  }
@@ -2990,8 +3010,7 @@ async function doVfsMove() {
2990
3010
  if (!src || !dst) return showStatus('vfs-move-status', 'Both paths required', false);
2991
3011
  const t0 = performance.now();
2992
3012
  try {
2993
- const res = await fetch('/files/' + src + '?move=' + encodeURIComponent(dst), { method: 'PUT' });
2994
- const json = await res.json();
3013
+ const json = await apiFetch('/files/' + src + '?move=' + encodeURIComponent(dst), { method: 'PUT' });
2995
3014
  showStatus('vfs-move-status', json.ok ? `Moved → ${json.data.path}` : (json.error || 'Failed'), json.ok, performance.now() - t0);
2996
3015
  } catch (e) { showStatus('vfs-move-status', e.message, false, performance.now() - t0); }
2997
3016
  }
@@ -3001,8 +3020,7 @@ async function doVfsDelete() {
3001
3020
  if (!path) return showStatus('vfs-delete-status', 'Path required', false);
3002
3021
  const t0 = performance.now();
3003
3022
  try {
3004
- const res = await fetch('/files/' + path, { method: 'DELETE' });
3005
- const json = await res.json();
3023
+ const json = await apiFetch('/files/' + path, { method: 'DELETE' });
3006
3024
  showStatus('vfs-delete-status', json.ok ? `Deleted: ${path}` : (json.error || 'Failed'), json.ok, performance.now() - t0);
3007
3025
  } catch (e) { showStatus('vfs-delete-status', e.message, false, performance.now() - t0); }
3008
3026
  }
@@ -3098,12 +3116,11 @@ function kaSend(op, params = {}) {
3098
3116
  async function loadKeyAuth() {
3099
3117
  if (!db.connected) { setTimeout(loadKeyAuth, 300); return; }
3100
3118
  try {
3101
- const [server, accounts, roles] = await Promise.all([
3119
+ // Always fetch server info (unauthenticated read) + fingerprints (no auth needed)
3120
+ const [server, fingerprints] = await Promise.all([
3102
3121
  db.get('_auth/server'),
3103
- kaSend('auth-list-accounts'),
3104
- kaSend('auth-list-roles'),
3122
+ kaSend('auth-list-account-fingerprints'),
3105
3123
  ]);
3106
- _kaAccounts = accounts || [];
3107
3124
 
3108
3125
  // Server info
3109
3126
  const sEl = document.getElementById('ka-server');
@@ -3113,23 +3130,36 @@ async function loadKeyAuth() {
3113
3130
  sEl.textContent = 'KeyAuth not enabled.\nAdd keyAuth: {} to config.ts';
3114
3131
  }
3115
3132
 
3133
+ // Try authenticated endpoints (full accounts + roles); fall back to fingerprints-only
3134
+ let accounts = null, roles = null;
3135
+ try {
3136
+ [accounts, roles] = await Promise.all([
3137
+ kaSend('auth-list-accounts'),
3138
+ kaSend('auth-list-roles'),
3139
+ ]);
3140
+ } catch {}
3141
+
3142
+ const authenticated = !!accounts;
3143
+ _kaAccounts = accounts || (fingerprints || []).map(f => ({ ...f, roles: [] }));
3144
+
3116
3145
  // Accounts list
3117
3146
  const aEl = document.getElementById('ka-accounts');
3118
3147
  if (_kaAccounts.length) {
3119
3148
  aEl.innerHTML = _kaAccounts.map(a => {
3120
3149
  const fp = a.fingerprint;
3121
3150
  const name = a.displayName || '—';
3122
- const rls = (a.roles || []).join(', ') || 'none';
3123
- const isDevice = !a.encryptedPrivateKey;
3151
+ const rls = authenticated ? ((a.roles || []).join(', ') || 'none') : '';
3152
+ const isDevice = authenticated && !a.encryptedPrivateKey;
3124
3153
  const expanded = _kaExpandedFp === fp;
3125
3154
  return `<div style="border:1px solid #333;border-radius:4px;padding:6px;margin-bottom:4px;cursor:pointer" onclick="kaExpandAccount('${fp}')">
3126
- <span style="font-weight:bold">${name}</span> [${rls}] ${isDevice ? '<span style="color:#888">(device)</span>' : ''}
3155
+ <span style="font-weight:bold">${name}</span>${rls ? ' [' + rls + ']' : ''} ${isDevice ? '<span style="color:#888">(device)</span>' : ''}
3127
3156
  <span style="color:#666;font-size:11px;float:right">${fp.slice(0,12)}… ${expanded?'▼':'▶'}</span>
3128
3157
  <div id="ka-detail-${fp}" style="display:${expanded?'block':'none'};margin-top:6px;padding-top:6px;border-top:1px solid #333"></div>
3129
3158
  </div>`;
3130
3159
  }).join('');
3160
+ if (!authenticated) aEl.innerHTML += '<div style="color:#888;font-size:12px;margin-top:4px">Authenticate to see roles &amp; details</div>';
3131
3161
  // If expanded, load detail
3132
- if (_kaExpandedFp) kaLoadDetail(_kaExpandedFp);
3162
+ if (_kaExpandedFp && authenticated) kaLoadDetail(_kaExpandedFp);
3133
3163
  } else {
3134
3164
  aEl.innerHTML = '<div class="result">No accounts yet.</div>';
3135
3165
  }
@@ -3142,7 +3172,7 @@ async function loadKeyAuth() {
3142
3172
  return `<div style="margin-bottom:2px">${r.id}: ${r.name||r.id} → ${perms||'none'} <button class="sm" onclick="kaDeleteRole('${r.id}')" style="float:right">×</button></div>`;
3143
3173
  }).join('');
3144
3174
  } else {
3145
- rEl.textContent = 'No roles.';
3175
+ rEl.textContent = authenticated ? 'No roles.' : 'Authenticate to view roles';
3146
3176
  }
3147
3177
 
3148
3178
  // Populate dropdowns
@@ -3150,8 +3180,8 @@ async function loadKeyAuth() {
3150
3180
  sel.innerHTML = _kaAccounts.map(a => `<option value="${a.fingerprint}">${a.displayName||a.fingerprint.slice(0,12)}</option>`).join('');
3151
3181
  const authSel = document.getElementById('ka-auth-fp');
3152
3182
  const prevFp = authSel.value;
3153
- const pwAccounts = _kaAccounts.filter(a => !!a.encryptedPrivateKey);
3154
- authSel.innerHTML = '<option value="">— select account —</option>' + pwAccounts.map(a => `<option value="${a.fingerprint}"${a.fingerprint===prevFp?' selected':''}>${a.displayName||a.fingerprint.slice(0,12)} [${(a.roles||[]).join(',')||'none'}]</option>`).join('');
3183
+ const pwAccounts = authenticated ? _kaAccounts.filter(a => !!a.encryptedPrivateKey) : _kaAccounts;
3184
+ authSel.innerHTML = '<option value="">— select account —</option>' + pwAccounts.map(a => `<option value="${a.fingerprint}"${a.fingerprint===prevFp?' selected':''}>${a.displayName||a.fingerprint.slice(0,12)}${authenticated ? ' [' + ((a.roles||[]).join(',')||'none') + ']' : ''}</option>`).join('');
3155
3185
  } catch (e) {
3156
3186
  document.getElementById('ka-server').textContent = 'Error: ' + e.message;
3157
3187
  }
@@ -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/index.ts CHANGED
@@ -26,3 +26,5 @@ export { increment, serverTimestamp, arrayUnion, arrayRemove, ref } from './src/
26
26
  export * from './src/shared/protocol.ts';
27
27
  export * from './src/shared/errors.ts';
28
28
  export { normalizePath, validatePath, ancestors, parentPath, pathKey, prefixEnd, flatten, reconstruct } from './src/shared/pathUtils.ts';
29
+ export { Logger } from './src/shared/logger.ts';
30
+ export type { LogConfig, LogLevel } from './src/shared/logger.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bod.ee/db",
3
- "version": "0.10.2",
3
+ "version": "0.12.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "exports": {