4bnode 4.1.0 → 4.1.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.
Files changed (46) hide show
  1. package/README.md +25 -206
  2. package/add-crud.js +6 -106
  3. package/add-docs.js +91 -0
  4. package/add-email.js +63 -0
  5. package/add-security.js +143 -0
  6. package/index.js +76 -8
  7. package/lib/codegen.js +1223 -0
  8. package/lib/codegen.test.js +480 -0
  9. package/lib/indexFile.js +10 -12
  10. package/lib/names.js +4 -21
  11. package/lib/routeFile.js +9 -27
  12. package/lib/sync-skeleton.js +47 -0
  13. package/lib/ui.js +15 -2
  14. package/package.json +4 -2
  15. package/scripts/audit-dev-surface.mjs +153 -0
  16. package/scripts/reveal-passkey.mjs +35 -0
  17. package/skeleton/.4bnode/assets/js/App.js +31 -9
  18. package/skeleton/.4bnode/assets/js/Root.js +1 -5
  19. package/skeleton/.4bnode/assets/js/components/Sidebar.js +9 -5
  20. package/skeleton/.4bnode/assets/js/lib.js +18 -5
  21. package/skeleton/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
  22. package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +164 -0
  23. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +275 -9
  24. package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +44 -205
  25. package/skeleton/.4bnode/assets/js/pages/DocsPage.js +142 -0
  26. package/skeleton/.4bnode/assets/js/pages/MailPage.js +134 -0
  27. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +5 -266
  28. package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +23 -213
  29. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +74 -0
  30. package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +61 -61
  31. package/skeleton/.4bnode/codegen.js +1223 -0
  32. package/skeleton/.4bnode/dev-api.js +634 -728
  33. package/skeleton/.4bnode/ui.html +62 -40
  34. package/skeleton/_gitignore +7 -0
  35. package/skeleton/ecosystem.config.cjs +25 -0
  36. package/skeleton/index.js +46 -5
  37. package/skeleton/package.json +4 -1
  38. package/skeleton/src/middleware/apiKey.js +36 -0
  39. package/skeleton/src/middleware/errorHandler.js +15 -0
  40. package/skeleton/src/middleware/roles.js +14 -0
  41. package/skeleton/src/middleware/validate.js +20 -0
  42. package/skeleton/.4bnode/assets/images/mysql.png +0 -0
  43. package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
  44. package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
  45. package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
  46. package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +0 -66
@@ -483,6 +483,7 @@ export function ApiTesterPage() {
483
483
  { key: 'Content-Type', value: 'application/json', id: 1 },
484
484
  ]);
485
485
  const [body, setBody] = useState('');
486
+ const bodyRef = useRef(null);
486
487
  const [response, setResponse] = useState(null);
487
488
  const [loading, setLoading] = useState(false);
488
489
  const [activeTab, setActiveTab] = useState('body');
