4bnode 4.1.4 → 4.1.6

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 (70) hide show
  1. package/add-api.js +5 -3
  2. package/add-docs.js +18 -5
  3. package/add-email.js +118 -28
  4. package/add-login.js +35 -16
  5. package/add-mongo-schema.js +13 -5
  6. package/lib/codegen.js +408 -104
  7. package/lib/codegen.test.js +122 -15
  8. package/lib/names.js +1 -1
  9. package/package.json +1 -1
  10. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
  11. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
  12. package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
  13. package/skeleton/.4bnode/assets/js/pages/MailPage.js +221 -71
  14. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
  15. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
  16. package/skeleton/.4bnode/codegen.js +408 -104
  17. package/skeleton/.4bnode/dev-api.js +384 -30
  18. package/skeleton/.env +6 -0
  19. package/skeleton/.env.example +25 -0
  20. package/skeleton/_gitignore +1 -0
  21. package/skeleton/index.js +7 -2
  22. package/skeleton/src/middleware/apiKey.js +14 -1
  23. package/skeleton/src/middleware/errorHandler.js +7 -2
  24. package/app-1/.4bnode/.passkey +0 -1
  25. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  26. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  27. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  28. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  29. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  30. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  31. package/app-1/.4bnode/assets/js/App.js +0 -139
  32. package/app-1/.4bnode/assets/js/Root.js +0 -40
  33. package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
  34. package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
  35. package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
  36. package/app-1/.4bnode/assets/js/context.js +0 -7
  37. package/app-1/.4bnode/assets/js/lib.js +0 -113
  38. package/app-1/.4bnode/assets/js/main.js +0 -5
  39. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
  40. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
  41. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
  42. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
  43. package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
  44. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
  45. package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
  46. package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -134
  47. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
  48. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1436
  49. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
  50. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
  51. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
  52. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
  53. package/app-1/.4bnode/codegen.js +0 -1249
  54. package/app-1/.4bnode/dev-api.js +0 -3621
  55. package/app-1/.4bnode/ui.html +0 -1578
  56. package/app-1/README.md +0 -47
  57. package/app-1/ecosystem.config.cjs +0 -25
  58. package/app-1/index.js +0 -145
  59. package/app-1/package.json +0 -35
  60. package/app-1/public/index.html +0 -292
  61. package/app-1/src/db.js +0 -13
  62. package/app-1/src/docs.js +0 -84
  63. package/app-1/src/middleware/errorHandler.js +0 -15
  64. package/app-1/src/middleware/roles.js +0 -14
  65. package/app-1/src/middleware/validate.js +0 -20
  66. package/app-1/src/models/login.js +0 -9
  67. package/app-1/src/openapi.json +0 -62
  68. package/app-1/src/routes/products.js +0 -26
  69. package/app-1/src/validators/products.js +0 -7
  70. package/app-1/src/validators/user.js +0 -7
@@ -40,6 +40,25 @@ export function sanitizeName(input) {
40
40
  return trimmed;
41
41
  }
42
42
 
43
+ // Validate a model/route FIELD name before it is interpolated into generated code.
44
+ // A field name becomes a JS identifier (destructuring, object keys), so anything
45
+ // but a plain identifier could break out of the template or pollute a prototype.
46
+ export function sanitizeFieldName(input) {
47
+ if (!input || typeof input !== "string") {
48
+ throw new Error("Field name is required.");
49
+ }
50
+ const trimmed = input.trim();
51
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
52
+ throw new Error(
53
+ `Invalid field name "${trimmed}". Use letters, numbers, and underscores; start with a letter or underscore.`
54
+ );
55
+ }
56
+ if (trimmed === "__proto__" || trimmed === "constructor" || trimmed === "prototype") {
57
+ throw new Error(`Field name "${trimmed}" is reserved.`);
58
+ }
59
+ return trimmed;
60
+ }
61
+
43
62
  // ── Pure string helpers for route files ─────────────────────────────────────
44
63
  // These operate on file CONTENT (a string) and return new content. The
45
64
  // file-reading/writing wrappers live in lib/routeFile.js (CLI) and inline in
