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.
- package/README.md +25 -206
- package/add-crud.js +6 -106
- package/add-docs.js +91 -0
- package/add-email.js +63 -0
- package/add-security.js +143 -0
- package/index.js +76 -8
- package/lib/codegen.js +1223 -0
- package/lib/codegen.test.js +480 -0
- package/lib/indexFile.js +10 -12
- package/lib/names.js +4 -21
- package/lib/routeFile.js +9 -27
- package/lib/sync-skeleton.js +47 -0
- package/lib/ui.js +15 -2
- package/package.json +4 -2
- package/scripts/audit-dev-surface.mjs +153 -0
- package/scripts/reveal-passkey.mjs +35 -0
- package/skeleton/.4bnode/assets/js/App.js +31 -9
- package/skeleton/.4bnode/assets/js/Root.js +1 -5
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +9 -5
- package/skeleton/.4bnode/assets/js/lib.js +18 -5
- package/skeleton/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +164 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +275 -9
- package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +44 -205
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +142 -0
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +5 -266
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +23 -213
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +61 -61
- package/skeleton/.4bnode/codegen.js +1223 -0
- package/skeleton/.4bnode/dev-api.js +634 -728
- package/skeleton/.4bnode/ui.html +62 -40
- package/skeleton/_gitignore +7 -0
- package/skeleton/index.js +46 -5
- package/skeleton/package.json +4 -1
- package/skeleton/src/middleware/apiKey.js +36 -0
- package/skeleton/src/middleware/errorHandler.js +15 -0
- package/skeleton/src/middleware/roles.js +14 -0
- package/skeleton/src/middleware/validate.js +20 -0
- package/skeleton/.4bnode/assets/images/mysql.png +0 -0
- package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
- package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
- package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
- package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +0 -66
package/skeleton/.4bnode/ui.html
CHANGED
|
@@ -56,19 +56,19 @@
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
:root {
|
|
59
|
-
--bg: #
|
|
60
|
-
--bg-surface: #
|
|
61
|
-
--bg-card: #
|
|
62
|
-
--bg-card-hover: #
|
|
63
|
-
--bg-input: #
|
|
64
|
-
--border: rgba(255, 255, 255, 0.
|
|
65
|
-
--border-focus: rgba(
|
|
66
|
-
--text: #
|
|
67
|
-
--text-secondary: #
|
|
68
|
-
--text-muted: #
|
|
69
|
-
--accent: #
|
|
70
|
-
--accent-light: #
|
|
71
|
-
--accent-glow: rgba(
|
|
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:
|
|
131
|
+
padding: 0 0 16px;
|
|
132
132
|
overflow-y: auto;
|
|
133
133
|
}
|
|
134
134
|
.sidebar-brand {
|
|
135
|
-
|
|
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:
|
|
145
|
+
font-size: 4px;
|
|
142
146
|
line-height: 1.1;
|
|
143
147
|
letter-spacing: 0.5px;
|
|
144
|
-
background: linear-gradient(135deg, #
|
|
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
|
|
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:
|
|
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:
|
|
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(
|
|
826
|
-
color: #
|
|
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: #
|
|
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, #
|
|
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, #
|
|
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(
|
|
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-
|
|
1491
|
-
|
|
1492
|
-
gap: 10px;
|
|
1512
|
+
.passkey-field {
|
|
1513
|
+
width: 100%;
|
|
1493
1514
|
}
|
|
1494
|
-
.passkey-
|
|
1495
|
-
width:
|
|
1496
|
-
height:
|
|
1497
|
-
|
|
1498
|
-
font-size:
|
|
1499
|
-
font-weight:
|
|
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-
|
|
1509
|
-
border-color: #
|
|
1510
|
-
box-shadow: 0 0 0 2px rgba(0,
|
|
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-
|
|
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, #
|
|
1551
|
+
background: linear-gradient(135deg, #0079ff, #005eff);
|
|
1530
1552
|
border: none;
|
|
1531
1553
|
border-radius: 10px;
|
|
1532
1554
|
cursor: pointer;
|
package/skeleton/_gitignore
CHANGED
package/skeleton/index.js
CHANGED
|
@@ -4,7 +4,11 @@ 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
14
|
dotenv.config();
|
|
@@ -18,11 +22,44 @@ app.use(express.json({ limit: "50mb" }));
|
|
|
18
22
|
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
|
19
23
|
app.use(cors({ origin: "*" }));
|
|
20
24
|
|
|
21
|
-
// ── Dev Dashboard
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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) {
|
|
36
|
+
try {
|
|
37
|
+
const devApi = await import("./.4bnode/dev-api.js");
|
|
38
|
+
app.use("/_dev", devApi.default);
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
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
|
+
}
|
|
26
63
|
|
|
27
64
|
// ── Static Files ────────────────────────────────────
|
|
28
65
|
app.use(express.static(path.join(__dirname, "public")));
|
|
@@ -66,6 +103,10 @@ function askPort(busyPort) {
|
|
|
66
103
|
});
|
|
67
104
|
}
|
|
68
105
|
|
|
106
|
+
// ── Error handling (must be last) ──
|
|
107
|
+
app.use(notFound);
|
|
108
|
+
app.use(errorHandler);
|
|
109
|
+
|
|
69
110
|
// ── Start Server ────────────────────────────────────
|
|
70
111
|
async function startServer(tryPort) {
|
|
71
112
|
const listener = app.listen(tryPort);
|
package/skeleton/package.json
CHANGED
|
@@ -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;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|