@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.
@@ -23,7 +23,13 @@
23
23
  "Bash(lsof -ti:4400,4401 2>/dev/null | xargs kill -9 2>/dev/null)",
24
24
  "WebFetch(domain:151.145.81.254)",
25
25
  "WebFetch(domain:db-main.bod.ee)",
26
- "Bash(md5:*)"
26
+ "Bash(md5:*)",
27
+ "Bash(lsof -ti :4400 | xargs kill -9 2>/dev/null)",
28
+ "WebFetch(domain:stack.convex.dev)",
29
+ "WebFetch(domain:caniuse.com)",
30
+ "WebFetch(domain:github.com)",
31
+ "WebFetch(domain:api.github.com)",
32
+ "WebFetch(domain:raw.githubusercontent.com)"
27
33
  ]
28
34
  }
29
35
  }
@@ -42,6 +42,7 @@ 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
46
  replication: {
46
47
  role: 'primary',
47
48
  sources: [
@@ -18,6 +18,7 @@ src/shared/pathUtils.ts — path validation, flatten/reconstruct, ancest
18
18
  src/shared/protocol.ts — wire message types (ClientMessage, ServerMessage, BatchOp)
19
19
  src/shared/errors.ts — BodError class, error codes
20
20
  src/shared/transforms.ts — sentinel classes, factory fns, resolveTransforms
21
+ src/shared/keyAuth.ts — KeyAuth types + Ed25519/AES-256-GCM crypto utils
21
22
  src/server/StorageEngine.ts — SQLite CRUD, leaf flattening, prefix queries, transforms, TTL, push
22
23
  src/server/SubscriptionEngine.ts — value + child subs, ancestor walk, added/changed/removed
23
24
  src/server/QueryEngine.ts — fluent builder delegating to StorageEngine.query
@@ -33,7 +34,8 @@ src/server/FileAdapter.ts — file system sync, watch, metadata, content r
33
34
  src/server/ReplicationEngine.ts — primary/replica replication: write hooks, _repl stream, bootstrap, proxy
34
35
  src/server/VFSEngine.ts — virtual file system: backend abstraction, LocalBackend, metadata in DB
35
36
  src/client/BodClient.ts — WS client: CRUD, batch, push, subs, streams, VFS, auto-reconnect
36
- src/client/CachedClient.ts — two-tier cache (memory + IndexedDB): stale-while-revalidate, sub-aware
37
+ src/server/KeyAuthEngine.ts — Ed25519 identity & IAM: accounts, devices, challenge-response, sessions
38
+ src/client/BodClientCached.ts — two-tier cache (memory + IndexedDB): stale-while-revalidate, sub-aware
37
39
  src/react/hooks.ts — useValue, useChildren, useQuery, useMutation
38
40
  client.ts — client entry point
39
41
  react.ts — React hooks entry point
@@ -91,8 +93,11 @@ Push paths are append-only logs. `StreamEngine` adds consumer group offsets (`_s
91
93
  ### VFS (Virtual File System)
92
94
  `VFSEngine` — pluggable `VFSBackend` interface (`read/write/delete/exists`). `LocalBackend` stores files at `<storageRoot>/<fileId>` via `Bun.file`/`Bun.write`. fileId = pushId (move/rename = metadata-only). Metadata stored at `_vfs/<path>/__meta` (uses `__meta` key to avoid collision with children in leaf-flattened storage). Gets subscriptions, rules, replication for free. REST transport at `/files/<path>`, WS chunked fallback (base64, 48KB chunks). Client: `VFSClient` via `client.vfs()`.
93
95
 
94
- ### CachedClient
95
- `CachedClient` wraps `BodClient` with two-tier cache: in-memory `Map` (LRU, insertion-ordered eviction) + IndexedDB (`entries` object store, keyPath `path`). `get()` uses stale-while-revalidate: subscribed paths return immediately (sub keeps cache fresh), unsubscribed return stale + background `getSnapshot()`. Writes (`set/update/delete`) invalidate path + all ancestors via `pathUtils.ancestors()`. `init()` opens IDB + sweeps expired. `warmup()` bulk-loads synchronously in single IDB transaction. `close()` cleans up. Protocol: `StorageEngine.getWithMeta()` returns `{ data, updatedAt }`, `ValueSnapshot.updatedAt` carries it to client.
96
+ ### BodClientCached
97
+ `BodClientCached` wraps `BodClient` with two-tier cache: in-memory `Map` (LRU, insertion-ordered eviction) + IndexedDB (`entries` object store, keyPath `path`). `get()` uses stale-while-revalidate: subscribed paths return immediately (sub keeps cache fresh), unsubscribed return stale + background `getSnapshot()`. Writes (`set/update/delete`) invalidate path + all ancestors via `pathUtils.ancestors()`. `init()` opens IDB + sweeps expired. `warmup()` bulk-loads synchronously in single IDB transaction. `close()` cleans up. Protocol: `StorageEngine.getWithMeta()` returns `{ data, updatedAt }`, `ValueSnapshot.updatedAt` carries it to client.
98
+
99
+ ### KeyAuth (Ed25519 Identity & IAM)
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.
96
101
 
97
102
  ### FileAdapter
98
103
  Scans directory recursively on `start()`. Optional `fs.watch` for live sync. Stores metadata (size, mtime, mime) at `basePath/<relPath>`. Content read/write-through methods.
@@ -125,11 +130,12 @@ Path patterns with `$wildcard` capture. Most specific match wins. Supports boole
125
130
  - **Phase 8** (DONE): MCP server — MCPAdapter, stdio + HTTP, BodClient-based, 21 tools
126
131
  - **Phase 9** (DONE): Replication — ReplicationEngine, primary + read replicas, write proxy, bootstrap, _repl stream
127
132
  - **Phase 10** (DONE): VFS — VFSEngine, LocalBackend, REST + WS transport, VFSClient SDK
128
- - **Phase 11** (DONE): CachedClient — two-tier cache (memory + IndexedDB), stale-while-revalidate, updatedAt protocol
133
+ - **Phase 11** (DONE): BodClientCached — two-tier cache (memory + IndexedDB), stale-while-revalidate, updatedAt protocol
134
+ - **Phase 12** (DONE): KeyAuth — Ed25519 identity & IAM, challenge-response, accounts, devices, sessions, roles
129
135
 
130
136
  ## Testing
131
137
 
132
- - `bun test` — 228 tests across 22 files
138
+ - `bun test` — 266 tests across 23 files
133
139
  - Each engine/feature gets its own test file in `tests/`
134
140
  - Test happy path, edge cases, error cases
135
141
  - Use `{ sweepInterval: 0 }` in tests to disable background sweep
@@ -212,18 +212,18 @@ snap.val(); // { name: 'Alice' }
212
212
  snap.updatedAt; // 1708900000000 (ms timestamp from server)
213
213
  ```
214
214
 
215
- ## CachedClient (Browser Cache)
215
+ ## BodClientCached (Browser Cache)
216
216
 
217
217
  Two-tier cache wrapper around BodClient: in-memory Map (LRU) + IndexedDB persistence.
218
218
  Stale-while-revalidate: subscribed paths always fresh, unsubscribed return stale + background refetch.
219
219
 
220
220
  ```typescript
221
- import { BodClient, CachedClient } from 'bod-db/client';
221
+ import { BodClient, BodClientCached } from 'bod-db/client';
222
222
 
223
223
  const client = new BodClient({ url: 'ws://localhost:4400' });
224
224
  await client.connect();
225
225
 
226
- const cached = new CachedClient(client, {
226
+ const cached = new BodClientCached(client, {
227
227
  maxAge: 7 * 24 * 60 * 60 * 1000, // IDB entry TTL (7 days)
228
228
  maxMemoryEntries: 500, // LRU eviction cap
229
229
  dbName: 'boddb-cache', // IndexedDB database name
@@ -469,7 +469,7 @@ await replica.replication!.start();
469
469
  - Replicas bootstrap full state on first connect, then consume live events
470
470
  - Push keys preserved across replicas (deterministic)
471
471
  - Auto-compaction on `_repl` stream (keepKey: 'path', maxCount: 10000)
472
- - Excluded prefixes: `_repl`, `_streams`, `_mq` (internal data not replicated)
472
+ - Excluded prefixes: `_repl`, `_streams`, `_mq`, `_auth` (internal data not replicated)
473
473
 
474
474
  ### Multi-Source Feed Subscriptions
475
475
 
@@ -552,6 +552,125 @@ PUT /files/<path>?move=dst — move/rename
552
552
  DELETE /files/<path> — delete
553
553
  ```
554
554
 
555
+ ## KeyAuth (Ed25519 Identity & IAM)
556
+
557
+ Portable, zero-dependency authentication. Your identity is an Ed25519 key pair.
558
+
559
+ ```typescript
560
+ // Server — enable KeyAuth
561
+ const db = new BodDB({
562
+ keyAuth: {}, // auto-generates server key pair
563
+ // or with root:
564
+ // keyAuth: { rootPublicKey: rootPubKeyBase64 },
565
+ });
566
+ db.serve();
567
+
568
+ // Create accounts (server-side) — first account auto-becomes root
569
+ const { publicKey, fingerprint, isRoot, deviceFingerprint } = db.keyAuth!.createAccount(
570
+ 'password', ['editor'], 'Alice', devicePubKeyBase64 // optional: auto-link calling device
571
+ );
572
+
573
+ // Link a device (password is the auth — no session required)
574
+ db.keyAuth!.linkDevice(fingerprint, 'password', devicePubKeyBase64, 'My Laptop');
575
+
576
+ // Manage roles
577
+ db.keyAuth!.createRole({ id: 'editor', name: 'Editor', permissions: [{ path: 'posts', read: true, write: true }] });
578
+ db.keyAuth!.updateAccountRoles(fingerprint, ['editor']);
579
+
580
+ // Change password (fingerprint stays stable)
581
+ db.keyAuth!.changePassword(fingerprint, 'old-pw', 'new-pw');
582
+
583
+ // Revoke device or session
584
+ db.keyAuth!.revokeDevice(accountFp, deviceFp);
585
+ db.keyAuth!.revokeSession(sid);
586
+ ```
587
+
588
+ ### Client Auth — Device Identity (recommended for browser apps)
589
+
590
+ ```typescript
591
+ import { BodClient } from 'bod-db/client';
592
+
593
+ // Zero-config: auto-generates Ed25519 keypair, stores in localStorage, registers, authenticates
594
+ const client = new BodClient({
595
+ url: 'ws://localhost:4400',
596
+ autoAuth: true, // or { displayName: 'My App' }
597
+ });
598
+ await client.connect(); // fully authenticated — no user interaction needed
599
+ console.log(client.deviceFingerprint); // SHA-256 of public key
600
+ ```
601
+
602
+ ### Client Auth — Manual (explicit keypair control)
603
+
604
+ ```typescript
605
+ import { BodClient, generateKeyPair, sign } from 'bod-db/client';
606
+
607
+ const kp = await generateKeyPair(); // browser-compatible Ed25519
608
+ const client = new BodClient({
609
+ url: 'ws://localhost:4400',
610
+ keyAuth: {
611
+ publicKey: kp.publicKeyBase64,
612
+ signFn: (nonce) => sign(nonce, kp.privateKeyBase64),
613
+ },
614
+ });
615
+ await client.connect();
616
+
617
+ // Convenience auth methods
618
+ await client.auth.registerDevice(kp.publicKeyBase64, 'My Device');
619
+ await client.auth.linkDevice(accountFp, password, devicePubKey);
620
+ await client.auth.revokeSession(sid);
621
+ await client.auth.changePassword(fp, oldPw, newPw);
622
+ await client.auth.hasAccounts(); // → boolean (no auth needed)
623
+ await client.auth.listAccountFingerprints(); // → [{ fingerprint, displayName }] (no auth needed)
624
+ await client.auth.getAccountInfo(accountFp?); // → { fingerprint, displayName, roles, isRoot, createdAt } (auth needed)
625
+ ```
626
+
627
+ ### Wire Protocol
628
+
629
+ ```typescript
630
+ // Register device (client-generated keypair, no password)
631
+ ws.send(JSON.stringify({ id: '0', op: 'auth-register-device', publicKey: '...', displayName: '...' }));
632
+ // → { id: '0', ok: true, data: { fingerprint: '...' } }
633
+
634
+ // Challenge
635
+ ws.send(JSON.stringify({ id: '1', op: 'auth-challenge' }));
636
+ // → { id: '1', ok: true, data: { nonce: '...', serverId: '...' } }
637
+
638
+ // Verify (sign nonce with Ed25519)
639
+ ws.send(JSON.stringify({ id: '2', op: 'auth-verify', publicKey: '...', signature: '...', nonce: '...' }));
640
+ // → { id: '2', ok: true, data: { token: '...', expiresAt: 1234567890 } }
641
+
642
+ // Create account (first account auto-becomes root; unauthenticated only when zero accounts exist)
643
+ ws.send(JSON.stringify({ id: '3', op: 'auth-create-account', password: '...', displayName: '...', devicePublicKey: '...' }));
644
+ // → { ..., data: { publicKey, fingerprint, isRoot, deviceFingerprint? } }
645
+
646
+ // Link device (password is the auth — no session required)
647
+ ws.send(JSON.stringify({ id: '4', op: 'auth-link-device', accountFingerprint: '...', password: '...', devicePublicKey: '...' }));
648
+
649
+ // Change password (password is the auth; if authenticated, non-root can only change own)
650
+ ws.send(JSON.stringify({ id: '5', op: 'auth-change-password', fingerprint: '...', oldPassword: '...', newPassword: '...' }));
651
+
652
+ // Check if accounts exist (no auth required)
653
+ ws.send(JSON.stringify({ id: '6', op: 'auth-has-accounts' }));
654
+ // → { ..., data: { hasAccounts: true } }
655
+
656
+ // List account fingerprints (no auth required, no secrets)
657
+ ws.send(JSON.stringify({ id: '7', op: 'auth-list-account-fingerprints' }));
658
+ // → { ..., data: [{ fingerprint: '...', displayName: '...' }] }
659
+
660
+ // Get account info (authenticated, defaults to own account)
661
+ ws.send(JSON.stringify({ id: '8', op: 'auth-account-info', accountFingerprint: '...' }));
662
+ // → { ..., data: { fingerprint, displayName, roles, isRoot, createdAt } }
663
+ ```
664
+
665
+ ### Security
666
+
667
+ - `_auth/` prefix protected: external writes blocked via Transport
668
+ - Server private key: filesystem only (never DB/wire)
669
+ - Account private key: AES-256-GCM encrypted, decrypted only in memory, wiped after use
670
+ - Nonce: deleted immediately after use + short TTL (prevents replay)
671
+ - Device reverse-index: O(1) device→account lookup
672
+ - Password change: atomic (single `db.update()`)
673
+
555
674
  ## Best Practices
556
675
 
557
676
  1. **Paths are your schema** — design upfront (`users/$uid/settings/theme`)
@@ -565,4 +684,5 @@ DELETE /files/<path> — delete
565
684
  9. **`port: 0` in tests** — random available port
566
685
  10. **Streams for event processing** — consumer groups with offset tracking, replay on reconnect
567
686
  11. **Idempotent push** — dedup with `idempotencyKey` to prevent duplicate events
568
- 12. **CachedClient for browsers** — wrap BodClient for instant reads + cross-reload persistence via IndexedDB
687
+ 12. **BodClientCached for browsers** — wrap BodClient for instant reads + cross-reload persistence via IndexedDB
688
+ 13. **KeyAuth for portable identity** — Ed25519 key pairs, no external auth services needed
package/CLAUDE.md CHANGED
@@ -24,8 +24,11 @@ src/server/FileAdapter.ts — file system sync: scan, watch, metadata, content
24
24
  src/server/MCPAdapter.ts — MCP (Model Context Protocol) server: JSON-RPC dispatch, tool registry, stdio + HTTP transports
25
25
  src/server/VFSEngine.ts — virtual file system: VFSBackend interface, LocalBackend (disk), metadata in DB
26
26
  src/server/ReplicationEngine.ts — replication: primary/replica + multi-source feed subscriptions, write proxy
27
- src/client/BodClient.ts WS client: connect, auth, CRUD, batch, push, subscriptions, auto-reconnect
28
- src/client/CachedClient.ts two-tier cache (memory + IndexedDB): stale-while-revalidate, sub-aware, write invalidation
27
+ src/server/KeyAuthEngine.ts Ed25519 identity & IAM: accounts, devices, challenge-response, sessions, roles
28
+ src/shared/keyAuth.ts KeyAuth types + crypto utils (Ed25519, AES-256-GCM, PBKDF2, self-signed tokens)
29
+ src/shared/keyAuth.browser.ts — Browser-compatible Ed25519 via @noble/ed25519 + DER↔raw key bridge
30
+ src/client/BodClient.ts — WS client: connect, auth, CRUD, batch, push, subscriptions, auto-reconnect, autoAuth
31
+ src/client/BodClientCached.ts — two-tier cache (memory + IndexedDB): stale-while-revalidate, sub-aware, write invalidation
29
32
  src/react/hooks.ts — useValue, useChildren, useQuery, useMutation
30
33
  client.ts — client entry point (import from 'bod-db/client')
31
34
  react.ts — React hooks entry point (import from 'bod-db/react')
@@ -42,7 +45,7 @@ config.ts — demo instance config (open rules, indexes, fts, v
42
45
  - **Subscription notify**: writes produce changedPaths → notify exact + all ancestors (deduped). Child events track added/changed/removed via pre-write existence snapshot.
43
46
  - **Path validation**: all public APIs use `validatePath()` — rejects empty paths, normalizes slashes.
44
47
  - **Options pattern**: `new XOptions()` defaults merged with partial user options.
45
- - **Rules**: path patterns with `$wildcard` capture, most-specific match wins. Context: `auth`, `params`, `data`, `newData`. Supports functions, booleans, expression strings, and JSON/TS config files.
48
+ - **Rules**: path patterns with `$wildcard` capture, most-specific match wins. Context: `auth`, `params`, `data`, `newData`. Supports functions, booleans, expression strings, and JSON/TS config files. `defaultDeny: true` blocks unmatched paths (default: open).
46
49
  - **Expression rules**: safe tokenizer → recursive descent parser → AST evaluator. Supports `auth.x`, `$wildcard`, `data`/`newData`, comparisons, logical ops, negation, parens. No eval.
47
50
  - **Transforms**: sentinel values (`increment`, `serverTimestamp`, `arrayUnion`, `arrayRemove`, `ref`) resolved against current data before write.
48
51
  - **Refs**: `ref(path)` stores `{ _ref: path }`. Resolved at read time via `storage.get(path, { resolve: true })`.
@@ -58,10 +61,11 @@ config.ts — demo instance config (open rules, indexes, fts, v
58
61
  - **Perf**: `snapshotExisting` and `notify` are skipped when no subscriptions are active. `exists()` uses `LIMIT 1`.
59
62
  - **Transport**: WS messages follow `protocol.ts` types. REST at `/db/<path>`. Auth via `op:'auth'` message. Subs cleaned up on disconnect.
60
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
- - **CachedClient**: 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`.
64
+ - **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`.
62
65
  - **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.
63
66
  - **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`.
64
- - **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`. **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.
67
+ - **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.
68
+ - **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`.
65
69
 
66
70
  ## MCP Server
67
71
 
@@ -116,4 +120,5 @@ bun test tests/storage.test.ts # single file
116
120
  - [x] Phase 8: MCP server (MCPAdapter — stdio + HTTP, BodClient-based, 21 tools)
117
121
  - [x] Phase 9: Replication (ReplicationEngine — primary + read replicas, write proxy, bootstrap, _repl stream, multi-source feed subscriptions)
118
122
  - [x] Phase 10: VFS — Virtual File System (VFSEngine, LocalBackend, REST + WS transport, VFSClient)
119
- - [x] Phase 11: CachedClient — two-tier cache (memory + IndexedDB), stale-while-revalidate, updatedAt protocol
123
+ - [x] Phase 11: BodClientCached — two-tier cache (memory + IndexedDB), stale-while-revalidate, updatedAt protocol
124
+ - [x] Phase 12: KeyAuth — Ed25519 identity & IAM (KeyAuthEngine, accounts, devices, challenge-response, sessions, roles, self-signed tokens)
package/README.md CHANGED
@@ -120,15 +120,15 @@ client.onChild('users', (e) => console.log(e.type, e.key));
120
120
  client.disconnect();
121
121
  ```
122
122
 
123
- ## CachedClient (Browser Cache)
123
+ ## BodClientCached (Browser Cache)
124
124
 
125
125
  ```typescript
126
- import { BodClient, CachedClient } from 'bod-db/client';
126
+ import { BodClient, BodClientCached } from 'bod-db/client';
127
127
 
128
128
  const client = new BodClient({ url: 'ws://localhost:4400' });
129
129
  await client.connect();
130
130
 
131
- const cached = new CachedClient(client, {
131
+ const cached = new BodClientCached(client, {
132
132
  maxMemoryEntries: 500, // LRU eviction cap
133
133
  maxAge: 7 * 24 * 3600000, // IDB TTL (7 days)
134
134
  });
package/admin/admin.ts ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * BodDB Admin UI — lightweight static server.
4
+ * Connects to any running BodDB instance.
5
+ *
6
+ * Usage:
7
+ * bun run admin # http://localhost:4500 → ws://localhost:4444
8
+ * bun run admin --url ws://remote:4400 # connect to remote
9
+ * bun run admin --port 8080 # serve UI on port 8080
10
+ */
11
+ import { join } from 'path';
12
+
13
+ // ── Configuration ─────────────────────────────────────────────────────────────
14
+ const DEFAULT_SERVER_URL = 'ws://localhost:4444';
15
+ const DEFAULT_UI_PORT = 4500;
16
+ // ──────────────────────────────────────────────────────────────────────────────
17
+
18
+ const UI_PATH = join(import.meta.dir, 'ui.html');
19
+
20
+ /** Start the admin UI static server. Returns the Bun server instance. */
21
+ export function startAdminUI(options?: { port?: number; serverUrl?: string }) {
22
+ const uiPort = options?.port ?? DEFAULT_UI_PORT;
23
+ const serverUrl = options?.serverUrl ?? DEFAULT_SERVER_URL;
24
+
25
+ const server = Bun.serve({
26
+ port: uiPort,
27
+ async fetch(req) {
28
+ const url = new URL(req.url);
29
+ if (url.pathname === '/' || url.pathname === '/ui.html') {
30
+ let html = await Bun.file(UI_PATH).text();
31
+ html = html.replace(
32
+ '</head>',
33
+ `<script>window.__BODDB_URL__ = ${JSON.stringify(serverUrl)};</script>\n</head>`,
34
+ );
35
+ return new Response(html, { headers: { 'Content-Type': 'text/html' } });
36
+ }
37
+ return new Response('Not found', { status: 404 });
38
+ },
39
+ });
40
+
41
+ console.log(`Admin UI → http://localhost:${server.port} → ${serverUrl}`);
42
+ return server;
43
+ }
44
+
45
+ // ── CLI entry point ───────────────────────────────────────────────────────────
46
+ if (import.meta.main) {
47
+ const args = process.argv.slice(2);
48
+ let serverUrl: string | undefined;
49
+ let port: number | undefined;
50
+
51
+ for (let i = 0; i < args.length; i++) {
52
+ if (args[i] === '--url' && args[i + 1]) serverUrl = args[++i];
53
+ if (args[i] === '--port' && args[i + 1]) port = parseInt(args[++i], 10);
54
+ }
55
+
56
+ startAdminUI({ port, serverUrl });
57
+ }
@@ -0,0 +1,132 @@
1
+ import type { BodDB } from '../src/server/BodDB.ts';
2
+ import type { BodDBOptions } from '../src/server/BodDB.ts';
3
+ import { join } from 'path';
4
+ import { rules } from './rules.ts';
5
+ import { generateEd25519KeyPair } from '../src/shared/keyAuth.ts';
6
+
7
+ // ── Configuration ─────────────────────────────────────────────────────────────
8
+ const PORT = 4445;
9
+ const DB_PATH = join(import.meta.dir, '../.tmp/bod-db-admin.sqlite');
10
+ const VFS_ROOT = join(import.meta.dir, '../.tmp/vfs');
11
+ const SOURCE_PORT = PORT + 1;
12
+ const ADMIN_UI_PORT = PORT + 2;
13
+ // ──────────────────────────────────────────────────────────────────────────────
14
+
15
+ let sourceDb: BodDB;
16
+
17
+ export default {
18
+ port: PORT,
19
+ path: DB_PATH,
20
+ rules,
21
+ sweepInterval: 60000,
22
+ fts: {},
23
+ vectors: { dimensions: 384 },
24
+ vfs: { storageRoot: VFS_ROOT },
25
+ keyAuth: {},
26
+ replication: {
27
+ role: 'primary',
28
+ sources: [{
29
+ url: `ws://localhost:${SOURCE_PORT}`,
30
+ paths: ['catalog', 'alerts'],
31
+ localPrefix: 'source',
32
+ id: 'admin-demo-source',
33
+ }],
34
+ },
35
+ transport: {
36
+ extraRoutes: {
37
+ '/replication/source-write': (req: Request) => {
38
+ if (req.method !== 'POST') return null;
39
+ return (async () => {
40
+ const { path, value } = await req.json() as { path: string; value: unknown };
41
+ sourceDb.set(path, value);
42
+ return Response.json({ ok: true });
43
+ })();
44
+ },
45
+ '/replication/source-delete': (req: Request, url: URL) => {
46
+ if (req.method !== 'DELETE') return null;
47
+ const path = url.pathname.slice('/replication/source-delete/'.length);
48
+ sourceDb.delete(path);
49
+ return Response.json({ ok: true });
50
+ },
51
+ },
52
+ },
53
+ } satisfies Partial<BodDBOptions & { transport: any }>;
54
+
55
+ export async function setup(db: BodDB) {
56
+ // ── Source DB (demo replication source) ─────────────────────────────────────
57
+ const { BodDB: BodDBClass } = await import('../src/server/BodDB.ts');
58
+ sourceDb = new BodDBClass({ path: ':memory:', sweepInterval: 0, replication: { role: 'primary' } });
59
+ sourceDb.replication!.start();
60
+ sourceDb.serve({ port: SOURCE_PORT });
61
+
62
+ sourceDb.set('catalog/widgets', { name: 'Widget A', price: 29.99, stock: 150 });
63
+ sourceDb.set('catalog/gadgets', { name: 'Gadget B', price: 49.99, stock: 75 });
64
+ sourceDb.set('catalog/gizmos', { name: 'Gizmo C', price: 19.99, stock: 300 });
65
+ sourceDb.set('alerts/sys-1', { level: 'warn', msg: 'CPU spike detected', ts: Date.now() });
66
+ sourceDb.set('alerts/sys-2', { level: 'info', msg: 'Backup completed', ts: Date.now() });
67
+ console.log(`Source DB: :memory: on port ${SOURCE_PORT}`);
68
+
69
+ // ── Seed KeyAuth roles & accounts ───────────────────────────────────────────
70
+ if (db.keyAuth && !db.get('_auth/roles/admin')) {
71
+ db.keyAuth.createRole({ id: 'admin', name: 'Admin', permissions: [{ path: '', read: true, write: true }] });
72
+ db.keyAuth.createRole({ id: 'editor', name: 'Editor', permissions: [{ path: 'posts/$id', read: true, write: true }] });
73
+ db.keyAuth.createRole({ id: 'viewer', name: 'Viewer', permissions: [{ path: '', read: true }] });
74
+ const alice = db.keyAuth.createAccount('demo123', ['admin'], 'Alice');
75
+ db.keyAuth.createAccount('demo123', ['editor'], 'Bob');
76
+ // Register a device-only "Bot" account (viewer role)
77
+ const botKp = generateEd25519KeyPair();
78
+ db.keyAuth.registerDevice(botKp.publicKeyBase64, 'Bot', ['viewer']);
79
+ // Link a device under Alice ("Alice's Phone")
80
+ const phoneKp = generateEd25519KeyPair();
81
+ db.keyAuth.linkDevice(alice.fingerprint, 'demo123', phoneKp.publicKeyBase64, "Alice's Phone");
82
+ console.log('[KeyAuth] Seeded roles: admin, editor, viewer + accounts: Alice (admin), Bob (editor), Bot (viewer/device)');
83
+ }
84
+
85
+ // ── Seed demo data ──────────────────────────────────────────────────────────
86
+ if (!db.get('users/alice')) {
87
+ db.set('users/alice', { name: 'Alice', age: 30, role: 'admin' });
88
+ db.set('users/bob', { name: 'Bob', age: 25, role: 'user' });
89
+ db.set('users/carol', { name: 'Carol', age: 28, role: 'user' });
90
+ db.set('settings/theme', 'dark');
91
+ db.set('settings/lang', 'en');
92
+ db.push('logs', { level: 'info', msg: 'Server started', ts: Date.now() });
93
+ db.set('counters/likes', 0);
94
+ db.set('counters/views', 0);
95
+ db.index('users/alice', 'Alice is an admin user who manages the system');
96
+ db.index('users/bob', 'Bob is a regular user who writes articles');
97
+ db.index('users/carol', 'Carol is a user interested in design and UX');
98
+ db.set('_fts/users_alice', { path: 'users/alice', content: 'Alice is an admin user who manages the system', indexedAt: Date.now() });
99
+ db.set('_fts/users_bob', { path: 'users/bob', content: 'Bob is a regular user who writes articles', indexedAt: Date.now() });
100
+ db.set('_fts/users_carol', { path: 'users/carol', content: 'Carol is a user interested in design and UX', indexedAt: Date.now() });
101
+ const makeEmb = (seed: number) => Array.from({ length: 384 }, (_, i) => Math.sin((i + seed) * 0.1) * 0.5);
102
+ db.vectors!.store('users/alice', makeEmb(0));
103
+ db.vectors!.store('users/bob', makeEmb(10));
104
+ db.vectors!.store('users/carol', makeEmb(20));
105
+ db.set('_vectors/users_alice', { path: 'users/alice', dimensions: 384, storedAt: Date.now() });
106
+ db.set('_vectors/users_bob', { path: 'users/bob', dimensions: 384, storedAt: Date.now() });
107
+ db.set('_vectors/users_carol', { path: 'users/carol', dimensions: 384, storedAt: Date.now() });
108
+ db.push('events/orders', { orderId: 'o1', amount: 99, status: 'completed' });
109
+ db.push('events/orders', { orderId: 'o2', amount: 42, status: 'pending' });
110
+ db.push('events/orders', { orderId: 'o3', amount: 150, status: 'completed' });
111
+ db.mq.push('queues/jobs', { type: 'email', to: 'alice@example.com', subject: 'Welcome' });
112
+ db.mq.push('queues/jobs', { type: 'sms', to: '+1234567890', body: 'Your code is 1234' });
113
+ db.mq.push('queues/jobs', { type: 'webhook', url: 'https://example.com/hook', payload: { event: 'signup' } });
114
+ if (db.vfs) {
115
+ db.vfs.mkdir('docs');
116
+ db.vfs.write('docs/readme.txt', new TextEncoder().encode('Welcome to BodDB VFS!\nThis is a demo file.'));
117
+ db.vfs.write('docs/config.json', new TextEncoder().encode(JSON.stringify({ theme: 'dark', lang: 'en' }, null, 2)), 'application/json');
118
+ db.vfs.mkdir('images');
119
+ }
120
+ }
121
+
122
+ // ── Start replication ───────────────────────────────────────────────────────
123
+ db.replication!.start().then(() => {
124
+ console.log(`[REPL] source feed connected → syncing catalog + alerts from :${SOURCE_PORT}`);
125
+ }).catch(e => console.error('[REPL] source feed failed:', e));
126
+
127
+ // ── Admin UI ────────────────────────────────────────────────────────────────
128
+ const { startAdminUI } = await import('./admin.ts');
129
+ startAdminUI({ port: ADMIN_UI_PORT, serverUrl: `ws://localhost:${PORT}` });
130
+
131
+ process.on('beforeExit', () => sourceDb.close());
132
+ }
package/admin/rules.ts CHANGED
@@ -4,9 +4,12 @@ import type { PathRule } from '../src/server/RulesEngine.ts';
4
4
  * Edit this file to configure access rules for the admin server.
5
5
  * Pattern syntax: exact segments or $wildcard (e.g. users/$uid)
6
6
  * Context: { auth, path, params, data, newData }
7
+ * auth = KeyAuthContext: { fingerprint, accountFingerprint, roles[], isRoot, sid }
7
8
  */
8
9
  export const rules: Record<string, PathRule> = {
10
+ '': { read: true },
9
11
  '_admin/$any': { read: true, write: false },
10
- 'users/$uid': { write: ({ auth, params }) => auth?.role === 'admin' || ['alice', 'bob'].includes(params.uid) },
12
+ 'users/$uid': { write: ({ auth }) => !!auth },
11
13
  'settings/$key': { write: ({ auth }) => !!auth },
14
+ 'admin/$any': { read: ({ auth }) => !!(auth as any)?.isRoot, write: ({ auth }) => !!(auth as any)?.isRoot },
12
15
  };