4bnode 4.1.0 → 4.1.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.
Files changed (45) 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/index.js +46 -5
  36. package/skeleton/package.json +4 -1
  37. package/skeleton/src/middleware/apiKey.js +36 -0
  38. package/skeleton/src/middleware/errorHandler.js +15 -0
  39. package/skeleton/src/middleware/roles.js +14 -0
  40. package/skeleton/src/middleware/validate.js +20 -0
  41. package/skeleton/.4bnode/assets/images/mysql.png +0 -0
  42. package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
  43. package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
  44. package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
  45. package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +0 -66
@@ -0,0 +1,74 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { LuCheck, LuGauge } from "react-icons/lu";
3
+ import { html, api } from "../lib.js";
4
+ import { useApp } from "../context.js";
5
+
6
+ export function SecurityPage() {
7
+ const { toast } = useApp();
8
+
9
+ const [rl, setRl] = useState(null);
10
+ const [savingRl, setSavingRl] = useState(false);
11
+
12
+ useEffect(() => {
13
+ api("/security/rate-limit")
14
+ .then((r) => setRl({ windowMinutes: r.windowMinutes, max: r.max }))
15
+ .catch(() => setRl({ windowMinutes: 15, max: 100 }));
16
+ }, []);
17
+
18
+ async function saveRl() {
19
+ setSavingRl(true);
20
+ try {
21
+ await api("/security/rate-limit", {
22
+ method: "POST",
23
+ body: { windowMinutes: Number(rl.windowMinutes), max: Number(rl.max) },
24
+ });
25
+ toast("Rate limit saved — restart the app to apply.", "success");
26
+ } catch (err) {
27
+ toast(err.message, "error");
28
+ }
29
+ setSavingRl(false);
30
+ }
31
+
32
+ return html`<div>
33
+ <div className="mb-4">
34
+ <div className="card-title">Security</div>
35
+ <div className="card-subtitle">
36
+ <b>Security is built in</b> — Helmet, rate limiting, CORS, error handling, RBAC, and Zod
37
+ validation ship with every app. Validation is generated per route from each route's own
38
+ fields when you create it. Adjust rate limiting below.
39
+ </div>
40
+ </div>
41
+
42
+ <div className="card">
43
+ <div className="flex items-center gap-2 mb-1">
44
+ <${LuGauge} size=${16} />
45
+ <div className="card-title" style=${{ margin: 0 }}>Rate limiting</div>
46
+ </div>
47
+ <div className="card-subtitle mb-4">
48
+ Throttle requests per IP. Applies to your API routes (the <span style=${{ fontFamily: "monospace" }}>/_dev</span> dashboard is exempt).
49
+ </div>
50
+ ${rl === null
51
+ ? html`<div className="text-muted text-sm">Loading…</div>`
52
+ : html`<div style=${{ display: "flex", gap: "16px", alignItems: "flex-end", flexWrap: "wrap" }}>
53
+ <div className="form-group" style=${{ marginBottom: 0 }}>
54
+ <label className="form-label">Window (minutes)</label>
55
+ <input className="form-input" type="number" min="1" value=${rl.windowMinutes}
56
+ onInput=${(e) => setRl((s) => ({ ...s, windowMinutes: e.target.value }))}
57
+ style=${{ width: "140px" }} />
58
+ </div>
59
+ <div className="form-group" style=${{ marginBottom: 0 }}>
60
+ <label className="form-label">Max requests / window</label>
61
+ <input className="form-input" type="number" min="1" value=${rl.max}
62
+ onInput=${(e) => setRl((s) => ({ ...s, max: e.target.value }))}
63
+ style=${{ width: "180px" }} />
64
+ </div>
65
+ <button className="btn btn-primary" onClick=${saveRl} disabled=${savingRl}>
66
+ ${savingRl ? html`<span className="spinner" />` : html`<${LuCheck} size=${15} />`} Save
67
+ </button>
68
+ </div>
69
+ <div className="text-sm text-muted" style=${{ marginTop: "10px" }}>
70
+ Restart the app for changes to take effect (values are read from <span style=${{ fontFamily: "monospace" }}>.env</span> at startup).
71
+ </div>`}
72
+ </div>
73
+ </div>`;
74
+ }
@@ -1,70 +1,52 @@
1
1
  import React, { useState, useEffect, useRef } from "react";
2
2
  import { motion } from "framer-motion";
3
- import { LuLock } from "react-icons/lu";
4
- import { html, API, getStoredPasskey } from "../lib.js";
3
+ import { LuLock, LuEye, LuEyeOff } from "react-icons/lu";
4
+ import { html, API } from "../lib.js";
5
5
 
