@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/.claude/settings.local.json +7 -1
- package/.claude/skills/config-file.md +1 -0
- package/.claude/skills/developing-bod-db.md +11 -5
- package/.claude/skills/using-bod-db.md +125 -5
- package/CLAUDE.md +11 -6
- package/README.md +3 -3
- package/admin/admin.ts +57 -0
- package/admin/demo.config.ts +132 -0
- package/admin/rules.ts +4 -1
- package/admin/ui.html +530 -6
- package/bun.lock +33 -0
- package/cli.ts +4 -43
- package/client.ts +5 -3
- package/config.ts +10 -3
- package/index.ts +5 -0
- package/package.json +8 -2
- package/src/client/BodClient.ts +225 -2
- package/src/client/{CachedClient.ts → BodClientCached.ts} +115 -6
- package/src/server/BodDB.ts +24 -8
- package/src/server/KeyAuthEngine.ts +481 -0
- package/src/server/ReplicationEngine.ts +1 -1
- package/src/server/RulesEngine.ts +4 -2
- package/src/server/Transport.ts +223 -0
- package/src/server/VFSEngine.ts +78 -7
- package/src/shared/keyAuth.browser.ts +80 -0
- package/src/shared/keyAuth.ts +177 -0
- package/src/shared/protocol.ts +28 -1
- package/tests/cached-client.test.ts +123 -7
- package/tests/keyauth.test.ts +1037 -0
- package/admin/server.ts +0 -607
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:
|
|
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">
|
|
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
|
+
}
|