@@ -504,11 +505,151 @@ export function ApiTesterPage() {
504
505
  const [lastSharedAt, setLastSharedAt] = useState(null);
505
506
  const [sharedSnapshot, setSharedSnapshot] = useState(null);
506
507
 
508
+ // Auto-grow the request-body textarea to fit its content (capped, then scrolls).
509
+ useEffect(() => {
510
+ const ta = bodyRef.current;
511
+ if (ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight + 2, 600) + 'px'; }
512
+ }, [body, bodyType, activeTab]);
513
+
514
+ // ── Environments / variables + auth (persisted per-browser in localStorage) ──
515
+ const [variables, setVariables] = useState([]);
516
+ const [showVars, setShowVars] = useState(false);
517
+ const [autoToken, setAutoToken] = useState(true);
518
+ const [auth, setAuth] = useState({ type: 'none', token: '', username: '', password: '', apiKeyKey: 'x-api-key', apiKeyValue: '' });
519
+
520
+ useEffect(() => {
521
+ try {
522
+ const v = JSON.parse(localStorage.getItem('_4bnode_vars') || '[]');
523
+ if (Array.isArray(v)) setVariables(v);
524
+ const a = JSON.parse(localStorage.getItem('_4bnode_auth') || 'null');
525
+ if (a && typeof a === 'object') setAuth(prev => ({ ...prev, ...a }));
526
+ const at = localStorage.getItem('_4bnode_autotoken');
527
+ if (at !== null) setAutoToken(at === '1');
528
+ } catch {}
529
+ }, []);
530
+ useEffect(() => { try { localStorage.setItem('_4bnode_vars', JSON.stringify(variables)); } catch {} }, [variables]);
531
+ useEffect(() => { try { localStorage.setItem('_4bnode_auth', JSON.stringify(auth)); } catch {} }, [auth]);
532
+ useEffect(() => { try { localStorage.setItem('_4bnode_autotoken', autoToken ? '1' : '0'); } catch {} }, [autoToken]);
533
+
534
+ // Replace {{name}} tokens with variable values.
535
+ function subst(str) {
536
+ if (typeof str !== 'string' || str.indexOf('{{') === -1) return str;
537
+ return str.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (m, k) => {
538
+ const v = variables.find(x => (x.key || '').trim() === k);
539
+ return v ? v.value : m;
540
+ });
541
+ }
542
+ function setVar(key, value) {
543
+ setVariables(prev => {
544
+ const i = prev.findIndex(x => (x.key || '').trim() === key);
545
+ if (i === -1) return [...prev, { key, value, id: Date.now() }];
546
+ const next = prev.slice(); next[i] = { ...next[i], value }; return next;
547
+ });
548
+ }
549
+
550
+ // ── Request tabs (each tab holds a snapshot; live state = active tab's buffer) ──
551
+ const [tabs, setTabs] = useState([{ id: 1, snap: null }]);
552
+ const [activeTabId, setActiveTabId] = useState(1);
553
+
554
+ function captureSnap() {
555
+ return { method, url, headers, body, bodyType, params, urlParams, formFields, response, activeTab };
556
+ }
557
+ function loadSnap(s) {
558
+ // Only protect a restored request that actually has data; blank tabs should
559
+ // still auto-fill from a matching route when the user types a URL.
560
+ suppressAutofillRef.current = !!(s && s.url);
561
+ setMethod((s && s.method) || 'GET');
562
+ setUrl((s && s.url) || '');
563
+ setHeaders((s && s.headers) || [{ key: 'Content-Type', value: 'application/json', id: 1 }]);
564
+ setBody((s && s.body) || '');
565
+ setBodyType((s && s.bodyType) || 'json');
566
+ setParams((s && s.params) || []);
567
+ setUrlParams((s && s.urlParams) || []);
568
+ setFormFields((s && s.formFields) || []);
569
+ setResponse((s && s.response) || null);
570
+ setActiveTab((s && s.activeTab) || 'body');
571
+ setBodyError(null);
572
+ setSelectedSaved(null);
573
+ setSelectedResponse(null);
574
+ }
575
+ function switchTab(id) {
576
+ if (id === activeTabId) return;
577
+ const snap = captureSnap();
578
+ const target = tabs.find(t => t.id === id);
579
+ setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, snap } : t));
580
+ loadSnap(target && target.snap);
581
+ setActiveTabId(id);
582
+ }
583
+ function newTab() {
584
+ const snap = captureSnap();
585
+ const id = Math.max(0, ...tabs.map(t => t.id)) + 1;
586
+ setTabs(prev => [...prev.map(t => t.id === activeTabId ? { ...t, snap } : t), { id, snap: null }]);
587
+ loadSnap(null);
588
+ setActiveTabId(id);
589
+ }
590
+ function closeTab(id) {
591
+ const remaining = tabs.filter(t => t.id !== id);
592
+ if (remaining.length === 0) {
593
+ const nid = Math.max(0, ...tabs.map(t => t.id)) + 1;
594
+ setTabs([{ id: nid, snap: null }]);
595
+ loadSnap(null);
596
+ setActiveTabId(nid);
597
+ return;
598
+ }
599
+ setTabs(remaining);
600
+ if (id === activeTabId) {
601
+ const next = remaining[remaining.length - 1];
602
+ loadSnap(next.snap);
603
+ setActiveTabId(next.id);
604
+ }
605
+ }
606
+ function tabLabel(t) {
607
+ const s = t.id === activeTabId ? { method, url } : (t.snap || {});
608
+ if (!s.url) return 'New Request';
609
+ let path = s.url;
610
+ try { path = new URL(s.url).pathname || '/'; } catch {}
611
+ return (s.method || 'GET') + ' ' + path;
612
+ }
613
+
614
+ // ── Request history (persisted per-browser) ──
615
+ const [history, setHistory] = useState([]);
616
+ const [showHistory, setShowHistory] = useState(false);
617
+ useEffect(() => {
618
+ try { const h = JSON.parse(localStorage.getItem('_4bnode_history') || '[]'); if (Array.isArray(h)) setHistory(h); } catch {}
619
+ }, []);
620
+ function pushHistory(entry) {
621
+ setHistory(prev => {
622
+ const next = [entry, ...prev].slice(0, 50);
623
+ try { localStorage.setItem('_4bnode_history', JSON.stringify(next)); } catch {}
624
+ return next;
625
+ });
626
+ }
627
+ function clearHistory() {
628
+ setHistory([]);
629
+ try { localStorage.removeItem('_4bnode_history'); } catch {}
630
+ }
631
+ function loadHistory(h) {
632
+ setMethod(h.method);
633
+ setUrl(h.url);
634
+ setResponse(null);
635
+ setSelectedSaved(null);
636
+ setSelectedResponse(null);
637
+ setShowHistory(false);
638
+ }
639
+
640
+ // Latest-committed mirrors of tests/responses. Persistence reads these so a PUT
641
+ // always sends the LATEST tests AND responses — never a stale half from an older
642
+ // closure (which previously could wipe saved API data when saving from sockets).
643
+ const savedTestsRef = useRef(savedTests);
644
+ const savedResponsesRef = useRef(savedResponses);
645
+ useEffect(() => { savedTestsRef.current = savedTests; }, [savedTests]);
646
+ useEffect(() => { savedResponsesRef.current = savedResponses; }, [savedResponses]);
647
+
507
648
  // Load saved tests and responses from server
