4bnode 4.0.9 → 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 +41 -3
  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
@@ -56,19 +56,19 @@
56
56
  }
57
57
 
58
58
  :root {
59
- --bg: #09090b;
60
- --bg-surface: #0f0f13;
61
- --bg-card: #16161d;
62
- --bg-card-hover: #1c1c26;
63
- --bg-input: #12121a;
64
- --border: rgba(255, 255, 255, 0.07);
65
- --border-focus: rgba(99, 102, 241, 0.5);
66
- --text: #f0f0f5;
67
- --text-secondary: #a1a1aa;
68
- --text-muted: #63637a;
69
- --accent: #6366f1;
70
- --accent-light: #818cf8;
71
- --accent-glow: rgba(99, 102, 241, 0.15);
59
+ --bg: #141720;
60
+ --bg-surface: #11141e;
61
+ --bg-card: #1a1d2b;
62
+ --bg-card-hover: #21253a;
63
+ --bg-input: #13161f;
64
+ --border: rgba(255, 255, 255, 0.08);
65
+ --border-focus: rgba(0, 121, 255, 0.5);
66
+ --text: #eceef4;
67
+ --text-secondary: #8b8fa3;
68
+ --text-muted: #626880;
69
+ --accent: #0079ff;
70
+ --accent-light: #3daefd;
71
+ --accent-glow: rgba(0, 94, 255, 0.18);
72
72
  --success: #22c55e;
73
73
  --success-glow: rgba(34, 197, 94, 0.12);
74
74
  --warning: #f59e0b;
@@ -128,23 +128,27 @@
128
128
  display: flex;
129
129
  flex-direction: column;
130
130
  flex-shrink: 0;
131
- padding: 20px 0;
131
+ padding: 0 0 16px;
132
132
  overflow-y: auto;
133
133
  }
134
134
  .sidebar-brand {
135
- padding: 0 20px 20px;
135
+ height: 61px;
136
+ display: flex;
137
+ align-items: center;
138
+ padding: 0 20px;
136
139
  border-bottom: 1px solid var(--border);
137
140
  margin-bottom: 8px;
141
+ flex-shrink: 0;
138
142
  }
139
143
  .sidebar-brand pre {
140
144
  font-family: monospace;
141
- font-size: 5.5px;
145
+ font-size: 4px;
142
146
  line-height: 1.1;
143
147
  letter-spacing: 0.5px;
144
- background: linear-gradient(135deg, #00c6ff, #0072ff, #7b2ff7);
148
+ background: linear-gradient(135deg, #3daefd, #0079ff, #005eff);
145
149
  -webkit-background-clip: text;
146
150
  -webkit-text-fill-color: transparent;
147
- margin: 0 0 6px -2px;
151
+ margin: 0 0 0 -2px;
148
152
  white-space: pre;
149
153
  user-select: none;
150
154
  }
@@ -180,7 +184,21 @@
180
184
  color: var(--text);
181
185
  }
182
186
  .sidebar-item.active {
183
- background: var(--accent-glow);
187
+ background: rgba(255, 255, 255, 0.06);
188
+ color: #f0f1f5;
189
+ }
190
+ .sidebar-item.active::before {
191
+ content: "";
192
+ position: absolute;
193
+ left: -8px;
194
+ top: 50%;
195
+ transform: translateY(-50%);
196
+ width: 3px;
197
+ height: 16px;
198
+ border-radius: 0 3px 3px 0;
199
+ background: var(--accent-light);
200
+ }
201
+ .sidebar-item.active .sidebar-icon {
184
202
  color: var(--accent-light);
185
203
  }
186
204
  .sidebar-item.locked {
@@ -216,10 +234,12 @@
216
234
  overflow: hidden;
217
235
  }
218
236
  .topbar {
237
+ height: 61px;
219
238
  display: flex;
220
239
  align-items: center;
221
240
  justify-content: space-between;
222
- padding: 16px 28px;
241
+ padding: 0 28px;
242
+ background: var(--bg-surface);
223
243
  border-bottom: 1px solid var(--border);
224
244
  flex-shrink: 0;
225
245
  }
@@ -479,6 +499,8 @@
479
499
  font-weight: 500;
480
500
  padding: 3px 8px;
481
501
  border-radius: 20px;
502
+ background: rgba(255, 255, 255, 0.07);
503
+ color: var(--text-secondary);
482
504
  }
