4bnode 4.1.4 → 4.1.5

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 (34) hide show
  1. package/add-docs.js +18 -5
  2. package/app-1/.4bnode/.passkey +1 -1
  3. package/app-1/.4bnode/assets/js/components/Sidebar.js +2 -2
  4. package/app-1/.4bnode/assets/js/pages/MailPage.js +144 -54
  5. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +51 -2
  6. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +1 -1
  7. package/app-1/.4bnode/codegen.js +247 -56
  8. package/app-1/.4bnode/dev-api.js +131 -5
  9. package/app-1/.env.example +11 -0
  10. package/app-1/index.js +49 -6
  11. package/app-1/package.json +2 -0
  12. package/app-1/src/docs.js +1 -1
  13. package/app-1/src/middleware/apiKey.js +36 -0
  14. package/app-1/src/middleware/auth.js +17 -0
  15. package/app-1/src/models/user.js +11 -0
  16. package/app-1/src/openapi.json +23 -5
  17. package/app-1/src/routes/upload-resume.js +21 -0
  18. package/app-1/src/services/mailer.js +52 -0
  19. package/app-1/src/validators/upload-resume.js +8 -0
  20. package/lib/codegen.js +225 -55
  21. package/lib/codegen.test.js +35 -0
  22. package/package.json +1 -1
  23. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
  24. package/skeleton/.4bnode/assets/js/pages/MailPage.js +144 -54
  25. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
  26. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
  27. package/skeleton/.4bnode/codegen.js +225 -55
  28. package/skeleton/.4bnode/dev-api.js +131 -5
  29. package/skeleton/.env +6 -0
  30. package/skeleton/.env.example +11 -0
  31. package/app-1/src/models/login.js +0 -9
  32. package/app-1/src/routes/products.js +0 -26
  33. package/app-1/src/validators/products.js +0 -7
  34. package/app-1/src/validators/user.js +0 -7
package/lib/codegen.js CHANGED
@@ -363,6 +363,56 @@ export default ${camel}Schema;
363
363
  `;
364
364
  }
365
365
 
366
+ // Parse a Zod validator's source into a { fieldName: isRequired } map for the
367
+ // top-level fields of its `z.object({ ... })`. A field is "required" for the API
368
+ // when its Zod chain has no `.optional()`/`.nullish()` — i.e. the request is
369
+ // rejected without it. This is the right source of truth for OpenAPI `required`,
370
+ // because the validator (not the Mongoose model) is what actually gates a request:
371
+ // a field can be optional in the DB schema yet required by the API, or vice versa.
372
+ // Returns null if no `z.object({...})` is found (caller should fall back to the model).
373
+ export function requiredFieldsFromZod(src) {
374
+ if (!src || typeof src !== "string") return null;
375
+ const objIdx = src.indexOf("z.object(");
376
+ if (objIdx === -1) return null;
377
+ const braceStart = src.indexOf("{", objIdx);
378
+ if (braceStart === -1) return null;
379
+ // Walk balanced brackets so nested objects/arrays/calls don't end the block early.
380
+ let depth = 0;
381
+ let end = -1;
382
+ for (let i = braceStart; i < src.length; i++) {
383
+ const c = src[i];
384
+ if (c === "{" || c === "(" || c === "[") depth++;
385
+ else if (c === "}" || c === ")" || c === "]") {
386
+ depth--;
387
+ if (depth === 0 && c === "}") { end = i; break; }
388
+ }
389
+ }
390
+ if (end === -1) return null;
391
+ const inner = src.slice(braceStart + 1, end);
392
+ const map = {};
393
+ const flush = (entry) => {
394
+ const colon = entry.indexOf(":");
395
+ if (colon === -1) return;
396
+ const key = entry.slice(0, colon).trim().replace(/^['"]|['"]$/g, "");
397
+ if (!/^[A-Za-z_$][\w$]*$/.test(key)) return;
398
+ const expr = entry.slice(colon + 1);
399
+ // .nullable() still requires the key (value may be null), so it doesn't count.
400
+ map[key] = !/\.(optional|nullish)\s*\(\s*\)/.test(expr);
401
+ };
402
+ // Split top-level "key: expr" entries on depth-0 commas.
403
+ let d = 0;
404
+ let cur = "";
405
+ for (let i = 0; i < inner.length; i++) {
406
+ const c = inner[i];
407
+ if (c === "{" || c === "(" || c === "[") d++;
408
+ else if (c === "}" || c === ")" || c === "]") d--;
409
+ if (c === "," && d === 0) { flush(cur); cur = ""; }
410
+ else cur += c;
411
+ }
412
+ if (cur.trim()) flush(cur);
413
+ return Object.keys(map).length ? map : null;
414
+ }
415
+
366
416
  // Wire `validate(<model>Schema)` into the write endpoints of a route file, and add
367
417
  // the two needed imports. POST/PUT use the full schema; PATCH uses
368
418
  // `<schema>.partial()` so partial updates aren't forced to send every required
@@ -446,13 +496,29 @@ app.use(helmet({
446
496
  imports.push("import rateLimit from 'express-rate-limit';");
447
497
  beforeRoutesParts.push(
448
498
  `// ── Rate limiting ──
