4bnode 4.1.2 → 4.1.4

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 (62) hide show
  1. package/add-security.js +0 -18
  2. package/app-1/.4bnode/.passkey +1 -0
  3. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  4. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  5. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  6. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  7. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  8. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  9. package/app-1/.4bnode/assets/js/App.js +139 -0
  10. package/app-1/.4bnode/assets/js/Root.js +40 -0
  11. package/app-1/.4bnode/assets/js/components/Modal.js +52 -0
  12. package/app-1/.4bnode/assets/js/components/Sidebar.js +73 -0
  13. package/app-1/.4bnode/assets/js/components/ToastContainer.js +15 -0
  14. package/app-1/.4bnode/assets/js/context.js +7 -0
  15. package/app-1/.4bnode/assets/js/lib.js +113 -0
  16. package/app-1/.4bnode/assets/js/main.js +5 -0
  17. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
  18. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +1879 -0
  19. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +435 -0
  20. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +300 -0
  21. package/app-1/.4bnode/assets/js/pages/DocsPage.js +150 -0
  22. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +103 -0
  23. package/app-1/.4bnode/assets/js/pages/LogsPage.js +205 -0
  24. package/app-1/.4bnode/assets/js/pages/MailPage.js +134 -0
  25. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +186 -0
  26. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +1436 -0
  27. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +838 -0
  28. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +74 -0
  29. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
  30. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
  31. package/app-1/.4bnode/codegen.js +1249 -0
  32. package/app-1/.4bnode/dev-api.js +3621 -0
  33. package/app-1/.4bnode/ui.html +1578 -0
  34. package/app-1/README.md +47 -0
  35. package/app-1/ecosystem.config.cjs +25 -0
  36. package/app-1/index.js +145 -0
  37. package/app-1/package.json +35 -0
  38. package/app-1/public/index.html +292 -0
  39. package/app-1/src/db.js +13 -0
  40. package/app-1/src/docs.js +84 -0
  41. package/app-1/src/middleware/errorHandler.js +15 -0
  42. package/app-1/src/middleware/roles.js +14 -0
  43. package/app-1/src/middleware/validate.js +20 -0
  44. package/app-1/src/models/login.js +9 -0
  45. package/app-1/src/openapi.json +62 -0
  46. package/app-1/src/routes/products.js +26 -0
  47. package/app-1/src/validators/products.js +7 -0
  48. package/app-1/src/validators/user.js +7 -0
  49. package/index.js +2 -17
  50. package/lib/codegen.js +91 -44
  51. package/lib/codegen.test.js +23 -3
  52. package/package.json +1 -1
  53. package/skeleton/.4bnode/assets/js/App.js +0 -2
  54. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -3
  55. package/skeleton/.4bnode/assets/js/lib.js +0 -3
  56. package/skeleton/.4bnode/assets/js/pages/DocsPage.js +29 -21
  57. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +12 -0
  58. package/skeleton/.4bnode/codegen.js +91 -44
  59. package/skeleton/.4bnode/dev-api.js +173 -87
  60. package/skeleton/_gitignore +0 -1
  61. package/skeleton/index.js +4 -8
  62. package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +0 -164
@@ -29,7 +29,7 @@ export function Sidebar() {
29
29
  lockMsg: "Requires database",
30
30
  },
31
31
  { id: "routes", label: "API Routes", icon: Icons.route },
32
- { id: "docs", label: "API Docs", icon: Icons.docs },
32
+ // { id: "docs", label: "API Docs", icon: Icons.docs }, // hidden for now
33
33
  { id: "api-tester", label: "API Tester", icon: Icons.apiTester },
34
34
  {
35
35
  id: "security",
@@ -37,10 +37,9 @@ export function Sidebar() {
37
37
  icon: Icons.shield,
38
38
  section: "Features",
39
39
  },
40
- { id: "api-keys", label: "API Keys", icon: Icons.apiKeys },
41
40
  { id: "realtime", label: "Real-time", icon: Icons.realtime },
42
41
  { id: "serialport", label: "SerialPort", icon: Icons.serial },
43
- { id: "mail", label: "Email", icon: Icons.mail },
42
+ // { id: "mail", label: "Email", icon: Icons.mail }, // hidden for now
44
43
  { id: "logs", label: "Logs", icon: Icons.logs, section: "Settings" },
45
44
  { id: "env", label: "Environment", icon: Icons.env },
46
45
  ];
@@ -23,7 +23,6 @@ import {
23
23
  LuSparkles,
24
24
  LuBookOpen,
25
25
  LuMail,
26
- LuKeyRound,
27
26
  } from "react-icons/lu";
28
27
 
29
28
  export const html = htm.bind(React.createElement);