483
505
  .badge-success {
484
506
  background: var(--success-glow);
@@ -822,8 +844,8 @@
822
844
  color: #ef4444;
823
845
  }
824
846
  .route-method.multi {
825
- background: rgba(99, 102, 241, 0.15);
826
- color: #818cf8;
847
+ background: rgba(0, 94, 255, 0.15);
848
+ color: #3daefd;
827
849
  }
828
850
  .route-endpoint-row {
829
851
  display: flex;
@@ -1264,7 +1286,7 @@
1264
1286
 
1265
1287
  /* ── Code block ── */
1266
1288
  .code-block {
1267
- background: #0d0d12;
1289
+ background: #11141e;
1268
1290
  border: 1px solid var(--border);
1269
1291
  border-radius: var(--radius);
1270
1292
  padding: 16px;
@@ -1415,7 +1437,7 @@
1415
1437
  line-height: 1.15;
1416
1438
  letter-spacing: 1px;
1417
1439
  white-space: pre;
1418
- background: linear-gradient(135deg, #00c6ff, #0072ff, #7b2ff7);
1440
+ background: linear-gradient(135deg, #3daefd, #0079ff, #005eff);
1419
1441
  -webkit-background-clip: text;
1420
1442
  -webkit-text-fill-color: transparent;
1421
1443
  user-select: none;
@@ -1439,7 +1461,7 @@
1439
1461
  }
1440
1462
  .splash-company span {
1441
1463
  font-weight: 600;
1442
- background: linear-gradient(135deg, #00c6ff, #7b2ff7);
1464
+ background: linear-gradient(135deg, #3daefd, #005eff);
1443
1465
  -webkit-background-clip: text;
1444
1466
  -webkit-text-fill-color: transparent;
1445
1467
  }
@@ -1448,7 +1470,7 @@
1448
1470
  width: 300px;
1449
1471
  height: 300px;
1450
1472
  border-radius: 50%;
1451
- background: radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%);
1473
+ background: radial-gradient(circle, rgba(0,94,255,0.12) 0%, transparent 70%);
1452
1474
  pointer-events: none;
1453
1475
  }
1454
1476
 
@@ -1487,16 +1509,15 @@
1487
1509
  color: var(--text-secondary);
1488
1510
  margin: 0;
1489
1511
  }
1490
- .passkey-inputs {
1491
- display: flex;
1492
- gap: 10px;
1512
+ .passkey-field {
1513
+ width: 100%;
1493
1514
  }
1494
- .passkey-inputs input {
1495
- width: 44px;
1496
- height: 52px;
1497
- text-align: center;
1498
- font-size: 22px;
1499
- font-weight: 600;
1515
+ .passkey-field input {
1516
+ width: 100%;
1517
+ height: 48px;
1518
+ padding: 0 14px;
1519
+ font-size: 16px;
1520
+ font-weight: 500;
1500
1521
  font-family: "Noto Sans", sans-serif;
1501
1522
  background: var(--bg);
1502
1523
  border: 1px solid var(--border);
@@ -1504,12 +1525,13 @@
1504
1525
  color: var(--text);
1505
1526
  outline: none;
1506
1527
  transition: border-color 0.2s;
1528
+ box-sizing: border-box;
1507
1529
  }
1508
- .passkey-inputs input:focus {
1509
- border-color: #0072ff;
1510
- box-shadow: 0 0 0 2px rgba(0, 114, 255, 0.15);
1530
+ .passkey-field input:focus {
1531
+ border-color: #0079ff;
1532
+ box-shadow: 0 0 0 2px rgba(0, 94, 255, 0.15);
1511
1533
  }
1512
- .passkey-inputs input.error {
1534
+ .passkey-field input.error {
1513
1535
  border-color: #ef4444;
1514
1536
  box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15);
1515
1537
  }
@@ -1526,7 +1548,7 @@
1526
1548
  font-size: 14px;
1527
1549
  font-weight: 600;
1528
1550
  color: #fff;
1529
- background: linear-gradient(135deg, #0072ff, #7b2ff7);
1551
+ background: linear-gradient(135deg, #0079ff, #005eff);
1530
1552
  border: none;
1531
1553
  border-radius: 10px;
1532
1554
  cursor: pointer;
@@ -1,3 +1,10 @@
1
+ # Secrets — never commit (deploy these out-of-band, like .env)
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ src/api-keys.json
6
+ src/docs-auth.json
7
+
1
8
  # Dependencies
2
9
  node_modules/
3
10
 
package/skeleton/index.js CHANGED
@@ -4,10 +4,13 @@ import path from "path";
4
4
  import fs from "fs";
5
5
  import cors from "cors";
6
6
  import os from "os";
7
+ import helmet from "helmet";
8
+ import rateLimit from "express-rate-limit";
7
9
  import { createInterface } from "readline";
10
+ import { notFound, errorHandler } from "./src/middleware/errorHandler.js";
11
+ import apiKey from "./src/middleware/apiKey.js";
8
12
 
9
13
  // ── Environment ─────────────────────────────────────
10
- const env = process.env.NODE_ENV || "development";
11
14
  dotenv.config();
12
15
 
13
16
  const app = express();
@@ -19,14 +22,45 @@ app.use(express.json({ limit: "50mb" }));
19
22
  app.use(express.urlencoded({ extended: true, limit: "50mb" }));
20
23
  app.use(cors({ origin: "*" }));
21
24
 
22
- // ── Dev Dashboard (development only) ────────────────
23
- if (env === "development") {
25
+ // ── Dev Dashboard (local development only) ───────────
26
+ // The dashboard at /_dev can write files and run shell commands — it is a
27
+ // remote-code-execution surface and must NEVER be reachable in production.
28
+ // In production it is not even imported. It additionally restricts itself to
29
+ // loopback requests and requires a passkey (see .4bnode/dev-api.js).
30
+ // • production (NODE_ENV=production): OFF — set _4BNODE_DASHBOARD=on to force.
31
+ // • non-production: ON — set _4BNODE_DASHBOARD=off to disable.
32
+ const dashboardEnabled =
33
+ process.env._4BNODE_DASHBOARD === "on" ||
34
+ (process.env.NODE_ENV !== "production" && process.env._4BNODE_DASHBOARD !== "off");
35
+ if (dashboardEnabled) {
24
36
  try {
25
37
  const devApi = await import("./.4bnode/dev-api.js");
26
38
  app.use("/_dev", devApi.default);
27
39
  } catch {}
28
40
  }
29
41
 
42
+ // ── Security (built-in by default) ───────────────────
43
+ // Mounted AFTER the /_dev dashboard so its CDN/inline scripts aren't blocked by
44
+ // helmet's CSP and the dashboard isn't throttled by the rate limiter.
45
+ app.use(helmet());
46
+
47
+ // ── Rate limiting ────────────────────────────────────
48
+ // Tune via .env: RATE_LIMIT_WINDOW_MS (ms) and RATE_LIMIT_MAX (per window).
49
+ const apiLimiter = rateLimit({
50
+ windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
51
+ max: Number(process.env.RATE_LIMIT_MAX) || 100,
52
+ standardHeaders: true,
53
+ legacyHeaders: false,
54
+ });
55
+ app.use(apiLimiter);
56
+
57
+ // ── API key gate ─────────────────────────────────────
58
+ // Every /api/* request must present a valid key in the `x-api-key` header.
59
+ // Manage keys in the dashboard (API Keys). Set API_KEY_REQUIRED=off to disable.
60
+ if (process.env.API_KEY_REQUIRED !== "off") {
61
+ app.use("/api", apiKey);
62
+ }
63
+
30
64
  // ── Static Files ────────────────────────────────────
31
65
  app.use(express.static(path.join(__dirname, "public")));
32
66
 
@@ -69,6 +103,10 @@ function askPort(busyPort) {
69
103
  });
70
104
  }
71
105
 
106
+ // ── Error handling (must be last) ──
107
+ app.use(notFound);
108
+ app.use(errorHandler);
109
+
72
110
  // ── Start Server ────────────────────────────────────
73
111
  async function startServer(tryPort) {
74
112
  const listener = app.listen(tryPort);
@@ -15,7 +15,10 @@
15
15
  "dependencies": {
16
16
  "express": "^5.2.1",
17
17
  "dotenv": "^17.3.1",
18
- "cors": "^2.8.6"
18
+ "cors": "^2.8.6",
19
+ "helmet": "^8.0.0",
20
+ "express-rate-limit": "^7.4.0",
21
+ "zod": "^3.23.0"
19
22
  },
20
23
  "devDependencies": {
21
24
  "nodemon": "^3.1.14"
@@ -0,0 +1,36 @@
1
+ // API key authentication. Named keys are managed by the 4bnode dashboard and
2
+ // stored in src/api-keys.json. Send a key in the `x-api-key` request header:
3
+ // import apiKey from './src/middleware/apiKey.js';
4
+ // app.use('/api/private', apiKey);
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ const KEYS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'api-keys.json');
10
+ let _cache = { mtimeMs: -1, keys: [] };
11
+
12
+ // Read the keys file fresh when it changes (mtime-cached), so keys generated or
13
+ // revoked in the dashboard take effect immediately — no restart needed.
14
+ function loadKeys() {
15
+ try {
16
+ const { mtimeMs } = fs.statSync(KEYS_FILE);
17
+ if (mtimeMs !== _cache.mtimeMs) {
18
+ const list = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8'));
19
+ _cache = { mtimeMs, keys: (Array.isArray(list) ? list : []).map((k) => k && k.key).filter(Boolean) };
20
+ }
21
+ } catch {
22
+ _cache = { mtimeMs: -1, keys: [] };
23
+ }
24
+ return _cache.keys;
25
+ }
26
+
27
+ const apiKey = (req, res, next) => {
28
+ const provided = req.header('x-api-key');
29
+ const valid = loadKeys();
30
+ if (!provided || !valid.includes(provided)) {
31
+ return res.status(401).json({ message: 'Invalid or missing API key' });
32
+ }
33
+ next();
34
+ };
35
+
36
+ export default apiKey;
@@ -0,0 +1,15 @@
1
+ // Central error handling. Register AFTER all routes:
2
+ // app.use(notFound);
3
+ // app.use(errorHandler);
4
+ export function notFound(req, res, next) {
5
+ res.status(404).json({ message: `Not found - ${req.originalUrl}` });
6
+ }
7
+
8
+ export function errorHandler(err, req, res, next) {
9
+ const status = err.status || err.statusCode || 500;
10
+ console.error(err);
11
+ res.status(status).json({
12
+ message: err.message || 'Server error',
13
+ ...(process.env.NODE_ENV === 'production' ? {} : { stack: err.stack }),
14
+ });
15
+ }
@@ -0,0 +1,14 @@
1
+ // Role-based access control. Use AFTER the JWT auth middleware so req.user is set:
2
+ // router.get('/admin', auth, roles('admin'), handler);
3
+ // Requires the login token payload to include a `role` (e.g. jwt.sign({ id, role })).
4
+ const roles = (...allowed) => (req, res, next) => {
5
+ if (!req.user) {
6
+ return res.status(401).json({ message: 'Not authenticated' });
7
+ }
8
+ if (allowed.length && !allowed.includes(req.user.role)) {
9
+ return res.status(403).json({ message: 'Forbidden: insufficient role' });
10
+ }
11
+ next();
12
+ };
13
+
14
+ export default roles;
@@ -0,0 +1,20 @@
1
+ import { ZodError } from 'zod';
2
+
3
+ // Validate part of the request against a Zod schema. Replaces req[source] with the
4
+ // parsed/coerced data on success. Use as middleware:
5
+ // router.post('/', validate(userSchema), handler); // validates req.body
6
+ // router.get('/', validate(querySchema, 'query'), handler); // validates req.query
7
+ const validate = (schema, source = 'body') => (req, res, next) => {
8
+ const result = schema.safeParse(req[source]);
9
+ if (!result.success) {
10
+ const errors = (result.error instanceof ZodError ? result.error.issues : []).map((i) => ({
11
+ path: i.path.join('.'),
12
+ message: i.message,
13
+ }));
14
+ return res.status(400).json({ message: 'Validation failed', errors });
15
+ }
16
+ req[source] = result.data;
17
+ next();
18
+ };
19
+
20
+ export default validate;