@bod.ee/db 0.9.1 → 0.10.2

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/admin/ui.html CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  /* Metrics bar */
11
11
  #metrics-bar { display: flex; background: #0a0a0a; border-bottom: 1px solid #2a2a2a; flex-shrink: 0; overflow-x: auto; align-items: stretch; }
12
- .metric-card { display: flex; flex-direction: column; padding: 5px 10px 4px; border-right: 1px solid #181818; width: 130px; flex-shrink: 0; gap: 1px; overflow: hidden; }
12
+ .metric-card { display: flex; flex-direction: column; padding: 5px 10px 4px; border-right: 1px solid #181818; min-width: 140px; flex-shrink: 0; gap: 1px; overflow: hidden; }
13
13
  .metric-card:last-child { border-right: none; width: auto; }
14
14
  .metric-right { margin-left: auto; }
15
15
  .metric-top { display: flex; justify-content: space-between; align-items: baseline; width: 100%; }
@@ -45,8 +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; }
49
- .tab { padding: 7px 16px; cursor: pointer; border-bottom: 2px solid transparent; color: #666; font-size: 12px; }
48
+ .tabs { display: flex; background: #161616; border-bottom: 1px solid #2a2a2a; overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; }
49
+ .tab { padding: 7px 16px; cursor: pointer; border-bottom: 2px solid transparent; color: #666; font-size: 12px; white-space: nowrap; flex-shrink: 0; }
50
50
  .tab.active { color: #fff; border-bottom-color: #569cd6; }
51
51
  .panel { display: none; flex: 1; overflow-y: auto; padding: 12px; flex-direction: column; gap: 10px; }
52
52
  .panel.active { display: flex; }
@@ -161,6 +161,7 @@
161
161
  <div class="tab" onclick="showTab('repl');loadRepl()">Replication</div>
162
162
  <div class="tab" onclick="showTab('vfs');vfsNavigate(vfsPath)">VFS</div>
163
163
  <div class="tab" onclick="showTab('cache')">Cache</div>
164
+ <div class="tab" onclick="showTab('keyauth');loadKeyAuth()">KeyAuth</div>
164
165
  <div class="tab" onclick="showTab('stress')">Stress Tests</div>
165
166
  <div class="tab" onclick="showTab('view')">View</div>
166
167
  </div>
@@ -717,7 +718,7 @@
717
718
  </div>
718
719
 
719
720
  <div class="panel" id="panel-cache">
720
- <h3 style="color:#569cd6;margin-bottom:8px">CachedClient Demo</h3>
721
+ <h3 style="color:#569cd6;margin-bottom:8px">BodClientCached Demo</h3>
721
722
  <p style="color:#666;font-size:11px;margin-bottom:10px">Two-tier cache (memory + IndexedDB) with stale-while-revalidate. Wraps BodClient for instant reads.</p>
722
723
 
723
724
  <div style="display:flex;gap:6px;margin-bottom:10px">
@@ -744,6 +745,84 @@
744
745
  <div id="cache-log" class="result" style="min-height:80px;margin-top:8px;color:#888;font-size:11px">Event log...</div>
745
746
  </div>
746
747
 
748
+ <!-- KeyAuth Panel -->
749
+ <div class="panel" id="panel-keyauth">
750
+ <div style="display:flex;gap:12px;flex-wrap:wrap">
751
+ <!-- LEFT: Accounts + Auth Flows -->
752
+ <div style="flex:2;min-width:400px">
753
+ <label>Accounts</label>
754
+ <div id="ka-accounts" style="margin-bottom:8px">Loading...</div>
755
+ <div class="row" style="margin-bottom:6px">
756
+ <input type="text" id="ka-new-pw" placeholder="Password" value="demo123">
757
+ <input type="text" id="ka-new-name" placeholder="Display name">
758
+ <input type="text" id="ka-new-roles" placeholder="Roles (comma-sep)">
759
+ <button onclick="kaCreateAccount()">+ Account</button>
760
+ <button onclick="kaRegisterDevice()">+ Device</button>
761
+ </div>
762
+ <!-- Auth Flows -->
763
+ <div style="margin-top:10px;border:1px solid #444;border-radius:6px;padding:10px">
764
+ <label>Auth Flows</label>
765
+ <!-- 1. Challenge-Response (password) -->
766
+ <div style="margin-bottom:8px">
767
+ <b>1. Challenge-Response (password)</b>
768
+ <div class="row" style="margin-top:4px">
769
+ <select id="ka-auth-fp" style="flex:1"><option value="">— select account —</option></select>
770
+ <input type="text" id="ka-auth-pw" placeholder="Password" value="demo123">
771
+ <button class="success" onclick="kaChallengeResponse()">Authenticate</button>
772
+ </div>
773
+ </div>
774
+ <!-- 2. Device Auth (keypair) -->
775
+ <div style="margin-bottom:8px">
776
+ <b>2. Device Auth (keypair)</b>
777
+ <div class="row" style="margin-top:4px">
778
+ <button onclick="kaDeviceAuth()">Authenticate with Stored Keypair</button>
779
+ </div>
780
+ </div>
781
+ <!-- 3. QR Cross-Device Auth -->
782
+ <div>
783
+ <b>3. QR Cross-Device Auth</b>
784
+ <div class="row" style="margin-top:4px">
785
+ <button onclick="kaShowQR()">Show QR (new device)</button>
786
+ <input type="text" id="ka-qr-request-id" placeholder="Request ID to approve">
787
+ <button class="success" onclick="kaApproveQR()">Approve (signed-in device)</button>
788
+ </div>
789
+ <div id="ka-qr-display" style="display:none;margin-top:6px;padding:8px;background:#1a1a2e;border-radius:4px;font-family:monospace;font-size:12px"></div>
790
+ </div>
791
+ <div id="ka-auth-result" class="result" style="min-height:40px;margin-top:6px">Select an auth flow above</div>
792
+ </div>
793
+ </div>
794
+ <!-- RIGHT: Roles & IAM + Server -->
795
+ <div style="flex:1;min-width:280px">
796
+ <label>Roles & IAM</label>
797
+ <div id="ka-roles" class="result" style="min-height:80px;margin-bottom:8px">Loading...</div>
798
+ <div class="row" style="margin-bottom:6px">
799
+ <input type="text" id="ka-role-id" placeholder="Role ID" value="editor">
800
+ <input type="text" id="ka-role-name" placeholder="Name" value="Editor">
801
+ <button onclick="kaCreateRole()">+ Role</button>
802
+ </div>
803
+ <div class="row" style="margin-bottom:8px">
804
+ <input type="text" id="ka-perm-path" placeholder="Path pattern" value="posts/$postId">
805
+ <label style="display:inline;margin:0"><input type="checkbox" id="ka-perm-read" checked> R</label>
806
+ <label style="display:inline;margin:0"><input type="checkbox" id="ka-perm-write" checked> W</label>
807
+ <button class="sm" onclick="kaAddPermission()">+ Perm</button>
808
+ </div>
809
+ <!-- Assign Roles -->
810
+ <div style="border:1px solid #444;border-radius:6px;padding:8px;margin-bottom:8px">
811
+ <label>Assign Roles</label>
812
+ <div class="row" style="margin-top:4px">
813
+ <select id="ka-assign-account" style="flex:1"></select>
814
+ <input type="text" id="ka-assign-roles" placeholder="Roles (comma-sep)">
815
+ <button onclick="kaUpdateRoles()">Update</button>
816
+ </div>
817
+ </div>
818
+ <!-- Server Info -->
819
+ <label>Server Info</label>
820
+ <div id="ka-server" class="result" style="min-height:60px">Loading...</div>
821
+ </div>
822
+ </div>
823
+ <div id="ka-status" class="status" style="margin-top:6px"></div>
824
+ </div>
825
+
747
826
  <!-- Stress Tests Panel -->
748
827
  <div class="panel" id="panel-stress">
749
828
  <div class="stress-grid">
@@ -891,7 +970,7 @@ class ClientQueryBuilder {
891
970
  }
892
971
 
893
972
  class ZuzClient {
894
- constructor(url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`) {
973
+ constructor(url = window.__BODDB_URL__ || new URLSearchParams(location.search).get('url') || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`) {
895
974
  this._url = url;
896
975
  this._ws = null;
897
976
  this._msgId = 0;
@@ -1185,6 +1264,20 @@ db.on('_admin/stats', (snap) => {
1185
1264
  // Connect after all subscriptions are registered so onopen re-subscribes them all
1186
1265
  db.connect();
1187
1266
 
1267
+ // Handle #approve=<requestId> deep link (from QR scan)
1268
+ if (location.hash.startsWith('#approve=')) {
1269
+ const rid = location.hash.slice('#approve='.length);
1270
+ location.hash = '';
1271
+ // Wait for WS to connect, switch to KeyAuth tab, fill & prompt
1272
+ const _awaitApprove = setInterval(() => {
1273
+ if (!db.connected) return;
1274
+ clearInterval(_awaitApprove);
1275
+ showTab('keyauth'); loadKeyAuth();
1276
+ document.getElementById('ka-qr-request-id').value = rid;
1277
+ kaStatus('QR scanned — click "Approve" to authorize the device');
1278
+ }, 300);
1279
+ }
1280
+
1188
1281
  // ── Live tree via child subscriptions ──────────────────────────────────────────
1189
1282
  const _treeUnsubs = new Map(); // key → unsub fn
1190
1283
  const showAdmin = new URLSearchParams(location.search).has('showAdmin');
@@ -1235,7 +1328,7 @@ function fmtUptime(sec) {
1235
1328
  }
1236
1329
 
1237
1330
  // ── Tab switching ──────────────────────────────────────────────────────────────
1238
- const TAB_IDS = ['rw','query','subs','auth','advanced','streams','mq','repl','vfs','cache','stress','view'];
1331
+ const TAB_IDS = ['rw','query','subs','auth','advanced','streams','mq','repl','vfs','cache','keyauth','stress','view'];
1239
1332
  function showTab(id) {
1240
1333
  document.querySelectorAll('.tab').forEach((t, i) => t.classList.toggle('active', TAB_IDS[i] === id));
1241
1334
  document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
@@ -1243,6 +1336,7 @@ function showTab(id) {
1243
1336
  const url = new URL(location.href);
1244
1337
  url.searchParams.set('tab', id);
1245
1338
  history.replaceState(null, '', url);
1339
+ localStorage.setItem('zuzdb:tab', id);
1246
1340
  }
1247
1341
 
1248
1342
  // ── Field persistence ──────────────────────────────────────────────────────────
@@ -1294,6 +1388,7 @@ function restoreFields() {
1294
1388
  if (tab === 'vfs') vfsNavigate(vfsPath);
1295
1389
  if (tab === 'repl') loadRepl();
1296
1390
  if (tab === 'auth') loadRules();
1391
+ if (tab === 'keyauth') loadKeyAuth();
1297
1392
  }, 0);
1298
1393
  }
1299
1394
  }
@@ -2987,6 +3082,435 @@ function cacheStats() {
2987
3082
  document.getElementById('cache-result').textContent = JSON.stringify(stats, null, 2);
2988
3083
  _cacheLog(`Stats: ${_cache.memory.size} entries in memory`);
2989
3084
  }
3085
+
3086
+ // ── KeyAuth ──────────────────────────────────────────────────────────────────
3087
+ let _kaPerms = [];
3088
+ let _kaLastFp = '';
3089
+ let _kaExpandedFp = null;
3090
+ let _kaDeviceKeys = null; // { publicKey, privateKeyDer } stored in sessionStorage-like
3091
+ let _kaQrPollTimer = null;
3092
+ let _kaAccounts = [];
3093
+
3094
+ function kaSend(op, params = {}) {
3095
+ return db._send(op, params);
3096
+ }
3097
+
3098
+ async function loadKeyAuth() {
3099
+ if (!db.connected) { setTimeout(loadKeyAuth, 300); return; }
3100
+ try {
3101
+ const [server, accounts, roles] = await Promise.all([
3102
+ db.get('_auth/server'),
3103
+ kaSend('auth-list-accounts'),
3104
+ kaSend('auth-list-roles'),
3105
+ ]);
3106
+ _kaAccounts = accounts || [];
3107
+
3108
+ // Server info
3109
+ const sEl = document.getElementById('ka-server');
3110
+ if (server) {
3111
+ sEl.textContent = 'fp: ' + (server.fingerprint || '—') + '\nkey: ' + (server.publicKey || '—').slice(0, 40) + '…';
3112
+ } else {
3113
+ sEl.textContent = 'KeyAuth not enabled.\nAdd keyAuth: {} to config.ts';
3114
+ }
3115
+
3116
+ // Accounts list
3117
+ const aEl = document.getElementById('ka-accounts');
3118
+ if (_kaAccounts.length) {
3119
+ aEl.innerHTML = _kaAccounts.map(a => {
3120
+ const fp = a.fingerprint;
3121
+ const name = a.displayName || '—';
3122
+ const rls = (a.roles || []).join(', ') || 'none';
3123
+ const isDevice = !a.encryptedPrivateKey;
3124
+ const expanded = _kaExpandedFp === fp;
3125
+ return `<div style="border:1px solid #333;border-radius:4px;padding:6px;margin-bottom:4px;cursor:pointer" onclick="kaExpandAccount('${fp}')">
3126
+ <span style="font-weight:bold">${name}</span> [${rls}] ${isDevice ? '<span style="color:#888">(device)</span>' : ''}
3127
+ <span style="color:#666;font-size:11px;float:right">${fp.slice(0,12)}… ${expanded?'▼':'▶'}</span>
3128
+ <div id="ka-detail-${fp}" style="display:${expanded?'block':'none'};margin-top:6px;padding-top:6px;border-top:1px solid #333"></div>
3129
+ </div>`;
3130
+ }).join('');
3131
+ // If expanded, load detail
3132
+ if (_kaExpandedFp) kaLoadDetail(_kaExpandedFp);
3133
+ } else {
3134
+ aEl.innerHTML = '<div class="result">No accounts yet.</div>';
3135
+ }
3136
+
3137
+ // Roles
3138
+ const rEl = document.getElementById('ka-roles');
3139
+ if (roles && roles.length) {
3140
+ rEl.innerHTML = roles.map(r => {
3141
+ const perms = (r.permissions || []).map(p => `${p.path||'/'} R:${!!p.read} W:${!!p.write}`).join(', ');
3142
+ 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
+ }).join('');
3144
+ } else {
3145
+ rEl.textContent = 'No roles.';
3146
+ }
3147
+
3148
+ // Populate dropdowns
3149
+ const sel = document.getElementById('ka-assign-account');
3150
+ sel.innerHTML = _kaAccounts.map(a => `<option value="${a.fingerprint}">${a.displayName||a.fingerprint.slice(0,12)}</option>`).join('');
3151
+ const authSel = document.getElementById('ka-auth-fp');
3152
+ const prevFp = authSel.value;
3153
+ const pwAccounts = _kaAccounts.filter(a => !!a.encryptedPrivateKey);
3154
+ authSel.innerHTML = '<option value="">— select account —</option>' + pwAccounts.map(a => `<option value="${a.fingerprint}"${a.fingerprint===prevFp?' selected':''}>${a.displayName||a.fingerprint.slice(0,12)} [${(a.roles||[]).join(',')||'none'}]</option>`).join('');
3155
+ } catch (e) {
3156
+ document.getElementById('ka-server').textContent = 'Error: ' + e.message;
3157
+ }
3158
+ }
3159
+
3160
+ async function kaExpandAccount(fp) {
3161
+ _kaExpandedFp = _kaExpandedFp === fp ? null : fp;
3162
+ // Auto-fill auth fingerprint field when expanding
3163
+ document.getElementById('ka-auth-fp').value = fp;
3164
+ loadKeyAuth();
3165
+ }
3166
+
3167
+ async function kaLoadDetail(fp) {
3168
+ const el = document.getElementById('ka-detail-' + fp);
3169
+ if (!el) return;
3170
+ try {
3171
+ const [devices, sessions] = await Promise.all([
3172
+ kaSend('auth-list-devices', { accountFingerprint: fp }),
3173
+ kaSend('auth-list-sessions', { accountFingerprint: fp }),
3174
+ ]);
3175
+ const acct = _kaAccounts.find(a => a.fingerprint === fp);
3176
+ const isDevice = acct && !acct.encryptedPrivateKey;
3177
+ let html = '<div style="font-size:12px">';
3178
+ // Devices
3179
+ html += '<b>Devices:</b> ';
3180
+ if (devices && devices.length) {
3181
+ html += devices.map(d => `${d.name||d.fingerprint.slice(0,8)} <button class="sm" onclick="event.stopPropagation();kaRevokeDevice('${fp}','${d.fingerprint}')">×</button>`).join(' | ');
3182
+ } else {
3183
+ html += '<span style="color:#666">none</span>';
3184
+ }
3185
+ // Sessions
3186
+ html += '<br><b>Sessions:</b> ';
3187
+ if (sessions && sessions.length) {
3188
+ html += sessions.map(s => `${s.sid.slice(0,8)}… exp:${new Date(s.expiresAt).toLocaleTimeString()} <button class="sm" onclick="event.stopPropagation();kaRevokeSession('${s.sid}')">×</button>`).join(' | ');
3189
+ } else {
3190
+ html += '<span style="color:#666">none</span>';
3191
+ }
3192
+ // Actions
3193
+ html += '<br>';
3194
+ if (!isDevice) {
3195
+ html += `<button class="sm" onclick="event.stopPropagation();kaLinkDevice('${fp}')">Link Device</button> `;
3196
+ html += `<button class="sm" onclick="event.stopPropagation();kaChangePassword('${fp}')">Change Pw</button>`;
3197
+ }
3198
+ html += '</div>';
3199
+ el.innerHTML = html;
3200
+ } catch (e) {
3201
+ el.textContent = 'Error: ' + e.message;
3202
+ }
3203
+ }
3204
+
3205
+ async function kaRevokeDevice(accountFp, deviceFp) {
3206
+ try {
3207
+ await kaSend('auth-revoke-device', { accountFingerprint: accountFp, deviceFingerprint: deviceFp });
3208
+ kaStatus('Device revoked');
3209
+ loadKeyAuth();
3210
+ } catch (e) { kaStatus('Revoke failed: ' + e.message, true); }
3211
+ }
3212
+
3213
+ async function kaRevokeSession(sid) {
3214
+ try {
3215
+ await kaSend('auth-revoke-session', { sid });
3216
+ kaStatus('Session revoked');
3217
+ loadKeyAuth();
3218
+ } catch (e) { kaStatus('Revoke failed: ' + e.message, true); }
3219
+ }
3220
+
3221
+ async function kaChangePassword(fp) {
3222
+ const oldPw = prompt('Current password:');
3223
+ if (!oldPw) return;
3224
+ const newPw = prompt('New password:');
3225
+ if (!newPw) return;
3226
+ try {
3227
+ await kaSend('auth-change-password', { fingerprint: fp, oldPassword: oldPw, newPassword: newPw });
3228
+ kaStatus('Password changed');
3229
+ } catch (e) { kaStatus('Change failed: ' + e.message, true); }
3230
+ }
3231
+
3232
+ async function kaLinkDevice(accountFp) {
3233
+ const pw = prompt('Account password (to authorize linking):');
3234
+ if (!pw) return;
3235
+ const name = prompt('Device name:', 'New Device');
3236
+ try {
3237
+ const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
3238
+ const spki = await crypto.subtle.exportKey('spki', kp.publicKey);
3239
+ const pubB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
3240
+ const result = await kaSend('auth-link-device', { accountFingerprint: accountFp, password: pw, devicePublicKey: pubB64, deviceName: name });
3241
+ kaStatus('Device linked: ' + result.fingerprint.slice(0, 12));
3242
+ loadKeyAuth();
3243
+ } catch (e) { kaStatus('Link failed: ' + e.message, true); }
3244
+ }
3245
+
3246
+ async function kaDeleteRole(roleId) {
3247
+ try {
3248
+ await kaSend('auth-delete-role', { roleId });
3249
+ kaStatus('Role deleted');
3250
+ loadKeyAuth();
3251
+ } catch (e) { kaStatus('Delete failed: ' + e.message, true); }
3252
+ }
3253
+
3254
+ async function kaUpdateRoles() {
3255
+ const fp = document.getElementById('ka-assign-account').value;
3256
+ const rolesStr = document.getElementById('ka-assign-roles').value.trim();
3257
+ if (!fp || !rolesStr) { kaStatus('Select account and enter roles', true); return; }
3258
+ const roles = rolesStr.split(',').map(r => r.trim()).filter(Boolean);
3259
+ try {
3260
+ await kaSend('auth-update-roles', { accountFingerprint: fp, roles });
3261
+ kaStatus('Roles updated');
3262
+ loadKeyAuth();
3263
+ } catch (e) { kaStatus('Update failed: ' + e.message, true); }
3264
+ }
3265
+
3266
+ async function kaCreateAccount() {
3267
+ const pw = document.getElementById('ka-new-pw').value.trim();
3268
+ if (!pw) { kaStatus('Password required', true); return; }
3269
+ const displayName = document.getElementById('ka-new-name').value.trim() || undefined;
3270
+ const rolesStr = document.getElementById('ka-new-roles').value.trim();
3271
+ const roles = rolesStr ? rolesStr.split(',').map(r => r.trim()) : [];
3272
+ try {
3273
+ const result = await kaSend('auth-create-account', { password: pw, roles, displayName });
3274
+ _kaLastFp = result.fingerprint;
3275
+ document.getElementById('ka-auth-fp').value = result.fingerprint;
3276
+ kaStatus('Account created: ' + result.fingerprint.slice(0, 12));
3277
+ loadKeyAuth();
3278
+ } catch (e) {
3279
+ kaStatus('Create failed: ' + e.message, true);
3280
+ }
3281
+ }
3282
+
3283
+ async function kaRegisterDevice() {
3284
+ const el = document.getElementById('ka-auth-result');
3285
+ try {
3286
+ const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
3287
+ const spki = await crypto.subtle.exportKey('spki', kp.publicKey);
3288
+ const pkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey);
3289
+ const pubB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
3290
+ _kaDeviceKeys = { publicKey: pubB64, privateKeyDer: new Uint8Array(pkcs8) };
3291
+ sessionStorage.setItem('ka-device-pub', pubB64);
3292
+ sessionStorage.setItem('ka-device-priv', btoa(String.fromCharCode(...new Uint8Array(pkcs8))));
3293
+ const result = await kaSend('auth-register-device', { publicKey: pubB64, displayName: 'Browser Device' });
3294
+ kaStatus('Device registered: ' + result.fingerprint.slice(0, 12));
3295
+ el.textContent = 'Device registered! fp: ' + result.fingerprint + '\nUse "Device Auth" to authenticate.';
3296
+ loadKeyAuth();
3297
+ } catch (e) {
3298
+ kaStatus('Register failed: ' + e.message, true);
3299
+ }
3300
+ }
3301
+
3302
+ async function kaDeviceAuth() {
3303
+ const el = document.getElementById('ka-auth-result');
3304
+ // Restore from sessionStorage if needed
3305
+ if (!_kaDeviceKeys) {
3306
+ const pub = sessionStorage.getItem('ka-device-pub');
3307
+ const priv = sessionStorage.getItem('ka-device-priv');
3308
+ if (pub && priv) {
3309
+ _kaDeviceKeys = { publicKey: pub, privateKeyDer: Uint8Array.from(atob(priv), c => c.charCodeAt(0)) };
3310
+ }
3311
+ }
3312
+ if (!_kaDeviceKeys) {
3313
+ el.textContent = 'No device keys. Click "Register Device" first.';
3314
+ kaStatus('No device keys', true);
3315
+ return;
3316
+ }
3317
+ try {
3318
+ el.textContent = 'Requesting challenge…';
3319
+ const challenge = await kaSend('auth-challenge');
3320
+ el.textContent = 'Signing with device key…';
3321
+ const sigKey = await crypto.subtle.importKey('pkcs8', _kaDeviceKeys.privateKeyDer, { name: 'Ed25519' }, false, ['sign']);
3322
+ const sigBuf = await crypto.subtle.sign('Ed25519', sigKey, new TextEncoder().encode(challenge.nonce));
3323
+ const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sigBuf)));
3324
+ const result = await kaSend('auth-verify', { publicKey: _kaDeviceKeys.publicKey, signature: sigB64, nonce: challenge.nonce });
3325
+ el.textContent = 'Device authenticated!\nToken: ' + result.token.slice(0, 60) + '…\nExpires: ' + new Date(result.expiresAt).toLocaleString();
3326
+ kaStatus('Device auth succeeded');
3327
+ loadKeyAuth();
3328
+ } catch (e) {
3329
+ el.textContent = 'Error: ' + e.message;
3330
+ kaStatus('Device auth failed: ' + e.message, true);
3331
+ }
3332
+ }
3333
+
3334
+ async function kaShowQR() {
3335
+ const el = document.getElementById('ka-auth-result');
3336
+ const qrEl = document.getElementById('ka-qr-display');
3337
+ try {
3338
+ // Generate ephemeral keypair for new device
3339
+ const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
3340
+ const spki = await crypto.subtle.exportKey('spki', kp.publicKey);
3341
+ const pkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey);
3342
+ const pubB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
3343
+ _kaDeviceKeys = { publicKey: pubB64, privateKeyDer: new Uint8Array(pkcs8) };
3344
+
3345
+ const result = await kaSend('auth-request-approval', { publicKey: pubB64 });
3346
+ const requestId = result.requestId;
3347
+ const approveUrl = `${location.origin}${location.pathname}#approve=${requestId}`;
3348
+
3349
+ qrEl.style.display = 'block';
3350
+ const qrImg = await generateQRSvg(approveUrl, 150);
3351
+ qrEl.innerHTML = `<div style="display:flex;gap:12px;align-items:flex-start">` +
3352
+ `<div style="background:#fff;padding:4px;border-radius:4px;line-height:0">${qrImg}</div>` +
3353
+ `<div style="flex:1">` +
3354
+ `<div style="margin-bottom:6px"><b>Scan QR</b> or copy Request ID:</div>` +
3355
+ `<input type="text" value="${requestId}" readonly onclick="this.select();navigator.clipboard?.writeText(this.value)" style="width:100%;font-size:13px;padding:6px;background:#0d1117;color:#58a6ff;border:1px solid #58a6ff;border-radius:3px;cursor:pointer" title="Click to copy">` +
3356
+ `<div style="margin-top:8px;color:#0f0">⏳ Waiting for approval…</div>` +
3357
+ `</div></div>`;
3358
+ // Auto-fill the approve field too for same-tab testing
3359
+ document.getElementById('ka-qr-request-id').value = requestId;
3360
+ el.textContent = 'Waiting for a signed-in device to approve…';
3361
+
3362
+ // Poll for approval
3363
+ if (_kaQrPollTimer) clearInterval(_kaQrPollTimer);
3364
+ _kaQrPollTimer = setInterval(async () => {
3365
+ try {
3366
+ const poll = await kaSend('auth-poll-approval', { requestId });
3367
+ if (poll.status === 'approved') {
3368
+ clearInterval(_kaQrPollTimer);
3369
+ _kaQrPollTimer = null;
3370
+ qrEl.innerHTML += '<br><b style="color:#0f0">✓ APPROVED!</b>';
3371
+ el.textContent = 'Cross-device auth complete!\nToken: ' + poll.token.slice(0, 60) + '…\nExpires: ' + new Date(poll.expiresAt).toLocaleString();
3372
+ kaStatus('QR approval succeeded');
3373
+ loadKeyAuth();
3374
+ } else if (poll.status === 'expired') {
3375
+ clearInterval(_kaQrPollTimer);
3376
+ _kaQrPollTimer = null;
3377
+ qrEl.innerHTML += '<br><b style="color:#f44">✗ Expired</b>';
3378
+ kaStatus('QR request expired', true);
3379
+ }
3380
+ } catch {}
3381
+ }, 2000);
3382
+ } catch (e) {
3383
+ el.textContent = 'Error: ' + e.message;
3384
+ kaStatus('QR failed: ' + e.message, true);
3385
+ }
3386
+ }
3387
+
3388
+ async function kaApproveQR() {
3389
+ const requestId = document.getElementById('ka-qr-request-id').value.trim();
3390
+ if (!requestId) { kaStatus('Enter a Request ID', true); return; }
3391
+ try {
3392
+ const result = await kaSend('auth-approve-device', { requestId });
3393
+ kaStatus('Approved! Device fp: ' + result.fingerprint.slice(0, 12));
3394
+ loadKeyAuth();
3395
+ } catch (e) {
3396
+ kaStatus('Approve failed: ' + e.message, true);
3397
+ }
3398
+ }
3399
+
3400
+ async function kaCreateRole() {
3401
+ const id = document.getElementById('ka-role-id').value.trim();
3402
+ const name = document.getElementById('ka-role-name').value.trim();
3403
+ if (!id) { kaStatus('Role ID required', true); return; }
3404
+ const role = { id, name: name || id, permissions: [..._kaPerms] };
3405
+ try {
3406
+ await kaSend('auth-create-role', { role });
3407
+ _kaPerms = [];
3408
+ kaStatus('Role created: ' + id);
3409
+ loadKeyAuth();
3410
+ } catch (e) {
3411
+ kaStatus('Create role failed: ' + e.message, true);
3412
+ }
3413
+ }
3414
+
3415
+ function kaAddPermission() {
3416
+ const path = document.getElementById('ka-perm-path').value.trim();
3417
+ const read = document.getElementById('ka-perm-read').checked;
3418
+ const write = document.getElementById('ka-perm-write').checked;
3419
+ if (!path) { kaStatus('Path required', true); return; }
3420
+ _kaPerms.push({ path, read, write });
3421
+ kaStatus(`Permission queued: ${path} (R:${read} W:${write}). ${_kaPerms.length} pending.`);
3422
+ }
3423
+
3424
+ async function kaChallengeResponse() {
3425
+ const fp = document.getElementById('ka-auth-fp').value.trim();
3426
+ const pw = document.getElementById('ka-auth-pw').value.trim();
3427
+ if (!fp) { kaStatus('Enter account fingerprint', true); return; }
3428
+ const el = document.getElementById('ka-auth-result');
3429
+ try {
3430
+ el.textContent = 'Step 1: Fetching account + decrypting key…';
3431
+ const account = await db.get('_auth/accounts/' + fp);
3432
+ if (!account) { el.textContent = 'Account not found: ' + fp; return; }
3433
+
3434
+ const salt = Uint8Array.from(atob(account.salt), c => c.charCodeAt(0));
3435
+ const iv = Uint8Array.from(atob(account.iv), c => c.charCodeAt(0));
3436
+ const authTag = Uint8Array.from(atob(account.authTag), c => c.charCodeAt(0));
3437
+ const encrypted = Uint8Array.from(atob(account.encryptedPrivateKey), c => c.charCodeAt(0));
3438
+
3439
+ const keyMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(pw), 'PBKDF2', false, ['deriveKey']);
3440
+ const aesKey = await crypto.subtle.deriveKey(
3441
+ { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
3442
+ keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['decrypt']
3443
+ );
3444
+
3445
+ const ciphertext = new Uint8Array(encrypted.length + authTag.length);
3446
+ ciphertext.set(encrypted);
3447
+ ciphertext.set(authTag, encrypted.length);
3448
+
3449
+ let privateKeyDer;
3450
+ try {
3451
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, ciphertext);
3452
+ privateKeyDer = new Uint8Array(decrypted);
3453
+ } catch {
3454
+ el.textContent = 'Wrong password — decryption failed';
3455
+ kaStatus('Wrong password', true);
3456
+ return;
3457
+ }
3458
+
3459
+ el.textContent = 'Step 2: Requesting challenge nonce…';
3460
+ const challenge = await kaSend('auth-challenge');
3461
+
3462
+ el.textContent = 'Step 3: Signing nonce with Ed25519…';
3463
+ const sigKey = await crypto.subtle.importKey('pkcs8', privateKeyDer, { name: 'Ed25519' }, false, ['sign']);
3464
+ const sigBuf = await crypto.subtle.sign('Ed25519', sigKey, new TextEncoder().encode(challenge.nonce));
3465
+ const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sigBuf)));
3466
+
3467
+ el.textContent = 'Step 4: Verifying signature…';
3468
+ const result = await kaSend('auth-verify', { publicKey: account.publicKey, signature: sigB64, nonce: challenge.nonce });
3469
+
3470
+ el.textContent = 'Authenticated!\n\nToken: ' + result.token.slice(0, 60) + '…\nExpires: ' + new Date(result.expiresAt).toLocaleString();
3471
+ kaStatus('Challenge-response succeeded');
3472
+ loadKeyAuth();
3473
+ } catch (e) {
3474
+ el.textContent = 'Error: ' + e.message;
3475
+ kaStatus('Auth failed: ' + e.message, true);
3476
+ }
3477
+ }
3478
+
3479
+ // QR Code generation via qrcode-generator (loaded from CDN, ~4kb gzipped)
3480
+ let _qrLib = null;
3481
+ async function loadQRLib() {
3482
+ if (_qrLib) return _qrLib;
3483
+ return new Promise((resolve, reject) => {
3484
+ const s = document.createElement('script');
3485
+ s.src = 'https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js';
3486
+ s.onload = () => { _qrLib = window.qrcode; resolve(_qrLib); };
3487
+ s.onerror = () => reject(new Error('Failed to load QR lib'));
3488
+ document.head.appendChild(s);
3489
+ });
3490
+ }
3491
+
3492
+ async function generateQRSvg(text, size = 150) {
3493
+ const qr = await loadQRLib();
3494
+ const q = qr(0, 'L');
3495
+ q.addData(text);
3496
+ q.make();
3497
+ const moduleCount = q.getModuleCount();
3498
+ const cellSize = Math.floor(size / moduleCount);
3499
+ const actualSize = cellSize * moduleCount;
3500
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${actualSize}" height="${actualSize}" viewBox="0 0 ${moduleCount} ${moduleCount}">`;
3501
+ svg += `<rect width="${moduleCount}" height="${moduleCount}" fill="#fff"/>`;
3502
+ for (let r = 0; r < moduleCount; r++)
3503
+ for (let c = 0; c < moduleCount; c++)
3504
+ if (q.isDark(r, c)) svg += `<rect x="${c}" y="${r}" width="1" height="1" fill="#000"/>`;
3505
+ svg += '</svg>';
3506
+ return svg;
3507
+ }
3508
+
3509
+ function kaStatus(msg, isErr) {
3510
+ const el = document.getElementById('ka-status');
3511
+ el.textContent = msg;
3512
+ el.className = 'status ' + (isErr ? 'err' : 'ok');
3513
+ }
2990
3514
  </script>
2991
3515
  </body>
2992
3516
  </html>
package/bun.lock ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "workspaces": {
4
+ "": {
5
+ "name": "zuzdb",
6
+ "dependencies": {
7
+ "@noble/ed25519": "^3.0.0",
8
+ },
9
+ "devDependencies": {
10
+ "@types/bun": "latest",
11
+ "yaml": "^2.8.2",
12
+ },
13
+ "peerDependencies": {
14
+ "typescript": "^5",
15
+ },
16
+ },
17
+ },
18
+ "packages": {
19
+ "@noble/ed25519": ["@noble/ed25519@3.0.0", "", {}, "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg=="],
20
+
21
+ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
22
+
23
+ "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
24
+
25
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
26
+
27
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
28
+
29
+ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
30
+
31
+ "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
32
+ }
33
+ }