@@ -88,7 +87,6 @@ export const Icons = {
88
87
  serial: html`<${LuCable} size=${16} />`,
89
88
  zap: html`<${LuZap} size=${16} />`,
90
89
  shield: html`<${LuShield} size=${16} />`,
91
- apiKeys: html`<${LuKeyRound} size=${16} />`,
92
90
  box: html`<${LuBox} size=${16} />`,
93
91
  apiTester: html`<${LuFlaskConical} size=${16} />`,
94
92
  send: html`<${LuSend} size=${16} />`,
@@ -106,7 +104,6 @@ export const PAGE_TITLES = {
106
104
  routes: "API Routes",
107
105
  docs: "API Docs",
108
106
  security: "Security",
109
- "api-keys": "API Keys",
110
107
  "api-tester": "API Tester",
111
108
  realtime: "Real-time",
112
109
  serialport: "SerialPort",
@@ -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, showConfirm } = useApp();
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
- try { setInfo(await api("/docs")); } catch (err) { toast(err.message, "error"); }
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
- const r = await api("/docs/setup", { method: "POST" });
28
- toast(r.message, "success");
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">OpenAPI 3 spec + interactive Swagger UI, generated from your routes</div>
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">Adds a /docs route (Swagger UI) and generates the OpenAPI spec from your ${info.endpointCount} endpoint${info.endpointCount === 1 ? "" : "s"}.</div>
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 HTTP Basic Auth (applies immediately, no restart).
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/apiKey.js — static API key auth via x-api-key header.
278
- export function buildApiKeyFile() {
279
- return `// API key authentication. Named keys are managed by the 4bnode dashboard and
280
- // stored in src/api-keys.json. Send a key in the \`x-api-key\` request header:
281
- // import apiKey from './src/middleware/apiKey.js';
282
- // app.use('/api/private', apiKey);
283
- import fs from 'fs';
284
- import path from 'path';
285
- import { fileURLToPath } from 'url';
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
- return _cache.keys;
303
- }
304
-
305
- const apiKey = (req, res, next) => {
306
- const provided = req.header('x-api-key');
307
- const valid = loadKeys();
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 apiKey;
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) ──";
@@ -421,7 +419,28 @@ export function securityWiring(features) {
421
419
 
422
420
  if (has("helmet")) {
423
421
  imports.push("import helmet from 'helmet';");
424
- beforeRoutesParts.push("// ── Security headers ──\napp.use(helmet());");
422
+ beforeRoutesParts.push(
423
+ `// ── Security headers ──
424
+ // helmet() defaults to a strict Content-Security-Policy (script-src 'self',
425
+ // etc.) that blocks CDN-loaded scripts, inline <script> blocks, and cross-origin
426
+ // assets. We widen the policy so a typical frontend/docs page works while keeping
427
+ // the rest of helmet's protections. Tighten these directives for production.
428
+ app.use(helmet({
429
+ contentSecurityPolicy: {
430
+ directives: {
431
+ defaultSrc: ["'self'"],
432
+ scriptSrc: ["'self'", 'https:', "'unsafe-inline'"],
433
+ styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
434
+ imgSrc: ["'self'", 'data:', 'https:'],
435
+ connectSrc: ["'self'"],
436
+ },
437
+ },
438
+ crossOriginEmbedderPolicy: false,
439
+ // Allow other origins (e.g. a separate frontend host) to load assets served
440
+ // by this app. Default is 'same-origin'. Tighten to 'same-site' for production.
441
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
442
+ }));`
443
+ );
425
444
  }
426
445
  if (has("rateLimit")) {
427
446
  imports.push("import rateLimit from 'express-rate-limit';");
@@ -960,7 +979,6 @@ export function buildDocsHtml(specUrl = "openapi.json") {
960
979
  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
980
  });
962
981
  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
982
  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
983
  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
984
  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 +991,6 @@ export function buildDocsHtml(specUrl = "openapi.json") {
973
991
 
974
992
  function wireTry(e,id,root){
975
993
  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
994
  // Auto-grow the body textarea to fit its content (capped, then scrolls).
979
995
  var bodyTa=root.querySelector('#'+id+'_body');
980
996
  if(bodyTa){
@@ -989,7 +1005,6 @@ export function buildDocsHtml(specUrl = "openapi.json") {
989
1005
  root.querySelectorAll('[data-kind=query]').forEach(function(inp){if(inp.value)qs.push(encodeURIComponent(inp.getAttribute('data-name'))+'='+encodeURIComponent(inp.value));});
990
1006
  if(qs.length)url+=(url.indexOf('?')>=0?'&':'?')+qs.join('&');
991
1007
  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
1008
  var auth=root.querySelector('#'+id+'_auth');if(auth&&auth.value)opts.headers['Authorization']='Bearer '+auth.value;
994
1009
  var bodyEl=root.querySelector('#'+id+'_body');
995
1010
  if(bodyEl&&['post','put','patch'].indexOf(e.method)>=0){opts.headers['Content-Type']='application/json';opts.body=bodyEl.value;}
@@ -1023,7 +1038,11 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1023
1038
  document.getElementById('apiVersion').textContent='v'+((spec.info&&spec.info.version)||'1.0.0');
1024
1039
  ITEMS=collect(spec);
1025
1040
  var nav=document.getElementById('nav');nav.innerHTML='';
1026
- if(!ITEMS.length){nav.innerHTML='<div class="group-title">No endpoints</div>';return;}
1041
+ if(!ITEMS.length){
1042
+ nav.innerHTML='<div class="group-title">No endpoints</div>';
1043
+ 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>';
1044
+ return;
1045
+ }
1027
1046
  var groups={};ITEMS.forEach(function(e){var k=groupKey(e.path);(groups[k]=groups[k]||[]).push(e);});
1028
1047
  var first=null;
1029
1048
  Object.keys(groups).sort().forEach(function(k){
@@ -1046,9 +1065,20 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1046
1065
  document.querySelectorAll('.nav-item').forEach(function(c){c.style.display=c.getAttribute('data-search').indexOf(q)>=0?'':'none';});
1047
1066
  });
1048
1067
 
1049
- fetch(SPEC_URL).then(function(r){return r.json();}).then(render).catch(function(){
1050
- document.getElementById('main').innerHTML='<div class="main-empty">Could not load the API spec.</div>';
1051
- });
1068
+ // The server inlines the spec below (so a protected page needs no second
1069
+ // authenticated request — avoids cross-browser cookie quirks on fetch). If it
1070
+ // isn't inlined (e.g. the page is served statically), fall back to fetching.
1071
+ var SPEC_DATA=/*__SPEC__*/null;
1072
+ if(SPEC_DATA&&typeof SPEC_DATA==='object'){
1073
+ render(SPEC_DATA);
1074
+ } else {
1075
+ fetch(SPEC_URL,{credentials:'same-origin'}).then(function(r){
1076
+ if(!r.ok){throw new Error('the server returned HTTP '+r.status+'. Regenerate the docs from the dashboard (API Docs).');}
1077
+ return r.json();
1078
+ }).then(render).catch(function(err){
1079
+ document.getElementById('main').innerHTML='<div class="main-empty">Could not load the API spec &mdash; '+esc(String((err&&err.message)||err))+'</div>';
1080
+ });
1081
+ }
1052
1082
  })();
1053
1083
  </script>
1054
1084
  </body>
@@ -1145,12 +1175,29 @@ router.use((req, res, next) => {
1145
1175
  // Interactive API documentation. The page is fully self-contained (no external
1146
1176
  // CDN); the spec is generated by 4bnode and served from this app.
1147
1177
  router.get('/openapi.json', (req, res) => {
1178
+ res.set('Cache-Control', 'no-store');
1148
1179
  if (!fs.existsSync(SPEC)) return res.status(404).json({ error: 'API spec not generated yet.' });
1149
1180
  res.sendFile(SPEC);
1150
1181
  });
1151
1182
 
1183
+ const DOCS_PAGE = ${JSON.stringify(buildDocsHtml("/docs/openapi.json"))};
1184
+
1152
1185
  router.get('/', (req, res) => {
1153
- res.type('html').send(${JSON.stringify(buildDocsHtml("/docs/openapi.json"))});
1186
+ // Inline the spec into the page so a password-protected page doesn't need a
1187
+ // second authenticated request (some browsers won't attach the docs cookie to
1188
+ // the fetch). Falls back to fetching /openapi.json if no spec is inlined.
1189
+ let spec = 'null';
1190
+ try {
1191
+ if (fs.existsSync(SPEC)) {
1192
+ const raw = fs.readFileSync(SPEC, 'utf8').trim();
1193
+ // Escape '<' so a spec value containing '</script>' can't break out of the
1194
+ // inline <script>. The '\\u003c' sequence still evaluates to '<' in JS.
1195
+ if (raw) spec = raw.split('<').join(String.fromCharCode(92) + 'u003c');
1196
+ }
1197
+ } catch {}
1198
+ // Never cache the docs page — keeps it from going stale after a regenerate.
1199
+ res.set('Cache-Control', 'no-store');
1200
+ res.type('html').send(DOCS_PAGE.replace('/*__SPEC__*/null', () => spec));
1154
1201
  });
1155
1202
 
1156
1203
  export default router;