@bod.ee/db 0.12.8 → 0.13.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.md +2 -1
- package/admin/admin.ts +23 -3
- package/admin/bun.lock +248 -0
- package/admin/index.html +12 -0
- package/admin/package.json +22 -0
- package/admin/src/App.tsx +23 -0
- package/admin/src/client/ZuzClient.ts +183 -0
- package/admin/src/client/types.ts +28 -0
- package/admin/src/components/MetricsBar.tsx +167 -0
- package/admin/src/components/Sparkline.tsx +72 -0
- package/admin/src/components/TreePane.tsx +287 -0
- package/admin/src/components/tabs/Advanced.tsx +222 -0
- package/admin/src/components/tabs/AuthRules.tsx +104 -0
- package/admin/src/components/tabs/Cache.tsx +113 -0
- package/admin/src/components/tabs/KeyAuth.tsx +462 -0
- package/admin/src/components/tabs/MessageQueue.tsx +237 -0
- package/admin/src/components/tabs/Query.tsx +75 -0
- package/admin/src/components/tabs/ReadWrite.tsx +177 -0
- package/admin/src/components/tabs/Replication.tsx +94 -0
- package/admin/src/components/tabs/Streams.tsx +329 -0
- package/admin/src/components/tabs/StressTests.tsx +209 -0
- package/admin/src/components/tabs/Subscriptions.tsx +69 -0
- package/admin/src/components/tabs/TabPane.tsx +151 -0
- package/admin/src/components/tabs/VFS.tsx +435 -0
- package/admin/src/components/tabs/View.tsx +14 -0
- package/admin/src/components/tabs/utils.ts +25 -0
- package/admin/src/context/DbContext.tsx +33 -0
- package/admin/src/context/StatsContext.tsx +56 -0
- package/admin/src/main.tsx +10 -0
- package/admin/src/styles.css +96 -0
- package/admin/tsconfig.app.json +21 -0
- package/admin/tsconfig.json +7 -0
- package/admin/tsconfig.node.json +15 -0
- package/admin/vite.config.ts +42 -0
- package/deploy/base.yaml +1 -1
- package/deploy/prod-il.config.ts +5 -2
- package/deploy/prod.config.ts +5 -2
- package/package.json +4 -1
- package/src/server/BodDB.ts +62 -5
- package/src/server/ReplicationEngine.ts +148 -35
- package/src/server/StreamEngine.ts +2 -2
- package/src/server/Transport.ts +17 -0
- package/tests/replication.test.ts +162 -1
- package/admin/ui.html +0 -3562
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { db } from '../../context/DbContext';
|
|
3
|
+
|
|
4
|
+
interface Props { active: boolean; }
|
|
5
|
+
|
|
6
|
+
interface AccountInfo {
|
|
7
|
+
fingerprint: string;
|
|
8
|
+
displayName?: string;
|
|
9
|
+
roles?: string[];
|
|
10
|
+
encryptedPrivateKey?: string;
|
|
11
|
+
salt?: string;
|
|
12
|
+
iv?: string;
|
|
13
|
+
authTag?: string;
|
|
14
|
+
publicKey?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface DeviceKeys { publicKey: string; privateKeyDer: Uint8Array; }
|
|
18
|
+
|
|
19
|
+
// QR lib loader
|
|
20
|
+
let _qrLib: unknown = null;
|
|
21
|
+
async function loadQRLib(): Promise<(typeNumber: number, errorCorrectionLevel: string) => { addData(s: string): void; make(): void; getModuleCount(): number; isDark(r: number, c: number): boolean }> {
|
|
22
|
+
if (_qrLib) return _qrLib as never;
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const s = document.createElement('script');
|
|
25
|
+
s.src = 'https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js';
|
|
26
|
+
s.onload = () => { _qrLib = (window as unknown as { qrcode: unknown }).qrcode; resolve(_qrLib as never); };
|
|
27
|
+
s.onerror = () => reject(new Error('Failed to load QR lib'));
|
|
28
|
+
document.head.appendChild(s);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function generateQRSvg(text: string, size = 150): Promise<string> {
|
|
33
|
+
const qr = await loadQRLib();
|
|
34
|
+
const q = qr(0, 'L');
|
|
35
|
+
q.addData(text);
|
|
36
|
+
q.make();
|
|
37
|
+
const moduleCount = q.getModuleCount();
|
|
38
|
+
const cellSize = Math.floor(size / moduleCount);
|
|
39
|
+
const actualSize = cellSize * moduleCount;
|
|
40
|
+
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${actualSize}" height="${actualSize}" viewBox="0 0 ${moduleCount} ${moduleCount}">`;
|
|
41
|
+
svg += `<rect width="${moduleCount}" height="${moduleCount}" fill="#fff"/>`;
|
|
42
|
+
for (let r = 0; r < moduleCount; r++)
|
|
43
|
+
for (let c = 0; c < moduleCount; c++)
|
|
44
|
+
if (q.isDark(r, c)) svg += `<rect x="${c}" y="${r}" width="1" height="1" fill="#000"/>`;
|
|
45
|
+
svg += '</svg>';
|
|
46
|
+
return svg;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function KeyAuth({ active }: Props) {
|
|
50
|
+
const kaPermsRef = useRef<Array<{ path: string; read: boolean; write: boolean }>>([]);
|
|
51
|
+
const kaExpandedFpRef = useRef<string | null>(null);
|
|
52
|
+
const kaDeviceKeysRef = useRef<DeviceKeys | null>(null);
|
|
53
|
+
const kaQrPollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
54
|
+
const kaAccountsRef = useRef<AccountInfo[]>([]);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (active) loadKeyAuth();
|
|
58
|
+
}, [active]);
|
|
59
|
+
|
|
60
|
+
function kaStatus(msg: string, isErr?: boolean) {
|
|
61
|
+
const el = document.getElementById('ka-status') as HTMLElement;
|
|
62
|
+
el.textContent = msg;
|
|
63
|
+
el.className = 'status ' + (isErr ? 'err' : 'ok');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function loadKeyAuth() {
|
|
67
|
+
if (!db.connected) { setTimeout(loadKeyAuth, 300); return; }
|
|
68
|
+
try {
|
|
69
|
+
const [server, fingerprints] = await Promise.all([
|
|
70
|
+
db.get('_auth/server'),
|
|
71
|
+
db._send('auth-list-account-fingerprints'),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const sEl = document.getElementById('ka-server') as HTMLElement;
|
|
75
|
+
if (server) {
|
|
76
|
+
const s = server as { fingerprint?: string; publicKey?: string };
|
|
77
|
+
sEl.textContent = 'fp: ' + (s.fingerprint || '—') + '\nkey: ' + (s.publicKey || '—').slice(0, 40) + '…';
|
|
78
|
+
} else {
|
|
79
|
+
sEl.textContent = 'KeyAuth not enabled.\nAdd keyAuth: {} to config.ts';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let accounts: AccountInfo[] | null = null, roles: unknown = null;
|
|
83
|
+
try {
|
|
84
|
+
[accounts, roles] = await Promise.all([
|
|
85
|
+
db._send('auth-list-accounts') as Promise<AccountInfo[]>,
|
|
86
|
+
db._send('auth-list-roles'),
|
|
87
|
+
]);
|
|
88
|
+
} catch {}
|
|
89
|
+
|
|
90
|
+
const authenticated = !!accounts;
|
|
91
|
+
kaAccountsRef.current = accounts || ((fingerprints as AccountInfo[]) || []).map((f: AccountInfo) => ({ ...f, roles: [] }));
|
|
92
|
+
|
|
93
|
+
const aEl = document.getElementById('ka-accounts') as HTMLElement;
|
|
94
|
+
if (kaAccountsRef.current.length) {
|
|
95
|
+
aEl.innerHTML = kaAccountsRef.current.map(a => {
|
|
96
|
+
const fp = a.fingerprint;
|
|
97
|
+
const name = a.displayName || '—';
|
|
98
|
+
const rls = authenticated ? ((a.roles || []).join(', ') || 'none') : '';
|
|
99
|
+
const isDevice = authenticated && !a.encryptedPrivateKey;
|
|
100
|
+
const expanded = kaExpandedFpRef.current === fp;
|
|
101
|
+
return `<div style="border:1px solid #333;border-radius:4px;padding:6px;margin-bottom:4px;cursor:pointer" data-fp="${fp}">
|
|
102
|
+
<span style="font-weight:bold">${name}</span>${rls ? ' [' + rls + ']' : ''} ${isDevice ? '<span style="color:#888">(device)</span>' : ''}
|
|
103
|
+
<span style="color:#666;font-size:11px;float:right">${fp.slice(0,12)}… ${expanded?'▼':'▶'}</span>
|
|
104
|
+
<div id="ka-detail-${fp}" style="display:${expanded?'block':'none'};margin-top:6px;padding-top:6px;border-top:1px solid #333"></div>
|
|
105
|
+
</div>`;
|
|
106
|
+
}).join('');
|
|
107
|
+
if (!authenticated) aEl.innerHTML += '<div style="color:#888;font-size:12px;margin-top:4px">Authenticate to see roles & details</div>';
|
|
108
|
+
aEl.querySelectorAll('[data-fp]').forEach(el => {
|
|
109
|
+
el.addEventListener('click', () => kaExpandAccount((el as HTMLElement).dataset.fp!));
|
|
110
|
+
});
|
|
111
|
+
if (kaExpandedFpRef.current && authenticated) kaLoadDetail(kaExpandedFpRef.current);
|
|
112
|
+
} else {
|
|
113
|
+
aEl.innerHTML = '<div class="result">No accounts yet.</div>';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rEl = document.getElementById('ka-roles') as HTMLElement;
|
|
117
|
+
if (roles && (roles as unknown[]).length) {
|
|
118
|
+
rEl.innerHTML = (roles as Array<{ id: string; name?: string; permissions?: Array<{ path?: string; read?: boolean; write?: boolean }> }>).map(r => {
|
|
119
|
+
const perms = (r.permissions || []).map(p => `${p.path||'/'} R:${!!p.read} W:${!!p.write}`).join(', ');
|
|
120
|
+
return `<div style="margin-bottom:2px">${r.id}: ${r.name||r.id} → ${perms||'none'} <button class="sm" data-role="${r.id}" style="float:right">×</button></div>`;
|
|
121
|
+
}).join('');
|
|
122
|
+
rEl.querySelectorAll('[data-role]').forEach(btn => {
|
|
123
|
+
btn.addEventListener('click', () => kaDeleteRole((btn as HTMLElement).dataset.role!));
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
rEl.textContent = authenticated ? 'No roles.' : 'Authenticate to view roles';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const sel = document.getElementById('ka-assign-account') as HTMLSelectElement;
|
|
130
|
+
sel.innerHTML = kaAccountsRef.current.map(a => `<option value="${a.fingerprint}">${a.displayName||a.fingerprint.slice(0,12)}</option>`).join('');
|
|
131
|
+
const authSel = document.getElementById('ka-auth-fp') as HTMLSelectElement;
|
|
132
|
+
const prevFp = authSel.value;
|
|
133
|
+
const pwAccounts = authenticated ? kaAccountsRef.current.filter(a => !!a.encryptedPrivateKey) : kaAccountsRef.current;
|
|
134
|
+
authSel.innerHTML = '<option value="">— select account —</option>' + pwAccounts.map(a =>
|
|
135
|
+
`<option value="${a.fingerprint}"${a.fingerprint===prevFp?' selected':''}>${a.displayName||a.fingerprint.slice(0,12)}${authenticated ? ' [' + ((a.roles||[]).join(',')||'none') + ']' : ''}</option>`
|
|
136
|
+
).join('');
|
|
137
|
+
} catch (e: unknown) {
|
|
138
|
+
(document.getElementById('ka-server') as HTMLElement).textContent = 'Error: ' + (e as Error).message;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function kaExpandAccount(fp: string) {
|
|
143
|
+
kaExpandedFpRef.current = kaExpandedFpRef.current === fp ? null : fp;
|
|
144
|
+
(document.getElementById('ka-auth-fp') as HTMLSelectElement).value = fp;
|
|
145
|
+
loadKeyAuth();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function kaLoadDetail(fp: string) {
|
|
149
|
+
const el = document.getElementById('ka-detail-' + fp) as HTMLElement;
|
|
150
|
+
if (!el) return;
|
|
151
|
+
try {
|
|
152
|
+
const [devices, sessions] = await Promise.all([
|
|
153
|
+
db._send('auth-list-devices', { accountFingerprint: fp }) as Promise<Array<{ name?: string; fingerprint: string }>>,
|
|
154
|
+
db._send('auth-list-sessions', { accountFingerprint: fp }) as Promise<Array<{ sid: string; expiresAt: number }>>,
|
|
155
|
+
]);
|
|
156
|
+
const acct = kaAccountsRef.current.find(a => a.fingerprint === fp);
|
|
157
|
+
const isDevice = acct && !acct.encryptedPrivateKey;
|
|
158
|
+
let html = '<div style="font-size:12px">';
|
|
159
|
+
html += '<b>Devices:</b> ';
|
|
160
|
+
if (devices?.length) {
|
|
161
|
+
html += devices.map(d => `${d.name||d.fingerprint.slice(0,8)} <button class="sm" data-dfp="${d.fingerprint}">×</button>`).join(' | ');
|
|
162
|
+
} else { html += '<span style="color:#666">none</span>'; }
|
|
163
|
+
html += '<br><b>Sessions:</b> ';
|
|
164
|
+
if (sessions?.length) {
|
|
165
|
+
html += sessions.map(s => `${s.sid.slice(0,8)}… exp:${new Date(s.expiresAt).toLocaleTimeString()} <button class="sm" data-sid="${s.sid}">×</button>`).join(' | ');
|
|
166
|
+
} else { html += '<span style="color:#666">none</span>'; }
|
|
167
|
+
html += '<br>';
|
|
168
|
+
if (!isDevice) {
|
|
169
|
+
html += `<button class="sm" data-action="link-device" data-fp="${fp}">Link Device</button> `;
|
|
170
|
+
html += `<button class="sm" data-action="change-pw" data-fp="${fp}">Change Pw</button>`;
|
|
171
|
+
}
|
|
172
|
+
html += '</div>';
|
|
173
|
+
el.innerHTML = html;
|
|
174
|
+
el.querySelectorAll('[data-dfp]').forEach(btn => {
|
|
175
|
+
btn.addEventListener('click', (e) => { e.stopPropagation(); kaRevokeDevice(fp, (btn as HTMLElement).dataset.dfp!); });
|
|
176
|
+
});
|
|
177
|
+
el.querySelectorAll('[data-sid]').forEach(btn => {
|
|
178
|
+
btn.addEventListener('click', (e) => { e.stopPropagation(); kaRevokeSession((btn as HTMLElement).dataset.sid!); });
|
|
179
|
+
});
|
|
180
|
+
el.querySelectorAll('[data-action="link-device"]').forEach(btn => {
|
|
181
|
+
btn.addEventListener('click', (e) => { e.stopPropagation(); kaLinkDevice((btn as HTMLElement).dataset.fp!); });
|
|
182
|
+
});
|
|
183
|
+
el.querySelectorAll('[data-action="change-pw"]').forEach(btn => {
|
|
184
|
+
btn.addEventListener('click', (e) => { e.stopPropagation(); kaChangePassword((btn as HTMLElement).dataset.fp!); });
|
|
185
|
+
});
|
|
186
|
+
} catch (e: unknown) { el.textContent = 'Error: ' + (e as Error).message; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function kaRevokeDevice(accountFp: string, deviceFp: string) {
|
|
190
|
+
try { await db._send('auth-revoke-device', { accountFingerprint: accountFp, deviceFingerprint: deviceFp }); kaStatus('Device revoked'); loadKeyAuth(); }
|
|
191
|
+
catch (e: unknown) { kaStatus('Revoke failed: ' + (e as Error).message, true); }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function kaRevokeSession(sid: string) {
|
|
195
|
+
try { await db._send('auth-revoke-session', { sid }); kaStatus('Session revoked'); loadKeyAuth(); }
|
|
196
|
+
catch (e: unknown) { kaStatus('Revoke failed: ' + (e as Error).message, true); }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function kaChangePassword(fp: string) {
|
|
200
|
+
const oldPw = prompt('Current password:'); if (!oldPw) return;
|
|
201
|
+
const newPw = prompt('New password:'); if (!newPw) return;
|
|
202
|
+
try { await db._send('auth-change-password', { fingerprint: fp, oldPassword: oldPw, newPassword: newPw }); kaStatus('Password changed'); }
|
|
203
|
+
catch (e: unknown) { kaStatus('Change failed: ' + (e as Error).message, true); }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function kaLinkDevice(accountFp: string) {
|
|
207
|
+
const pw = prompt('Account password (to authorize linking):'); if (!pw) return;
|
|
208
|
+
const name = prompt('Device name:', 'New Device');
|
|
209
|
+
try {
|
|
210
|
+
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
211
|
+
const spki = await crypto.subtle.exportKey('spki', kp.publicKey);
|
|
212
|
+
const pubB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
|
213
|
+
const result = await db._send('auth-link-device', { accountFingerprint: accountFp, password: pw, devicePublicKey: pubB64, deviceName: name }) as { fingerprint: string };
|
|
214
|
+
kaStatus('Device linked: ' + result.fingerprint.slice(0, 12));
|
|
215
|
+
loadKeyAuth();
|
|
216
|
+
} catch (e: unknown) { kaStatus('Link failed: ' + (e as Error).message, true); }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function kaDeleteRole(roleId: string) {
|
|
220
|
+
try { await db._send('auth-delete-role', { roleId }); kaStatus('Role deleted'); loadKeyAuth(); }
|
|
221
|
+
catch (e: unknown) { kaStatus('Delete failed: ' + (e as Error).message, true); }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function kaUpdateRoles() {
|
|
225
|
+
const fp = (document.getElementById('ka-assign-account') as HTMLSelectElement).value;
|
|
226
|
+
const rolesStr = (document.getElementById('ka-assign-roles') as HTMLInputElement).value.trim();
|
|
227
|
+
if (!fp || !rolesStr) { kaStatus('Select account and enter roles', true); return; }
|
|
228
|
+
const roles = rolesStr.split(',').map(r => r.trim()).filter(Boolean);
|
|
229
|
+
try { await db._send('auth-update-roles', { accountFingerprint: fp, roles }); kaStatus('Roles updated'); loadKeyAuth(); }
|
|
230
|
+
catch (e: unknown) { kaStatus('Update failed: ' + (e as Error).message, true); }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function kaCreateAccount() {
|
|
234
|
+
const pw = (document.getElementById('ka-new-pw') as HTMLInputElement).value.trim();
|
|
235
|
+
if (!pw) { kaStatus('Password required', true); return; }
|
|
236
|
+
const displayName = (document.getElementById('ka-new-name') as HTMLInputElement).value.trim() || undefined;
|
|
237
|
+
const rolesStr = (document.getElementById('ka-new-roles') as HTMLInputElement).value.trim();
|
|
238
|
+
const roles = rolesStr ? rolesStr.split(',').map(r => r.trim()) : [];
|
|
239
|
+
try {
|
|
240
|
+
const result = await db._send('auth-create-account', { password: pw, roles, displayName }) as { fingerprint: string };
|
|
241
|
+
(document.getElementById('ka-auth-fp') as HTMLSelectElement).value = result.fingerprint;
|
|
242
|
+
kaStatus('Account created: ' + result.fingerprint.slice(0, 12));
|
|
243
|
+
loadKeyAuth();
|
|
244
|
+
} catch (e: unknown) { kaStatus('Create failed: ' + (e as Error).message, true); }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function kaRegisterDevice() {
|
|
248
|
+
const el = document.getElementById('ka-auth-result') as HTMLElement;
|
|
249
|
+
try {
|
|
250
|
+
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
251
|
+
const spki = await crypto.subtle.exportKey('spki', kp.publicKey);
|
|
252
|
+
const pkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey);
|
|
253
|
+
const pubB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
|
254
|
+
kaDeviceKeysRef.current = { publicKey: pubB64, privateKeyDer: new Uint8Array(pkcs8) };
|
|
255
|
+
sessionStorage.setItem('ka-device-pub', pubB64);
|
|
256
|
+
sessionStorage.setItem('ka-device-priv', btoa(String.fromCharCode(...new Uint8Array(pkcs8))));
|
|
257
|
+
const result = await db._send('auth-register-device', { publicKey: pubB64, displayName: 'Browser Device' }) as { fingerprint: string };
|
|
258
|
+
kaStatus('Device registered: ' + result.fingerprint.slice(0, 12));
|
|
259
|
+
el.textContent = 'Device registered! fp: ' + result.fingerprint + '\nUse "Device Auth" to authenticate.';
|
|
260
|
+
loadKeyAuth();
|
|
261
|
+
} catch (e: unknown) { kaStatus('Register failed: ' + (e as Error).message, true); }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function kaDeviceAuth() {
|
|
265
|
+
const el = document.getElementById('ka-auth-result') as HTMLElement;
|
|
266
|
+
if (!kaDeviceKeysRef.current) {
|
|
267
|
+
const pub = sessionStorage.getItem('ka-device-pub');
|
|
268
|
+
const priv = sessionStorage.getItem('ka-device-priv');
|
|
269
|
+
if (pub && priv) kaDeviceKeysRef.current = { publicKey: pub, privateKeyDer: Uint8Array.from(atob(priv), c => c.charCodeAt(0)) };
|
|
270
|
+
}
|
|
271
|
+
if (!kaDeviceKeysRef.current) { el.textContent = 'No device keys. Click "Register Device" first.'; kaStatus('No device keys', true); return; }
|
|
272
|
+
try {
|
|
273
|
+
el.textContent = 'Requesting challenge…';
|
|
274
|
+
const challenge = await db._send('auth-challenge') as { nonce: string };
|
|
275
|
+
el.textContent = 'Signing with device key…';
|
|
276
|
+
const sigKey = await crypto.subtle.importKey('pkcs8', kaDeviceKeysRef.current.privateKeyDer.buffer as ArrayBuffer, { name: 'Ed25519' }, false, ['sign']);
|
|
277
|
+
const sigBuf = await crypto.subtle.sign('Ed25519', sigKey, new TextEncoder().encode(challenge.nonce));
|
|
278
|
+
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sigBuf)));
|
|
279
|
+
const result = await db._send('auth-verify', { publicKey: kaDeviceKeysRef.current.publicKey, signature: sigB64, nonce: challenge.nonce }) as { token: string; expiresAt: number };
|
|
280
|
+
el.textContent = 'Device authenticated!\nToken: ' + result.token.slice(0, 60) + '…\nExpires: ' + new Date(result.expiresAt).toLocaleString();
|
|
281
|
+
kaStatus('Device auth succeeded');
|
|
282
|
+
loadKeyAuth();
|
|
283
|
+
} catch (e: unknown) { el.textContent = 'Error: ' + (e as Error).message; kaStatus('Device auth failed: ' + (e as Error).message, true); }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function kaShowQR() {
|
|
287
|
+
const el = document.getElementById('ka-auth-result') as HTMLElement;
|
|
288
|
+
const qrEl = document.getElementById('ka-qr-display') as HTMLElement;
|
|
289
|
+
try {
|
|
290
|
+
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
291
|
+
const spki = await crypto.subtle.exportKey('spki', kp.publicKey);
|
|
292
|
+
const pkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey);
|
|
293
|
+
const pubB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
|
294
|
+
kaDeviceKeysRef.current = { publicKey: pubB64, privateKeyDer: new Uint8Array(pkcs8) };
|
|
295
|
+
const result = await db._send('auth-request-approval', { publicKey: pubB64 }) as { requestId: string };
|
|
296
|
+
const requestId = result.requestId;
|
|
297
|
+
const approveUrl = `${location.origin}${location.pathname}#approve=${requestId}`;
|
|
298
|
+
qrEl.style.display = 'block';
|
|
299
|
+
const qrImg = await generateQRSvg(approveUrl, 150);
|
|
300
|
+
qrEl.innerHTML = `<div style="display:flex;gap:12px;align-items:flex-start">` +
|
|
301
|
+
`<div style="background:#fff;padding:4px;border-radius:4px;line-height:0">${qrImg}</div>` +
|
|
302
|
+
`<div style="flex:1">` +
|
|
303
|
+
`<div style="margin-bottom:6px"><b>Scan QR</b> or copy Request ID:</div>` +
|
|
304
|
+
`<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">` +
|
|
305
|
+
`<div style="margin-top:8px;color:#0f0">⏳ Waiting for approval…</div>` +
|
|
306
|
+
`</div></div>`;
|
|
307
|
+
(document.getElementById('ka-qr-request-id') as HTMLInputElement).value = requestId;
|
|
308
|
+
el.textContent = 'Waiting for a signed-in device to approve…';
|
|
309
|
+
if (kaQrPollTimerRef.current) clearInterval(kaQrPollTimerRef.current);
|
|
310
|
+
kaQrPollTimerRef.current = setInterval(async () => {
|
|
311
|
+
try {
|
|
312
|
+
const poll = await db._send('auth-poll-approval', { requestId }) as { status: string; token?: string; expiresAt?: number };
|
|
313
|
+
if (poll.status === 'approved') {
|
|
314
|
+
clearInterval(kaQrPollTimerRef.current!); kaQrPollTimerRef.current = null;
|
|
315
|
+
qrEl.innerHTML += '<br><b style="color:#0f0">✓ APPROVED!</b>';
|
|
316
|
+
el.textContent = 'Cross-device auth complete!\nToken: ' + poll.token!.slice(0, 60) + '…\nExpires: ' + new Date(poll.expiresAt!).toLocaleString();
|
|
317
|
+
kaStatus('QR approval succeeded'); loadKeyAuth();
|
|
318
|
+
} else if (poll.status === 'expired') {
|
|
319
|
+
clearInterval(kaQrPollTimerRef.current!); kaQrPollTimerRef.current = null;
|
|
320
|
+
qrEl.innerHTML += '<br><b style="color:#f44">✗ Expired</b>'; kaStatus('QR request expired', true);
|
|
321
|
+
}
|
|
322
|
+
} catch {}
|
|
323
|
+
}, 2000);
|
|
324
|
+
} catch (e: unknown) { el.textContent = 'Error: ' + (e as Error).message; kaStatus('QR failed: ' + (e as Error).message, true); }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function kaApproveQR() {
|
|
328
|
+
const requestId = (document.getElementById('ka-qr-request-id') as HTMLInputElement).value.trim();
|
|
329
|
+
if (!requestId) { kaStatus('Enter a Request ID', true); return; }
|
|
330
|
+
try {
|
|
331
|
+
const result = await db._send('auth-approve-device', { requestId }) as { fingerprint: string };
|
|
332
|
+
kaStatus('Approved! Device fp: ' + result.fingerprint.slice(0, 12));
|
|
333
|
+
loadKeyAuth();
|
|
334
|
+
} catch (e: unknown) { kaStatus('Approve failed: ' + (e as Error).message, true); }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function kaCreateRole() {
|
|
338
|
+
const id = (document.getElementById('ka-role-id') as HTMLInputElement).value.trim();
|
|
339
|
+
const name = (document.getElementById('ka-role-name') as HTMLInputElement).value.trim();
|
|
340
|
+
if (!id) { kaStatus('Role ID required', true); return; }
|
|
341
|
+
const role = { id, name: name || id, permissions: [...kaPermsRef.current] };
|
|
342
|
+
try { await db._send('auth-create-role', { role }); kaPermsRef.current = []; kaStatus('Role created: ' + id); loadKeyAuth(); }
|
|
343
|
+
catch (e: unknown) { kaStatus('Create role failed: ' + (e as Error).message, true); }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function kaAddPermission() {
|
|
347
|
+
const path = (document.getElementById('ka-perm-path') as HTMLInputElement).value.trim();
|
|
348
|
+
const read = (document.getElementById('ka-perm-read') as HTMLInputElement).checked;
|
|
349
|
+
const write = (document.getElementById('ka-perm-write') as HTMLInputElement).checked;
|
|
350
|
+
if (!path) { kaStatus('Path required', true); return; }
|
|
351
|
+
kaPermsRef.current.push({ path, read, write });
|
|
352
|
+
kaStatus(`Permission queued: ${path} (R:${read} W:${write}). ${kaPermsRef.current.length} pending.`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function kaChallengeResponse() {
|
|
356
|
+
const fp = (document.getElementById('ka-auth-fp') as HTMLSelectElement).value.trim();
|
|
357
|
+
const pw = (document.getElementById('ka-auth-pw') as HTMLInputElement).value.trim();
|
|
358
|
+
if (!fp) { kaStatus('Enter account fingerprint', true); return; }
|
|
359
|
+
const el = document.getElementById('ka-auth-result') as HTMLElement;
|
|
360
|
+
try {
|
|
361
|
+
el.textContent = 'Step 1: Fetching account + decrypting key…';
|
|
362
|
+
const account = await db.get('_auth/accounts/' + fp) as AccountInfo | null;
|
|
363
|
+
if (!account) { el.textContent = 'Account not found: ' + fp; return; }
|
|
364
|
+
const salt = Uint8Array.from(atob(account.salt!), c => c.charCodeAt(0));
|
|
365
|
+
const iv = Uint8Array.from(atob(account.iv!), c => c.charCodeAt(0));
|
|
366
|
+
const authTag = Uint8Array.from(atob(account.authTag!), c => c.charCodeAt(0));
|
|
367
|
+
const encrypted = Uint8Array.from(atob(account.encryptedPrivateKey!), c => c.charCodeAt(0));
|
|
368
|
+
const keyMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(pw), 'PBKDF2', false, ['deriveKey']);
|
|
369
|
+
const aesKey = await crypto.subtle.deriveKey(
|
|
370
|
+
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
|
|
371
|
+
keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['decrypt']
|
|
372
|
+
);
|
|
373
|
+
const ciphertext = new Uint8Array(encrypted.length + authTag.length);
|
|
374
|
+
ciphertext.set(encrypted); ciphertext.set(authTag, encrypted.length);
|
|
375
|
+
let privateKeyDer: Uint8Array;
|
|
376
|
+
try {
|
|
377
|
+
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, ciphertext);
|
|
378
|
+
privateKeyDer = new Uint8Array(decrypted);
|
|
379
|
+
} catch { el.textContent = 'Wrong password — decryption failed'; kaStatus('Wrong password', true); return; }
|
|
380
|
+
el.textContent = 'Step 2: Requesting challenge nonce…';
|
|
381
|
+
const challenge = await db._send('auth-challenge') as { nonce: string };
|
|
382
|
+
el.textContent = 'Step 3: Signing nonce with Ed25519…';
|
|
383
|
+
const sigKey = await crypto.subtle.importKey('pkcs8', privateKeyDer.buffer as ArrayBuffer, { name: 'Ed25519' }, false, ['sign']);
|
|
384
|
+
const sigBuf = await crypto.subtle.sign('Ed25519', sigKey, new TextEncoder().encode(challenge.nonce));
|
|
385
|
+
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sigBuf)));
|
|
386
|
+
el.textContent = 'Step 4: Verifying signature…';
|
|
387
|
+
const result = await db._send('auth-verify', { publicKey: account.publicKey, signature: sigB64, nonce: challenge.nonce }) as { token: string; expiresAt: number };
|
|
388
|
+
el.textContent = 'Authenticated!\n\nToken: ' + result.token.slice(0, 60) + '…\nExpires: ' + new Date(result.expiresAt).toLocaleString();
|
|
389
|
+
kaStatus('Challenge-response succeeded');
|
|
390
|
+
loadKeyAuth();
|
|
391
|
+
} catch (e: unknown) { el.textContent = 'Error: ' + (e as Error).message; kaStatus('Auth failed: ' + (e as Error).message, true); }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
396
|
+
<div style={{ flex: 2, minWidth: 400 }}>
|
|
397
|
+
<label>Accounts</label>
|
|
398
|
+
<div id="ka-accounts" style={{ marginBottom: 8 }}>Loading...</div>
|
|
399
|
+
<div className="row" style={{ marginBottom: 6 }}>
|
|
400
|
+
<input type="text" id="ka-new-pw" placeholder="Password" defaultValue="demo123" />
|
|
401
|
+
<input type="text" id="ka-new-name" placeholder="Display name" />
|
|
402
|
+
<input type="text" id="ka-new-roles" placeholder="Roles (comma-sep)" />
|
|
403
|
+
<button onClick={kaCreateAccount}>+ Account</button>
|
|
404
|
+
<button onClick={kaRegisterDevice}>+ Device</button>
|
|
405
|
+
</div>
|
|
406
|
+
<div style={{ marginTop: 10, border: '1px solid #444', borderRadius: 6, padding: 10 }}>
|
|
407
|
+
<label>Auth Flows</label>
|
|
408
|
+
<div style={{ marginBottom: 8 }}>
|
|
409
|
+
<b>1. Challenge-Response (password)</b>
|
|
410
|
+
<div className="row" style={{ marginTop: 4 }}>
|
|
411
|
+
<select id="ka-auth-fp" style={{ flex: 1 }}><option value="">— select account —</option></select>
|
|
412
|
+
<input type="text" id="ka-auth-pw" placeholder="Password" defaultValue="demo123" />
|
|
413
|
+
<button className="success" onClick={kaChallengeResponse}>Authenticate</button>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
<div style={{ marginBottom: 8 }}>
|
|
417
|
+
<b>2. Device Auth (keypair)</b>
|
|
418
|
+
<div className="row" style={{ marginTop: 4 }}>
|
|
419
|
+
<button onClick={kaDeviceAuth}>Authenticate with Stored Keypair</button>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
<div>
|
|
423
|
+
<b>3. QR Cross-Device Auth</b>
|
|
424
|
+
<div className="row" style={{ marginTop: 4 }}>
|
|
425
|
+
<button onClick={kaShowQR}>Show QR (new device)</button>
|
|
426
|
+
<input type="text" id="ka-qr-request-id" placeholder="Request ID to approve" />
|
|
427
|
+
<button className="success" onClick={kaApproveQR}>Approve (signed-in device)</button>
|
|
428
|
+
</div>
|
|
429
|
+
<div id="ka-qr-display" style={{ display: 'none', marginTop: 6, padding: 8, background: '#1a1a2e', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}></div>
|
|
430
|
+
</div>
|
|
431
|
+
<div id="ka-auth-result" className="result" style={{ minHeight: 40, marginTop: 6 }}>Select an auth flow above</div>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
<div style={{ flex: 1, minWidth: 280 }}>
|
|
435
|
+
<label>Roles & IAM</label>
|
|
436
|
+
<div id="ka-roles" className="result" style={{ minHeight: 80, marginBottom: 8 }}>Loading...</div>
|
|
437
|
+
<div className="row" style={{ marginBottom: 6 }}>
|
|
438
|
+
<input type="text" id="ka-role-id" placeholder="Role ID" defaultValue="editor" />
|
|
439
|
+
<input type="text" id="ka-role-name" placeholder="Name" defaultValue="Editor" />
|
|
440
|
+
<button onClick={kaCreateRole}>+ Role</button>
|
|
441
|
+
</div>
|
|
442
|
+
<div className="row" style={{ marginBottom: 8 }}>
|
|
443
|
+
<input type="text" id="ka-perm-path" placeholder="Path pattern" defaultValue="posts/$postId" />
|
|
444
|
+
<label style={{ display: 'inline', margin: 0 }}><input type="checkbox" id="ka-perm-read" defaultChecked /> R</label>
|
|
445
|
+
<label style={{ display: 'inline', margin: 0 }}><input type="checkbox" id="ka-perm-write" defaultChecked /> W</label>
|
|
446
|
+
<button className="sm" onClick={kaAddPermission}>+ Perm</button>
|
|
447
|
+
</div>
|
|
448
|
+
<div style={{ border: '1px solid #444', borderRadius: 6, padding: 8, marginBottom: 8 }}>
|
|
449
|
+
<label>Assign Roles</label>
|
|
450
|
+
<div className="row" style={{ marginTop: 4 }}>
|
|
451
|
+
<select id="ka-assign-account" style={{ flex: 1 }}></select>
|
|
452
|
+
<input type="text" id="ka-assign-roles" placeholder="Roles (comma-sep)" />
|
|
453
|
+
<button onClick={kaUpdateRoles}>Update</button>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
<label>Server Info</label>
|
|
457
|
+
<div id="ka-server" className="result" style={{ minHeight: 60 }}>Loading...</div>
|
|
458
|
+
</div>
|
|
459
|
+
<div id="ka-status" className="status" style={{ marginTop: 6, width: '100%' }}></div>
|
|
460
|
+
</div>
|
|
461
|
+
);
|
|
462
|
+
}
|