@@ -76,7 +95,20 @@ export function insertCodeIntoContent(content, codeBlock) {
76
95
 
77
96
  // ── CRUD route builders ─────────────────────────────────────────────────────
78
97
 
98
+ // Field names that must never be mass-assigned from a request body — privilege
99
+ // flags and prototype-pollution keys. Excluded from generated create/update DTOs
100
+ // by default; set these server-side (e.g. in an admin route) instead.
101
+ export const MASS_ASSIGN_BLOCKED = new Set([
102
+ "role", "roles", "isadmin", "admin", "isadministrator",
103
+ "verified", "isverified", "permissions", "scopes",
104
+ "__proto__", "constructor", "prototype",
105
+ ]);
106
+ function writableFields(fields) {
107
+ return fields.filter((f) => !MASS_ASSIGN_BLOCKED.has(String(f).toLowerCase()));
108
+ }
109
+
79
110
  export function buildInsertCode(pascalName, fields, dataSource) {
111
+ fields = writableFields(fields);
80
112
  const assignments = fields
81
113
  .map((f) => ` ${f}: ${dataSource}.${f}`)
82
114
  .join(",\n");
@@ -111,8 +143,15 @@ export function buildReadCode(pascalName) {
111
143
  return `
112
144
  router.get('/', async (req, res) => {
113
145
  try {
114
- const documents = await ${pascalName}.find();
115
- res.json({ data: documents });
146
+ // Paginate to avoid returning an unbounded collection. ?page & ?limit (max 100).
147
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
148
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
149
+ const skip = (page - 1) * limit;
150
+ const [documents, total] = await Promise.all([
151
+ ${pascalName}.find().select('-password').skip(skip).limit(limit),
152
+ ${pascalName}.countDocuments(),
153
+ ]);
154
+ res.json({ data: documents, page, limit, total, totalPages: Math.ceil(total / limit) });
116
155
  } catch (err) {
117
156
  console.error(err);
118
157
  res.status(500).json({ message: 'Server error' });
@@ -121,7 +160,8 @@ router.get('/', async (req, res) => {
121
160
 
122
161
  router.get('/:id', async (req, res) => {
123
162
  try {
124
- const document = await ${pascalName}.findById(req.params.id);
163
+ // .select('-password') never leaks credential hashes even if the model has one.
164
+ const document = await ${pascalName}.findById(req.params.id).select('-password');
125
165
  if (!document) {
126
166
  return res.status(404).json({ message: 'Document not found' });
127
167
  }
@@ -134,6 +174,7 @@ router.get('/:id', async (req, res) => {
134
174
  }
135
175
 
136
176
  export function buildUpdateCode(pascalName, fields, dataSource) {
177
+ fields = writableFields(fields);
137
178
  const assignments = fields
138
179
  .map((f) => ` ${f}: ${dataSource}.${f}`)
139
180
  .join(",\n");
@@ -201,6 +242,10 @@ export function generateMongooseModel(pascalName, fields, indexes = []) {
201
242
  if (f.index) parts.push("index: true");
202
243
  if (f.default !== undefined && f.default !== "")
203
244
  parts.push(`default: ${f.default}`);
245
+ // Never return credential fields by default — read queries must opt in with
246
+ // .select('+password'). Prevents accidental hash leaks through find()/findById.
247
+ if (/^password$/i.test(f.name) && f.select === undefined)
248
+ parts.push("select: false");
204
249
  return ` ${f.name}: { ${parts.join(", ")} }`;
205
250
  });
206
251
 
@@ -247,9 +292,14 @@ export function notFound(req, res, next) {
247
292
  export function errorHandler(err, req, res, next) {
248
293
  const status = err.status || err.statusCode || 500;
249
294
  console.error(err);
295
+ const isProd = process.env.NODE_ENV === 'production';
296
+ // In production, only surface messages for client (4xx) errors or errors
297
+ // explicitly marked safe (err.expose). Never leak raw 5xx internals — a Mongo
298
+ // E11000 or driver message discloses schema/indexes and enables enumeration.
299
+ const showMessage = !isProd || err.expose === true || (status >= 400 && status < 500);
250
300
  res.status(status).json({
251
- message: err.message || 'Server error',
252
- ...(process.env.NODE_ENV === 'production' ? {} : { stack: err.stack }),
301
+ message: showMessage ? (err.message || 'Server error') : 'Server error',
302
+ ...(isProd ? {} : { stack: err.stack }),
253
303
  });
254
304
  }
255
305
  `;
@@ -285,7 +335,12 @@ const auth = (req, res, next) => {
285
335
  return res.status(401).json({ message: 'No token, authorization denied' });
286
336
  }
287
337
  try {
288
- const decoded = jwt.verify(token, process.env.JWT_SECRET);
338
+ // Pin the algorithm so a token can't be forged via alg-confusion (e.g. an
339
+ // attacker signing HS256 with a public key if RS256 is ever introduced).
340
+ const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
341
+ if (decoded.type && decoded.type !== 'access') {
342
+ return res.status(401).json({ message: 'Token is not valid' });
343
+ }
289
344
  req.user = decoded;
290
345
  next();
291
346
  } catch (err) {
@@ -363,6 +418,56 @@ export default ${camel}Schema;
363
418
  `;
364
419
  }
365
420
 
421
+ // Parse a Zod validator's source into a { fieldName: isRequired } map for the
422
+ // top-level fields of its `z.object({ ... })`. A field is "required" for the API
423
+ // when its Zod chain has no `.optional()`/`.nullish()` — i.e. the request is
424
+ // rejected without it. This is the right source of truth for OpenAPI `required`,
425
+ // because the validator (not the Mongoose model) is what actually gates a request:
426
+ // a field can be optional in the DB schema yet required by the API, or vice versa.
427
+ // Returns null if no `z.object({...})` is found (caller should fall back to the model).
428
+ export function requiredFieldsFromZod(src) {
429
+ if (!src || typeof src !== "string") return null;
430
+ const objIdx = src.indexOf("z.object(");
431
+ if (objIdx === -1) return null;
432
+ const braceStart = src.indexOf("{", objIdx);
433
+ if (braceStart === -1) return null;
434
+ // Walk balanced brackets so nested objects/arrays/calls don't end the block early.
435
+ let depth = 0;
436
+ let end = -1;
437
+ for (let i = braceStart; i < src.length; i++) {
438
+ const c = src[i];
439
+ if (c === "{" || c === "(" || c === "[") depth++;
440
+ else if (c === "}" || c === ")" || c === "]") {
441
+ depth--;
442
+ if (depth === 0 && c === "}") { end = i; break; }
443
+ }
444
+ }
445
+ if (end === -1) return null;
446
+ const inner = src.slice(braceStart + 1, end);
447
+ const map = {};
448
+ const flush = (entry) => {
449
+ const colon = entry.indexOf(":");
450
+ if (colon === -1) return;
451
+ const key = entry.slice(0, colon).trim().replace(/^['"]|['"]$/g, "");
452
+ if (!/^[A-Za-z_$][\w$]*$/.test(key)) return;
453
+ const expr = entry.slice(colon + 1);
454
+ // .nullable() still requires the key (value may be null), so it doesn't count.
455
+ map[key] = !/\.(optional|nullish)\s*\(\s*\)/.test(expr);
456
+ };
457
+ // Split top-level "key: expr" entries on depth-0 commas.
458
+ let d = 0;
459
+ let cur = "";
460
+ for (let i = 0; i < inner.length; i++) {
461
+ const c = inner[i];
462
+ if (c === "{" || c === "(" || c === "[") d++;
463
+ else if (c === "}" || c === ")" || c === "]") d--;
464
+ if (c === "," && d === 0) { flush(cur); cur = ""; }
465
+ else cur += c;
466
+ }
467
+ if (cur.trim()) flush(cur);
468
+ return Object.keys(map).length ? map : null;
469
+ }
470
+
366
471
  // Wire `validate(<model>Schema)` into the write endpoints of a route file, and add
367
472
  // the two needed imports. POST/PUT use the full schema; PATCH uses
368
473
  // `<schema>.partial()` so partial updates aren't forced to send every required
@@ -446,13 +551,31 @@ app.use(helmet({
446
551
  imports.push("import rateLimit from 'express-rate-limit';");
447
552
  beforeRoutesParts.push(
448
553
  `// ── Rate limiting ──
554
+ // Generous global cap — high enough that real frontends (which fire many calls
555
+ // per page) never notice, low enough to stop scraping/abuse at scale.
556
+ // Window + cap are read from .env (RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX) so the
557
+ // dashboard's Security page can tune them; fall back to 15 min / 500 requests.
449
558
  const apiLimiter = rateLimit({
559
+ windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
560
+ max: Number(process.env.RATE_LIMIT_MAX) || 500,
561
+ standardHeaders: true,
562
+ legacyHeaders: false,
563
+ });
564
+ // Strict cap for authentication routes — defends against password brute-forcing.
565
+ // Point this at your real auth routes (login / signup / password-reset).
566
+ const authLimiter = rateLimit({
450
567
  windowMs: 15 * 60 * 1000, // 15 minutes
451
- max: 100, // limit each IP to 100 requests per window
568
+ max: 10, // 10 attempts per IP per window
452
569
  standardHeaders: true,
453
570
  legacyHeaders: false,
454
571
  });
455
- app.use(apiLimiter);`
572
+ // Throttle everywhere EXCEPT explicit local development. Defaulting to on means a
573
+ // deploy that forgets to set NODE_ENV=production is still protected (an unset
574
+ // NODE_ENV must never mean "no rate limiting" — that was the /_dev breach posture).
575
+ if (process.env.NODE_ENV !== 'development') {
576
+ app.use('/api/auth', authLimiter); // strict limiter first, on auth routes
577
+ app.use(apiLimiter); // generous global limiter for everything else
578
+ }`
456
579
  );
457
580
  }
458
581
  if (has("errorHandler")) {
@@ -729,6 +852,8 @@ export const PACKAGE_VERSIONS = {
729
852
  "@serialport/parser-readline": "^12.0.0",
730
853
  mongoose: "^8.8.0",
731
854
  multer: "^1.4.5-lts.1",
855
+ nodemailer: "^6.9.0",
856
+ resend: "^4.0.0",
732
857
  };
733
858
 
734
859
  // Add packages to package.json (dependencies, or devDependencies when dev:true).
@@ -780,6 +905,30 @@ export function extractRequestFields(handlerBody, source = "body") {
780
905
  return names;
781
906
  }
782
907
 
908
+ // Detect multer upload fields in a route handler so the OpenAPI builder can mark
909
+ // the body as multipart/form-data with the right file fields. Handles:
910
+ // upload.single('avatar') -> [{name:'avatar'}]
911
+ // upload.array('photos', 5) -> [{name:'photos'}]
912
+ // upload.fields([{ name: 'a' }, { name: 'b' }]) -> [{name:'a'},{name:'b'}]
913
+ export function extractFileFields(handlerBody) {
914
+ const body = handlerBody || "";
915
+ const out = [];
916
+ const seen = new Set();
917
+ const add = (name) => { if (name && !seen.has(name)) { seen.add(name); out.push({ name }); } };
918
+ (body.match(/upload\.(?:single|array)\(\s*['"]([^'"]+)['"]/g) || []).forEach((s) => {
919
+ const m = s.match(/['"]([^'"]+)['"]/);
920
+ if (m) add(m[1]);
921
+ });
922
+ const fieldsCall = body.match(/upload\.fields\(\s*\[([\s\S]*?)\]/);
923
+ if (fieldsCall) {
924
+ (fieldsCall[1].match(/name\s*:\s*['"]([^'"]+)['"]/g) || []).forEach((n) => {
925
+ const m = n.match(/['"]([^'"]+)['"]/);
926
+ if (m) add(m[1]);
927
+ });
928
+ }
929
+ return out;
930
+ }
931
+
783
932
  // ─────────────────────────────────────────────────────────────────────────────
784
933
  // API Documentation — OpenAPI 3 spec + Swagger UI (served at /docs).
785
934
  // Reuses extractRequestFields output (endpoints) and model field metadata.
@@ -825,7 +974,9 @@ export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints =
825
974
  const properties = {};
826
975
  const required = [];
827
976
  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" }; });
977
+ // Uploaded files are required by default (an upload endpoint exists to receive
978
+ // the file). A file field can opt out with required:false.
979
+ fileFields.forEach((f) => { properties[f.name] = { type: "string", format: "binary" }; if (f.required !== false) required.push(f.name); });
829
980
  const schema = { type: "object", properties };
830
981
  if (required.length) schema.required = required;
831
982
  const mime = fileFields.length ? "multipart/form-data" : "application/json";
@@ -912,21 +1063,19 @@ export function buildDocsHtml(specUrl = "openapi.json") {
912
1063
  .req { color:#ef4444; font-size:11px; font-weight:700; }
913
1064
  .opt { color:var(--faint); font-size:11px; }
914
1065
  .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;
1066
+ .tabs { display:flex; gap:4px; flex-wrap:wrap; }
1067
+ .tab { background:var(--input); color:var(--muted); border:1px solid var(--border); border-bottom:none;
1068
+ border-radius:8px 8px 0 0; padding:6px 13px; font-size:12px; cursor:pointer; }
1069
+ .tab:hover { color:var(--text); }
1070
+ .tab.active { background:#0a0c12; color:var(--accent-light); }
1071
+ .code-wrap { position:relative; max-width:560px; }
1072
+ pre.code { background:#0a0c12; border:1px solid var(--border); border-radius:0 8px 8px 8px; padding:14px;
1073
+ font-size:12.5px; color:#d5d8e3; overflow:auto; white-space:pre; margin:0;
917
1074
  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; }
1075
+ pre.code.hidden { display:none; }
1076
+ .copy-btn { position:absolute; top:8px; right:8px; background:var(--card); color:var(--muted);
1077
+ border:1px solid var(--border); border-radius:6px; padding:4px 10px; font-size:11px; cursor:pointer; z-index:2; }
1078
+ .copy-btn:hover { color:var(--text); }
930
1079
  ::-webkit-scrollbar { width:9px; height:9px; }
931
1080
  ::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }
932
1081
  </style>
@@ -957,6 +1106,90 @@ export function buildDocsHtml(specUrl = "openapi.json") {
957
1106
  function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}
958
1107
  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
1108
 
1109
+ // ── Client code samples (generated from the spec — no AI, no network) ──
1110
+ function sampleUrl(e){return (location.origin||'')+e.path;}
1111
+ function bodyForSample(e){var b=bodyOf(e.op);if(b&&['post','put','patch'].indexOf(e.method)>=0)return b;return null;}
1112
+ 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);}
1113
+ function isMultipart(b){return !!(b&&b.mime==='multipart/form-data');}
1114
+ // Body fields with a flag for binary (file) fields, used to build multipart samples.
1115
+ 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')};});}
1116
+ // FormData lines for a multipart body: files become a File object, others a string.
1117
+ function formLines(b){
1118
+ var out=["const form = new FormData();"];
1119
+ fieldList(b).forEach(function(f){
1120
+ out.push("form.append('"+f.name+"', "+(f.file?"fileInput.files[0]":"''")+");"+(f.file?" // a File from an <input type=file>":""));
1121
+ });
1122
+ return out;
1123
+ }
1124
+ function curlSample(e){
1125
+ var NL=String.fromCharCode(10),BS=String.fromCharCode(92);var b=bodyForSample(e);var L=[];
1126
+ L.push("curl -X "+e.method.toUpperCase()+" '"+sampleUrl(e)+"'");
1127
+ if(e.op.security)L.push("-H 'Authorization: Bearer <token>'");
1128
+ if(b&&isMultipart(b)){
1129
+ // curl sets the multipart Content-Type (with boundary) itself; -F per field.
1130
+ fieldList(b).forEach(function(f){L.push("-F '"+f.name+"="+(f.file?"@/path/to/file":"")+"'");});
1131
+ } else if(b){
1132
+ L.push("-H 'Content-Type: application/json'");
1133
+ var c;try{c=JSON.stringify(JSON.parse(bodySkeleton(b.schema)));}catch(x){c=bodySkeleton(b.schema);}
1134
+ L.push("-d '"+c+"'");
1135
+ }
1136
+ return L.join(" "+BS+NL+" ");
1137
+ }
1138
+ function fetchSample(e){
1139
+ var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
1140
+ if(mp)formLines(b).forEach(function(ln){L.push(ln);});
1141
+ L.push("const res = await fetch('"+sampleUrl(e)+"', {");
1142
+ L.push(" method: '"+e.method.toUpperCase()+"',");
1143
+ var h=[];
1144
+ // For multipart, never set Content-Type by hand — the browser adds the boundary.
1145
+ if(b&&!mp)h.push(" 'Content-Type': 'application/json'");
1146
+ if(e.op.security)h.push(" 'Authorization': 'Bearer <token>'");
1147
+ if(h.length){L.push(" headers: {");L.push(h.join(","+NL));L.push(" },");}
1148
+ if(mp)L.push(" body: form");
1149
+ else if(b)L.push(" body: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+")");
1150
+ L.push("});");
1151
+ L.push("const data = await res.json();");
1152
+ L.push("console.log(data);");
1153
+ return L.join(NL);
1154
+ }
1155
+ function axiosSample(e){
1156
+ var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
1157
+ if(mp)formLines(b).forEach(function(ln){L.push(ln);});
1158
+ L.push("const res = await axios({");
1159
+ L.push(" method: '"+e.method.toLowerCase()+"',");
1160
+ L.push(" url: '"+sampleUrl(e)+"',");
1161
+ if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
1162
+ if(mp)L.push(" data: form");
1163
+ else if(b)L.push(" data: "+indentBody(bodySkeleton(b.schema)," "));
1164
+ L.push("});");
1165
+ L.push("console.log(res.data);");
1166
+ return L.join(NL);
1167
+ }
1168
+ function jquerySample(e){
1169
+ var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
1170
+ if(mp)formLines(b).forEach(function(ln){L.push(ln);});
1171
+ L.push("$.ajax({");
1172
+ L.push(" url: '"+sampleUrl(e)+"',");
1173
+ L.push(" method: '"+e.method.toUpperCase()+"',");
1174
+ if(mp){L.push(" data: form,");L.push(" processData: false,");L.push(" contentType: false,");}
1175
+ else if(b)L.push(" contentType: 'application/json',");
1176
+ if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
1177
+ if(b&&!mp)L.push(" data: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+"),");
1178
+ L.push(" success: function(data) {");
1179
+ L.push(" console.log(data);");
1180
+ L.push(" }");
1181
+ L.push("});");
1182
+ return L.join(NL);
1183
+ }
1184
+ function buildSamples(e){
1185
+ return [
1186
+ {lang:'curl',label:'cURL',code:curlSample(e)},
1187
+ {lang:'fetch',label:'JavaScript (fetch)',code:fetchSample(e)},
1188
+ {lang:'axios',label:'axios',code:axiosSample(e)},
1189
+ {lang:'jquery',label:'jQuery',code:jquerySample(e)}
1190
+ ];
1191
+ }
1192
+
960
1193
  function detailsHtml(e,id){
961
1194
  var html='';
962
1195
  var pp=paramsOf(e.op,'path'),qp=paramsOf(e.op,'query'),body=bodyOf(e.op);
@@ -978,44 +1211,34 @@ export function buildDocsHtml(specUrl = "openapi.json") {
978
1211
  Object.keys(resp).forEach(function(code){
979
1212
  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
1213
  });
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>';
1214
+ var samples=buildSamples(e);
1215
+ html+='<div class="sec">Code samples</div>';
1216
+ html+='<div class="tabs" id="'+id+'_tabs">';
1217
+ samples.forEach(function(s,i){html+='<button class="tab'+(i===0?' active':'')+'" data-lang="'+s.lang+'">'+esc(s.label)+'</button>';});
1218
+ html+='</div>';
1219
+ html+='<div class="code-wrap"><button class="copy-btn">Copy</button>';
1220
+ samples.forEach(function(s,i){html+='<pre class="code'+(i===0?'':' hidden')+'" data-lang="'+s.lang+'">'+esc(s.code)+'</pre>';});
1221
+ html+='</div>';
989
1222
  return html;
990
1223
  }
991
1224
 
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;});
1225
+ function wireSamples(id,root){
1226
+ var tabs=root.querySelector('#'+id+'_tabs');if(!tabs)return;
1227
+ var btns=tabs.querySelectorAll('.tab');
1228
+ btns.forEach(function(b){
1229
+ b.addEventListener('click',function(){
1230
+ var lang=b.getAttribute('data-lang');
1231
+ btns.forEach(function(x){x.classList.remove('active');});b.classList.add('active');
1232
+ root.querySelectorAll('pre.code[data-lang]').forEach(function(p){p.classList.toggle('hidden',p.getAttribute('data-lang')!==lang);});
1233
+ });
1234
+ });
1235
+ var copy=root.querySelector('.copy-btn');
1236
+ if(copy)copy.addEventListener('click',function(){
1237
+ var active=root.querySelector('pre.code[data-lang]:not(.hidden)');if(!active)return;
1238
+ var txt=active.textContent;
1239
+ if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt);}
1240
+ 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);}
1241
+ copy.textContent='Copied';setTimeout(function(){copy.textContent='Copy';},1200);
1019
1242
  });
1020
1243
  }