6
6
  export function PasskeyScreen({ onSuccess }) {
7
- const [digits, setDigits] = useState(['', '', '', '', '', '']);
8
- const [error, setError] = useState('');
7
+ const [passkey, setPasskey] = useState("");
8
+ const [show, setShow] = useState(false);
9
+ const [error, setError] = useState("");
9
10
  const [loading, setLoading] = useState(false);
10
- const inputRefs = useRef([]);
11
-
12
- const handleChange = (index, value) => {
13
- if (!/^\d?$/.test(value)) return;
14
- const next = [...digits];
15
- next[index] = value;
16
- setDigits(next);
17
- setError('');
18
- if (value && index < 5) {
19
- inputRefs.current[index + 1]?.focus();
20
- }
21
- };
22
-
23
- const handleKeyDown = (index, e) => {
24
- if (e.key === 'Backspace' && !digits[index] && index > 0) {
25
- inputRefs.current[index - 1]?.focus();
26
- }
27
- if (e.key === 'Enter') handleSubmit();
28
- };
29
-
30
- const handlePaste = (e) => {
31
- e.preventDefault();
32
- const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
33
- if (pasted.length === 0) return;
34
- const next = [...digits];
35
- for (let i = 0; i < 6; i++) next[i] = pasted[i] || '';
36
- setDigits(next);
37
- const focusIdx = Math.min(pasted.length, 5);
38
- inputRefs.current[focusIdx]?.focus();
39
- };
11
+ const inputRef = useRef(null);
40
12
 
41
13
  const handleSubmit = async () => {
42
- const pin = digits.join('');
43
- if (pin.length !== 6) { setError('Enter all 6 digits'); return; }
14
+ if (loading) return;
15
+ if (!passkey) {
16
+ setError("Enter your passkey");
17
+ return;
18
+ }
44
19
  setLoading(true);
20
+ setError("");
45
21
  try {
46
- const res = await fetch(API + '/auth', {
47
- method: 'POST',
48
- headers: { 'Content-Type': 'application/json' },
49
- body: JSON.stringify({ passkey: pin }),
22
+ const res = await fetch(API + "/auth", {
23
+ method: "POST",
24
+ headers: { "Content-Type": "application/json" },
25
+ body: JSON.stringify({ passkey }),
50
26
  });
51
- const data = await res.json();
27
+ const data = await res.json().catch(() => ({}));
52
28
  if (!res.ok) {
53
- setError(data.error || 'Invalid passkey');
54
- setDigits(['', '', '', '', '', '']);
55
- inputRefs.current[0]?.focus();
29
+ setError(data.error || "Invalid passkey");
30
+ setPasskey("");
31
+ inputRef.current?.focus();
56
32
  } else {
57
- sessionStorage.setItem('_dev_passkey', pin);
33
+ sessionStorage.setItem("_dev_passkey", passkey);
58
34
  onSuccess();
59
35
  }
60
36
  } catch {
61
- setError('Connection failed');
37
+ setError("Connection failed");
62
38
  } finally {
63
39
  setLoading(false);
64
40
  }
65
41
  };
66
42
 
67
- useEffect(() => { inputRefs.current[0]?.focus(); }, []);
43
+ const handleKeyDown = (e) => {
44
+ if (e.key === "Enter") handleSubmit();
45
+ };
46
+
47
+ useEffect(() => {
48
+ inputRef.current?.focus();
49
+ }, []);
68
50
 
69
51
  return html`<${motion.div}
70
52
  className="passkey-screen"
@@ -82,23 +64,41 @@ export function PasskeyScreen({ onSuccess }) {
82
64
  >
83
65
  <${LuLock} size=${28} color="#0072ff" />
84
66
  <h2>Dashboard Passkey</h2>
85
- <p>Enter your 6-digit passkey to continue</p>
86
- <div className="passkey-inputs" onPaste=${handlePaste}>
87
- ${digits.map((d, i) => html`<input
88
- key=${i}
89
- ref=${(el) => { inputRefs.current[i] = el; }}
90
- type="text"
91
- inputMode="numeric"
92
- maxLength="1"
93
- value=${d}
94
- className=${error ? 'error' : ''}
95
- onInput=${(e) => handleChange(i, e.target.value)}
96
- onKeyDown=${(e) => handleKeyDown(i, e)}
97
- />`)}
67
+ <p>Enter your passkey to continue</p>
68
+ <div className="passkey-field" style=${{ position: "relative", width: "100%" }}>
69
+ <input
70
+ ref=${inputRef}
71
+ type=${show ? "text" : "password"}
72
+ autoComplete="current-password"
73
+ className=${error ? "error" : ""}
74
+ value=${passkey}
75
+ placeholder="Passkey"
76
+ style=${{ width: "100%", paddingRight: "40px" }}
77
+ onInput=${(e) => {
78
+ setPasskey(e.target.value);
79
+ setError("");
80
+ }}
81
+ onKeyDown=${handleKeyDown}
82
+ />
83
+ <span
84
+ onClick=${() => setShow((s) => !s)}
85
+ title=${show ? "Hide" : "Show"}
86
+ style=${{
87
+ position: "absolute",
88
+ right: "12px",
89
+ top: "50%",
90
+ transform: "translateY(-50%)",
91
+ cursor: "pointer",
92
+ color: "var(--text-muted, #888)",
93
+ display: "flex",
94
+ }}
95
+ >
96
+ ${show ? html`<${LuEyeOff} size=${18} />` : html`<${LuEye} size=${18} />`}
97
+ </span>
98
98
  </div>
99
99
  <div className="passkey-error">${error}</div>
100
100
  <button className="passkey-btn" onClick=${handleSubmit} disabled=${loading}>
101
- ${loading ? 'Verifying...' : 'Unlock Dashboard'}
101
+ ${loading ? "Verifying..." : "Unlock Dashboard"}
102
102
  </button>
103
103
  <//>
104
104
  <//>`;