4bnode 4.1.1 → 4.1.3
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/add-security.js +0 -18
- package/app-1/.4bnode/.passkey +1 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +139 -0
- package/app-1/.4bnode/assets/js/Root.js +40 -0
- package/app-1/.4bnode/assets/js/components/Modal.js +52 -0
- package/app-1/.4bnode/assets/js/components/Sidebar.js +73 -0
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +15 -0
- package/app-1/.4bnode/assets/js/context.js +7 -0
- package/app-1/.4bnode/assets/js/lib.js +113 -0
- package/app-1/.4bnode/assets/js/main.js +5 -0
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +1879 -0
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +435 -0
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +300 -0
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +150 -0
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +103 -0
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +205 -0
- package/app-1/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +186 -0
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +1436 -0
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +838 -0
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
- package/app-1/.4bnode/codegen.js +1249 -0
- package/app-1/.4bnode/dev-api.js +3621 -0
- package/app-1/.4bnode/ui.html +1578 -0
- package/app-1/README.md +47 -0
- package/app-1/ecosystem.config.cjs +25 -0
- package/app-1/index.js +145 -0
- package/app-1/package.json +35 -0
- package/app-1/public/index.html +292 -0
- package/app-1/src/db.js +13 -0
- package/app-1/src/docs.js +84 -0
- package/app-1/src/middleware/errorHandler.js +15 -0
- package/app-1/src/middleware/roles.js +14 -0
- package/app-1/src/middleware/validate.js +20 -0
- package/app-1/src/models/login.js +9 -0
- package/app-1/src/openapi.json +62 -0
- package/app-1/src/routes/products.js +26 -0
- package/app-1/src/validators/products.js +7 -0
- package/app-1/src/validators/user.js +7 -0
- package/index.js +2 -17
- package/lib/codegen.js +69 -43
- package/lib/codegen.test.js +21 -2
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +0 -2
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -3
- package/skeleton/.4bnode/assets/js/lib.js +0 -3
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +29 -21
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +12 -0
- package/skeleton/.4bnode/codegen.js +69 -43
- package/skeleton/.4bnode/dev-api.js +173 -87
- package/skeleton/_gitignore +0 -1
- package/skeleton/ecosystem.config.cjs +25 -0
- package/skeleton/index.js +4 -8
- package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +0 -164
|
@@ -4,14 +4,21 @@ import { html, api } from "../lib.js";
|
|
|
4
4
|
import { useApp } from "../context.js";
|
|
5
5
|
|
|
6
6
|
export function DocsPage() {
|
|
7
|
-
const { toast, refresh
|
|
7
|
+
const { toast, refresh } = useApp();
|
|
8
8
|
const [info, setInfo] = useState(null);
|
|
9
9
|
const [busy, setBusy] = useState(false);
|
|
10
10
|
const [pw, setPw] = useState("");
|
|
11
11
|
const [pwBusy, setPwBusy] = useState(false);
|
|
12
12
|
|
|
13
13
|
const load = useCallback(async () => {
|
|
14
|
-
|
|
14
|
+
// Docs setup/regenerate writes src/docs.js → nodemon restart. Retry across the
|
|
15
|
+
// restart window so the re-fetch doesn't surface as a "NetworkError".
|
|
16
|
+
let lastErr;
|
|
17
|
+
for (let i = 0; i < 8; i++) {
|
|
18
|
+
try { setInfo(await api("/docs")); return; }
|
|
19
|
+
catch (err) { lastErr = err; await new Promise((r) => setTimeout(r, 500)); }
|
|
20
|
+
}
|
|
21
|
+
if (lastErr) toast(lastErr.message, "error");
|
|
15
22
|
}, [toast]);
|
|
16
23
|
|
|
17
24
|
useEffect(() => { load(); }, [load]);
|
|
@@ -22,10 +29,18 @@ export function DocsPage() {
|
|
|
22
29
|
})();
|
|
23
30
|
|
|
24
31
|
async function setup() {
|
|
32
|
+
if (pw && pw.length < 6) {
|
|
33
|
+
toast("Password must be at least 6 characters (or leave blank for public docs)", "error");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
25
36
|
setBusy(true);
|
|
26
37
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
// Set the password first (writes docs-auth.json), then create the docs route —
|
|
39
|
+
// so /docs is protected from the moment it goes live.
|
|
40
|
+
if (pw) await api("/docs/password", { method: "POST", body: { password: pw } });
|
|
41
|
+
await api("/docs/setup", { method: "POST" });
|
|
42
|
+
toast(pw ? "API docs set up and password-protected." : "API docs set up at /docs.", "success");
|
|
43
|
+
setPw("");
|
|
29
44
|
await load();
|
|
30
45
|
refresh().catch(() => {});
|
|
31
46
|
} catch (err) { toast(err.message, "error"); }
|
|
@@ -54,25 +69,13 @@ export function DocsPage() {
|
|
|
54
69
|
setPwBusy(false);
|
|
55
70
|
}
|
|
56
71
|
|
|
57
|
-
async function removePassword() {
|
|
58
|
-
const ok = await showConfirm("Remove the docs password? /docs will be publicly accessible.", { danger: true, confirmLabel: "Remove" });
|
|
59
|
-
if (!ok) return;
|
|
60
|
-
setPwBusy(true);
|
|
61
|
-
try {
|
|
62
|
-
const r = await api("/docs/password", { method: "DELETE" });
|
|
63
|
-
toast(r.message, "success");
|
|
64
|
-
await load();
|
|
65
|
-
} catch (err) { toast(err.message, "error"); }
|
|
66
|
-
setPwBusy(false);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
72
|
if (!info) return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
|
|
70
73
|
|
|
71
74
|
return html`<div>
|
|
72
75
|
<div className="flex items-center justify-between mb-4">
|
|
73
76
|
<div>
|
|
74
77
|
<div className="card-title">API Documentation</div>
|
|
75
|
-
<div className="card-subtitle">
|
|
78
|
+
<div className="card-subtitle">Interactive API docs generated from your routes and models</div>
|
|
76
79
|
</div>
|
|
77
80
|
</div>
|
|
78
81
|
|
|
@@ -82,9 +85,16 @@ export function DocsPage() {
|
|
|
82
85
|
<${LuFileText} size=${20} color="var(--accent)" />
|
|
83
86
|
<div>
|
|
84
87
|
<div style=${{ fontWeight: 600 }}>Set up API docs</div>
|
|
85
|
-
<div className="text-sm text-muted">
|
|
88
|
+
<div className="text-sm text-muted">Generates a spec from your ${info.endpointCount} endpoint${info.endpointCount === 1 ? "" : "s"} and serves an interactive docs page at <span style=${{ fontFamily: "monospace" }}>/docs</span>.</div>
|
|
86
89
|
</div>
|
|
87
90
|
</div>
|
|
91
|
+
<div className="form-group">
|
|
92
|
+
<label className="form-label">Docs password</label>
|
|
93
|
+
<input className="form-input" type="password" value=${pw}
|
|
94
|
+
placeholder="Protect /docs (min 6 chars) — leave blank for public docs"
|
|
95
|
+
onInput=${(e) => setPw(e.target.value)}
|
|
96
|
+
onKeyDown=${(e) => { if (e.key === "Enter") setup(); }} />
|
|
97
|
+
</div>
|
|
88
98
|
<button className="btn btn-primary" onClick=${setup} disabled=${busy}>
|
|
89
99
|
${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
|
|
90
100
|
${busy ? "Setting up..." : "Set up API docs"}
|
|
@@ -111,13 +121,11 @@ export function DocsPage() {
|
|
|
111
121
|
<div className="card-title mb-1">Docs access</div>
|
|
112
122
|
<div className="card-subtitle mb-4">
|
|
113
123
|
By default <span style=${{ fontFamily: "monospace" }}>/docs</span> is public — anyone who can reach the app can read it.
|
|
114
|
-
Set a password to require
|
|
124
|
+
Set a password to require a login before the docs are shown.
|
|
115
125
|
</div>
|
|
116
126
|
${info.passwordProtected
|
|
117
127
|
? html`<div className="flex items-center gap-3" style=${{ flexWrap: "wrap" }}>
|
|
118
128
|
<span className="badge badge-success">🔒 Password protected</span>
|
|
119
|
-
<button className="btn btn-sm btn-ghost" onClick=${removePassword} disabled=${pwBusy}
|
|
120
|
-
style=${{ color: "var(--danger, #ef4444)" }}>Remove password</button>
|
|
121
129
|
<div style=${{ display: "flex", gap: "8px", marginLeft: "auto", flexWrap: "wrap" }}>
|
|
122
130
|
<input className="form-input" type="password" placeholder="New password" value=${pw}
|
|
123
131
|
onInput=${(e) => setPw(e.target.value)} style=${{ width: "200px" }} />
|
|
@@ -614,6 +614,7 @@ function CreateRoutePanel({ onCreated }) {
|
|
|
614
614
|
const [registerFields, setRegisterFields] = useState([]);
|
|
615
615
|
const [selectedFields, setSelectedFields] = useState([]);
|
|
616
616
|
const [customFields, setCustomFields] = useState([]);
|
|
617
|
+
const [authRequired, setAuthRequired] = useState(false);
|
|
617
618
|
const [newFieldName, setNewFieldName] = useState("");
|
|
618
619
|
const [newFieldType, setNewFieldType] = useState("String");
|
|
619
620
|
const [loading, setLoading] = useState(false);
|
|
@@ -732,6 +733,7 @@ function CreateRoutePanel({ onCreated }) {
|
|
|
732
733
|
(routeType === "methods" || routeType === "crud") && customFields.length > 0
|
|
733
734
|
? customFields
|
|
734
735
|
: undefined,
|
|
736
|
+
authRequired: !isAuthType ? authRequired : undefined,
|
|
735
737
|
},
|
|
736
738
|
});
|
|
737
739
|
toast(result.message, "success");
|
|
@@ -1176,6 +1178,16 @@ function CreateRoutePanel({ onCreated }) {
|
|
|
1176
1178
|
</button>
|
|
1177
1179
|
</div>
|
|
1178
1180
|
</div>`}
|
|
1181
|
+
${routeType && !isAuthType &&
|
|
1182
|
+
html`<div className="form-group" style=${{ marginTop: "4px" }}>
|
|
1183
|
+
<label style=${{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px", cursor: "pointer" }}>
|
|
1184
|
+
<input type="checkbox" checked=${authRequired} onChange=${(e) => setAuthRequired(e.target.checked)} />
|
|
1185
|
+
<span>Require authentication (JWT)</span>
|
|
1186
|
+
</label>
|
|
1187
|
+
<div className="text-sm text-muted" style=${{ marginTop: "4px", marginLeft: "24px" }}>
|
|
1188
|
+
Protects every endpoint with the <span style=${{ fontFamily: "monospace" }}>auth</span> middleware — callers must send a valid Bearer token in the Authorization header.
|
|
1189
|
+
</div>
|
|
1190
|
+
</div>`}
|
|
1179
1191
|
${routeType === "login" &&
|
|
1180
1192
|
modelName &&
|
|
1181
1193
|
modelFields.length > 0 &&
|
|
@@ -274,44 +274,26 @@ export default roles;
|
|
|
274
274
|
`;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
// src/middleware/
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const KEYS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'api-keys.json');
|
|
288
|
-
let _cache = { mtimeMs: -1, keys: [] };
|
|
289
|
-
|
|
290
|
-
// Read the keys file fresh when it changes (mtime-cached), so keys generated or
|
|
291
|
-
// revoked in the dashboard take effect immediately — no restart needed.
|
|
292
|
-
function loadKeys() {
|
|
293
|
-
try {
|
|
294
|
-
const { mtimeMs } = fs.statSync(KEYS_FILE);
|
|
295
|
-
if (mtimeMs !== _cache.mtimeMs) {
|
|
296
|
-
const list = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8'));
|
|
297
|
-
_cache = { mtimeMs, keys: (Array.isArray(list) ? list : []).map((k) => k && k.key).filter(Boolean) };
|
|
298
|
-
}
|
|
299
|
-
} catch {
|
|
300
|
-
_cache = { mtimeMs: -1, keys: [] };
|
|
277
|
+
// src/middleware/auth.js — JWT verification. Reads a Bearer token, verifies it
|
|
278
|
+
// with JWT_SECRET, and sets req.user. Use as middleware: router.get('/', auth, h).
|
|
279
|
+
export function buildAuthMiddleware() {
|
|
280
|
+
return `import jwt from 'jsonwebtoken';
|
|
281
|
+
|
|
282
|
+
const auth = (req, res, next) => {
|
|
283
|
+
const token = req.header('Authorization')?.replace('Bearer ', '');
|
|
284
|
+
if (!token) {
|
|
285
|
+
return res.status(401).json({ message: 'No token, authorization denied' });
|
|
301
286
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (!provided || !valid.includes(provided)) {
|
|
309
|
-
return res.status(401).json({ message: 'Invalid or missing API key' });
|
|
287
|
+
try {
|
|
288
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
289
|
+
req.user = decoded;
|
|
290
|
+
next();
|
|
291
|
+
} catch (err) {
|
|
292
|
+
res.status(401).json({ message: 'Token is not valid' });
|
|
310
293
|
}
|
|
311
|
-
next();
|
|
312
294
|
};
|
|
313
295
|
|
|
314
|
-
export default
|
|
296
|
+
export default auth;
|
|
315
297
|
`;
|
|
316
298
|
}
|
|
317
299
|
|
|
@@ -406,6 +388,22 @@ export function wireValidateIntoRouteContent(content, modelBase) {
|
|
|
406
388
|
return out;
|
|
407
389
|
}
|
|
408
390
|
|
|
391
|
+
// Protect a whole route file with JWT: insert `auth` as the FIRST middleware on
|
|
392
|
+
// every endpoint (so it runs before validation), and add the import. Idempotent.
|
|
393
|
+
export function wireAuthIntoRouteContent(content) {
|
|
394
|
+
// (router.<method>('/path', )(existing middleware)(handler arrow)
|
|
395
|
+
const handlerRe =
|
|
396
|
+
/(router\.(?:get|post|put|patch|delete)\(\s*['"`][^'"`]*['"`]\s*,\s*)([\s\S]*?)((?:async\s*)?\(\s*req\s*,\s*res\s*(?:,\s*next\s*)?\)\s*=>)/g;
|
|
397
|
+
let changed = false;
|
|
398
|
+
const replaced = content.replace(handlerRe, (full, pathPart, middleware, handler) => {
|
|
399
|
+
if (/\bauth\s*,/.test(middleware)) return full; // already protected
|
|
400
|
+
changed = true;
|
|
401
|
+
return `${pathPart}auth, ${middleware}${handler}`;
|
|
402
|
+
});
|
|
403
|
+
if (!changed) return content;
|
|
404
|
+
return addImportToContent(replaced, "import auth from '../middleware/auth.js';");
|
|
405
|
+
}
|
|
406
|
+
|
|
409
407
|
// Anchor comment that marks the central error handler in index.js. Route
|
|
410
408
|
// registration looks for this so new routes are always inserted ABOVE it.
|
|
411
409
|
export const ERROR_HANDLER_MARKER = "// ── Error handling (must be last) ──";
|
|
@@ -960,7 +958,6 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
960
958
|
html+='<div class="row"><span class="resp-code">'+esc(code)+'</span><span class="detail-sum" style="margin:0">'+esc((resp[code]||{}).description||'')+'</span></div>';
|
|
961
959
|
});
|
|
962
960
|
html+='<div class="sec">Try it</div>';
|
|
963
|
-
html+='<div class="try-field"><label class="try-label">x-api-key</label><input class="f" id="'+id+'_apikey" placeholder="API key (saved for this browser)" /></div>';
|
|
964
961
|
pp.forEach(function(p){html+='<div class="try-field"><label class="try-label">'+esc(p.name)+' (path)</label><input class="f" data-kind="path" data-name="'+esc(p.name)+'" /></div>';});
|
|
965
962
|
qp.forEach(function(p){html+='<div class="try-field"><label class="try-label">'+esc(p.name)+' (query)</label><input class="f" data-kind="query" data-name="'+esc(p.name)+'" /></div>';});
|
|
966
963
|
if(e.op.security){html+='<div class="try-field"><label class="try-label">Authorization (Bearer token)</label><input class="f" id="'+id+'_auth" placeholder="token" /></div>';}
|
|
@@ -973,8 +970,6 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
973
970
|
|
|
974
971
|
function wireTry(e,id,root){
|
|
975
972
|
var btn=root.querySelector('#'+id+'_send');if(!btn)return;
|
|
976
|
-
var keyEl=root.querySelector('#'+id+'_apikey');
|
|
977
|
-
if(keyEl){ try{ keyEl.value=localStorage.getItem('docs_api_key')||''; }catch(err){} }
|
|
978
973
|
// Auto-grow the body textarea to fit its content (capped, then scrolls).
|
|
979
974
|
var bodyTa=root.querySelector('#'+id+'_body');
|
|
980
975
|
if(bodyTa){
|
|
@@ -989,7 +984,6 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
989
984
|
root.querySelectorAll('[data-kind=query]').forEach(function(inp){if(inp.value)qs.push(encodeURIComponent(inp.getAttribute('data-name'))+'='+encodeURIComponent(inp.value));});
|
|
990
985
|
if(qs.length)url+=(url.indexOf('?')>=0?'&':'?')+qs.join('&');
|
|
991
986
|
var opts={method:e.method.toUpperCase(),headers:{}};
|
|
992
|
-
if(keyEl&&keyEl.value){ opts.headers['x-api-key']=keyEl.value; try{ localStorage.setItem('docs_api_key',keyEl.value); }catch(err){} }
|
|
993
987
|
var auth=root.querySelector('#'+id+'_auth');if(auth&&auth.value)opts.headers['Authorization']='Bearer '+auth.value;
|
|
994
988
|
var bodyEl=root.querySelector('#'+id+'_body');
|
|
995
989
|
if(bodyEl&&['post','put','patch'].indexOf(e.method)>=0){opts.headers['Content-Type']='application/json';opts.body=bodyEl.value;}
|
|
@@ -1023,7 +1017,11 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1023
1017
|
document.getElementById('apiVersion').textContent='v'+((spec.info&&spec.info.version)||'1.0.0');
|
|
1024
1018
|
ITEMS=collect(spec);
|
|
1025
1019
|
var nav=document.getElementById('nav');nav.innerHTML='';
|
|
1026
|
-
if(!ITEMS.length){
|
|
1020
|
+
if(!ITEMS.length){
|
|
1021
|
+
nav.innerHTML='<div class="group-title">No endpoints</div>';
|
|
1022
|
+
document.getElementById('main').innerHTML='<div class="main-empty">No endpoints in the spec yet. Add routes, then regenerate the docs from the dashboard (API Docs).</div>';
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1027
1025
|
var groups={};ITEMS.forEach(function(e){var k=groupKey(e.path);(groups[k]=groups[k]||[]).push(e);});
|
|
1028
1026
|
var first=null;
|
|
1029
1027
|
Object.keys(groups).sort().forEach(function(k){
|
|
@@ -1046,9 +1044,20 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1046
1044
|
document.querySelectorAll('.nav-item').forEach(function(c){c.style.display=c.getAttribute('data-search').indexOf(q)>=0?'':'none';});
|
|
1047
1045
|
});
|
|
1048
1046
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1047
|
+
// The server inlines the spec below (so a protected page needs no second
|
|
1048
|
+
// authenticated request — avoids cross-browser cookie quirks on fetch). If it
|
|
1049
|
+
// isn't inlined (e.g. the page is served statically), fall back to fetching.
|
|
1050
|
+
var SPEC_DATA=/*__SPEC__*/null;
|
|
1051
|
+
if(SPEC_DATA&&typeof SPEC_DATA==='object'){
|
|
1052
|
+
render(SPEC_DATA);
|
|
1053
|
+
} else {
|
|
1054
|
+
fetch(SPEC_URL,{credentials:'same-origin'}).then(function(r){
|
|
1055
|
+
if(!r.ok){throw new Error('the server returned HTTP '+r.status+'. Regenerate the docs from the dashboard (API Docs).');}
|
|
1056
|
+
return r.json();
|
|
1057
|
+
}).then(render).catch(function(err){
|
|
1058
|
+
document.getElementById('main').innerHTML='<div class="main-empty">Could not load the API spec — '+esc(String((err&&err.message)||err))+'</div>';
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1052
1061
|
})();
|
|
1053
1062
|
</script>
|
|
1054
1063
|
</body>
|
|
@@ -1145,12 +1154,29 @@ router.use((req, res, next) => {
|
|
|
1145
1154
|
// Interactive API documentation. The page is fully self-contained (no external
|
|
1146
1155
|
// CDN); the spec is generated by 4bnode and served from this app.
|
|
1147
1156
|
router.get('/openapi.json', (req, res) => {
|
|
1157
|
+
res.set('Cache-Control', 'no-store');
|
|
1148
1158
|
if (!fs.existsSync(SPEC)) return res.status(404).json({ error: 'API spec not generated yet.' });
|
|
1149
1159
|
res.sendFile(SPEC);
|
|
1150
1160
|
});
|
|
1151
1161
|
|
|
1162
|
+
const DOCS_PAGE = ${JSON.stringify(buildDocsHtml("/docs/openapi.json"))};
|
|
1163
|
+
|
|
1152
1164
|
router.get('/', (req, res) => {
|
|
1153
|
-
|
|
1165
|
+
// Inline the spec into the page so a password-protected page doesn't need a
|
|
1166
|
+
// second authenticated request (some browsers won't attach the docs cookie to
|
|
1167
|
+
// the fetch). Falls back to fetching /openapi.json if no spec is inlined.
|
|
1168
|
+
let spec = 'null';
|
|
1169
|
+
try {
|
|
1170
|
+
if (fs.existsSync(SPEC)) {
|
|
1171
|
+
const raw = fs.readFileSync(SPEC, 'utf8').trim();
|
|
1172
|
+
// Escape '<' so a spec value containing '</script>' can't break out of the
|
|
1173
|
+
// inline <script>. The '\\u003c' sequence still evaluates to '<' in JS.
|
|
1174
|
+
if (raw) spec = raw.split('<').join(String.fromCharCode(92) + 'u003c');
|
|
1175
|
+
}
|
|
1176
|
+
} catch {}
|
|
1177
|
+
// Never cache the docs page — keeps it from going stale after a regenerate.
|
|
1178
|
+
res.set('Cache-Control', 'no-store');
|
|
1179
|
+
res.type('html').send(DOCS_PAGE.replace('/*__SPEC__*/null', () => spec));
|
|
1154
1180
|
});
|
|
1155
1181
|
|
|
1156
1182
|
export default router;
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
generateMongooseModel,
|
|
23
23
|
buildZodSchemaFile,
|
|
24
24
|
wireValidateIntoRouteContent,
|
|
25
|
+
buildAuthMiddleware,
|
|
26
|
+
wireAuthIntoRouteContent,
|
|
25
27
|
addImportAfterLastImport,
|
|
26
28
|
insertBeforeListenContent,
|
|
27
29
|
AI_PLAN_SCHEMA,
|
|
@@ -1184,7 +1186,6 @@ router.get('/api/status', (req, res) => {
|
|
|
1184
1186
|
rateLimit: indexContent.includes("from 'express-rate-limit'") || indexContent.includes('rateLimit('),
|
|
1185
1187
|
errorHandler: mwExists('errorHandler.js'),
|
|
1186
1188
|
roles: mwExists('roles.js'),
|
|
1187
|
-
apiKey: mwExists('apiKey.js'),
|
|
1188
1189
|
validation: mwExists('validate.js'),
|
|
1189
1190
|
};
|
|
1190
1191
|
|
|
@@ -1203,11 +1204,8 @@ router.get('/api/status', (req, res) => {
|
|
|
1203
1204
|
serialportConfigured,
|
|
1204
1205
|
security,
|
|
1205
1206
|
aiConfigured: !!getActiveAiKey(),
|
|
1206
|
-
jobsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'jobs', 'scheduler.js')),
|
|
1207
1207
|
docsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'docs.js')),
|
|
1208
|
-
|
|
1209
|
-
mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js')),
|
|
1210
|
-
testsConfigured: fs.existsSync(path.join(process.cwd(), 'vitest.config.js'))
|
|
1208
|
+
mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js'))
|
|
1211
1209
|
});
|
|
1212
1210
|
} catch (err) {
|
|
1213
1211
|
res.status(500).json({ error: err.message });
|
|
@@ -1215,38 +1213,8 @@ router.get('/api/status', (req, res) => {
|
|
|
1215
1213
|
});
|
|
1216
1214
|
|
|
1217
1215
|
// ---------------------------------------------------------------------------
|
|
1218
|
-
//
|
|
1219
|
-
// ships with the app (unlike .4bnode), and the apiKey middleware reads it live,
|
|
1220
|
-
// so generate/revoke take effect without a restart. Writing a .json file is not
|
|
1221
|
-
// watched by nodemon, so no restart is triggered here either.
|
|
1216
|
+
// Security configuration
|
|
1222
1217
|
// ---------------------------------------------------------------------------
|
|
1223
|
-
function apiKeysFilePath() {
|
|
1224
|
-
return path.join(process.cwd(), 'src', 'api-keys.json');
|
|
1225
|
-
}
|
|
1226
|
-
function readApiKeys() {
|
|
1227
|
-
try {
|
|
1228
|
-
const p = apiKeysFilePath();
|
|
1229
|
-
if (fs.existsSync(p)) {
|
|
1230
|
-
const list = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
1231
|
-
if (Array.isArray(list)) return list;
|
|
1232
|
-
}
|
|
1233
|
-
} catch {}
|
|
1234
|
-
return [];
|
|
1235
|
-
}
|
|
1236
|
-
function writeApiKeys(list) {
|
|
1237
|
-
const p = apiKeysFilePath();
|
|
1238
|
-
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1239
|
-
fs.writeFileSync(p, JSON.stringify(list, null, 2) + '\n', 'utf8');
|
|
1240
|
-
}
|
|
1241
|
-
function genApiKey(label) {
|
|
1242
|
-
return {
|
|
1243
|
-
id: crypto.randomBytes(8).toString('hex'),
|
|
1244
|
-
label: (String(label || '').trim() || 'Untitled').slice(0, 60),
|
|
1245
|
-
key: 'sk_' + crypto.randomBytes(24).toString('hex'),
|
|
1246
|
-
createdAt: new Date().toISOString(),
|
|
1247
|
-
};
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
1218
|
// GET/POST rate-limit config — stored in .env (read by index.js at startup).
|
|
1251
1219
|
router.get('/api/security/rate-limit', (req, res) => {
|
|
1252
1220
|
try {
|
|
@@ -1271,56 +1239,6 @@ router.post('/api/security/rate-limit', (req, res) => {
|
|
|
1271
1239
|
}
|
|
1272
1240
|
});
|
|
1273
1241
|
|
|
1274
|
-
// GET — list keys + whether the apiKey middleware is configured
|
|
1275
|
-
router.get('/api/security/api-keys', (req, res) => {
|
|
1276
|
-
try {
|
|
1277
|
-
const configured = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'apiKey.js'));
|
|
1278
|
-
res.json({ configured, keys: readApiKeys() });
|
|
1279
|
-
} catch (err) {
|
|
1280
|
-
res.status(500).json({ error: err.message });
|
|
1281
|
-
}
|
|
1282
|
-
});
|
|
1283
|
-
|
|
1284
|
-
// POST — generate a new named key
|
|
1285
|
-
router.post('/api/security/api-keys', (req, res) => {
|
|
1286
|
-
try {
|
|
1287
|
-
const list = readApiKeys();
|
|
1288
|
-
const key = genApiKey(req.body && req.body.label);
|
|
1289
|
-
list.push(key);
|
|
1290
|
-
writeApiKeys(list);
|
|
1291
|
-
res.json({ message: 'API key generated.', key });
|
|
1292
|
-
} catch (err) {
|
|
1293
|
-
res.status(500).json({ error: err.message });
|
|
1294
|
-
}
|
|
1295
|
-
});
|
|
1296
|
-
|
|
1297
|
-
// PATCH — rename a key
|
|
1298
|
-
router.patch('/api/security/api-keys/:id', (req, res) => {
|
|
1299
|
-
try {
|
|
1300
|
-
const list = readApiKeys();
|
|
1301
|
-
const key = list.find((k) => k.id === req.params.id);
|
|
1302
|
-
if (!key) return res.status(404).json({ error: 'Key not found.' });
|
|
1303
|
-
key.label = (String((req.body && req.body.label) || '').trim() || key.label).slice(0, 60);
|
|
1304
|
-
writeApiKeys(list);
|
|
1305
|
-
res.json({ message: 'API key renamed.', key });
|
|
1306
|
-
} catch (err) {
|
|
1307
|
-
res.status(500).json({ error: err.message });
|
|
1308
|
-
}
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
|
-
// DELETE — revoke a key
|
|
1312
|
-
router.delete('/api/security/api-keys/:id', (req, res) => {
|
|
1313
|
-
try {
|
|
1314
|
-
const before = readApiKeys();
|
|
1315
|
-
const after = before.filter((k) => k.id !== req.params.id);
|
|
1316
|
-
if (after.length === before.length) return res.status(404).json({ error: 'Key not found.' });
|
|
1317
|
-
writeApiKeys(after);
|
|
1318
|
-
res.json({ message: 'API key revoked.' });
|
|
1319
|
-
} catch (err) {
|
|
1320
|
-
res.status(500).json({ error: err.message });
|
|
1321
|
-
}
|
|
1322
|
-
});
|
|
1323
|
-
|
|
1324
1242
|
// ---------------------------------------------------------------------------
|
|
1325
1243
|
// AI Builder — describe a backend in English, generate models + CRUD routes
|
|
1326
1244
|
// ---------------------------------------------------------------------------
|
|
@@ -1564,12 +1482,164 @@ router.post('/api/ai/apply', async (req, res) => {
|
|
|
1564
1482
|
}
|
|
1565
1483
|
});
|
|
1566
1484
|
|
|
1485
|
+
// ---------------------------------------------------------------------------
|
|
1486
|
+
// API Documentation (OpenAPI spec + self-contained docs page at /docs)
|
|
1487
|
+
// ---------------------------------------------------------------------------
|
|
1488
|
+
function projectTitle() {
|
|
1489
|
+
try {
|
|
1490
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
|
|
1491
|
+
return pkg.name || 'API';
|
|
1492
|
+
} catch { return 'API'; }
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// All models → { name, fields[] } for the OpenAPI components/schemas.
|
|
1496
|
+
function collectModels() {
|
|
1497
|
+
const dir = path.join(process.cwd(), 'src', 'models');
|
|
1498
|
+
if (!fs.existsSync(dir)) return [];
|
|
1499
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.js')).map((f) => {
|
|
1500
|
+
const name = f.replace(/\.js$/, '');
|
|
1501
|
+
const detailed = getModelSchemaDetailed(name);
|
|
1502
|
+
return { name, fields: (detailed && detailed.fields) || [] };
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Parse every route file into endpoint descriptors (method, path, params, query,
|
|
1507
|
+
// body fields with model types) for the OpenAPI builder.
|
|
1508
|
+
function collectEndpoints() {
|
|
1509
|
+
const routesDir = path.join(process.cwd(), 'src', 'routes');
|
|
1510
|
+
if (!fs.existsSync(routesDir)) return [];
|
|
1511
|
+
const indexPath = path.join(process.cwd(), 'index.js');
|
|
1512
|
+
const indexContent = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
|
|
1513
|
+
const out = [];
|
|
1514
|
+
fs.readdirSync(routesDir).filter((f) => f.endsWith('.js')).forEach((file) => {
|
|
1515
|
+
const name = file.replace(/\.js$/, '');
|
|
1516
|
+
const content = fs.readFileSync(path.join(routesDir, file), 'utf8');
|
|
1517
|
+
|
|
1518
|
+
let prefix = '/api/' + name;
|
|
1519
|
+
const reg = indexContent.match(new RegExp("app\\.use\\(['\"]([^'\"]+)['\"]\\s*,\\s*\\w+Router\\s*\\)[^\\n]*" + name, 'i'))
|
|
1520
|
+
|| indexContent.match(new RegExp("app\\.use\\(['\"]([^'\"]+)['\"]\\s*,\\s*" + toCamelCase(name) + "Router"));
|
|
1521
|
+
if (reg) prefix = reg[1];
|
|
1522
|
+
|
|
1523
|
+
const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
|
|
1524
|
+
const modelFields = modelImportMatch ? (getModelSchemaDetailed(modelImportMatch[2]).fields || []) : [];
|
|
1525
|
+
|
|
1526
|
+
const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*([\s\S]*?)(?=\nrouter\.|\nexport |$)/gi;
|
|
1527
|
+
let m;
|
|
1528
|
+
while ((m = epRegex.exec(content)) !== null) {
|
|
1529
|
+
const method = m[1].toUpperCase();
|
|
1530
|
+
const epath = m[2];
|
|
1531
|
+
const body = m[3] || '';
|
|
1532
|
+
const bodyFields = extractRequestFields(body, 'body').map((fn) => {
|
|
1533
|
+
const meta = modelFields.find((x) => x.name === fn);
|
|
1534
|
+
return { name: fn, type: (meta && meta.type) || 'String', required: !!(meta && meta.required) };
|
|
1535
|
+
});
|
|
1536
|
+
const queryFields = extractRequestFields(body, 'query').map((fn) => ({ name: fn, type: 'String' }));
|
|
1537
|
+
const params = (epath.match(/:(\w+)/g) || []).map((p) => p.slice(1));
|
|
1538
|
+
const hasAuth = /authorization|jwt|bearer|x-api-key/i.test(body);
|
|
1539
|
+
const fullPath = (prefix + (epath === '/' ? '' : epath)).replace(/\/{2,}/g, '/');
|
|
1540
|
+
out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields: [], hasAuth });
|
|
1541
|
+
}
|
|
1542
|
+
});
|
|
1543
|
+
return out;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function generateSpecFile() {
|
|
1547
|
+
const spec = buildOpenApiSpec({ title: projectTitle(), endpoints: collectEndpoints(), models: collectModels() });
|
|
1548
|
+
fs.writeFileSync(path.join(process.cwd(), 'src', 'openapi.json'), JSON.stringify(spec, null, 2), 'utf8');
|
|
1549
|
+
return spec;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Keep the API docs in sync automatically: whenever routes or models change, the
|
|
1553
|
+
// spec is regenerated — but only if docs have been set up. Writing openapi.json is
|
|
1554
|
+
// a .json write, so it doesn't trigger a nodemon restart.
|
|
1555
|
+
function refreshDocsSpec() {
|
|
1556
|
+
try {
|
|
1557
|
+
if (fs.existsSync(path.join(process.cwd(), 'src', 'docs.js'))) generateSpecFile();
|
|
1558
|
+
} catch {}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const docsAuthFile = () => path.join(process.cwd(), 'src', 'docs-auth.json');
|
|
1562
|
+
|
|
1563
|
+
// GET /api/docs — setup status + endpoint count + whether docs are password-protected
|
|
1564
|
+
router.get('/api/docs', (req, res) => {
|
|
1565
|
+
try {
|
|
1566
|
+
const configured = fs.existsSync(path.join(process.cwd(), 'src', 'docs.js'));
|
|
1567
|
+
res.json({ configured, endpointCount: collectEndpoints().length, passwordProtected: fs.existsSync(docsAuthFile()) });
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
res.status(500).json({ error: err.message });
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
// POST /api/docs/setup — create the docs route, generate the spec, wire index.js
|
|
1574
|
+
router.post('/api/docs/setup', (req, res) => {
|
|
1575
|
+
try {
|
|
1576
|
+
// Writing src/docs.js and index.js triggers a nodemon restart (it watches .js
|
|
1577
|
+
// files). Respond FIRST so the restart can't drop this request, THEN write.
|
|
1578
|
+
res.json({ message: 'API docs set up at /docs.', endpointCount: collectEndpoints().length });
|
|
1579
|
+
fs.writeFileSync(path.join(process.cwd(), 'src', 'docs.js'), buildDocsRouter(), 'utf8');
|
|
1580
|
+
generateSpecFile();
|
|
1581
|
+
addImportToIndex("import docsRouter from './src/docs.js';");
|
|
1582
|
+
const reg = "app.use('/docs', docsRouter);";
|
|
1583
|
+
if (!readIndexFile().includes(reg)) insertBeforeListen(reg);
|
|
1584
|
+
} catch (err) {
|
|
1585
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
// POST /api/docs/regenerate — rebuild the spec + refresh the docs page
|
|
1590
|
+
router.post('/api/docs/regenerate', (req, res) => {
|
|
1591
|
+
try {
|
|
1592
|
+
const docsPath = path.join(process.cwd(), 'src', 'docs.js');
|
|
1593
|
+
if (!fs.existsSync(docsPath)) {
|
|
1594
|
+
return res.status(400).json({ error: 'Set up API docs first.' });
|
|
1595
|
+
}
|
|
1596
|
+
res.json({ message: 'API spec regenerated.', endpointCount: collectEndpoints().length });
|
|
1597
|
+
generateSpecFile();
|
|
1598
|
+
fs.writeFileSync(docsPath, buildDocsRouter(), 'utf8');
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
// POST /api/docs/password — protect /docs with a password (single password, no username)
|
|
1605
|
+
router.post('/api/docs/password', (req, res) => {
|
|
1606
|
+
try {
|
|
1607
|
+
const password = String((req.body && req.body.password) || '');
|
|
1608
|
+
if (password.length < 6) {
|
|
1609
|
+
return res.status(400).json({ error: 'Password must be at least 6 characters.' });
|
|
1610
|
+
}
|
|
1611
|
+
const hash = crypto.createHash('sha256').update(password).digest('hex');
|
|
1612
|
+
fs.mkdirSync(path.join(process.cwd(), 'src'), { recursive: true });
|
|
1613
|
+
fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash }, null, 2) + '\n', 'utf8');
|
|
1614
|
+
const docsPath = path.join(process.cwd(), 'src', 'docs.js');
|
|
1615
|
+
const needsUpgrade = fs.existsSync(docsPath) && !fs.readFileSync(docsPath, 'utf8').includes('docs_auth');
|
|
1616
|
+
res.json({
|
|
1617
|
+
message: needsUpgrade
|
|
1618
|
+
? 'Docs password set — updating the docs route (server will restart).'
|
|
1619
|
+
: 'Docs are now password-protected.',
|
|
1620
|
+
});
|
|
1621
|
+
if (needsUpgrade) fs.writeFileSync(docsPath, buildDocsRouter(), 'utf8');
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
// DELETE /api/docs/password — remove protection (docs become open again)
|
|
1628
|
+
router.delete('/api/docs/password', (req, res) => {
|
|
1629
|
+
try {
|
|
1630
|
+
if (fs.existsSync(docsAuthFile())) fs.unlinkSync(docsAuthFile());
|
|
1631
|
+
res.json({ message: 'Docs password removed.' });
|
|
1632
|
+
} catch (err) {
|
|
1633
|
+
res.status(500).json({ error: err.message });
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1567
1637
|
// ---------------------------------------------------------------------------
|
|
1568
1638
|
// POST /api/add-api
|
|
1569
1639
|
// ---------------------------------------------------------------------------
|
|
1570
1640
|
router.post('/api/add-api', async (req, res) => {
|
|
1571
1641
|
try {
|
|
1572
|
-
const { name, pathPrefix = '/api/', routeType, methods = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [] } = req.body;
|
|
1642
|
+
const { name, pathPrefix = '/api/', routeType, methods = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [], authRequired = false } = req.body;
|
|
1573
1643
|
// Legacy support: single method field
|
|
1574
1644
|
const method = req.body.method;
|
|
1575
1645
|
|
|
@@ -1596,6 +1666,7 @@ router.post('/api/add-api', async (req, res) => {
|
|
|
1596
1666
|
const allFieldsCombined = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
|
|
1597
1667
|
const needsMulter = allFieldsCombined.some(f => f.type === 'File');
|
|
1598
1668
|
if (needsMulter) packagesToInstall.push('multer');
|
|
1669
|
+
if (authRequired && type !== 'login' && type !== 'register') packagesToInstall.push('jsonwebtoken');
|
|
1599
1670
|
if (packagesToInstall.length > 0) {
|
|
1600
1671
|
recordDeps(packagesToInstall);
|
|
1601
1672
|
await execAsync(`npm install ${packagesToInstall.join(' ')}`, { cwd: process.cwd() });
|
|
@@ -1930,6 +2001,21 @@ export default router;
|
|
|
1930
2001
|
content = wireValidateIntoRouteContent(content, safeName);
|
|
1931
2002
|
}
|
|
1932
2003
|
|
|
2004
|
+
// Require JWT auth on this route: ensure the auth middleware + JWT_SECRET exist,
|
|
2005
|
+
// then protect every endpoint. Login/register are exempt (they issue tokens).
|
|
2006
|
+
if (authRequired && type !== 'login' && type !== 'register') {
|
|
2007
|
+
const authPath = path.join(process.cwd(), 'src', 'middleware', 'auth.js');
|
|
2008
|
+
if (!fs.existsSync(authPath)) {
|
|
2009
|
+
fs.mkdirSync(path.dirname(authPath), { recursive: true });
|
|
2010
|
+
fs.writeFileSync(authPath, buildAuthMiddleware(), 'utf8');
|
|
2011
|
+
}
|
|
2012
|
+
const envFile = path.join(process.cwd(), '.env');
|
|
2013
|
+
if (!getEnvValue(envFile, 'JWT_SECRET')) {
|
|
2014
|
+
updateEnvFile(envFile, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
|
|
2015
|
+
}
|
|
2016
|
+
content = wireAuthIntoRouteContent(content);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1933
2019
|
fs.writeFileSync(routeFilePath, content, 'utf8');
|
|
1934
2020
|
|
|
1935
2021
|
const label = type === 'methods' ? methodList.map(m => m.toUpperCase()).join(', ') : type === 'crud' ? 'CRUD (' + (methodList.length > 0 ? methodList.map(m => m.toUpperCase()).join(', ') : 'ALL') + ')' : type === 'plain' ? 'PLAIN' : type.toUpperCase();
|