499
+ // Generous global cap — high enough that real frontends (which fire many calls
500
+ // per page) never notice, low enough to stop scraping/abuse at scale.
501
+ // Window + cap are read from .env (RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX) so the
502
+ // dashboard's Security page can tune them; fall back to 15 min / 500 requests.
449
503
  const apiLimiter = rateLimit({
504
+ windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
505
+ max: Number(process.env.RATE_LIMIT_MAX) || 500,
506
+ standardHeaders: true,
507
+ legacyHeaders: false,
508
+ });
509
+ // Strict cap for authentication routes — defends against password brute-forcing.
510
+ // Point this at your real auth routes (login / signup / password-reset).
511
+ const authLimiter = rateLimit({
450
512
  windowMs: 15 * 60 * 1000, // 15 minutes
451
- max: 100, // limit each IP to 100 requests per window
513
+ max: 10, // 10 attempts per IP per window
452
514
  standardHeaders: true,
453
515
  legacyHeaders: false,
454
516
  });
455
- app.use(apiLimiter);`
517
+ // Only throttle in production — in dev, repeated/hot-reload requests shouldn't hit 429.
518
+ if (process.env.NODE_ENV === 'production') {
519
+ app.use('/api/auth', authLimiter); // strict limiter first, on auth routes
520
+ app.use(apiLimiter); // generous global limiter for everything else
521
+ }`
456
522
  );
457
523
  }