1021
1244
 
@@ -1030,7 +1253,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
1030
1253
  html+='<div class="detail-sum">'+esc(e.op.summary||'')+lock+'</div>';
1031
1254
  html+=detailsHtml(e,id);
1032
1255
  main.innerHTML=html;main.scrollTop=0;
1033
- wireTry(e,id,main);
1256
+ wireSamples(id,main);
1034
1257
  }
1035
1258
 
1036
1259
  function render(spec){
@@ -1128,37 +1351,70 @@ const DIR = path.dirname(fileURLToPath(import.meta.url));
1128
1351
  const SPEC = path.join(DIR, 'openapi.json');
1129
1352
  const AUTH = path.join(DIR, 'docs-auth.json');
1130
1353
 
1131
- // Optional password protection. If src/docs-auth.json holds a password hash, the
1354
+ // Optional password protection. If src/docs-auth.json holds { hash, secret }, the
1132
1355
  // whole /docs surface requires a single password (NO username) via a small login
1133
- // page + cookie. Set/remove the password from the dashboard (API Docs). Read live
1134
- // (mtime-cached), so changes apply without a restart.
1135
- let _authCache = { mtimeMs: -1, hash: null };
1136
- function docsHash() {
1356
+ // page + a signed session cookie. Set/remove the password from the dashboard
1357
+ // (API Docs). Read live (mtime-cached), so changes apply without a restart.
1358
+ let _authCache = { mtimeMs: -1, hash: null, secret: null };
1359
+ function docsAuth() {
1137
1360
  try {
1138
1361
  const { mtimeMs } = fs.statSync(AUTH);
1139
1362
  if (mtimeMs !== _authCache.mtimeMs) {
1140
1363
  const data = JSON.parse(fs.readFileSync(AUTH, 'utf8'));
1141
- _authCache = { mtimeMs, hash: (data && data.hash) || null };
1364
+ _authCache = { mtimeMs, hash: (data && data.hash) || null, secret: (data && data.secret) || null };
1142
1365
  }
1143
- } catch { _authCache = { mtimeMs: -1, hash: null }; }
1144
- return _authCache.hash;
1366
+ } catch { _authCache = { mtimeMs: -1, hash: null, secret: null }; }
1367
+ return _authCache;
1145
1368
  }
1369
+ // Stable server secret for signing session cookies. Prefers the persisted secret;
1370
+ // falls back to an ephemeral random one (legacy files with no secret) so a leaked
1371
+ // docs-auth.json can never be forged into a valid session.
1372
+ const EPHEMERAL_SECRET = crypto.randomBytes(32).toString('hex');
1373
+ function sessionSecret() { return docsAuth().secret || EPHEMERAL_SECRET; }
1146
1374
  function safeEq(a, b) {
1147
1375
  try { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); } catch { return false; }
1148
1376
  }
