@bod.ee/db 0.10.1 → 0.11.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 +5 -1
- package/.claude/skills/config-file.md +3 -1
- package/.claude/skills/developing-bod-db.md +10 -1
- package/.claude/skills/using-bod-db.md +18 -1
- package/CLAUDE.md +11 -3
- package/admin/admin.ts +9 -0
- package/admin/demo.config.ts +1 -0
- package/admin/ui.html +67 -37
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/client/BodClient.ts +31 -6
- package/src/server/BodDB.ts +113 -42
- package/src/server/KeyAuthEngine.ts +32 -7
- package/src/server/ReplicationEngine.ts +31 -1
- package/src/server/StorageEngine.ts +117 -6
- package/src/server/StreamEngine.ts +20 -2
- package/src/server/SubscriptionEngine.ts +122 -35
- package/src/server/Transport.ts +131 -19
- package/src/shared/keyAuth.ts +4 -4
- package/src/shared/logger.ts +68 -0
- package/src/shared/protocol.ts +3 -1
- package/tests/keyauth.test.ts +28 -1
- package/tests/optimization.test.ts +392 -0
|
@@ -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` —
|
|
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: '
|
|
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,9 +58,16 @@ 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`
|
|
62
|
-
- **
|
|
63
|
-
- **
|
|
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`.
|
|
@@ -122,3 +129,4 @@ bun test tests/storage.test.ts # single file
|
|
|
122
129
|
- [x] Phase 10: VFS — Virtual File System (VFSEngine, LocalBackend, REST + WS transport, VFSClient)
|
|
123
130
|
- [x] Phase 11: BodClientCached — two-tier cache (memory + IndexedDB), stale-while-revalidate, updatedAt protocol
|
|
124
131
|
- [x] Phase 12: KeyAuth — Ed25519 identity & IAM (KeyAuthEngine, accounts, devices, challenge-response, sessions, roles, self-signed tokens)
|
|
132
|
+
- [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(
|
package/admin/demo.config.ts
CHANGED
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
|
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 & 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)} [
|
|
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
|
}
|
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
package/src/client/BodClient.ts
CHANGED
|
@@ -15,6 +15,8 @@ export class BodClientOptions {
|
|
|
15
15
|
reconnect: boolean = true;
|
|
16
16
|
reconnectInterval: number = 1000;
|
|
17
17
|
maxReconnectInterval: number = 30000;
|
|
18
|
+
/** Timeout for individual requests in ms (0 = no timeout) */
|
|
19
|
+
requestTimeout: number = 30000;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/** Interface for persisting device keys. Default: localStorage (browser) or in-memory (Node). */
|
|
@@ -115,11 +117,18 @@ export class BodClient {
|
|
|
115
117
|
const token = await this.options.auth();
|
|
116
118
|
await this.send('auth', { token });
|
|
117
119
|
}
|
|
118
|
-
// Re-subscribe all active subscriptions
|
|
119
|
-
|
|
120
|
-
const [
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
// Re-subscribe all active subscriptions (batch)
|
|
121
|
+
if (this.activeSubs.size > 0) {
|
|
122
|
+
const subs = [...this.activeSubs].map(key => {
|
|
123
|
+
const [event, ...pathParts] = key.split(':');
|
|
124
|
+
return { path: pathParts.join(':'), event };
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
await this.send('batch-sub', { subscriptions: subs });
|
|
128
|
+
} catch {
|
|
129
|
+
// Fallback to individual subs if server doesn't support batch-sub
|
|
130
|
+
for (const sub of subs) await this.send('sub', sub);
|
|
131
|
+
}
|
|
123
132
|
}
|
|
124
133
|
// Re-subscribe stream subs
|
|
125
134
|
for (const key of this.activeStreamSubs) {
|
|
@@ -239,7 +248,18 @@ export class BodClient {
|
|
|
239
248
|
return reject(new Error('Not connected'));
|
|
240
249
|
}
|
|
241
250
|
const id = this.nextId();
|
|
242
|
-
|
|
251
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
252
|
+
const cleanup = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
253
|
+
this.pending.set(id, {
|
|
254
|
+
resolve: (v: unknown) => { cleanup(); resolve(v as Record<string, unknown>); },
|
|
255
|
+
reject: (e: Error) => { cleanup(); reject(e); },
|
|
256
|
+
});
|
|
257
|
+
if (this.options.requestTimeout > 0) {
|
|
258
|
+
timer = setTimeout(() => {
|
|
259
|
+
this.pending.delete(id);
|
|
260
|
+
reject(new Error(`Request timeout after ${this.options.requestTimeout}ms`));
|
|
261
|
+
}, this.options.requestTimeout);
|
|
262
|
+
}
|
|
243
263
|
this.ws.send(JSON.stringify({ id, op, ...params }));
|
|
244
264
|
});
|
|
245
265
|
}
|
|
@@ -546,6 +566,11 @@ export class AuthFacade {
|
|
|
546
566
|
return this.client._send('auth-link-device', { accountFingerprint, password, devicePublicKey, deviceName }) as Promise<{ fingerprint: string }>;
|
|
547
567
|
}
|
|
548
568
|
|
|
569
|
+
/** Link device by account display name (resolves fingerprint server-side) */
|
|
570
|
+
async linkDeviceByName(displayName: string, password: string, devicePublicKey: string, deviceName?: string): Promise<{ fingerprint: string }> {
|
|
571
|
+
return this.client._send('auth-link-device', { displayName, password, devicePublicKey, deviceName }) as Promise<{ fingerprint: string }>;
|
|
572
|
+
}
|
|
573
|
+
|
|
549
574
|
/** Revoke a session */
|
|
550
575
|
async revokeSession(sid: string): Promise<void> {
|
|
551
576
|
await this.client._send('auth-revoke-session', { sid });
|