458
524
  if (has("errorHandler")) {
@@ -729,6 +795,7 @@ export const PACKAGE_VERSIONS = {
729
795
  "@serialport/parser-readline": "^12.0.0",
730
796
  mongoose: "^8.8.0",
731
797
  multer: "^1.4.5-lts.1",
798
+ nodemailer: "^6.9.0",
732
799
  };
733
800
 
734
801
  // Add packages to package.json (dependencies, or devDependencies when dev:true).
@@ -780,6 +847,30 @@ export function extractRequestFields(handlerBody, source = "body") {
780
847
  return names;
781
848
  }
782
849
 
850
+ // Detect multer upload fields in a route handler so the OpenAPI builder can mark
851
+ // the body as multipart/form-data with the right file fields. Handles:
852
+ // upload.single('avatar') -> [{name:'avatar'}]
853
+ // upload.array('photos', 5) -> [{name:'photos'}]
854
+ // upload.fields([{ name: 'a' }, { name: 'b' }]) -> [{name:'a'},{name:'b'}]
855
+ export function extractFileFields(handlerBody) {
856
+ const body = handlerBody || "";
857
+ const out = [];
858
+ const seen = new Set();
859
+ const add = (name) => { if (name && !seen.has(name)) { seen.add(name); out.push({ name }); } };
860
+ (body.match(/upload\.(?:single|array)\(\s*['"]([^'"]+)['"]/g) || []).forEach((s) => {
861
+ const m = s.match(/['"]([^'"]+)['"]/);
862
+ if (m) add(m[1]);
863
+ });
864
+ const fieldsCall = body.match(/upload\.fields\(\s*\[([\s\S]*?)\]/);
865
+ if (fieldsCall) {
866
+ (fieldsCall[1].match(/name\s*:\s*['"]([^'"]+)['"]/g) || []).forEach((n) => {
867
+ const m = n.match(/['"]([^'"]+)['"]/);
868
+ if (m) add(m[1]);
869
+ });
870
+ }
871
+ return out;
872
+ }
873
+
783
874
  // ─────────────────────────────────────────────────────────────────────────────
784
875
  // API Documentation — OpenAPI 3 spec + Swagger UI (served at /docs).
785
876
  // Reuses extractRequestFields output (endpoints) and model field metadata.
@@ -825,7 +916,9 @@ export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints =
825
916
  const properties = {};
826
917
  const required = [];
827
918
  bodyFields.forEach((f) => { properties[f.name] = openApiType(f.type); if (f.required) required.push(f.name); });
828
- fileFields.forEach((f) => { properties[f.name] = { type: "string", format: "binary" }; });
919
+ // Uploaded files are required by default (an upload endpoint exists to receive
920
+ // the file). A file field can opt out with required:false.
921
+ fileFields.forEach((f) => { properties[f.name] = { type: "string", format: "binary" }; if (f.required !== false) required.push(f.name); });
829
922
  const schema = { type: "object", properties };
830
923
  if (required.length) schema.required = required;
831
924
  const mime = fileFields.length ? "multipart/form-data" : "application/json";
@@ -912,21 +1005,19 @@ export function buildDocsHtml(specUrl = "openapi.json") {
912
1005
  .req { color:#ef4444; font-size:11px; font-weight:700; }
913
1006
  .opt { color:var(--faint); font-size:11px; }
914
1007
  .resp-code { font-family:ui-monospace,Menlo,monospace; color:#22c55e; font-weight:700; }
915
- input.f, textarea.f { width:100%; max-width:540px; background:var(--input); border:1px solid var(--border);
916
- color:var(--text); border-radius:8px; padding:8px 10px; font-size:13px; outline:none;
1008
+ .tabs { display:flex; gap:4px; flex-wrap:wrap; }
1009
+ .tab { background:var(--input); color:var(--muted); border:1px solid var(--border); border-bottom:none;
1010
+ border-radius:8px 8px 0 0; padding:6px 13px; font-size:12px; cursor:pointer; }
1011
+ .tab:hover { color:var(--text); }
1012
+ .tab.active { background:#0a0c12; color:var(--accent-light); }
1013
+ .code-wrap { position:relative; max-width:560px; }
1014
+ pre.code { background:#0a0c12; border:1px solid var(--border); border-radius:0 8px 8px 8px; padding:14px;
1015
+ font-size:12.5px; color:#d5d8e3; overflow:auto; white-space:pre; margin:0;
917
1016
  font-family:ui-monospace,Menlo,monospace; }
918
- input.f:focus, textarea.f:focus { border-color:var(--accent); }
919
- textarea.f { min-height:110px; resize:vertical; }
920
- .try-field { margin-bottom:10px; }
921
- .try-label { font-size:12px; color:var(--muted); margin-bottom:4px; display:block; }
922
- .btn { background:var(--accent); color:#fff; border:none; border-radius:8px; padding:9px 18px;
923
- font-size:13px; font-weight:600; cursor:pointer; }
924
- .btn:hover { filter:brightness(1.08); }
925
- .btn:disabled { opacity:.5; cursor:not-allowed; }
926
- pre.out { background:#0a0c12; border:1px solid var(--border); border-radius:8px; padding:12px;
927
- font-size:12.5px; color:#d5d8e3; overflow:auto; max-height:360px; max-width:540px; white-space:pre-wrap;
928
- word-break:break-word; margin-top:10px; }
929
- .out-status { font-family:ui-monospace,Menlo,monospace; font-size:12px; margin-top:10px; }
1017
+ pre.code.hidden { display:none; }
1018
+ .copy-btn { position:absolute; top:8px; right:8px; background:var(--card); color:var(--muted);
1019
+ border:1px solid var(--border); border-radius:6px; padding:4px 10px; font-size:11px; cursor:pointer; z-index:2; }
1020
+ .copy-btn:hover { color:var(--text); }
930
1021
  ::-webkit-scrollbar { width:9px; height:9px; }
931
1022
  ::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }
932
1023
  </style>
@@ -957,6 +1048,90 @@ export function buildDocsHtml(specUrl = "openapi.json") {
957
1048
  function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}
958
1049
  function collect(spec){var out=[];var paths=spec.paths||{};Object.keys(paths).forEach(function(p){var item=paths[p];Object.keys(item).forEach(function(m){out.push({path:p,method:m,op:item[m]});});});return out;}
959
1050
 
1051
+ // ── Client code samples (generated from the spec — no AI, no network) ──
1052
+ function sampleUrl(e){return (location.origin||'')+e.path;}
1053
+ function bodyForSample(e){var b=bodyOf(e.op);if(b&&['post','put','patch'].indexOf(e.method)>=0)return b;return null;}
1054
+ function indentBody(str,extra){var NL=String.fromCharCode(10);return str.split(NL).map(function(ln,i){return i===0?ln:extra+ln;}).join(NL);}
1055
+ function isMultipart(b){return !!(b&&b.mime==='multipart/form-data');}
1056
+ // Body fields with a flag for binary (file) fields, used to build multipart samples.
1057
+ function fieldList(b){var p=(b&&b.schema&&b.schema.properties)||{};return Object.keys(p).map(function(k){return {name:k,file:!!(p[k]&&p[k].format==='binary')};});}
1058
+ // FormData lines for a multipart body: files become a File object, others a string.
1059
+ function formLines(b){
1060
+ var out=["const form = new FormData();"];
1061
+ fieldList(b).forEach(function(f){
1062
+ out.push("form.append('"+f.name+"', "+(f.file?"fileInput.files[0]":"''")+");"+(f.file?" // a File from an <input type=file>":""));
1063
+ });
1064
+ return out;
1065
+ }
1066
+ function curlSample(e){
1067
+ var NL=String.fromCharCode(10),BS=String.fromCharCode(92);var b=bodyForSample(e);var L=[];
1068
+ L.push("curl -X "+e.method.toUpperCase()+" '"+sampleUrl(e)+"'");
1069
+ if(e.op.security)L.push("-H 'Authorization: Bearer <token>'");
1070
+ if(b&&isMultipart(b)){
1071
+ // curl sets the multipart Content-Type (with boundary) itself; -F per field.
1072
+ fieldList(b).forEach(function(f){L.push("-F '"+f.name+"="+(f.file?"@/path/to/file":"")+"'");});
1073
+ } else if(b){
1074
+ L.push("-H 'Content-Type: application/json'");
1075
+ var c;try{c=JSON.stringify(JSON.parse(bodySkeleton(b.schema)));}catch(x){c=bodySkeleton(b.schema);}
1076
+ L.push("-d '"+c+"'");
1077
+ }
1078
+ return L.join(" "+BS+NL+" ");
1079
+ }
1080
+ function fetchSample(e){
1081
+ var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
1082
+ if(mp)formLines(b).forEach(function(ln){L.push(ln);});
1083
+ L.push("const res = await fetch('"+sampleUrl(e)+"', {");
1084
+ L.push(" method: '"+e.method.toUpperCase()+"',");
1085
+ var h=[];
1086
+ // For multipart, never set Content-Type by hand — the browser adds the boundary.
1087
+ if(b&&!mp)h.push(" 'Content-Type': 'application/json'");
1088
+ if(e.op.security)h.push(" 'Authorization': 'Bearer <token>'");
1089
+ if(h.length){L.push(" headers: {");L.push(h.join(","+NL));L.push(" },");}
1090
+ if(mp)L.push(" body: form");
1091
+ else if(b)L.push(" body: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+")");
1092
+ L.push("});");
1093
+ L.push("const data = await res.json();");
1094
+ L.push("console.log(data);");
1095
+ return L.join(NL);
1096
+ }
1097
+ function axiosSample(e){
1098
+ var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
1099
+ if(mp)formLines(b).forEach(function(ln){L.push(ln);});
1100
+ L.push("const res = await axios({");
1101
+ L.push(" method: '"+e.method.toLowerCase()+"',");
1102
+ L.push(" url: '"+sampleUrl(e)+"',");
1103
+ if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
1104
+ if(mp)L.push(" data: form");
1105
+ else if(b)L.push(" data: "+indentBody(bodySkeleton(b.schema)," "));
1106
+ L.push("});");
1107
+ L.push("console.log(res.data);");
1108
+ return L.join(NL);
1109
+ }
1110
+ function jquerySample(e){
1111
+ var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
1112
+ if(mp)formLines(b).forEach(function(ln){L.push(ln);});
1113
+ L.push("$.ajax({");
1114
+ L.push(" url: '"+sampleUrl(e)+"',");
1115
+ L.push(" method: '"+e.method.toUpperCase()+"',");
1116
+ if(mp){L.push(" data: form,");L.push(" processData: false,");L.push(" contentType: false,");}
1117
+ else if(b)L.push(" contentType: 'application/json',");
1118
+ if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
1119
+ if(b&&!mp)L.push(" data: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+"),");
1120
+ L.push(" success: function(data) {");
1121
+ L.push(" console.log(data);");
1122
+ L.push(" }");
1123
+ L.push("});");
1124
+ return L.join(NL);
1125
+ }
1126
+ function buildSamples(e){
1127
+ return [
1128
+ {lang:'curl',label:'cURL',code:curlSample(e)},
1129
+ {lang:'fetch',label:'JavaScript (fetch)',code:fetchSample(e)},
1130
+ {lang:'axios',label:'axios',code:axiosSample(e)},
1131
+ {lang:'jquery',label:'jQuery',code:jquerySample(e)}
1132
+ ];
1133
+ }
1134
+
960
1135
  function detailsHtml(e,id){
961
1136
  var html='';
962
1137
  var pp=paramsOf(e.op,'path'),qp=paramsOf(e.op,'query'),body=bodyOf(e.op);
@@ -978,44 +1153,34 @@ export function buildDocsHtml(specUrl = "openapi.json") {
978
1153
  Object.keys(resp).forEach(function(code){
979
1154
  html+='<div class="row"><span class="resp-code">'+esc(code)+'</span><span class="detail-sum" style="margin:0">'+esc((resp[code]||{}).description||'')+'</span></div>';
980
1155
  });
981
- html+='<div class="sec">Try it</div>';
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>';});
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>';});
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>';}
985
- if(body){html+='<div class="try-field"><label class="try-label">Body (JSON)</label><textarea class="f" id="'+id+'_body">'+esc(bodySkeleton(body.schema))+'</textarea></div>';}
986
- html+='<button class="btn" id="'+id+'_send">Send request</button>';
987
- html+='<div class="out-status" id="'+id+'_status"></div>';
988
- html+='<pre class="out" id="'+id+'_out" style="display:none"></pre>';
1156
+ var samples=buildSamples(e);
1157
+ html+='<div class="sec">Code samples</div>';
1158
+ html+='<div class="tabs" id="'+id+'_tabs">';
1159
+ samples.forEach(function(s,i){html+='<button class="tab'+(i===0?' active':'')+'" data-lang="'+s.lang+'">'+esc(s.label)+'</button>';});
1160
+ html+='</div>';
1161
+ html+='<div class="code-wrap"><button class="copy-btn">Copy</button>';
1162
+ samples.forEach(function(s,i){html+='<pre class="code'+(i===0?'':' hidden')+'" data-lang="'+s.lang+'">'+esc(s.code)+'</pre>';});
1163
+ html+='</div>';
989
1164
  return html;
990
1165
  }
991
1166
 
992
- function wireTry(e,id,root){
993
- var btn=root.querySelector('#'+id+'_send');if(!btn)return;
994
- // Auto-grow the body textarea to fit its content (capped, then scrolls).
995
- var bodyTa=root.querySelector('#'+id+'_body');
996
- if(bodyTa){
997
- var autosize=function(){ bodyTa.style.height='auto'; bodyTa.style.height=Math.min(bodyTa.scrollHeight+2,600)+'px'; };
998
- bodyTa.addEventListener('input',autosize);
999
- autosize();
1000
- }
1001
- btn.addEventListener('click',function(){
1002
- var url=e.path;
1003
- root.querySelectorAll('[data-kind=path]').forEach(function(inp){url=url.replace('{'+inp.getAttribute('data-name')+'}',encodeURIComponent(inp.value||''));});
1004
- var qs=[];
1005
- root.querySelectorAll('[data-kind=query]').forEach(function(inp){if(inp.value)qs.push(encodeURIComponent(inp.getAttribute('data-name'))+'='+encodeURIComponent(inp.value));});
1006
- if(qs.length)url+=(url.indexOf('?')>=0?'&':'?')+qs.join('&');
1007
- var opts={method:e.method.toUpperCase(),headers:{}};
1008
- var auth=root.querySelector('#'+id+'_auth');if(auth&&auth.value)opts.headers['Authorization']='Bearer '+auth.value;
1009
- var bodyEl=root.querySelector('#'+id+'_body');
1010
- if(bodyEl&&['post','put','patch'].indexOf(e.method)>=0){opts.headers['Content-Type']='application/json';opts.body=bodyEl.value;}
1011
- var statusEl=root.querySelector('#'+id+'_status');var outEl=root.querySelector('#'+id+'_out');
1012
- btn.disabled=true;statusEl.textContent='Sending...';var t0=Date.now();
1013
- fetch(url,opts).then(function(r){var ct=r.headers.get('content-type')||'';return r.text().then(function(txt){return {r:r,txt:txt,ct:ct};});}).then(function(d){
1014
- var color=d.r.ok?'#22c55e':'#ef4444';
1015
- statusEl.innerHTML='<span style="color:'+color+'">'+d.r.status+' '+esc(d.r.statusText)+'</span> &middot; '+(Date.now()-t0)+'ms';
1016
- var out=d.txt;if(d.ct.indexOf('application/json')>=0){try{out=JSON.stringify(JSON.parse(d.txt),null,2);}catch(err){}}
1017
- outEl.style.display='block';outEl.textContent=out;
1018
- }).catch(function(err){statusEl.innerHTML='<span style="color:#ef4444">Request failed</span>';outEl.style.display='block';outEl.textContent=String(err);}).then(function(){btn.disabled=false;});
1167
+ function wireSamples(id,root){
1168
+ var tabs=root.querySelector('#'+id+'_tabs');if(!tabs)return;
1169
+ var btns=tabs.querySelectorAll('.tab');
1170
+ btns.forEach(function(b){
1171
+ b.addEventListener('click',function(){
1172
+ var lang=b.getAttribute('data-lang');
1173
+ btns.forEach(function(x){x.classList.remove('active');});b.classList.add('active');
1174
+ root.querySelectorAll('pre.code[data-lang]').forEach(function(p){p.classList.toggle('hidden',p.getAttribute('data-lang')!==lang);});
1175
+ });
1176
+ });
1177
+ var copy=root.querySelector('.copy-btn');
1178
+ if(copy)copy.addEventListener('click',function(){
1179
+ var active=root.querySelector('pre.code[data-lang]:not(.hidden)');if(!active)return;
1180
+ var txt=active.textContent;
1181
+ if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt);}
1182
+ else{var ta=document.createElement('textarea');ta.value=txt;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(x){}document.body.removeChild(ta);}
1183
+ copy.textContent='Copied';setTimeout(function(){copy.textContent='Copy';},1200);
1019
1184
  });
1020
1185
  }
1021
1186
 
@@ -1030,7 +1195,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1030
1195
  html+='<div class="detail-sum">'+esc(e.op.summary||'')+lock+'</div>';
1031
1196
  html+=detailsHtml(e,id);
1032
1197
  main.innerHTML=html;main.scrollTop=0;
1033
- wireTry(e,id,main);
1198
+ wireSamples(id,main);
1034
1199
  }
1035
1200
 
1036
1201
  function render(spec){
@@ -1208,9 +1373,9 @@ export default router;
1208
1373
  // Email (nodemailer) — SMTP with presets for popular providers (Brevo, etc.).
1209
1374
  // ─────────────────────────────────────────────────────────────────────────────
1210
1375
  export const MAIL_PROVIDERS = {
1376
+ mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
1211
1377
  brevo: { label: "Brevo", host: "smtp-relay.brevo.com", port: 587, secure: false, hint: "Login email + SMTP key (SMTP & API → SMTP)" },
1212
1378
  sendgrid: { label: "SendGrid", host: "smtp.sendgrid.net", port: 587, secure: false, user: "apikey", hint: "User is literally 'apikey'; pass is your API key" },
1213
- mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
1214
1379
  gmail: { label: "Gmail", host: "smtp.gmail.com", port: 465, secure: true, hint: "Use an App Password, not your login password" },
1215
1380
  mailtrap: { label: "Mailtrap (test)", host: "sandbox.smtp.mailtrap.io", port: 587, secure: false, hint: "Sandbox inbox for testing" },
1216
1381
  smtp: { label: "Custom SMTP", host: "", port: 587, secure: false, hint: "Any SMTP server" },
@@ -1248,14 +1413,19 @@ export function emailTemplate({ title = '', body = '' } = {}) {
1248
1413
  }
1249
1414
 
1250
1415
  // Send an email. Pass html or text (or use emailTemplate for html).
1251
- export async function sendMail({ to, subject, html, text, from } = {}) {
1416
+ // attachments: [{ filename, path }] or [{ filename, content }] (nodemailer format).
1417
+ export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments } = {}) {
1252
1418
  if (!to || !subject) throw new Error('sendMail requires { to, subject }');
1253
1419
  return transporter.sendMail({
1254
1420
  from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
1255
1421
  to,
1422
+ cc,
1423
+ bcc,
1424
+ replyTo,
1256
1425
  subject,
1257
1426
  text,
1258
1427
  html,
1428
+ attachments,
1259
1429
  });
1260
1430
  }
1261
1431
 
@@ -23,6 +23,8 @@ import {
23
23
  buildRolesGuardFile,
24
24
  buildValidateFile,
25
25
  buildZodSchemaFile,
26
+ requiredFieldsFromZod,
27
+ extractFileFields,
26
28
  wireValidateIntoRouteContent,
27
29
  buildAuthMiddleware,
28
30
  wireAuthIntoRouteContent,
@@ -175,6 +177,35 @@ test("buildZodSchemaFile maps mongoose types and optionality", () => {
175
177
  assert.ok(out.includes("export const userSchema"));
176
178
  });
177
179
 
180
+ test("extractFileFields detects multer single/array/fields", () => {
181
+ assert.deepEqual(extractFileFields("upload.single('resume'), async"), [{ name: "resume" }]);
182
+ assert.deepEqual(extractFileFields("upload.array('photos', 5),"), [{ name: "photos" }]);
183
+ assert.deepEqual(
184
+ extractFileFields("upload.fields([{ name: 'a', maxCount: 1 }, { name: 'b' }]),"),
185
+ [{ name: "a" }, { name: "b" }]
186
+ );
187
+ assert.deepEqual(extractFileFields("async (req, res) => { res.json({}); }"), []);
188
+ });
189
+
190
+ test("requiredFieldsFromZod reads required-ness from .optional()", () => {
191
+ // Round-trip the actual generated validator format.
192
+ const src = buildZodSchemaFile("user", [
193
+ { name: "email", type: "String", required: true },
194
+ { name: "age", type: "Number" },
195
+ { name: "owner", type: "ObjectId", required: true },
196
+ ]);
197
+ const req = requiredFieldsFromZod(src);
198
+ assert.equal(req.email, true); // no .optional() → required
199
+ assert.equal(req.age, false); // .optional() → not required
200
+ assert.equal(req.owner, true); // chained .regex() but no .optional() → required
201
+ // Handles nested objects/arrays without ending the block early.
202
+ const nested = `z.object({ a: z.string(), b: z.array(z.object({ c: z.number() })).optional(), d: z.string() })`;
203
+ const r2 = requiredFieldsFromZod(nested);
204
+ assert.deepEqual(r2, { a: true, b: false, d: true });
205
+ // No z.object → null so callers fall back to the model.
206
+ assert.equal(requiredFieldsFromZod("export const x = 1;"), null);
207
+ });
208
+
178
209
  test("wireValidateIntoRouteContent guards write endpoints only, idempotently", () => {
179
210
  const route = [
180
211
  "import express from 'express';",
@@ -448,6 +479,10 @@ test("buildOpenApiSpec uses multipart when file fields present", () => {
448
479
  const spec = buildOpenApiSpec({ endpoints: [{ method: "POST", fullPath: "/api/upload", bodyFields: [], fileFields: [{ name: "avatar" }] }] });
449
480
  const schema = spec.paths["/api/upload"].post.requestBody.content["multipart/form-data"].schema;
450
481
  assert.deepEqual(schema.properties.avatar, { type: "string", format: "binary" });
482
+ assert.deepEqual(schema.required, ["avatar"]); // files are required by default
483
+ // ...unless the field opts out
484
+ const optional = buildOpenApiSpec({ endpoints: [{ method: "POST", fullPath: "/api/upload", bodyFields: [], fileFields: [{ name: "avatar", required: false }] }] });
485
+ assert.equal(optional.paths["/api/upload"].post.requestBody.content["multipart/form-data"].schema.required, undefined);
451
486
  });
452
487
 
453
488
  test("buildDocsHtml + buildDocsRouter are well-formed, self-contained (no CDN, no Swagger)", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "4bnode",
3
- "version": "4.1.4",
3
+ "version": "4.1.5",
4
4
  "description": "4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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 }, // hidden for now
32
+ { id: "docs", label: "API Docs", icon: Icons.docs },
33
33
  { id: "api-tester", label: "API Tester", icon: Icons.apiTester },
34
34
  {
35
35
  id: "security",
@@ -39,7 +39,7 @@ export function Sidebar() {
39
39
  },
40
40
  { id: "realtime", label: "Real-time", icon: Icons.realtime },
41
41
  { id: "serialport", label: "SerialPort", icon: Icons.serial },
42
- // { id: "mail", label: "Email", icon: Icons.mail }, // hidden for now
42
+ { id: "mail", label: "Email", icon: Icons.mail },
43
43
  { id: "logs", label: "Logs", icon: Icons.logs, section: "Settings" },
44
44
  { id: "env", label: "Environment", icon: Icons.env },
45
45
  ];