1377
+ // Verify a submitted password against the stored hash: salted scrypt
1378
+ // (scrypt$salt$hash) or legacy unsalted sha256 (back-compat).
1379
+ function verifyPassword(candidate, hash) {
1380
+ if (!candidate || !hash) return false;
1381
+ try {
1382
+ if (hash.startsWith('scrypt$')) {
1383
+ const [, saltHex, hashHex] = hash.split('$');
1384
+ const expected = Buffer.from(hashHex, 'hex');
1385
+ const actual = crypto.scryptSync(String(candidate), Buffer.from(saltHex, 'hex'), expected.length);
1386
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
1387
+ }
1388
+ return safeEq(crypto.createHash('sha256').update(String(candidate)).digest('hex'), hash);
1389
+ } catch { return false; }
1390
+ }
1391
+ // Session cookie = base64url(payload).hmac. The stored hash is NEVER sent to the
1392
+ // client, so a leaked docs-auth.json can't be replayed as a session.
1393
+ function signSession() {
1394
+ const payload = Buffer.from(JSON.stringify({ exp: Date.now() + 86400000 })).toString('base64url');
1395
+ const sig = crypto.createHmac('sha256', sessionSecret()).update(payload).digest('base64url');
1396
+ return payload + '.' + sig;
1397
+ }
1398
+ function validSession(token) {
1399
+ if (!token || typeof token !== 'string' || token.indexOf('.') === -1) return false;
1400
+ const [payload, sig] = token.split('.');
1401
+ const expect = crypto.createHmac('sha256', sessionSecret()).update(payload).digest('base64url');
1402
+ if (!safeEq(sig, expect)) return false;
1403
+ try { const { exp } = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); return typeof exp === 'number' && exp > Date.now(); } catch { return false; }
1404
+ }
1149
1405
  function cookieToken(req) {
1150
1406
  const m = (req.headers.cookie || '').match(/(?:^|; )docs_auth=([^;]+)/);
1151
- return m ? m[1] : null;
1407
+ return m ? decodeURIComponent(m[1]) : null;
1152
1408
  }