508
649
  useEffect(() => {
509
650
  api('/api-tester/data').then(data => {
510
- if (data.tests) setSavedTests(data.tests);
511
- if (data.responses) setSavedResponses(data.responses);
651
+ if (data.tests) { setSavedTests(data.tests); savedTestsRef.current = data.tests; }
652
+ if (data.responses) { setSavedResponses(data.responses); savedResponsesRef.current = data.responses; }
512
653
  if (data.lastSharedAt) { setLastSharedAt(data.lastSharedAt); setSharedSnapshot(data.sharedSnapshot || null); }
513
654
  }).catch(() => {});
514
655
  }, []);
@@ -551,9 +692,12 @@ export function ApiTesterPage() {
551
692
  // Load fields from server when route matches
552
693
  const lastMatchRef = useRef(null);
553
694
  const fieldsLoadedRef = useRef(false);
695
+ const suppressAutofillRef = useRef(false);
554
696
  useEffect(() => {
555
697
  if (!matchedEndpoint) { lastMatchRef.current = null; return; }
556
698
  const matchKey = matchedEndpoint.fullPath + ':' + matchedEndpoint.method;
699
+ // When a tab/history/saved request is restored, don't let route auto-fill clobber it.
700
+ if (suppressAutofillRef.current) { suppressAutofillRef.current = false; lastMatchRef.current = matchKey; return; }
557
701
  if (lastMatchRef.current === matchKey) return;
558
702
  lastMatchRef.current = matchKey;
559
703
  fieldsLoadedRef.current = false;
@@ -614,13 +758,15 @@ export function ApiTesterPage() {
614
758
  const shareNeeded = lastSharedAt && sharedSnapshot !== null && sharedSnapshot !== JSON.stringify({ t: savedTests.length, r: Object.keys(savedResponses).length, ids: savedTests.map(t => t.id).join(',') });
615
759
 
616
760
  function persistTests(tests) {
761
+ savedTestsRef.current = tests;
617
762
  setSavedTests(tests);
618
- api('/api-tester/data', { method: 'PUT', body: { tests, responses: savedResponses } }).catch(() => {});
763
+ api('/api-tester/data', { method: 'PUT', body: { tests, responses: savedResponsesRef.current } }).catch(() => {});
619
764
  }
620
765
 
621
766
  function persistResponses(resp) {
767
+ savedResponsesRef.current = resp;
622
768
  setSavedResponses(resp);
623
- api('/api-tester/data', { method: 'PUT', body: { tests: savedTests, responses: resp } }).catch(() => {});
769
+ api('/api-tester/data', { method: 'PUT', body: { tests: savedTestsRef.current, responses: resp } }).catch(() => {});
624
770
  }
625
771
 
626
772
  function saveResponse() {
@@ -709,8 +855,16 @@ export function ApiTesterPage() {
709
855
  setSelectedResponse(null);
710
856
  try {
711
857
  const hdrs = {};
712
- headers.forEach(h => { if (h.key?.trim() && h.value?.trim()) hdrs[h.key] = h.value; });
713
- const finalUrl = buildDisplayUrl();
858
+ headers.forEach(h => { if (h.key?.trim() && h.value?.trim()) hdrs[h.key] = subst(h.value); });
859
+ // Apply the Auth tab (variables supported via {{...}})
860
+ if (auth.type === 'bearer' && auth.token.trim()) {
861
+ hdrs['Authorization'] = 'Bearer ' + subst(auth.token).trim();
862
+ } else if (auth.type === 'basic' && (auth.username || auth.password)) {
863
+ try { hdrs['Authorization'] = 'Basic ' + btoa(subst(auth.username) + ':' + subst(auth.password)); } catch {}
864
+ } else if (auth.type === 'apikey' && auth.apiKeyKey.trim()) {
865
+ hdrs[auth.apiKeyKey.trim()] = subst(auth.apiKeyValue);
866
+ }
867
+ const finalUrl = subst(buildDisplayUrl());
714
868
  // For base64 send-as, convert form field value into body JSON
715
869
  let sendBody = undefined;
716
870
  let sendBodyType = bodyType;
@@ -728,12 +882,12 @@ export function ApiTesterPage() {
728
882
  if (f.type === 'file' && !f.fileData && !f.value) {
729
883
  return null; // Skip empty file fields
730
884
  }
731
- return { key: f.key, value: f.value || '' };
885
+ return { key: f.key, value: subst(f.value || '') };
732
886
  }).filter(Boolean);
733
887
  } else if (bodyType === 'binary') {
734
888
  sendBody = body; // base64 encoded
735
889
  } else {
736
- sendBody = body;
890
+ sendBody = subst(body);
737
891
  }
738
892
  }
739
893
 
@@ -759,12 +913,19 @@ export function ApiTesterPage() {
759
913
  time: result.time,
760
914
  size: result.size,
761
915
  });
916
+ // Auto-capture an auth token from the response into {{token}}
917
+ if (autoToken && parsed && typeof parsed === 'object') {
918
+ const tok = parsed.token || parsed.accessToken || parsed.access_token || (parsed.data && parsed.data.token);
919
+ if (tok && typeof tok === 'string') { setVar('token', tok); toast('Captured token → {{token}}', 'success'); }
920
+ }
921
+ pushHistory({ id: Date.now(), method, url, status: result.status, time: result.time, ts: Date.now() });
762
922
  } catch (err) {
763
923
  setResponse({
764
924
  status: 0, statusText: 'Error',
765
925
  body: err.message, isJson: false,
766
926
  time: 0, size: 0, headers: {},
767
927
  });
928
+ pushHistory({ id: Date.now(), method, url, status: 0, time: 0, ts: Date.now() });
768
929
  }
769
930
  setLoading(false);
770
931
  }
@@ -923,7 +1084,15 @@ export function ApiTesterPage() {
923
1084
  setUrlParams([]);
924
1085
  }
925
1086
 
926
- // Set body fields for POST/PUT/PATCH
1087
+ // Populate keys for testing — both the JSON body and the form-data rows, so
1088
+ // fields are pre-filled regardless of which body mode is used.
1089
+ const formRows = [
1090
+ ...((ep.bodyFields || []).map((f, i) => ({ key: f.name, value: '', type: 'text', id: Date.now() + i, fromRoute: true }))),
1091
+ ...((ep.fileFields || []).map((f, i) => ({ key: f.name, value: '', type: 'file', id: Date.now() + 500 + i, fromRoute: true }))),
1092
+ ];
1093
+ setFormFields(formRows);
1094
+
1095
+ const hasFiles = ep.fileFields?.length > 0;
927
1096
  if (ep.bodyFields?.length > 0 && ['POST','PUT','PATCH'].includes(ep.method)) {
928
1097
  const bodyObj = {};
929
1098
  ep.bodyFields.forEach(f => {
@@ -935,6 +1104,10 @@ export function ApiTesterPage() {
935
1104
  } else {
936
1105
  setBody('');
937
1106
  }
1107
+ if (hasFiles && ['POST','PUT','PATCH'].includes(ep.method)) {
1108
+ setBodyType('form-data');
1109
+ setActiveTab('body');
1110
+ }
938
1111
 
939
1112
  // Set query params for GET/DELETE
940
1113
  if (ep.queryFields?.length > 0 && ['GET','DELETE'].includes(ep.method)) {
@@ -1213,6 +1386,23 @@ export function ApiTesterPage() {
1213
1386
  onSaveLog=${handleSaveLog} />`}
1214
1387
 
1215
1388
  ${testerMode === 'rest' && html`<div>
1389
+ <!-- Request tabs -->
1390
+ <div style=${{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '10px', flexWrap: 'wrap' }}>
1391
+ ${tabs.map(t => { const active = t.id === activeTabId; return html`<div key=${t.id}
1392
+ onClick=${() => switchTab(t.id)}
1393
+ style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', cursor: 'pointer',
1394
+ border: '1px solid ' + (active ? 'var(--accent)' : 'var(--border)'),
1395
+ background: active ? 'var(--accent-glow)' : 'transparent',
1396
+ color: active ? 'var(--accent-light)' : 'var(--text-secondary)',
1397
+ borderRadius: '6px', padding: '5px 10px', maxWidth: '220px', fontSize: '12px' }}>
1398
+ <span style=${{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${tabLabel(t)}</span>
1399
+ ${tabs.length > 1 && html`<span onClick=${(e) => { e.stopPropagation(); closeTab(t.id); }}
1400
+ style=${{ cursor: 'pointer', fontSize: '15px', lineHeight: 1, opacity: 0.6 }}>×</span>`}
1401
+ </div>`; })}
1402
+ <button onClick=${newTab} title="New request tab"
1403
+ style=${{ border: '1px solid var(--border)', background: 'transparent', color: 'var(--text-secondary)', borderRadius: '6px', padding: '4px 11px', cursor: 'pointer', fontSize: '16px', lineHeight: 1 }}>+</button>
1404
+ </div>
1405
+
1216
1406
  <!-- Request bar -->
1217
1407
  <div className="api-tester-bar">
1218
1408
  <select className="api-tester-method"
@@ -1233,6 +1423,16 @@ export function ApiTesterPage() {
1233
1423
  ${loading ? html`<${LuLoader} size=${14} className="spin" />` : html`<${LuSend} size=${14} />`}
1234
1424
  Send
1235
1425
  </button>
1426
+ <button className="api-tester-send" onClick=${() => { setShowVars(v => !v); setShowHistory(false); }}
1427
+ title="Environment variables"
1428
+ style=${{ background: showVars ? 'var(--accent-glow)' : 'transparent', border: '1px solid var(--border)', color: showVars ? 'var(--accent-light)' : 'var(--text-primary)' }}>
1429
+ Variables${variables.filter(v => v.key?.trim()).length ? ` (${variables.filter(v => v.key?.trim()).length})` : ''}
1430
+ </button>
1431
+ <button className="api-tester-send" onClick=${() => { setShowHistory(h => !h); setShowVars(false); }}
1432
+ title="Request history"
1433
+ style=${{ background: showHistory ? 'var(--accent-glow)' : 'transparent', border: '1px solid var(--border)', color: showHistory ? 'var(--accent-light)' : 'var(--text-primary)' }}>
1434
+ History
1435
+ </button>
1236
1436
  ${!savedTests.some(t => t.type !== 'socketio' && t.type !== 'websocket' && t.method === method && t.url === url) && html`<button className="api-tester-send" onClick=${saveTest} disabled=${!url.trim()}
1237
1437
  style=${{ background: 'transparent', border: '1px solid var(--border)', color: 'var(--text-primary)' }}>
1238
1438
  <${LuSave} size=${14} /> Save
@@ -1254,6 +1454,47 @@ export function ApiTesterPage() {
1254
1454
  borderRadius: '6px', marginBottom: '8px', wordBreak: 'break-all',
1255
1455
  }}>${displayUrl}</div>`}
1256
1456
 
1457
+ ${showVars && html`<div className="card" style=${{ padding: '12px 14px', marginBottom: '12px' }}>
1458
+ <div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
1459
+ <span style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Environment Variables</span>
1460
+ <label style=${{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: 'var(--text-secondary)', cursor: 'pointer' }}>
1461
+ <input type="checkbox" checked=${autoToken} onChange=${e => setAutoToken(e.target.checked)} /> Auto-capture token from response
1462
+ </label>
1463
+ </div>
1464
+ ${variables.map((v, i) => html`<div key=${v.id || i} className="api-tester-kv-row">
1465
+ <input placeholder="name" value=${v.key}
1466
+ onInput=${e => setVariables(prev => prev.map((x, j) => j === i ? { ...x, key: e.target.value } : x))} />
1467
+ <input placeholder="value" value=${v.value}
1468
+ onInput=${e => setVariables(prev => prev.map((x, j) => j === i ? { ...x, value: e.target.value } : x))} />
1469
+ <button className="btn btn-ghost" style=${{ padding: '4px', opacity: 0.5 }}
1470
+ onClick=${() => setVariables(prev => prev.filter((_, j) => j !== i))}><${LuTrash2} size=${12} /></button>
1471
+ </div>`)}
1472
+ <div style=${{ padding: '8px 0 0' }}>
1473
+ <button className="btn btn-ghost btn-sm" onClick=${() => setVariables(prev => [...prev, { key: '', value: '', id: Date.now() }])}>
1474
+ <${LuPlus} size=${12} /> Add variable
1475
+ </button>
1476
+ </div>
1477
+ <div className="text-sm text-muted" style=${{ marginTop: '6px' }}>
1478
+ Reference anywhere with <span style=${{ fontFamily: "'SF Mono', monospace", color: 'var(--accent-light)' }}>{{name}}</span> — URL, headers, body, or Auth.
1479
+ </div>
1480
+ </div>`}
1481
+
1482
+ ${showHistory && html`<div className="card" style=${{ padding: 0, marginBottom: '12px', overflow: 'hidden' }}>
1483
+ <div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
1484
+ <span style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>History</span>
1485
+ ${history.length > 0 && html`<button className="btn btn-ghost btn-sm" onClick=${clearHistory} style=${{ fontSize: '11px' }}>Clear</button>`}
1486
+ </div>
1487
+ ${history.length === 0
1488
+ ? html`<div style=${{ padding: '16px', color: 'var(--text-muted)', fontSize: '12px' }}>No requests yet — sent requests show up here.</div>`
1489
+ : history.map(h => html`<div key=${h.id}
1490
+ onClick=${() => loadHistory(h)}
1491
+ style=${{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer', fontSize: '12px' }}>
1492
+ <span style=${{ fontWeight: 600, color: methodColors[h.method] || 'var(--text-secondary)', minWidth: '46px' }}>${h.method}</span>
1493
+ <span style=${{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: "'SF Mono', monospace" }}>${h.url}</span>
1494
+ <span style=${{ fontWeight: 600, color: h.status >= 200 && h.status < 300 ? 'var(--success)' : h.status === 0 ? 'var(--text-muted)' : 'var(--warning)' }}>${h.status || '—'}</span>
1495
+ </div>`)}
1496
+ </div>`}
1497
+
1257
1498
  <!-- URL Params (:id, etc.) -->
1258
1499
  ${urlParams.length > 0 && html`<div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
1259
1500
  <div style=${{ padding: '8px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
@@ -1285,8 +1526,32 @@ export function ApiTesterPage() {
1285
1526
  onClick=${() => setActiveTab('body')}>
1286
1527
  Body
1287
1528
  </button>`}
1529
+ <button className=${`api-tester-tab${activeTab === 'auth' ? ' active' : ''}`}
1530
+ onClick=${() => setActiveTab('auth')}>
1531
+ Auth${auth.type !== 'none' ? html` <span style=${{ color: 'var(--success)', marginLeft: '2px' }}>●</span>` : ''}
1532
+ </button>
1288
1533
  </div>
1289
1534
 
1535
+ ${activeTab === 'auth' && html`<div style=${{ padding: '12px 14px' }}>
1536
+ <div style=${{ display: 'flex', gap: '6px', marginBottom: '12px', flexWrap: 'wrap' }}>
1537
+ ${[['none', 'No Auth'], ['bearer', 'Bearer Token'], ['basic', 'Basic'], ['apikey', 'API Key']].map(([t, label]) => html`<button key=${t}
1538
+ onClick=${() => setAuth(a => ({ ...a, type: t }))}
1539
+ className="badge"
1540
+ style=${{ cursor: 'pointer', border: '1px solid var(--border)', background: auth.type === t ? 'var(--accent-glow)' : 'var(--bg-input)', color: auth.type === t ? 'var(--accent-light)' : 'var(--text-secondary)' }}>${label}</button>`)}
1541
+ </div>
1542
+ ${auth.type === 'bearer' && html`<input className="form-input" placeholder="Token (e.g. {{token}})"
1543
+ value=${auth.token} onInput=${e => setAuth(a => ({ ...a, token: e.target.value }))} />`}
1544
+ ${auth.type === 'basic' && html`<div style=${{ display: 'flex', gap: '8px' }}>
1545
+ <input className="form-input" placeholder="Username" value=${auth.username} onInput=${e => setAuth(a => ({ ...a, username: e.target.value }))} />
1546
+ <input className="form-input" placeholder="Password" value=${auth.password} onInput=${e => setAuth(a => ({ ...a, password: e.target.value }))} />
1547
+ </div>`}
1548
+ ${auth.type === 'apikey' && html`<div style=${{ display: 'flex', gap: '8px' }}>
1549
+ <input className="form-input" placeholder="Header name" value=${auth.apiKeyKey} onInput=${e => setAuth(a => ({ ...a, apiKeyKey: e.target.value }))} style=${{ flex: '0 0 170px' }} />
1550
+ <input className="form-input" placeholder="Value (e.g. {{apiKey}})" value=${auth.apiKeyValue} onInput=${e => setAuth(a => ({ ...a, apiKeyValue: e.target.value }))} />
1551
+ </div>`}
1552
+ ${auth.type === 'none' && html`<div className="text-sm text-muted">No authentication will be sent with this request.</div>`}
1553
+ </div>`}
1554
+
1290
1555
  ${activeTab === 'params' && html`<div>
1291
1556
  ${params.map(p => html`<div key=${p.id} className="api-tester-kv-row">
1292
1557
  <input placeholder="Param name" value=${p.key}
@@ -1351,6 +1616,7 @@ export function ApiTesterPage() {
1351
1616
 
1352
1617
  ${(bodyType === 'json' || bodyType === 'raw') && html`<div>
1353
1618
  <textarea
1619
+ ref=${bodyRef}
1354
1620
  className="api-tester-body-editor"
1355
1621
  placeholder=${bodyType === 'json' ? '{\n "key": "value"\n}' : 'Enter raw body...'}
1356
1622
  value=${body}