4bnode 4.1.3 → 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 +247 -56
  21. package/lib/codegen.test.js +37 -1
  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 +247 -56
  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
@@ -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
@@ -419,19 +469,56 @@ export function securityWiring(features) {
419
469
 
420
470
  if (has("helmet")) {
421
471
  imports.push("import helmet from 'helmet';");
422
- beforeRoutesParts.push("// ── Security headers ──\napp.use(helmet());");
472
+ beforeRoutesParts.push(
473
+ `// ── Security headers ──
474
+ // helmet() defaults to a strict Content-Security-Policy (script-src 'self',
475
+ // etc.) that blocks CDN-loaded scripts, inline <script> blocks, and cross-origin
476
+ // assets. We widen the policy so a typical frontend/docs page works while keeping
477
+ // the rest of helmet's protections. Tighten these directives for production.
478
+ app.use(helmet({
479
+ contentSecurityPolicy: {
480
+ directives: {
481
+ defaultSrc: ["'self'"],
482
+ scriptSrc: ["'self'", 'https:', "'unsafe-inline'"],
483
+ styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
484
+ imgSrc: ["'self'", 'data:', 'https:'],
485
+ connectSrc: ["'self'"],
486
+ },
487
+ },
488
+ crossOriginEmbedderPolicy: false,
489
+ // Allow other origins (e.g. a separate frontend host) to load assets served
490
+ // by this app. Default is 'same-origin'. Tighten to 'same-site' for production.
491
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
492
+ }));`
493
+ );
423
494
  }
424
495
  if (has("rateLimit")) {
425
496
  imports.push("import rateLimit from 'express-rate-limit';");
426
497
  beforeRoutesParts.push(
427
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.
428
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({
429
512
  windowMs: 15 * 60 * 1000, // 15 minutes
430
- max: 100, // limit each IP to 100 requests per window
513
+ max: 10, // 10 attempts per IP per window
431
514
  standardHeaders: true,
432
515
  legacyHeaders: false,
433
516
  });
434
- 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
+ }`
435
522
  );
436
523
  }
437
524
  if (has("errorHandler")) {
@@ -708,6 +795,7 @@ export const PACKAGE_VERSIONS = {
708
795
  "@serialport/parser-readline": "^12.0.0",
709
796
  mongoose: "^8.8.0",
710
797
  multer: "^1.4.5-lts.1",
798
+ nodemailer: "^6.9.0",
711
799
  };
712
800
 
713
801
  // Add packages to package.json (dependencies, or devDependencies when dev:true).
@@ -759,6 +847,30 @@ export function extractRequestFields(handlerBody, source = "body") {
759
847
  return names;
760
848
  }
761
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
+
762
874
  // ─────────────────────────────────────────────────────────────────────────────
763
875
  // API Documentation — OpenAPI 3 spec + Swagger UI (served at /docs).
764
876
  // Reuses extractRequestFields output (endpoints) and model field metadata.
@@ -804,7 +916,9 @@ export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints =
804
916
  const properties = {};
805
917
  const required = [];
806
918
  bodyFields.forEach((f) => { properties[f.name] = openApiType(f.type); if (f.required) required.push(f.name); });
807
- 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); });
808
922
  const schema = { type: "object", properties };
809
923
  if (required.length) schema.required = required;
810
924
  const mime = fileFields.length ? "multipart/form-data" : "application/json";
@@ -891,21 +1005,19 @@ export function buildDocsHtml(specUrl = "openapi.json") {
891
1005
  .req { color:#ef4444; font-size:11px; font-weight:700; }
892
1006
  .opt { color:var(--faint); font-size:11px; }
893
1007
  .resp-code { font-family:ui-monospace,Menlo,monospace; color:#22c55e; font-weight:700; }
894
- input.f, textarea.f { width:100%; max-width:540px; background:var(--input); border:1px solid var(--border);
895
- 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;
896
1016
  font-family:ui-monospace,Menlo,monospace; }
897
- input.f:focus, textarea.f:focus { border-color:var(--accent); }
898
- textarea.f { min-height:110px; resize:vertical; }
899
- .try-field { margin-bottom:10px; }
900
- .try-label { font-size:12px; color:var(--muted); margin-bottom:4px; display:block; }
901
- .btn { background:var(--accent); color:#fff; border:none; border-radius:8px; padding:9px 18px;
902
- font-size:13px; font-weight:600; cursor:pointer; }
903
- .btn:hover { filter:brightness(1.08); }
904
- .btn:disabled { opacity:.5; cursor:not-allowed; }
905
- pre.out { background:#0a0c12; border:1px solid var(--border); border-radius:8px; padding:12px;
906
- font-size:12.5px; color:#d5d8e3; overflow:auto; max-height:360px; max-width:540px; white-space:pre-wrap;
907
- word-break:break-word; margin-top:10px; }
908
- .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); }
909
1021
  ::-webkit-scrollbar { width:9px; height:9px; }
910
1022
  ::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }
911
1023
  </style>
@@ -936,6 +1048,90 @@ export function buildDocsHtml(specUrl = "openapi.json") {
936
1048
  function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}
937
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;}
938
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
+
939
1135
  function detailsHtml(e,id){
940
1136
  var html='';
941
1137
  var pp=paramsOf(e.op,'path'),qp=paramsOf(e.op,'query'),body=bodyOf(e.op);
@@ -957,44 +1153,34 @@ export function buildDocsHtml(specUrl = "openapi.json") {
957
1153
  Object.keys(resp).forEach(function(code){
958
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>';
959
1155
  });
960
- html+='<div class="sec">Try it</div>';
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>';});
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>';});
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>';}
964
- 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>';}
965
- html+='<button class="btn" id="'+id+'_send">Send request</button>';
966
- html+='<div class="out-status" id="'+id+'_status"></div>';
967
- 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>';
968
1164
  return html;
969
1165
  }
970
1166
 
971
- function wireTry(e,id,root){
972
- var btn=root.querySelector('#'+id+'_send');if(!btn)return;
973
- // Auto-grow the body textarea to fit its content (capped, then scrolls).
974
- var bodyTa=root.querySelector('#'+id+'_body');
975
- if(bodyTa){
976
- var autosize=function(){ bodyTa.style.height='auto'; bodyTa.style.height=Math.min(bodyTa.scrollHeight+2,600)+'px'; };
977
- bodyTa.addEventListener('input',autosize);
978
- autosize();
979
- }
980
- btn.addEventListener('click',function(){
981
- var url=e.path;
982
- root.querySelectorAll('[data-kind=path]').forEach(function(inp){url=url.replace('{'+inp.getAttribute('data-name')+'}',encodeURIComponent(inp.value||''));});
983
- var qs=[];
984
- root.querySelectorAll('[data-kind=query]').forEach(function(inp){if(inp.value)qs.push(encodeURIComponent(inp.getAttribute('data-name'))+'='+encodeURIComponent(inp.value));});
985
- if(qs.length)url+=(url.indexOf('?')>=0?'&':'?')+qs.join('&');
986
- var opts={method:e.method.toUpperCase(),headers:{}};
987
- var auth=root.querySelector('#'+id+'_auth');if(auth&&auth.value)opts.headers['Authorization']='Bearer '+auth.value;
988
- var bodyEl=root.querySelector('#'+id+'_body');
989
- if(bodyEl&&['post','put','patch'].indexOf(e.method)>=0){opts.headers['Content-Type']='application/json';opts.body=bodyEl.value;}
990
- var statusEl=root.querySelector('#'+id+'_status');var outEl=root.querySelector('#'+id+'_out');
991
- btn.disabled=true;statusEl.textContent='Sending...';var t0=Date.now();
992
- 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){
993
- var color=d.r.ok?'#22c55e':'#ef4444';
994
- statusEl.innerHTML='<span style="color:'+color+'">'+d.r.status+' '+esc(d.r.statusText)+'</span> &middot; '+(Date.now()-t0)+'ms';
995
- var out=d.txt;if(d.ct.indexOf('application/json')>=0){try{out=JSON.stringify(JSON.parse(d.txt),null,2);}catch(err){}}
996
- outEl.style.display='block';outEl.textContent=out;
997
- }).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);
998
1184
  });
999
1185
  }
1000
1186
 
@@ -1009,7 +1195,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1009
1195
  html+='<div class="detail-sum">'+esc(e.op.summary||'')+lock+'</div>';
1010
1196
  html+=detailsHtml(e,id);
1011
1197
  main.innerHTML=html;main.scrollTop=0;
1012
- wireTry(e,id,main);
1198
+ wireSamples(id,main);
1013
1199
  }
1014
1200
 
1015
1201
  function render(spec){
@@ -1187,9 +1373,9 @@ export default router;
1187
1373
  // Email (nodemailer) — SMTP with presets for popular providers (Brevo, etc.).
1188
1374
  // ─────────────────────────────────────────────────────────────────────────────
1189
1375
  export const MAIL_PROVIDERS = {
1376
+ mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
1190
1377
  brevo: { label: "Brevo", host: "smtp-relay.brevo.com", port: 587, secure: false, hint: "Login email + SMTP key (SMTP & API → SMTP)" },
1191
1378
  sendgrid: { label: "SendGrid", host: "smtp.sendgrid.net", port: 587, secure: false, user: "apikey", hint: "User is literally 'apikey'; pass is your API key" },
1192
- mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
1193
1379
  gmail: { label: "Gmail", host: "smtp.gmail.com", port: 465, secure: true, hint: "Use an App Password, not your login password" },
1194
1380
  mailtrap: { label: "Mailtrap (test)", host: "sandbox.smtp.mailtrap.io", port: 587, secure: false, hint: "Sandbox inbox for testing" },
1195
1381
  smtp: { label: "Custom SMTP", host: "", port: 587, secure: false, hint: "Any SMTP server" },
@@ -1227,14 +1413,19 @@ export function emailTemplate({ title = '', body = '' } = {}) {
1227
1413
  }
1228
1414
 
1229
1415
  // Send an email. Pass html or text (or use emailTemplate for html).
1230
- 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 } = {}) {
1231
1418
  if (!to || !subject) throw new Error('sendMail requires { to, subject }');
1232
1419
  return transporter.sendMail({
1233
1420
  from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
1234
1421
  to,
1422
+ cc,
1423
+ bcc,
1424
+ replyTo,
1235
1425
  subject,
1236
1426
  text,
1237
1427
  html,
1428
+ attachments,
1238
1429
  });
1239
1430
  }
1240
1431
 
@@ -34,6 +34,8 @@ import {
34
34
  buildCrudRouteFile,
35
35
  addDepsToPackageJson,
36
36
  extractRequestFields,
37
+ extractFileFields,
38
+ requiredFieldsFromZod,
37
39
  buildOpenApiSpec,
38
40
  buildDocsRouter,
39
41
  MAIL_PROVIDERS,
@@ -683,8 +685,21 @@ router.delete('/api/routes/:name', (req, res) => {
683
685
  return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
684
686
  }
685
687
 
688
+ // Find the Zod validator this route uses (if any) before deleting the route,
689
+ // so we can clean it up too — otherwise validators/<name>.js is orphaned.
690
+ let validatorPath = null;
691
+ try {
692
+ const routeContent = fs.readFileSync(filePath, 'utf8');
693
+ const vi = routeContent.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
694
+ if (vi) validatorPath = path.join(process.cwd(), 'src', 'validators', vi[1] + '.js');
695
+ } catch {}
696
+
686
697
  // Delete the route file
687
698
  fs.unlinkSync(filePath);
699
+ // Delete its validator (only this route used it — validators are 1:1 with routes).
700
+ if (validatorPath && fs.existsSync(validatorPath)) {
701
+ try { fs.unlinkSync(validatorPath); } catch {}
702
+ }
688
703
  refreshDocsSpec();
689
704
 
690
705
  // Send response BEFORE modifying index.js (nodemon restart kills connection)
@@ -1220,7 +1235,7 @@ router.get('/api/security/rate-limit', (req, res) => {
1220
1235
  try {
1221
1236
  const envPath = path.join(process.cwd(), '.env');
1222
1237
  const windowMs = Number(getEnvValue(envPath, 'RATE_LIMIT_WINDOW_MS')) || 15 * 60 * 1000;
1223
- const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) || 100;
1238
+ const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) || 500;
1224
1239
  res.json({ windowMinutes: Math.max(1, Math.round(windowMs / 60000)), max });
1225
1240
  } catch (err) {
1226
1241
  res.status(500).json({ error: err.message });
@@ -1230,7 +1245,7 @@ router.post('/api/security/rate-limit', (req, res) => {
1230
1245
  try {
1231
1246
  const envPath = path.join(process.cwd(), '.env');
1232
1247
  const windowMinutes = Math.max(1, Math.min(1440, Number(req.body && req.body.windowMinutes) || 15));
1233
- const max = Math.max(1, Math.min(1000000, Number(req.body && req.body.max) || 100));
1248
+ const max = Math.max(1, Math.min(1000000, Number(req.body && req.body.max) || 500));
1234
1249
  updateEnvFile(envPath, 'RATE_LIMIT_WINDOW_MS', String(windowMinutes * 60000));
1235
1250
  updateEnvFile(envPath, 'RATE_LIMIT_MAX', String(max));
1236
1251
  res.json({ message: 'Rate limit updated.', windowMinutes, max });
@@ -1239,6 +1254,94 @@ router.post('/api/security/rate-limit', (req, res) => {
1239
1254
  }
1240
1255
  });
1241
1256
 
1257
+ // ---------------------------------------------------------------------------
1258
+ // Email (nodemailer) — provider presets, SMTP config in .env, install + test
1259
+ // ---------------------------------------------------------------------------
1260
+ const mailerFilePath = () => path.join(process.cwd(), 'src', 'services', 'mailer.js');
1261
+
1262
+ // GET /api/mail — current config + provider presets for the Email page.
1263
+ router.get('/api/mail', (req, res) => {
1264
+ try {
1265
+ const envPath = path.join(process.cwd(), '.env');
1266
+ res.json({
1267
+ configured: fs.existsSync(mailerFilePath()),
1268
+ providers: Object.entries(MAIL_PROVIDERS).map(([id, p]) => ({ id, ...p })),
1269
+ host: getEnvValue(envPath, 'SMTP_HOST'),
1270
+ port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
1271
+ secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
1272
+ user: getEnvValue(envPath, 'SMTP_USER'),
1273
+ from: getEnvValue(envPath, 'SMTP_FROM'),
1274
+ hasPass: !!getEnvValue(envPath, 'SMTP_PASS'),
1275
+ });
1276
+ } catch (err) {
1277
+ res.status(500).json({ error: err.message });
1278
+ }
1279
+ });
1280
+
1281
+ // POST /api/mail/setup — write src/services/mailer.js, save SMTP_* to .env, install nodemailer.
1282
+ router.post('/api/mail/setup', async (req, res) => {
1283
+ try {
1284
+ const { host, port, secure, user, pass, from } = req.body || {};
1285
+ if (!host || !String(host).trim()) return res.status(400).json({ error: 'SMTP host is required.' });
1286
+ const envPath = path.join(process.cwd(), '.env');
1287
+
1288
+ // Persist SMTP config to .env first (a .env write does NOT restart nodemon).
1289
+ // A blank password keeps the saved one.
1290
+ updateEnvFile(envPath, 'SMTP_HOST', String(host).trim());
1291
+ updateEnvFile(envPath, 'SMTP_PORT', String(port || 587).trim());
1292
+ updateEnvFile(envPath, 'SMTP_SECURE', secure ? 'true' : 'false');
1293
+ updateEnvFile(envPath, 'SMTP_USER', String(user || '').trim());
1294
+ if (pass) updateEnvFile(envPath, 'SMTP_PASS', String(pass));
1295
+ updateEnvFile(envPath, 'SMTP_FROM', String(from || user || '').trim());
1296
+
1297
+ // Install nodemailer BEFORE writing mailer.js. Writing into src/ restarts
1298
+ // nodemon, which would kill an in-flight npm install — so finish it first.
1299
+ await installDeps('nodemailer');
1300
+
1301
+ // Write the mailer service last (idempotent — keep an existing one). This is
1302
+ // the step that triggers the nodemon restart, by which point install is done.
1303
+ fs.mkdirSync(path.join(process.cwd(), 'src', 'services'), { recursive: true });
1304
+ if (!fs.existsSync(mailerFilePath())) fs.writeFileSync(mailerFilePath(), buildMailerService(), 'utf8');
1305
+
1306
+ res.json({ message: 'Email configured. Use sendMail({ to, subject, html }) from src/services/mailer.js.' });
1307
+ } catch (err) {
1308
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1309
+ }
1310
+ });
1311
+
1312
+ // POST /api/mail/test — send a test email using the saved SMTP settings.
1313
+ router.post('/api/mail/test', async (req, res) => {
1314
+ try {
1315
+ const to = String((req.body && req.body.to) || '').trim();
1316
+ if (!to) return res.status(400).json({ error: 'Recipient is required.' });
1317
+ const envPath = path.join(process.cwd(), '.env');
1318
+ const host = getEnvValue(envPath, 'SMTP_HOST');
1319
+ if (!host) return res.status(400).json({ error: 'Email is not configured yet — save SMTP settings first.' });
1320
+
1321
+ let nodemailer;
1322
+ try { nodemailer = (await import('nodemailer')).default; }
1323
+ catch { return res.status(400).json({ error: 'nodemailer is still installing. Wait a moment after saving, then retry.' }); }
1324
+
1325
+ // Build the transport from .env directly (process.env may be stale post-setup).
1326
+ const transporter = nodemailer.createTransport({
1327
+ host,
1328
+ port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
1329
+ secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
1330
+ auth: { user: getEnvValue(envPath, 'SMTP_USER'), pass: getEnvValue(envPath, 'SMTP_PASS') },
1331
+ });
1332
+ await transporter.sendMail({
1333
+ from: getEnvValue(envPath, 'SMTP_FROM') || getEnvValue(envPath, 'SMTP_USER'),
1334
+ to,
1335
+ subject: 'Test email from your app',
1336
+ text: 'This is a test email confirming your SMTP settings work.',
1337
+ html: '<p>This is a <b>test email</b> confirming your SMTP settings work.</p>',
1338
+ });
1339
+ res.json({ message: `Test email sent to ${to}.` });
1340
+ } catch (err) {
1341
+ res.status(500).json({ error: 'Send failed: ' + err.message });
1342
+ }
1343
+ });
1344
+
1242
1345
  // ---------------------------------------------------------------------------
1243
1346
  // AI Builder — describe a backend in English, generate models + CRUD routes
1244
1347
  // ---------------------------------------------------------------------------
@@ -1523,21 +1626,44 @@ function collectEndpoints() {
1523
1626
  const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
1524
1627
  const modelFields = modelImportMatch ? (getModelSchemaDetailed(modelImportMatch[2]).fields || []) : [];
1525
1628
 
1629
+ // The API's real required-ness comes from the Zod validator (what actually
1630
+ // gates the request), not the Mongoose model. Parse the route's validator file
1631
+ // so OpenAPI `required` reflects the API contract, not the DB schema.
1632
+ const validatorImport = content.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
1633
+ let validatorReq = null;
1634
+ if (validatorImport) {
1635
+ const vPath = path.join(process.cwd(), 'src', 'validators', validatorImport[1] + '.js');
1636
+ if (fs.existsSync(vPath)) {
1637
+ try { validatorReq = requiredFieldsFromZod(fs.readFileSync(vPath, 'utf8')); } catch {}
1638
+ }
1639
+ }
1640
+
1526
1641
  const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*([\s\S]*?)(?=\nrouter\.|\nexport |$)/gi;
1527
1642
  let m;
1528
1643
  while ((m = epRegex.exec(content)) !== null) {
1529
1644
  const method = m[1].toUpperCase();
1530
1645
  const epath = m[2];
1531
1646
  const body = m[3] || '';
1647
+ // PATCH wires `validate(schema.partial())`, making every field optional.
1648
+ const usesPartial = /\.partial\s*\(\s*\)/.test(body);
1532
1649
  const bodyFields = extractRequestFields(body, 'body').map((fn) => {
1533
1650
  const meta = modelFields.find((x) => x.name === fn);
1534
- return { name: fn, type: (meta && meta.type) || 'String', required: !!(meta && meta.required) };
1651
+ let required;
1652
+ if (usesPartial) required = false;
1653
+ else if (validatorReq && Object.prototype.hasOwnProperty.call(validatorReq, fn)) required = validatorReq[fn];
1654
+ else required = !!(meta && meta.required);
1655
+ return { name: fn, type: (meta && meta.type) || 'String', required };
1535
1656
  });
1536
1657
  const queryFields = extractRequestFields(body, 'query').map((fn) => ({ name: fn, type: 'String' }));
1537
1658
  const params = (epath.match(/:(\w+)/g) || []).map((p) => p.slice(1));
1538
- const hasAuth = /authorization|jwt|bearer|x-api-key/i.test(body);
1659
+ // Auth is detected by the `auth` middleware on the route — NOT by scanning the
1660
+ // handler for "jwt"/"bearer", which would wrongly flag login/register routes
1661
+ // (they *sign* a JWT to return it; they don't *require* a Bearer token).
1662
+ const hasAuth = /\bauth\b\s*,/.test(body);
1663
+ // Detect multer file fields so the docs show a multipart upload + file inputs.
1664
+ const fileFields = extractFileFields(body);
1539
1665
  const fullPath = (prefix + (epath === '/' ? '' : epath)).replace(/\/{2,}/g, '/');
1540
- out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields: [], hasAuth });
1666
+ out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields, hasAuth });
1541
1667
  }
1542
1668
  });
1543
1669
  return out;
package/skeleton/.env CHANGED
@@ -1 +1,7 @@
1
1
  PORT=3000
2
+
3
+ # Rate limiting (only enforced when NODE_ENV=production)
4
+ # RATE_LIMIT_WINDOW_MS = window length in ms (900000 = 15 minutes)
5
+ # RATE_LIMIT_MAX = max requests per IP per window
6
+ RATE_LIMIT_WINDOW_MS=900000
7
+ RATE_LIMIT_MAX=500
@@ -0,0 +1,11 @@
1
+ # Copy this file to .env and fill in real values.
2
+ # cp .env.example .env
3
+
4
+ # Port the server listens on
5
+ PORT=3000
6
+
7
+ # Rate limiting (only enforced when NODE_ENV=production)
8
+ # RATE_LIMIT_WINDOW_MS = window length in ms (900000 = 15 minutes)
9
+ # RATE_LIMIT_MAX = max requests per IP per window
10
+ RATE_LIMIT_WINDOW_MS=900000
11
+ RATE_LIMIT_MAX=500
@@ -1,9 +0,0 @@
1
- import mongoose from 'mongoose';
2
-
3
- const LoginSchema = new mongoose.Schema({
4
- first_name: { type: String }
5
- }, { timestamps: true });
6
-
7
- const Login = mongoose.model('Login', LoginSchema);
8
-
9
- export default Login;