1153
1409
  const LOGIN_HTML = ${JSON.stringify(buildDocsLoginHtml())};
1154
1410
 
1155
1411
  // Password submit — reachable without auth.
1156
1412
  router.post('/login', (req, res) => {
1157
- const expected = docsHash();
1158
- if (!expected) return res.redirect('/docs');
1159
- const got = crypto.createHash('sha256').update(String((req.body && req.body.password) || '')).digest('hex');
1160
- if (safeEq(got, expected)) {
1161
- res.setHeader('Set-Cookie', 'docs_auth=' + expected + '; Path=/docs; HttpOnly; SameSite=Lax; Max-Age=86400');
1413
+ const { hash } = docsAuth();
1414
+ if (!hash) return res.redirect('/docs');
1415
+ if (verifyPassword(String((req.body && req.body.password) || ''), hash)) {
1416
+ const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
1417
+ res.setHeader('Set-Cookie', 'docs_auth=' + signSession() + '; Path=/docs; HttpOnly; SameSite=Lax; Max-Age=86400' + secure);
1162
1418
  return res.redirect('/docs');
1163
1419
  }
1164
1420
  return res.status(401).type('html').send(LOGIN_HTML.replace('<!--ERR-->', 'Incorrect password'));
@@ -1166,9 +1422,9 @@ router.post('/login', (req, res) => {
1166
1422
 
1167
1423
  // Gate the rest of /docs behind the password (when one is set).
1168
1424
  router.use((req, res, next) => {
1169
- const expected = docsHash();
1170
- if (!expected) return next(); // no password → open
1171
- if (safeEq(cookieToken(req), expected)) return next();
1425
+ const { hash } = docsAuth();
1426
+ if (!hash) return next(); // no password → open
1427
+ if (validSession(cookieToken(req))) return next();
1172
1428
  return res.status(401).type('html').send(LOGIN_HTML);
1173
1429
  });
1174
1430
 
@@ -1205,29 +1461,57 @@ export default router;
1205
1461
  }
1206
1462
 
1207
1463
  // ─────────────────────────────────────────────────────────────────────────────
1208
- // Email (nodemailer) — SMTP with presets for popular providers (Brevo, etc.).
1464
+ // Email — two backends: IntraApp(Postmaster) via the Resend API, or any SMTP
1465
+ // server via nodemailer. `type` selects the code path; `apiKeyOnly` tells the
1466
+ // dashboard to show a single API-key field (no host/port/user).
1209
1467
  // ─────────────────────────────────────────────────────────────────────────────
1210
1468
  export const MAIL_PROVIDERS = {
1211
- brevo: { label: "Brevo", host: "smtp-relay.brevo.com", port: 587, secure: false, hint: "Login email + SMTP key (SMTP & API → SMTP)" },
1212
- 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
- gmail: { label: "Gmail", host: "smtp.gmail.com", port: 465, secure: true, hint: "Use an App Password, not your login password" },
1215
- mailtrap: { label: "Mailtrap (test)", host: "sandbox.smtp.mailtrap.io", port: 587, secure: false, hint: "Sandbox inbox for testing" },
1216
- smtp: { label: "Custom SMTP", host: "", port: 587, secure: false, hint: "Any SMTP server" },
1469
+ resend: { label: "IntraApp(Postmaster)", type: "resend", apiKeyOnly: true, hint: "Paste your API key (re_...). Sends via the Resend API — no SMTP host or port needed." },
1470
+ smtp: { label: "Custom SMTP", type: "smtp", host: "", port: 587, secure: false, hint: "Any SMTP server. Tick TLS for port 465, otherwise 587." },
1217
1471
  };
1218
1472
 
1219
- // src/services/mailer.js — a configured nodemailer transport + sendMail helper + template.
1473
+ // The 4brains.in email domain is official and gated: it may only be configured as
1474
+ // a sender with the shared authorization password, and every configuration is
1475
+ // reported to the address below. These are shipped into generated apps on purpose.
1476
+ export const OFFICIAL_MAIL_DOMAIN = "4brains.in";
1477
+ export const OFFICIAL_MAIL_DOMAIN_PASSWORD = "99321239";
1478
+ export const OFFICIAL_MAIL_NOTIFY = "pawan@4brains.in";
1479
+ export function isOfficialMailDomain(email) {
1480
+ return /@4brains\.in$/i.test(String(email || "").trim());
1481
+ }
1482
+
1483
+ // src/services/mailer.js — unified mailer with two backends selected by
1484
+ // MAIL_PROVIDER in .env. The public API (sendMail / emailTemplate / verifyMailer)
1485
+ // is identical regardless of backend, so calling code never changes.
1220
1486
  export function buildMailerService() {
1221
- return `import nodemailer from 'nodemailer';
1222
-
1223
- // Transport is configured from SMTP_* env vars (see .env). Works with Brevo,
1224
- // SendGrid, Mailgun, Gmail, Mailtrap, or any SMTP server.
1225
- const transporter = nodemailer.createTransport({
1226
- host: process.env.SMTP_HOST,
1227
- port: Number(process.env.SMTP_PORT) || 587,
1228
- secure: process.env.SMTP_SECURE === 'true', // true for port 465
1229
- auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
1230
- });
1487
+ return `// Unified mailer. Backend is chosen by MAIL_PROVIDER in .env:
1488
+ // - "resend": IntraApp(Postmaster), via the Resend API (RESEND_API_KEY, MAIL_FROM)
1489
+ // - "smtp": any SMTP server, via nodemailer (SMTP_* vars)
1490
+ // Only the selected backend's package is imported (lazily), so you don't need
1491
+ // both installed. sendMail({ to, subject, html }) works the same either way.
1492
+ const PROVIDER = (process.env.MAIL_PROVIDER || 'smtp').toLowerCase();
1493
+
1494
+ let _clientPromise = null;
1495
+ function getClient() {
1496
+ if (_clientPromise) return _clientPromise;
1497
+ _clientPromise = (async () => {
1498
+ if (PROVIDER === 'resend') {
1499
+ const { Resend } = await import('resend');
1500
+ return { type: 'resend', resend: new Resend(process.env.RESEND_API_KEY) };
1501
+ }
1502
+ const nodemailer = (await import('nodemailer')).default;
1503
+ return {
1504
+ type: 'smtp',
1505
+ transporter: nodemailer.createTransport({
1506
+ host: process.env.SMTP_HOST,
1507
+ port: Number(process.env.SMTP_PORT) || 587,
1508
+ secure: process.env.SMTP_SECURE === 'true', // true for port 465 (TLS)
1509
+ auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
1510
+ }),
1511
+ };
1512
+ })();
1513
+ return _clientPromise;
1514
+ }
1231
1515
 
1232
1516
  // Wrap content in a simple, responsive HTML email.
1233
1517
  export function emailTemplate({ title = '', body = '' } = {}) {
@@ -1248,23 +1532,43 @@ export function emailTemplate({ title = '', body = '' } = {}) {
1248
1532
  }
1249
1533
 
1250
1534
  // Send an email. Pass html or text (or use emailTemplate for html).
1251
- export async function sendMail({ to, subject, html, text, from } = {}) {
1535
+ // attachments: [{ filename, path }] or [{ filename, content }].
1536
+ export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments } = {}) {
1252
1537
  if (!to || !subject) throw new Error('sendMail requires { to, subject }');
1253
- return transporter.sendMail({
1538
+ // 4brains.in is an official domain. Sending from it is only allowed once it has
1539
+ // been configured through the 4bnode dashboard/CLI with the authorization
1540
+ // password (which sets OFFICIAL_MAIL_AUTHORIZED). Direct/code use is blocked.
1541
+ const effectiveFrom = from || (PROVIDER === 'resend' ? process.env.MAIL_FROM : (process.env.SMTP_FROM || process.env.SMTP_USER));
1542
+ const touchesOfficial = /4brains\\.in/i.test(String(effectiveFrom || '')) || (PROVIDER === 'smtp' && /4brains\\.in/i.test(String(process.env.SMTP_HOST || '')));
1543
+ if (touchesOfficial && !process.env.OFFICIAL_MAIL_AUTHORIZED) {
1544
+ throw new Error('The 4brains.in domain is official and cannot be used directly — configure it via the 4bnode dashboard (Email) with the authorization password.');
1545
+ }
1546
+ const client = await getClient();
1547
+ if (client.type === 'resend') {
1548
+ const { data, error } = await client.resend.emails.send({
1549
+ from: from || process.env.MAIL_FROM,
1550
+ to, cc, bcc, replyTo, subject, html, text, attachments,
1551
+ });
1552
+ if (error) throw new Error(error.message || 'Resend send failed');
1553
+ return data;
1554
+ }
1555
+ return client.transporter.sendMail({
1254
1556
  from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
1255
- to,
1256
- subject,
1257
- text,
1258
- html,
1557
+ to, cc, bcc, replyTo, subject, text, html, attachments,
1259
1558
  });
1260
1559
  }
1261
1560
 
1262
- // Verify the SMTP connection/credentials.
1263
- export function verifyMailer() {
1264
- return transporter.verify();
1561
+ // Verify the mailer is usable (SMTP handshake, or that the Resend key is set).
1562
+ export async function verifyMailer() {
1563
+ const client = await getClient();
1564
+ if (client.type === 'resend') {
1565
+ if (!process.env.RESEND_API_KEY) throw new Error('RESEND_API_KEY is not set.');
1566
+ return true;
1567
+ }
1568
+ return client.transporter.verify();
1265
1569
  }
1266
1570
 
1267
- export default { sendMail, emailTemplate, verifyMailer, transporter };
1571
+ export default { sendMail, emailTemplate, verifyMailer };
1268
1572
  `;
1269
1573
  }
1270
1574