@bod.ee/db 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -1
- package/.claude/skills/config-file.md +1 -0
- package/.claude/skills/developing-bod-db.md +11 -5
- package/.claude/skills/using-bod-db.md +125 -5
- package/CLAUDE.md +11 -6
- package/README.md +3 -3
- package/admin/admin.ts +57 -0
- package/admin/demo.config.ts +132 -0
- package/admin/rules.ts +4 -1
- package/admin/ui.html +530 -6
- package/bun.lock +33 -0
- package/cli.ts +4 -43
- package/client.ts +5 -3
- package/config.ts +10 -3
- package/index.ts +5 -0
- package/package.json +8 -2
- package/src/client/BodClient.ts +220 -2
- package/src/client/{CachedClient.ts → BodClientCached.ts} +115 -6
- package/src/server/BodDB.ts +24 -8
- package/src/server/KeyAuthEngine.ts +481 -0
- package/src/server/ReplicationEngine.ts +1 -1
- package/src/server/RulesEngine.ts +4 -2
- package/src/server/Transport.ts +213 -0
- package/src/server/VFSEngine.ts +78 -7
- package/src/shared/keyAuth.browser.ts +80 -0
- package/src/shared/keyAuth.ts +177 -0
- package/src/shared/protocol.ts +28 -1
- package/tests/cached-client.test.ts +123 -7
- package/tests/keyauth.test.ts +1010 -0
- package/admin/server.ts +0 -607
|
@@ -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/
|
|
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
|
-
###
|
|
95
|
-
`
|
|
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):
|
|
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` —
|
|
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
|
-
##
|
|
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,
|
|
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
|
|
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. **
|
|
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/
|
|
28
|
-
src/
|
|
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
|
-
- **
|
|
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:
|
|
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
|
-
##
|
|
123
|
+
## BodClientCached (Browser Cache)
|
|
124
124
|
|
|
125
125
|
```typescript
|
|
126
|
-
import { BodClient,
|
|
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
|
|
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
|
|
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
|
};
|