4bnode 4.1.5 → 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.
- package/add-api.js +5 -3
- package/add-email.js +118 -28
- package/add-login.js +35 -16
- package/add-mongo-schema.js +13 -5
- package/lib/codegen.js +191 -57
- package/lib/codegen.test.js +87 -15
- package/lib/names.js +1 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
- package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +110 -50
- package/skeleton/.4bnode/codegen.js +191 -57
- package/skeleton/.4bnode/dev-api.js +284 -56
- package/skeleton/.env.example +17 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +7 -2
- package/skeleton/src/middleware/apiKey.js +14 -1
- package/skeleton/src/middleware/errorHandler.js +7 -2
- package/app-1/.4bnode/.passkey +0 -1
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +0 -139
- package/app-1/.4bnode/assets/js/Root.js +0 -40
- package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
- package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
- package/app-1/.4bnode/assets/js/context.js +0 -7
- package/app-1/.4bnode/assets/js/lib.js +0 -113
- package/app-1/.4bnode/assets/js/main.js +0 -5
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
- package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -224
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1485
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
- package/app-1/.4bnode/codegen.js +0 -1440
- package/app-1/.4bnode/dev-api.js +0 -3747
- package/app-1/.4bnode/ui.html +0 -1578
- package/app-1/.env.example +0 -11
- package/app-1/README.md +0 -47
- package/app-1/ecosystem.config.cjs +0 -25
- package/app-1/index.js +0 -188
- package/app-1/package.json +0 -37
- package/app-1/public/index.html +0 -292
- package/app-1/src/db.js +0 -13
- package/app-1/src/docs.js +0 -84
- package/app-1/src/middleware/apiKey.js +0 -36
- package/app-1/src/middleware/auth.js +0 -17
- package/app-1/src/middleware/errorHandler.js +0 -15
- package/app-1/src/middleware/roles.js +0 -14
- package/app-1/src/middleware/validate.js +0 -20
- package/app-1/src/models/user.js +0 -11
- package/app-1/src/openapi.json +0 -80
- package/app-1/src/routes/upload-resume.js +0 -21
- package/app-1/src/services/mailer.js +0 -52
- package/app-1/src/validators/upload-resume.js +0 -8
package/app-1/.4bnode/codegen.js
DELETED
|
@@ -1,1440 +0,0 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
-
// 4bnode shared code generation
|
|
3
|
-
//
|
|
4
|
-
// SINGLE SOURCE OF TRUTH for code that is emitted into generated projects.
|
|
5
|
-
//
|
|
6
|
-
// This module is PURE: no `fs`, no `path`, no npm dependencies, no I/O. Every
|
|
7
|
-
// export takes plain values and returns strings. That purity is what lets the
|
|
8
|
-
// same file run in two very different places:
|
|
9
|
-
//
|
|
10
|
-
// 1. The 4bnode CLI (add-*.js, index.js) — imports it from `./lib/codegen.js`.
|
|
11
|
-
// 2. The in-project dashboard (.4bnode/dev-api.js) — imports a SYNCED COPY at
|
|
12
|
-
// `./codegen.js`, because it runs inside the user's project where the
|
|
13
|
-
// package's lib/ does not exist.
|
|
14
|
-
//
|
|
15
|
-
// The copy in skeleton/.4bnode/codegen.js is generated by `lib/sync-skeleton.js`
|
|
16
|
-
// (npm run sync). Do NOT edit that copy by hand — edit THIS file and re-sync.
|
|
17
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
// ── Name helpers ────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
export function toCamelCase(str) {
|
|
22
|
-
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function toPascalCase(str) {
|
|
26
|
-
const camel = toCamelCase(str);
|
|
27
|
-
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function sanitizeName(input) {
|
|
31
|
-
if (!input || typeof input !== "string") {
|
|
32
|
-
throw new Error("Name is required.");
|
|
33
|
-
}
|
|
34
|
-
const trimmed = input.trim();
|
|
35
|
-
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(trimmed)) {
|
|
36
|
-
throw new Error(
|
|
37
|
-
`Invalid name "${trimmed}". Use only letters, numbers, hyphens, and underscores. Must start with a letter.`
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
return trimmed;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ── Pure string helpers for route files ─────────────────────────────────────
|
|
44
|
-
// These operate on file CONTENT (a string) and return new content. The
|
|
45
|
-
// file-reading/writing wrappers live in lib/routeFile.js (CLI) and inline in
|
|
46
|
-
// dev-api.js (dashboard); both delegate here so the insertion logic is shared.
|
|
47
|
-
|
|
48
|
-
export function addImportToContent(content, importStatement) {
|
|
49
|
-
const stmt = importStatement.trim();
|
|
50
|
-
if (content.includes(stmt)) return content;
|
|
51
|
-
return stmt + "\n" + content;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function insertCodeIntoContent(content, codeBlock) {
|
|
55
|
-
const block = codeBlock.trim();
|
|
56
|
-
|
|
57
|
-
// Prefer inserting before "export default router;" — most reliable anchor.
|
|
58
|
-
const exportIndex = content.lastIndexOf("export default router;");
|
|
59
|
-
if (exportIndex !== -1) {
|
|
60
|
-
return (
|
|
61
|
-
content.slice(0, exportIndex) +
|
|
62
|
-
block +
|
|
63
|
-
"\n\n" +
|
|
64
|
-
content.slice(exportIndex)
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Fallback: before the last "});".
|
|
69
|
-
const lastClose = content.lastIndexOf("});");
|
|
70
|
-
if (lastClose !== -1) {
|
|
71
|
-
return content.slice(0, lastClose) + block + "\n" + content.slice(lastClose);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return content + "\n" + block + "\n";
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ── CRUD route builders ─────────────────────────────────────────────────────
|
|
78
|
-
|
|
79
|
-
export function buildInsertCode(pascalName, fields, dataSource) {
|
|
80
|
-
const assignments = fields
|
|
81
|
-
.map((f) => ` ${f}: ${dataSource}.${f}`)
|
|
82
|
-
.join(",\n");
|
|
83
|
-
|
|
84
|
-
let passwordLogic = "";
|
|
85
|
-
if (fields.includes("password")) {
|
|
86
|
-
passwordLogic = `
|
|
87
|
-
if (newData.password) {
|
|
88
|
-
const salt = await bcrypt.genSalt(10);
|
|
89
|
-
newData.password = await bcrypt.hash(newData.password, salt);
|
|
90
|
-
}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return `
|
|
94
|
-
router.post('/create', async (req, res) => {
|
|
95
|
-
const newData = {
|
|
96
|
-
${assignments}
|
|
97
|
-
};
|
|
98
|
-
${passwordLogic}
|
|
99
|
-
try {
|
|
100
|
-
const newDocument = new ${pascalName}(newData);
|
|
101
|
-
await newDocument.save();
|
|
102
|
-
res.json({ message: 'Data inserted successfully', data: newDocument });
|
|
103
|
-
} catch (err) {
|
|
104
|
-
console.error(err);
|
|
105
|
-
res.status(500).json({ message: 'Server error' });
|
|
106
|
-
}
|
|
107
|
-
});`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function buildReadCode(pascalName) {
|
|
111
|
-
return `
|
|
112
|
-
router.get('/', async (req, res) => {
|
|
113
|
-
try {
|
|
114
|
-
const documents = await ${pascalName}.find();
|
|
115
|
-
res.json({ data: documents });
|
|
116
|
-
} catch (err) {
|
|
117
|
-
console.error(err);
|
|
118
|
-
res.status(500).json({ message: 'Server error' });
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
router.get('/:id', async (req, res) => {
|
|
123
|
-
try {
|
|
124
|
-
const document = await ${pascalName}.findById(req.params.id);
|
|
125
|
-
if (!document) {
|
|
126
|
-
return res.status(404).json({ message: 'Document not found' });
|
|
127
|
-
}
|
|
128
|
-
res.json({ data: document });
|
|
129
|
-
} catch (err) {
|
|
130
|
-
console.error(err);
|
|
131
|
-
res.status(500).json({ message: 'Server error' });
|
|
132
|
-
}
|
|
133
|
-
});`;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function buildUpdateCode(pascalName, fields, dataSource) {
|
|
137
|
-
const assignments = fields
|
|
138
|
-
.map((f) => ` ${f}: ${dataSource}.${f}`)
|
|
139
|
-
.join(",\n");
|
|
140
|
-
|
|
141
|
-
let passwordLogic = "";
|
|
142
|
-
if (fields.includes("password")) {
|
|
143
|
-
passwordLogic = `
|
|
144
|
-
if (updatedData.password) {
|
|
145
|
-
const salt = await bcrypt.genSalt(10);
|
|
146
|
-
updatedData.password = await bcrypt.hash(updatedData.password, salt);
|
|
147
|
-
}`;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return `
|
|
151
|
-
router.put('/:id', async (req, res) => {
|
|
152
|
-
const updatedData = {
|
|
153
|
-
${assignments}
|
|
154
|
-
};
|
|
155
|
-
${passwordLogic}
|
|
156
|
-
try {
|
|
157
|
-
const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { returnDocument: 'after' });
|
|
158
|
-
if (!updatedDocument) {
|
|
159
|
-
return res.status(404).json({ message: 'Document not found' });
|
|
160
|
-
}
|
|
161
|
-
res.json({ message: 'Data updated successfully', data: updatedDocument });
|
|
162
|
-
} catch (err) {
|
|
163
|
-
console.error(err);
|
|
164
|
-
res.status(500).json({ message: 'Server error' });
|
|
165
|
-
}
|
|
166
|
-
});`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function buildDeleteCode(pascalName) {
|
|
170
|
-
return `
|
|
171
|
-
router.delete('/:id', async (req, res) => {
|
|
172
|
-
try {
|
|
173
|
-
const deletedDocument = await ${pascalName}.findByIdAndDelete(req.params.id);
|
|
174
|
-
if (!deletedDocument) {
|
|
175
|
-
return res.status(404).json({ message: 'Document not found' });
|
|
176
|
-
}
|
|
177
|
-
res.json({ message: 'Data deleted successfully', data: deletedDocument });
|
|
178
|
-
} catch (err) {
|
|
179
|
-
console.error(err);
|
|
180
|
-
res.status(500).json({ message: 'Server error' });
|
|
181
|
-
}
|
|
182
|
-
});`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ── Mongoose model generation ───────────────────────────────────────────────
|
|
186
|
-
// Detailed generator supporting type, ref, required, unique, index, default,
|
|
187
|
-
// and compound/text indexes. `pascalName` is used for both the schema variable
|
|
188
|
-
// and the model name.
|
|
189
|
-
|
|
190
|
-
export function generateMongooseModel(pascalName, fields, indexes = []) {
|
|
191
|
-
const fieldLines = fields.map((f) => {
|
|
192
|
-
const type = f.type || "String";
|
|
193
|
-
const parts = [
|
|
194
|
-
type === "ObjectId"
|
|
195
|
-
? "type: mongoose.Schema.Types.ObjectId"
|
|
196
|
-
: `type: ${type}`,
|
|
197
|
-
];
|
|
198
|
-
if (f.ref) parts.push(`ref: '${f.ref}'`);
|
|
199
|
-
if (f.required) parts.push("required: true");
|
|
200
|
-
if (f.unique) parts.push("unique: true");
|
|
201
|
-
if (f.index) parts.push("index: true");
|
|
202
|
-
if (f.default !== undefined && f.default !== "")
|
|
203
|
-
parts.push(`default: ${f.default}`);
|
|
204
|
-
return ` ${f.name}: { ${parts.join(", ")} }`;
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
let indexLines = "";
|
|
208
|
-
for (const idx of indexes) {
|
|
209
|
-
const fieldObj = idx.fields
|
|
210
|
-
.map((f) => {
|
|
211
|
-
if (f.direction === "text") return `${f.field}: 'text'`;
|
|
212
|
-
return `${f.field}: ${f.direction || 1}`;
|
|
213
|
-
})
|
|
214
|
-
.join(", ");
|
|
215
|
-
const options = idx.unique ? ", { unique: true }" : "";
|
|
216
|
-
indexLines += `\n${pascalName}Schema.index({ ${fieldObj} }${options});`;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return `import mongoose from 'mongoose';
|
|
220
|
-
|
|
221
|
-
const ${pascalName}Schema = new mongoose.Schema({
|
|
222
|
-
${fieldLines.join(",\n")}
|
|
223
|
-
}, { timestamps: true });
|
|
224
|
-
${indexLines}
|
|
225
|
-
const ${pascalName} = mongoose.model('${pascalName}', ${pascalName}Schema);
|
|
226
|
-
|
|
227
|
-
export default ${pascalName};
|
|
228
|
-
`;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
232
|
-
// Phase 1 — Security & Middleware
|
|
233
|
-
//
|
|
234
|
-
// File-content builders for generated middleware. Index.js wiring (imports +
|
|
235
|
-
// app.use placement) is handled by the caller via `securityWiring()`.
|
|
236
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
237
|
-
|
|
238
|
-
// src/middleware/errorHandler.js — central 404 + error handler.
|
|
239
|
-
export function buildErrorHandlerFile() {
|
|
240
|
-
return `// Central error handling. Register AFTER all routes:
|
|
241
|
-
// app.use(notFound);
|
|
242
|
-
// app.use(errorHandler);
|
|
243
|
-
export function notFound(req, res, next) {
|
|
244
|
-
res.status(404).json({ message: \`Not found - \${req.originalUrl}\` });
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export function errorHandler(err, req, res, next) {
|
|
248
|
-
const status = err.status || err.statusCode || 500;
|
|
249
|
-
console.error(err);
|
|
250
|
-
res.status(status).json({
|
|
251
|
-
message: err.message || 'Server error',
|
|
252
|
-
...(process.env.NODE_ENV === 'production' ? {} : { stack: err.stack }),
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
`;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// src/middleware/roles.js — role-based access control (RBAC).
|
|
259
|
-
export function buildRolesGuardFile() {
|
|
260
|
-
return `// Role-based access control. Use AFTER the JWT auth middleware so req.user is set:
|
|
261
|
-
// router.get('/admin', auth, roles('admin'), handler);
|
|
262
|
-
// Requires the login token payload to include a \`role\` (e.g. jwt.sign({ id, role })).
|
|
263
|
-
const roles = (...allowed) => (req, res, next) => {
|
|
264
|
-
if (!req.user) {
|
|
265
|
-
return res.status(401).json({ message: 'Not authenticated' });
|
|
266
|
-
}
|
|
267
|
-
if (allowed.length && !allowed.includes(req.user.role)) {
|
|
268
|
-
return res.status(403).json({ message: 'Forbidden: insufficient role' });
|
|
269
|
-
}
|
|
270
|
-
next();
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
export default roles;
|
|
274
|
-
`;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// src/middleware/auth.js — JWT verification. Reads a Bearer token, verifies it
|
|
278
|
-
// with JWT_SECRET, and sets req.user. Use as middleware: router.get('/', auth, h).
|
|
279
|
-
export function buildAuthMiddleware() {
|
|
280
|
-
return `import jwt from 'jsonwebtoken';
|
|
281
|
-
|
|
282
|
-
const auth = (req, res, next) => {
|
|
283
|
-
const token = req.header('Authorization')?.replace('Bearer ', '');
|
|
284
|
-
if (!token) {
|
|
285
|
-
return res.status(401).json({ message: 'No token, authorization denied' });
|
|
286
|
-
}
|
|
287
|
-
try {
|
|
288
|
-
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
289
|
-
req.user = decoded;
|
|
290
|
-
next();
|
|
291
|
-
} catch (err) {
|
|
292
|
-
res.status(401).json({ message: 'Token is not valid' });
|
|
293
|
-
}
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
export default auth;
|
|
297
|
-
`;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// src/middleware/validate.js — Zod request validation middleware.
|
|
301
|
-
export function buildValidateFile() {
|
|
302
|
-
return `import { ZodError } from 'zod';
|
|
303
|
-
|
|
304
|
-
// Validate part of the request against a Zod schema. Replaces req[source] with the
|
|
305
|
-
// parsed/coerced data on success. Use as middleware:
|
|
306
|
-
// router.post('/', validate(userSchema), handler); // validates req.body
|
|
307
|
-
// router.get('/', validate(querySchema, 'query'), handler); // validates req.query
|
|
308
|
-
const validate = (schema, source = 'body') => (req, res, next) => {
|
|
309
|
-
const result = schema.safeParse(req[source]);
|
|
310
|
-
if (!result.success) {
|
|
311
|
-
const errors = (result.error instanceof ZodError ? result.error.issues : []).map((i) => ({
|
|
312
|
-
path: i.path.join('.'),
|
|
313
|
-
message: i.message,
|
|
314
|
-
}));
|
|
315
|
-
return res.status(400).json({ message: 'Validation failed', errors });
|
|
316
|
-
}
|
|
317
|
-
req[source] = result.data;
|
|
318
|
-
next();
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
export default validate;
|
|
322
|
-
`;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Map a Mongoose-style field descriptor to a Zod expression (no trailing optionality).
|
|
326
|
-
function zodForField(field) {
|
|
327
|
-
const t = field.type || "String";
|
|
328
|
-
switch (t) {
|
|
329
|
-
case "Number":
|
|
330
|
-
return "z.coerce.number()";
|
|
331
|
-
case "Boolean":
|
|
332
|
-
return "z.coerce.boolean()";
|
|
333
|
-
case "Date":
|
|
334
|
-
return "z.coerce.date()";
|
|
335
|
-
case "ObjectId":
|
|
336
|
-
return "z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid id')";
|
|
337
|
-
case "Array":
|
|
338
|
-
return "z.array(z.any())";
|
|
339
|
-
case "Buffer":
|
|
340
|
-
case "Mixed":
|
|
341
|
-
return "z.any()";
|
|
342
|
-
case "String":
|
|
343
|
-
default:
|
|
344
|
-
return "z.string()";
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// src/validators/<name>.js — a Zod schema derived from a model's fields.
|
|
349
|
-
export function buildZodSchemaFile(name, fields) {
|
|
350
|
-
const camel = toCamelCase(name);
|
|
351
|
-
const lines = fields.map((f) => {
|
|
352
|
-
let expr = zodForField(f);
|
|
353
|
-
if (!f.required) expr += ".optional()";
|
|
354
|
-
return ` ${f.name}: ${expr},`;
|
|
355
|
-
});
|
|
356
|
-
return `import { z } from 'zod';
|
|
357
|
-
|
|
358
|
-
export const ${camel}Schema = z.object({
|
|
359
|
-
${lines.join("\n")}
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
export default ${camel}Schema;
|
|
363
|
-
`;
|
|
364
|
-
}
|
|
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
|
-
|
|
416
|
-
// Wire `validate(<model>Schema)` into the write endpoints of a route file, and add
|
|
417
|
-
// the two needed imports. POST/PUT use the full schema; PATCH uses
|
|
418
|
-
// `<schema>.partial()` so partial updates aren't forced to send every required
|
|
419
|
-
// field. GET/DELETE are left alone (no body). Idempotent: skips endpoints that
|
|
420
|
-
// already have a validate(...) middleware. `modelBase` is the validator file base
|
|
421
|
-
// name (e.g. "user" → ../validators/user.js, userSchema). Returns the content
|
|
422
|
-
// unchanged when there are no write endpoints to wire.
|
|
423
|
-
export function wireValidateIntoRouteContent(content, modelBase) {
|
|
424
|
-
const schemaVar = toCamelCase(modelBase) + "Schema";
|
|
425
|
-
// Capture: (router.<method>('/...', <any existing middleware>, )(method)(handler arrow)
|
|
426
|
-
const handlerRe =
|
|
427
|
-
/(router\.(post|put|patch)\([^\n]*?,\s*)((?:async\s*)?\(\s*req\s*,\s*res\s*(?:,\s*next\s*)?\)\s*=>)/g;
|
|
428
|
-
let changed = false;
|
|
429
|
-
const replaced = content.replace(handlerRe, (full, head, method, handler) => {
|
|
430
|
-
if (head.includes("validate(")) return full; // already guarded
|
|
431
|
-
const expr = method === "patch" ? `${schemaVar}.partial()` : schemaVar;
|
|
432
|
-
changed = true;
|
|
433
|
-
return `${head}validate(${expr}), ${handler}`;
|
|
434
|
-
});
|
|
435
|
-
if (!changed) return content;
|
|
436
|
-
let out = addImportToContent(replaced, `import validate from '../middleware/validate.js';`);
|
|
437
|
-
out = addImportToContent(out, `import { ${schemaVar} } from '../validators/${modelBase}.js';`);
|
|
438
|
-
return out;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// Protect a whole route file with JWT: insert `auth` as the FIRST middleware on
|
|
442
|
-
// every endpoint (so it runs before validation), and add the import. Idempotent.
|
|
443
|
-
export function wireAuthIntoRouteContent(content) {
|
|
444
|
-
// (router.<method>('/path', )(existing middleware)(handler arrow)
|
|
445
|
-
const handlerRe =
|
|
446
|
-
/(router\.(?:get|post|put|patch|delete)\(\s*['"`][^'"`]*['"`]\s*,\s*)([\s\S]*?)((?:async\s*)?\(\s*req\s*,\s*res\s*(?:,\s*next\s*)?\)\s*=>)/g;
|
|
447
|
-
let changed = false;
|
|
448
|
-
const replaced = content.replace(handlerRe, (full, pathPart, middleware, handler) => {
|
|
449
|
-
if (/\bauth\s*,/.test(middleware)) return full; // already protected
|
|
450
|
-
changed = true;
|
|
451
|
-
return `${pathPart}auth, ${middleware}${handler}`;
|
|
452
|
-
});
|
|
453
|
-
if (!changed) return content;
|
|
454
|
-
return addImportToContent(replaced, "import auth from '../middleware/auth.js';");
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Anchor comment that marks the central error handler in index.js. Route
|
|
458
|
-
// registration looks for this so new routes are always inserted ABOVE it.
|
|
459
|
-
export const ERROR_HANDLER_MARKER = "// ── Error handling (must be last) ──";
|
|
460
|
-
|
|
461
|
-
// Index.js wiring plan for the selected app-level protections. Returns the imports
|
|
462
|
-
// to add and the code blocks to insert before routes / before the listen call.
|
|
463
|
-
// `features` is a set of: 'helmet', 'rateLimit', 'errorHandler'.
|
|
464
|
-
export function securityWiring(features) {
|
|
465
|
-
const has = (f) => features.includes(f);
|
|
466
|
-
const imports = [];
|
|
467
|
-
const beforeRoutesParts = [];
|
|
468
|
-
let beforeListen = "";
|
|
469
|
-
|
|
470
|
-
if (has("helmet")) {
|
|
471
|
-
imports.push("import helmet from '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
|
-
);
|
|
494
|
-
}
|
|
495
|
-
if (has("rateLimit")) {
|
|
496
|
-
imports.push("import rateLimit from 'express-rate-limit';");
|
|
497
|
-
beforeRoutesParts.push(
|
|
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.
|
|
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({
|
|
512
|
-
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
513
|
-
max: 10, // 10 attempts per IP per window
|
|
514
|
-
standardHeaders: true,
|
|
515
|
-
legacyHeaders: false,
|
|
516
|
-
});
|
|
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
|
-
}`
|
|
522
|
-
);
|
|
523
|
-
}
|
|
524
|
-
if (has("errorHandler")) {
|
|
525
|
-
imports.push(
|
|
526
|
-
"import { notFound, errorHandler } from './src/middleware/errorHandler.js';"
|
|
527
|
-
);
|
|
528
|
-
beforeListen = `${ERROR_HANDLER_MARKER}\napp.use(notFound);\napp.use(errorHandler);`;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
return { imports, beforeRoutes: beforeRoutesParts.join("\n\n"), beforeListen };
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// ── Pure index.js string transforms ─────────────────────────────────────────
|
|
535
|
-
// Used by both the CLI (lib/indexFile.js) and the in-project dashboard
|
|
536
|
-
// (dev-api.js) so the insertion logic lives in exactly one place.
|
|
537
|
-
|
|
538
|
-
// Add an import line after the last existing import. Idempotent.
|
|
539
|
-
export function addImportAfterLastImport(content, importLine) {
|
|
540
|
-
const line = importLine.trim();
|
|
541
|
-
if (content.includes(line)) return content;
|
|
542
|
-
const lines = content.split("\n");
|
|
543
|
-
let lastImportIdx = -1;
|
|
544
|
-
lines.forEach((l, i) => {
|
|
545
|
-
if (l.startsWith("import ")) lastImportIdx = i;
|
|
546
|
-
});
|
|
547
|
-
lines.splice(lastImportIdx + 1, 0, line);
|
|
548
|
-
return lines.join("\n");
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// Insert a code block just before the server starts listening. If the central
|
|
552
|
-
// error handler is present its marker takes precedence (new code stays above it,
|
|
553
|
-
// the handler stays last). Otherwise anchor on the listen/start CALL — matched by
|
|
554
|
-
// .index so the bare token inside `async function startServer(...)` is never hit.
|
|
555
|
-
export function insertBeforeListenContent(content, codeBlock) {
|
|
556
|
-
const block = codeBlock.trim();
|
|
557
|
-
const markerIdx = content.indexOf(ERROR_HANDLER_MARKER);
|
|
558
|
-
let at;
|
|
559
|
-
if (markerIdx !== -1) {
|
|
560
|
-
at = markerIdx;
|
|
561
|
-
} else {
|
|
562
|
-
const m =
|
|
563
|
-
content.match(/(app|server)\.listen\(port/) || content.match(/^startServer\(/m);
|
|
564
|
-
if (!m) return content + "\n" + block + "\n";
|
|
565
|
-
at = m.index;
|
|
566
|
-
}
|
|
567
|
-
return content.slice(0, at) + block + "\n\n" + content.slice(at);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// Insert app-level middleware (e.g. helmet) ahead of the route/start section so it
|
|
571
|
-
// wraps all subsequently-registered routes.
|
|
572
|
-
export function insertBeforeRoutesContent(content, codeBlock) {
|
|
573
|
-
const block = codeBlock.trim();
|
|
574
|
-
const markers = ["// ── Start Server", "// ── Helpers", "\nstartServer("];
|
|
575
|
-
let idx = -1;
|
|
576
|
-
for (const marker of markers) {
|
|
577
|
-
idx = content.indexOf(marker);
|
|
578
|
-
if (idx !== -1) break;
|
|
579
|
-
}
|
|
580
|
-
if (idx === -1) {
|
|
581
|
-
const m = content.match(/^startServer\(/m);
|
|
582
|
-
idx = m ? m.index : content.length;
|
|
583
|
-
}
|
|
584
|
-
return content.slice(0, idx) + block + "\n\n" + content.slice(idx);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
588
|
-
// Phase 2 — AI Builder
|
|
589
|
-
//
|
|
590
|
-
// The AI never emits raw code. It emits a structured PLAN (models + CRUD routes)
|
|
591
|
-
// that these deterministic builders turn into files — so AI output is held to the
|
|
592
|
-
// same shape and safety as hand-driven generation.
|
|
593
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
594
|
-
|
|
595
|
-
export const AI_FIELD_TYPES = [
|
|
596
|
-
"String", "Number", "Boolean", "Date", "ObjectId", "Array", "Mixed", "Buffer",
|
|
597
|
-
];
|
|
598
|
-
export const AI_OPERATIONS = ["create", "read", "update", "delete"];
|
|
599
|
-
|
|
600
|
-
// Supported AI providers. Each plan is requested with a forced tool/function call
|
|
601
|
-
// for structured output, so the same AI_PLAN_SCHEMA works across providers.
|
|
602
|
-
export const AI_PROVIDERS = {
|
|
603
|
-
openai: {
|
|
604
|
-
id: "openai",
|
|
605
|
-
label: "OpenAI",
|
|
606
|
-
keyEnv: "OPENAI_API_KEY",
|
|
607
|
-
defaultModel: "gpt-4o-mini",
|
|
608
|
-
keyPlaceholder: "sk-...",
|
|
609
|
-
keysUrl: "platform.openai.com",
|
|
610
|
-
},
|
|
611
|
-
anthropic: {
|
|
612
|
-
id: "anthropic",
|
|
613
|
-
label: "Anthropic (Claude)",
|
|
614
|
-
keyEnv: "ANTHROPIC_API_KEY",
|
|
615
|
-
defaultModel: "claude-sonnet-4-6",
|
|
616
|
-
keyPlaceholder: "sk-ant-...",
|
|
617
|
-
keysUrl: "console.anthropic.com",
|
|
618
|
-
},
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
export const DEFAULT_AI_PROVIDER = "openai";
|
|
622
|
-
|
|
623
|
-
// JSON Schema handed to Claude as a forced tool input — guarantees structured output.
|
|
624
|
-
export const AI_PLAN_SCHEMA = {
|
|
625
|
-
type: "object",
|
|
626
|
-
properties: {
|
|
627
|
-
summary: { type: "string", description: "One sentence describing what will be built." },
|
|
628
|
-
models: {
|
|
629
|
-
type: "array",
|
|
630
|
-
description: "Mongoose models to create.",
|
|
631
|
-
items: {
|
|
632
|
-
type: "object",
|
|
633
|
-
properties: {
|
|
634
|
-
name: { type: "string", description: "Singular lowercase model name, e.g. 'post'." },
|
|
635
|
-
fields: {
|
|
636
|
-
type: "array",
|
|
637
|
-
items: {
|
|
638
|
-
type: "object",
|
|
639
|
-
properties: {
|
|
640
|
-
name: { type: "string" },
|
|
641
|
-
type: { type: "string", enum: AI_FIELD_TYPES },
|
|
642
|
-
required: { type: "boolean" },
|
|
643
|
-
unique: { type: "boolean" },
|
|
644
|
-
ref: { type: "string", description: "Referenced model name (PascalCase) when type is ObjectId." },
|
|
645
|
-
},
|
|
646
|
-
required: ["name", "type"],
|
|
647
|
-
},
|
|
648
|
-
},
|
|
649
|
-
},
|
|
650
|
-
required: ["name", "fields"],
|
|
651
|
-
},
|
|
652
|
-
},
|
|
653
|
-
routes: {
|
|
654
|
-
type: "array",
|
|
655
|
-
description: "CRUD API routes to create, each bound to a model.",
|
|
656
|
-
items: {
|
|
657
|
-
type: "object",
|
|
658
|
-
properties: {
|
|
659
|
-
name: { type: "string", description: "Route name, usually plural, e.g. 'posts'." },
|
|
660
|
-
model: { type: "string", description: "The model name this route operates on." },
|
|
661
|
-
operations: { type: "array", items: { type: "string", enum: AI_OPERATIONS } },
|
|
662
|
-
},
|
|
663
|
-
required: ["name", "model", "operations"],
|
|
664
|
-
},
|
|
665
|
-
},
|
|
666
|
-
},
|
|
667
|
-
required: ["summary", "models", "routes"],
|
|
668
|
-
};
|
|
669
|
-
|
|
670
|
-
export const AI_SYSTEM_PROMPT = `You are a backend architect for a Node.js + Express + Mongoose application.
|
|
671
|
-
Turn the user's request into a concrete plan of Mongoose models and CRUD API routes.
|
|
672
|
-
Rules:
|
|
673
|
-
- Respond ONLY by calling the emit_plan tool. Do not write prose or code.
|
|
674
|
-
- Model names are singular and lowercase (e.g. "post", "user").
|
|
675
|
-
- Route names are usually the plural of their model (e.g. "posts").
|
|
676
|
-
- Allowed field types: ${AI_FIELD_TYPES.join(", ")}.
|
|
677
|
-
- For relations between models, use type "ObjectId" with "ref" set to the related model's PascalCase name.
|
|
678
|
-
- Include a "password" field (type String) only when authentication is clearly intended; it will be auto-hashed.
|
|
679
|
-
- Every route must reference one of the models in the plan and choose from operations: ${AI_OPERATIONS.join(", ")}.
|
|
680
|
-
- Keep the plan minimal and faithful to the request; do not invent unrelated models.`;
|
|
681
|
-
|
|
682
|
-
// Coerce an arbitrary string into a safe JS identifier name, or null if impossible.
|
|
683
|
-
function safeIdentifier(name) {
|
|
684
|
-
if (typeof name !== "string") return null;
|
|
685
|
-
const cleaned = name.trim().replace(/[^a-zA-Z0-9_-]/g, "");
|
|
686
|
-
if (!cleaned || !/^[a-zA-Z]/.test(cleaned)) return null;
|
|
687
|
-
return cleaned;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Validate and clean a raw plan from the model into a safe, de-duplicated plan.
|
|
691
|
-
// Defensive: bad models/fields/routes are dropped rather than trusted.
|
|
692
|
-
export function normalizeAiPlan(plan) {
|
|
693
|
-
const out = {
|
|
694
|
-
summary: typeof plan?.summary === "string" ? plan.summary : "",
|
|
695
|
-
models: [],
|
|
696
|
-
routes: [],
|
|
697
|
-
};
|
|
698
|
-
|
|
699
|
-
const modelNames = new Set();
|
|
700
|
-
for (const m of Array.isArray(plan?.models) ? plan.models : []) {
|
|
701
|
-
const ident = safeIdentifier(m?.name);
|
|
702
|
-
if (!ident) continue;
|
|
703
|
-
const name = toCamelCase(ident);
|
|
704
|
-
if (modelNames.has(name)) continue;
|
|
705
|
-
|
|
706
|
-
const fields = [];
|
|
707
|
-
const seen = new Set();
|
|
708
|
-
for (const f of Array.isArray(m?.fields) ? m.fields : []) {
|
|
709
|
-
const fIdent = safeIdentifier(f?.name);
|
|
710
|
-
if (!fIdent || seen.has(fIdent)) continue;
|
|
711
|
-
const type = AI_FIELD_TYPES.includes(f?.type) ? f.type : "String";
|
|
712
|
-
const field = { name: fIdent, type };
|
|
713
|
-
if (f?.required) field.required = true;
|
|
714
|
-
if (f?.unique) field.unique = true;
|
|
715
|
-
const refIdent = type === "ObjectId" ? safeIdentifier(f?.ref) : null;
|
|
716
|
-
if (refIdent) field.ref = toPascalCase(refIdent);
|
|
717
|
-
seen.add(fIdent);
|
|
718
|
-
fields.push(field);
|
|
719
|
-
}
|
|
720
|
-
if (fields.length === 0) continue;
|
|
721
|
-
modelNames.add(name);
|
|
722
|
-
out.models.push({ name, fields });
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Map a model's lowercased name to its canonical name so a route that
|
|
726
|
-
// references the model with different capitalization still resolves to the
|
|
727
|
-
// exact file that was written. Without this, the model file (model.name) and
|
|
728
|
-
// the route import path (route.model) can diverge only in first-letter case —
|
|
729
|
-
// harmless on case-insensitive macOS, but a "Cannot find module" crash on a
|
|
730
|
-
// case-sensitive Linux production server.
|
|
731
|
-
const modelByKey = new Map(out.models.map((m) => [m.name.toLowerCase(), m.name]));
|
|
732
|
-
|
|
733
|
-
const routeNames = new Set();
|
|
734
|
-
for (const r of Array.isArray(plan?.routes) ? plan.routes : []) {
|
|
735
|
-
const ident = safeIdentifier(r?.name);
|
|
736
|
-
const model = safeIdentifier(r?.model);
|
|
737
|
-
if (!ident || !model) continue;
|
|
738
|
-
const name = toCamelCase(ident);
|
|
739
|
-
if (routeNames.has(name)) continue;
|
|
740
|
-
let operations = (Array.isArray(r?.operations) ? r.operations : []).filter((o) =>
|
|
741
|
-
AI_OPERATIONS.includes(o)
|
|
742
|
-
);
|
|
743
|
-
if (operations.length === 0) operations = [...AI_OPERATIONS];
|
|
744
|
-
routeNames.add(name);
|
|
745
|
-
const modelCamel = toCamelCase(model);
|
|
746
|
-
const resolvedModel = modelByKey.get(modelCamel.toLowerCase()) || modelCamel;
|
|
747
|
-
out.routes.push({ name, model: resolvedModel, operations });
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
return out;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// Build a complete CRUD route file (imports + router + selected operations + export)
|
|
754
|
-
// for a model, reusing the same builders as `add crud`.
|
|
755
|
-
export function buildCrudRouteFile(routeName, modelName, fieldNames = [], operations = AI_OPERATIONS) {
|
|
756
|
-
const pascal = toPascalCase(modelName);
|
|
757
|
-
const needsBcrypt =
|
|
758
|
-
fieldNames.includes("password") &&
|
|
759
|
-
(operations.includes("create") || operations.includes("update"));
|
|
760
|
-
|
|
761
|
-
const importLines = ["import express from 'express';"];
|
|
762
|
-
if (needsBcrypt) importLines.push("import bcrypt from 'bcrypt';");
|
|
763
|
-
importLines.push(`import ${pascal} from '../models/${modelName}.js';`);
|
|
764
|
-
|
|
765
|
-
const blocks = [];
|
|
766
|
-
if (operations.includes("create")) blocks.push(buildInsertCode(pascal, fieldNames, "req.body"));
|
|
767
|
-
if (operations.includes("read")) blocks.push(buildReadCode(pascal));
|
|
768
|
-
if (operations.includes("update")) blocks.push(buildUpdateCode(pascal, fieldNames, "req.body"));
|
|
769
|
-
if (operations.includes("delete")) blocks.push(buildDeleteCode(pascal));
|
|
770
|
-
|
|
771
|
-
return `${importLines.join("\n")}
|
|
772
|
-
|
|
773
|
-
const router = express.Router();
|
|
774
|
-
${blocks.join("\n")}
|
|
775
|
-
|
|
776
|
-
export default router;
|
|
777
|
-
`;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
781
|
-
// Dependency bookkeeping — record packages in package.json so the generated app
|
|
782
|
-
// is always installable, even when a live `npm install` is interrupted/offline.
|
|
783
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
784
|
-
|
|
785
|
-
// Known versions for packages 4bnode wires into generated apps.
|
|
786
|
-
export const PACKAGE_VERSIONS = {
|
|
787
|
-
helmet: "^8.0.0",
|
|
788
|
-
"express-rate-limit": "^7.4.0",
|
|
789
|
-
zod: "^3.23.0",
|
|
790
|
-
bcrypt: "^5.1.1",
|
|
791
|
-
jsonwebtoken: "^9.0.2",
|
|
792
|
-
"socket.io": "^4.8.1",
|
|
793
|
-
ws: "^8.18.0",
|
|
794
|
-
serialport: "^12.0.0",
|
|
795
|
-
"@serialport/parser-readline": "^12.0.0",
|
|
796
|
-
mongoose: "^8.8.0",
|
|
797
|
-
multer: "^1.4.5-lts.1",
|
|
798
|
-
nodemailer: "^6.9.0",
|
|
799
|
-
};
|
|
800
|
-
|
|
801
|
-
// Add packages to package.json (dependencies, or devDependencies when dev:true).
|
|
802
|
-
// Pure: takes the file CONTENT, returns { content, changed }. Skips names already
|
|
803
|
-
// present in either deps or devDeps. Unknown packages fall back to "latest".
|
|
804
|
-
export function addDepsToPackageJson(content, names, { dev = false } = {}) {
|
|
805
|
-
const pkg = JSON.parse(content);
|
|
806
|
-
const key = dev ? "devDependencies" : "dependencies";
|
|
807
|
-
pkg[key] = pkg[key] || {};
|
|
808
|
-
let changed = false;
|
|
809
|
-
for (const name of names) {
|
|
810
|
-
const inDeps = pkg.dependencies && pkg.dependencies[name];
|
|
811
|
-
const inDev = pkg.devDependencies && pkg.devDependencies[name];
|
|
812
|
-
if (!inDeps && !inDev) {
|
|
813
|
-
pkg[key][name] = PACKAGE_VERSIONS[name] || "latest";
|
|
814
|
-
changed = true;
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
return { content: JSON.stringify(pkg, null, 2) + "\n", changed };
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
821
|
-
// Request-field detection — used by the API Tester to auto-fill keys from a route.
|
|
822
|
-
// Handles BOTH destructuring (`const { a, b } = req.body`) AND direct member access
|
|
823
|
-
// (`req.body.a`, `req.body['a']`), since generated CRUD routes use the latter.
|
|
824
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
825
|
-
export function extractRequestFields(handlerBody, source = "body") {
|
|
826
|
-
const src = source === "query" ? "query" : "body";
|
|
827
|
-
const names = [];
|
|
828
|
-
const seen = new Set();
|
|
829
|
-
const add = (raw) => {
|
|
830
|
-
const n = (raw || "").trim();
|
|
831
|
-
if (n && /^[A-Za-z_$][\w$]*$/.test(n) && !seen.has(n)) {
|
|
832
|
-
seen.add(n);
|
|
833
|
-
names.push(n);
|
|
834
|
-
}
|
|
835
|
-
};
|
|
836
|
-
const body = handlerBody || "";
|
|
837
|
-
// const { a, b: x } = req.<src> → take the key before any ':'
|
|
838
|
-
const destructure = body.match(new RegExp(`const\\s*\\{\\s*([^}]+)\\}\\s*=\\s*req\\.${src}`));
|
|
839
|
-
if (destructure) destructure[1].split(",").forEach((f) => add(f.split(":")[0]));
|
|
840
|
-
// req.<src>.field
|
|
841
|
-
const dot = new RegExp(`req\\.${src}\\.(\\w+)`, "g");
|
|
842
|
-
let m;
|
|
843
|
-
while ((m = dot.exec(body)) !== null) add(m[1]);
|
|
844
|
-
// req.<src>['field'] / req.<src>["field"]
|
|
845
|
-
const bracket = new RegExp(`req\\.${src}\\[\\s*['"](\\w+)['"]\\s*\\]`, "g");
|
|
846
|
-
while ((m = bracket.exec(body)) !== null) add(m[1]);
|
|
847
|
-
return names;
|
|
848
|
-
}
|
|
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
|
-
|
|
874
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
875
|
-
// API Documentation — OpenAPI 3 spec + Swagger UI (served at /docs).
|
|
876
|
-
// Reuses extractRequestFields output (endpoints) and model field metadata.
|
|
877
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
878
|
-
function openApiType(t) {
|
|
879
|
-
switch (t) {
|
|
880
|
-
case "Number":
|
|
881
|
-
case "Int": return { type: "integer" };
|
|
882
|
-
case "Float": return { type: "number" };
|
|
883
|
-
case "Boolean": return { type: "boolean" };
|
|
884
|
-
case "Date":
|
|
885
|
-
case "DateTime": return { type: "string", format: "date-time" };
|
|
886
|
-
case "ObjectId": return { type: "string" };
|
|
887
|
-
case "Array": return { type: "array", items: {} };
|
|
888
|
-
case "Buffer": return { type: "string", format: "binary" };
|
|
889
|
-
case "Json":
|
|
890
|
-
case "Mixed": return { type: "object" };
|
|
891
|
-
case "String":
|
|
892
|
-
default: return { type: "string" };
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// Build an OpenAPI 3.0 document from parsed endpoints + models.
|
|
897
|
-
// endpoints: [{ method, path, fullPath, bodyFields[], queryFields[], params[], fileFields[], hasAuth }]
|
|
898
|
-
// models: [{ name, fields:[{name,type,required}] }]
|
|
899
|
-
export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints = [], models = [] } = {}) {
|
|
900
|
-
const paths = {};
|
|
901
|
-
for (const ep of endpoints) {
|
|
902
|
-
const raw = ep.fullPath || ep.path || "/";
|
|
903
|
-
const oaPath = raw.replace(/:(\w+)/g, "{$1}");
|
|
904
|
-
const method = (ep.method || "GET").toLowerCase();
|
|
905
|
-
paths[oaPath] = paths[oaPath] || {};
|
|
906
|
-
const op = { summary: `${ep.method} ${raw}`, responses: { "200": { description: "Success" } } };
|
|
907
|
-
|
|
908
|
-
const parameters = [];
|
|
909
|
-
(ep.params || []).forEach((p) => parameters.push({ name: p, in: "path", required: true, schema: { type: "string" } }));
|
|
910
|
-
(ep.queryFields || []).forEach((f) => parameters.push({ name: f.name, in: "query", required: false, schema: openApiType(f.type) }));
|
|
911
|
-
if (parameters.length) op.parameters = parameters;
|
|
912
|
-
|
|
913
|
-
const bodyFields = ep.bodyFields || [];
|
|
914
|
-
const fileFields = ep.fileFields || [];
|
|
915
|
-
if (["post", "put", "patch"].includes(method) && (bodyFields.length || fileFields.length)) {
|
|
916
|
-
const properties = {};
|
|
917
|
-
const required = [];
|
|
918
|
-
bodyFields.forEach((f) => { properties[f.name] = openApiType(f.type); if (f.required) required.push(f.name); });
|
|
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); });
|
|
922
|
-
const schema = { type: "object", properties };
|
|
923
|
-
if (required.length) schema.required = required;
|
|
924
|
-
const mime = fileFields.length ? "multipart/form-data" : "application/json";
|
|
925
|
-
op.requestBody = { content: { [mime]: { schema } } };
|
|
926
|
-
}
|
|
927
|
-
if (ep.hasAuth) op.security = [{ bearerAuth: [] }];
|
|
928
|
-
paths[oaPath][method] = op;
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
const schemas = {};
|
|
932
|
-
for (const m of models) {
|
|
933
|
-
const properties = {};
|
|
934
|
-
(m.fields || []).forEach((f) => { properties[f.name] = openApiType(f.type); });
|
|
935
|
-
schemas[toPascalCase(m.name)] = { type: "object", properties };
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
const components = { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" } } };
|
|
939
|
-
if (Object.keys(schemas).length) components.schemas = schemas;
|
|
940
|
-
|
|
941
|
-
return { openapi: "3.0.0", info: { title, version }, servers: [{ url: "/" }], paths, components };
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
// A self-contained, modern API documentation page rendered from the spec.
|
|
945
|
-
// No Swagger, no external CDN, no third-party branding — pure 4bnode dark style.
|
|
946
|
-
// Two-pane layout: endpoint list on the left, selected endpoint details + an
|
|
947
|
-
// inline "Try it" client on the right. Fetches the spec at runtime.
|
|
948
|
-
export function buildDocsHtml(specUrl = "openapi.json") {
|
|
949
|
-
return `<!doctype html>
|
|
950
|
-
<html lang="en">
|
|
951
|
-
<head>
|
|
952
|
-
<meta charset="utf-8" />
|
|
953
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
954
|
-
<title>API Documentation</title>
|
|
955
|
-
<style>
|
|
956
|
-
* { box-sizing: border-box; }
|
|
957
|
-
:root {
|
|
958
|
-
--bg:#0f1117; --surface:#11141e; --card:#1a1d2b; --card-hover:#21253a; --input:#13161f;
|
|
959
|
-
--border:rgba(255,255,255,0.08); --text:#eceef4; --muted:#8b8fa3; --faint:#626880;
|
|
960
|
-
--accent:#0079ff; --accent-light:#3daefd; --accent-glow:rgba(0,121,255,0.14);
|
|
961
|
-
}
|
|
962
|
-
* { scrollbar-width: thin; }
|
|
963
|
-
html, body { margin:0; padding:0; height:100%; }
|
|
964
|
-
body { background:var(--bg); color:var(--text); line-height:1.5; height:100vh;
|
|
965
|
-
display:flex; flex-direction:column; overflow:hidden;
|
|
966
|
-
font-family:"Noto Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
|
|
967
|
-
.topbar { flex:none; display:flex; align-items:center; gap:14px;
|
|
968
|
-
background:var(--surface); border-bottom:1px solid var(--border); padding:14px 24px; }
|
|
969
|
-
.topbar h1 { font-size:17px; font-weight:700; margin:0;
|
|
970
|
-
background:linear-gradient(135deg,#3daefd,#0079ff); -webkit-background-clip:text;
|
|
971
|
-
background-clip:text; -webkit-text-fill-color:transparent; }
|
|
972
|
-
.pill-ver { font-size:12px; color:var(--accent-light); background:rgba(0,121,255,0.12);
|
|
973
|
-
border:1px solid rgba(0,121,255,0.25); padding:3px 9px; border-radius:999px; }
|
|
974
|
-
.layout { flex:1; display:flex; min-height:0; }
|
|
975
|
-
.sidebar { width:330px; flex:none; border-right:1px solid var(--border);
|
|
976
|
-
overflow-y:auto; padding:14px 12px; }
|
|
977
|
-
.search { width:100%; background:var(--input); border:1px solid var(--border);
|
|
978
|
-
color:var(--text); border-radius:9px; padding:8px 12px; font-size:13px; outline:none; margin-bottom:12px; }
|
|
979
|
-
.search:focus { border-color:var(--accent); }
|
|
980
|
-
.group-title { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.6px;
|
|
981
|
-
color:var(--faint); margin:14px 6px 6px; }
|
|
982
|
-
.nav-item { display:flex; align-items:center; gap:9px; padding:8px 10px; border-radius:8px;
|
|
983
|
-
cursor:pointer; border:1px solid transparent; }
|
|
984
|
-
.nav-item:hover { background:var(--card-hover); }
|
|
985
|
-
.nav-item.active { background:var(--accent-glow); border-color:rgba(0,121,255,0.35); }
|
|
986
|
-
.nav-method { font-size:9.5px; font-weight:700; letter-spacing:0.4px; text-transform:uppercase;
|
|
987
|
-
color:#fff; padding:3px 0; border-radius:5px; width:46px; flex:none; text-align:center; }
|
|
988
|
-
.nav-path { font-family:ui-monospace,Menlo,monospace; font-size:12.5px; color:var(--text);
|
|
989
|
-
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
990
|
-
.main { flex:1; overflow-y:auto; padding:30px 40px; }
|
|
991
|
-
.main-empty { color:var(--faint); display:flex; height:100%; align-items:center; justify-content:center; }
|
|
992
|
-
.detail-head { display:flex; align-items:center; gap:14px; margin-bottom:6px; }
|
|
993
|
-
.method-lg { font-size:13px; font-weight:700; letter-spacing:0.5px; text-transform:uppercase;
|
|
994
|
-
color:#fff; padding:6px 12px; border-radius:8px; }
|
|
995
|
-
.path-lg { font-family:ui-monospace,Menlo,monospace; font-size:18px; color:var(--text); word-break:break-all; }
|
|
996
|
-
.detail-sum { color:var(--muted); margin:0 0 8px; }
|
|
997
|
-
.lock { color:var(--faint); font-size:12px; margin-left:8px; white-space:nowrap; }
|
|
998
|
-
.sec { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px;
|
|
999
|
-
color:var(--faint); margin:22px 0 8px; }
|
|
1000
|
-
.row { display:flex; gap:10px; align-items:center; padding:8px 0;
|
|
1001
|
-
border-bottom:1px solid var(--border); font-size:13px; }
|
|
1002
|
-
.row:last-child { border-bottom:none; }
|
|
1003
|
-
.fname { font-family:ui-monospace,Menlo,monospace; color:var(--text); min-width:150px; }
|
|
1004
|
-
.ftype { color:var(--accent-light); font-size:12px; }
|
|
1005
|
-
.req { color:#ef4444; font-size:11px; font-weight:700; }
|
|
1006
|
-
.opt { color:var(--faint); font-size:11px; }
|
|
1007
|
-
.resp-code { font-family:ui-monospace,Menlo,monospace; color:#22c55e; font-weight:700; }
|
|
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;
|
|
1016
|
-
font-family:ui-monospace,Menlo,monospace; }
|
|
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); }
|
|
1021
|
-
::-webkit-scrollbar { width:9px; height:9px; }
|
|
1022
|
-
::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }
|
|
1023
|
-
</style>
|
|
1024
|
-
</head>
|
|
1025
|
-
<body>
|
|
1026
|
-
<div class="topbar">
|
|
1027
|
-
<h1 id="apiTitle">API</h1>
|
|
1028
|
-
<span class="pill-ver" id="apiVersion">v1.0.0</span>
|
|
1029
|
-
</div>
|
|
1030
|
-
<div class="layout">
|
|
1031
|
-
<aside class="sidebar">
|
|
1032
|
-
<input class="search" id="search" placeholder="Filter endpoints..." />
|
|
1033
|
-
<div id="nav"></div>
|
|
1034
|
-
</aside>
|
|
1035
|
-
<main class="main" id="main"><div class="main-empty">Select an endpoint to view its details.</div></main>
|
|
1036
|
-
</div>
|
|
1037
|
-
<script>
|
|
1038
|
-
(function(){
|
|
1039
|
-
var SPEC_URL='${specUrl}';
|
|
1040
|
-
var COLORS={get:'#0079ff',post:'#22c55e',put:'#f59e0b',patch:'#a855f7',delete:'#ef4444',head:'#64748b',options:'#64748b'};
|
|
1041
|
-
var uid=0; var ITEMS=[];
|
|
1042
|
-
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
1043
|
-
function typeName(sc){if(!sc)return'';if(sc.format==='binary')return'file';if(sc.$ref)return sc.$ref.split('/').pop();return sc.type||'string';}
|
|
1044
|
-
function paramsOf(op,where){return (op.parameters||[]).filter(function(p){return p.in===where;});}
|
|
1045
|
-
function bodyOf(op){if(!op.requestBody)return null;var c=op.requestBody.content||{};var mime=Object.keys(c)[0];if(!mime)return null;return {mime:mime,schema:(c[mime]||{}).schema||{}};}
|
|
1046
|
-
function sampleVal(sc){var t=typeName(sc);if(t==='integer'||t==='number')return 0;if(t==='boolean')return false;return'';}
|
|
1047
|
-
function bodySkeleton(schema){var o={};var p=(schema&&schema.properties)||{};Object.keys(p).forEach(function(k){o[k]=sampleVal(p[k]);});return JSON.stringify(o,null,2);}
|
|
1048
|
-
function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}
|
|
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;}
|
|
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
|
-
|
|
1135
|
-
function detailsHtml(e,id){
|
|
1136
|
-
var html='';
|
|
1137
|
-
var pp=paramsOf(e.op,'path'),qp=paramsOf(e.op,'query'),body=bodyOf(e.op);
|
|
1138
|
-
if(pp.length||qp.length){
|
|
1139
|
-
html+='<div class="sec">Parameters</div>';
|
|
1140
|
-
pp.concat(qp).forEach(function(p){
|
|
1141
|
-
html+='<div class="row"><span class="fname">'+esc(p.name)+'</span><span class="ftype">'+esc(typeName(p.schema)||'string')+' · '+esc(p.in)+'</span>'+(p.required?'<span class="req">required</span>':'<span class="opt">optional</span>')+'</div>';
|
|
1142
|
-
});
|
|
1143
|
-
}
|
|
1144
|
-
if(body){
|
|
1145
|
-
html+='<div class="sec">Request body <span class="opt">('+esc(body.mime)+')</span></div>';
|
|
1146
|
-
var props=(body.schema&&body.schema.properties)||{};var reqd=(body.schema&&body.schema.required)||[];
|
|
1147
|
-
Object.keys(props).forEach(function(k){
|
|
1148
|
-
html+='<div class="row"><span class="fname">'+esc(k)+'</span><span class="ftype">'+esc(typeName(props[k]))+'</span>'+(reqd.indexOf(k)>=0?'<span class="req">required</span>':'<span class="opt">optional</span>')+'</div>';
|
|
1149
|
-
});
|
|
1150
|
-
}
|
|
1151
|
-
html+='<div class="sec">Responses</div>';
|
|
1152
|
-
var resp=e.op.responses||{};
|
|
1153
|
-
Object.keys(resp).forEach(function(code){
|
|
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>';
|
|
1155
|
-
});
|
|
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>';
|
|
1164
|
-
return html;
|
|
1165
|
-
}
|
|
1166
|
-
|
|
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);
|
|
1184
|
-
});
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
function showDetail(e,navEl){
|
|
1188
|
-
document.querySelectorAll('.nav-item').forEach(function(n){n.classList.remove('active');});
|
|
1189
|
-
if(navEl)navEl.classList.add('active');
|
|
1190
|
-
var id='ep'+(uid++);
|
|
1191
|
-
var main=document.getElementById('main');
|
|
1192
|
-
var color=COLORS[e.method]||'#64748b';
|
|
1193
|
-
var lock=e.op.security?'<span class="lock">🔒 requires auth</span>':'';
|
|
1194
|
-
var html='<div class="detail-head"><span class="method-lg" style="background:'+color+'">'+esc(e.method)+'</span><span class="path-lg">'+esc(e.path)+'</span></div>';
|
|
1195
|
-
html+='<div class="detail-sum">'+esc(e.op.summary||'')+lock+'</div>';
|
|
1196
|
-
html+=detailsHtml(e,id);
|
|
1197
|
-
main.innerHTML=html;main.scrollTop=0;
|
|
1198
|
-
wireSamples(id,main);
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
function render(spec){
|
|
1202
|
-
document.getElementById('apiTitle').textContent=(spec.info&&spec.info.title)||'API';
|
|
1203
|
-
document.getElementById('apiVersion').textContent='v'+((spec.info&&spec.info.version)||'1.0.0');
|
|
1204
|
-
ITEMS=collect(spec);
|
|
1205
|
-
var nav=document.getElementById('nav');nav.innerHTML='';
|
|
1206
|
-
if(!ITEMS.length){
|
|
1207
|
-
nav.innerHTML='<div class="group-title">No endpoints</div>';
|
|
1208
|
-
document.getElementById('main').innerHTML='<div class="main-empty">No endpoints in the spec yet. Add routes, then regenerate the docs from the dashboard (API Docs).</div>';
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
var groups={};ITEMS.forEach(function(e){var k=groupKey(e.path);(groups[k]=groups[k]||[]).push(e);});
|
|
1212
|
-
var first=null;
|
|
1213
|
-
Object.keys(groups).sort().forEach(function(k){
|
|
1214
|
-
var gt=document.createElement('div');gt.className='group-title';gt.textContent=k;nav.appendChild(gt);
|
|
1215
|
-
groups[k].forEach(function(e){
|
|
1216
|
-
var color=COLORS[e.method]||'#64748b';
|
|
1217
|
-
var item=document.createElement('div');item.className='nav-item';
|
|
1218
|
-
item.setAttribute('data-search',(e.method+' '+e.path+' '+(e.op.summary||'')).toLowerCase());
|
|
1219
|
-
item.innerHTML='<span class="nav-method" style="background:'+color+'">'+esc(e.method)+'</span><span class="nav-path">'+esc(e.path)+'</span>';
|
|
1220
|
-
item.addEventListener('click',function(){showDetail(e,item);});
|
|
1221
|
-
nav.appendChild(item);
|
|
1222
|
-
if(!first)first={e:e,item:item};
|
|
1223
|
-
});
|
|
1224
|
-
});
|
|
1225
|
-
if(first)showDetail(first.e,first.item);
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
document.getElementById('search').addEventListener('input',function(ev){
|
|
1229
|
-
var q=ev.target.value.toLowerCase();
|
|
1230
|
-
document.querySelectorAll('.nav-item').forEach(function(c){c.style.display=c.getAttribute('data-search').indexOf(q)>=0?'':'none';});
|
|
1231
|
-
});
|
|
1232
|
-
|
|
1233
|
-
// The server inlines the spec below (so a protected page needs no second
|
|
1234
|
-
// authenticated request — avoids cross-browser cookie quirks on fetch). If it
|
|
1235
|
-
// isn't inlined (e.g. the page is served statically), fall back to fetching.
|
|
1236
|
-
var SPEC_DATA=/*__SPEC__*/null;
|
|
1237
|
-
if(SPEC_DATA&&typeof SPEC_DATA==='object'){
|
|
1238
|
-
render(SPEC_DATA);
|
|
1239
|
-
} else {
|
|
1240
|
-
fetch(SPEC_URL,{credentials:'same-origin'}).then(function(r){
|
|
1241
|
-
if(!r.ok){throw new Error('the server returned HTTP '+r.status+'. Regenerate the docs from the dashboard (API Docs).');}
|
|
1242
|
-
return r.json();
|
|
1243
|
-
}).then(render).catch(function(err){
|
|
1244
|
-
document.getElementById('main').innerHTML='<div class="main-empty">Could not load the API spec — '+esc(String((err&&err.message)||err))+'</div>';
|
|
1245
|
-
});
|
|
1246
|
-
}
|
|
1247
|
-
})();
|
|
1248
|
-
</script>
|
|
1249
|
-
</body>
|
|
1250
|
-
</html>`;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// src/docs.js — a router that serves the spec + the self-contained docs page.
|
|
1254
|
-
// Password-only login page for protected docs (no username field). The
|
|
1255
|
-
// <!--ERR--> placeholder is swapped for an error message on a wrong password.
|
|
1256
|
-
export function buildDocsLoginHtml() {
|
|
1257
|
-
return `<!doctype html>
|
|
1258
|
-
<html lang="en"><head>
|
|
1259
|
-
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1260
|
-
<title>API Documentation</title>
|
|
1261
|
-
<style>
|
|
1262
|
-
*{box-sizing:border-box}
|
|
1263
|
-
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0f1117;color:#eceef4;font-family:"Noto Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
|
|
1264
|
-
form{background:#1a1d2b;border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:28px;width:340px;max-width:90vw;text-align:center}
|
|
1265
|
-
h1{font-size:18px;margin:0 0 6px;background:linear-gradient(135deg,#3daefd,#0079ff);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
|
1266
|
-
p{color:#8b8fa3;font-size:13px;margin:0 0 18px}
|
|
1267
|
-
input{width:100%;background:#13161f;border:1px solid rgba(255,255,255,0.08);border-radius:9px;color:#eceef4;padding:11px 13px;font-size:14px;outline:none}
|
|
1268
|
-
input:focus{border-color:#0079ff}
|
|
1269
|
-
.err{color:#ef4444;font-size:12px;min-height:16px;margin:8px 0 0}
|
|
1270
|
-
button{width:100%;margin-top:14px;background:#0079ff;color:#fff;border:none;border-radius:9px;padding:11px;font-size:14px;font-weight:600;cursor:pointer}
|
|
1271
|
-
button:hover{filter:brightness(1.08)}
|
|
1272
|
-
</style></head>
|
|
1273
|
-
<body>
|
|
1274
|
-
<form method="post" action="/docs/login">
|
|
1275
|
-
<h1>API Documentation</h1>
|
|
1276
|
-
<p>Enter the password to view the docs.</p>
|
|
1277
|
-
<input type="password" name="password" placeholder="Password" autofocus autocomplete="current-password" />
|
|
1278
|
-
<div class="err"><!--ERR--></div>
|
|
1279
|
-
<button type="submit">Unlock</button>
|
|
1280
|
-
</form>
|
|
1281
|
-
</body></html>`;
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
export function buildDocsRouter() {
|
|
1285
|
-
return `import express from 'express';
|
|
1286
|
-
import fs from 'fs';
|
|
1287
|
-
import path from 'path';
|
|
1288
|
-
import crypto from 'crypto';
|
|
1289
|
-
import { fileURLToPath } from 'url';
|
|
1290
|
-
|
|
1291
|
-
const router = express.Router();
|
|
1292
|
-
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
1293
|
-
const SPEC = path.join(DIR, 'openapi.json');
|
|
1294
|
-
const AUTH = path.join(DIR, 'docs-auth.json');
|
|
1295
|
-
|
|
1296
|
-
// Optional password protection. If src/docs-auth.json holds a password hash, the
|
|
1297
|
-
// whole /docs surface requires a single password (NO username) via a small login
|
|
1298
|
-
// page + cookie. Set/remove the password from the dashboard (API Docs). Read live
|
|
1299
|
-
// (mtime-cached), so changes apply without a restart.
|
|
1300
|
-
let _authCache = { mtimeMs: -1, hash: null };
|
|
1301
|
-
function docsHash() {
|
|
1302
|
-
try {
|
|
1303
|
-
const { mtimeMs } = fs.statSync(AUTH);
|
|
1304
|
-
if (mtimeMs !== _authCache.mtimeMs) {
|
|
1305
|
-
const data = JSON.parse(fs.readFileSync(AUTH, 'utf8'));
|
|
1306
|
-
_authCache = { mtimeMs, hash: (data && data.hash) || null };
|
|
1307
|
-
}
|
|
1308
|
-
} catch { _authCache = { mtimeMs: -1, hash: null }; }
|
|
1309
|
-
return _authCache.hash;
|
|
1310
|
-
}
|
|
1311
|
-
function safeEq(a, b) {
|
|
1312
|
-
try { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); } catch { return false; }
|
|
1313
|
-
}
|
|
1314
|
-
function cookieToken(req) {
|
|
1315
|
-
const m = (req.headers.cookie || '').match(/(?:^|; )docs_auth=([^;]+)/);
|
|
1316
|
-
return m ? m[1] : null;
|
|
1317
|
-
}
|
|
1318
|
-
const LOGIN_HTML = ${JSON.stringify(buildDocsLoginHtml())};
|
|
1319
|
-
|
|
1320
|
-
// Password submit — reachable without auth.
|
|
1321
|
-
router.post('/login', (req, res) => {
|
|
1322
|
-
const expected = docsHash();
|
|
1323
|
-
if (!expected) return res.redirect('/docs');
|
|
1324
|
-
const got = crypto.createHash('sha256').update(String((req.body && req.body.password) || '')).digest('hex');
|
|
1325
|
-
if (safeEq(got, expected)) {
|
|
1326
|
-
res.setHeader('Set-Cookie', 'docs_auth=' + expected + '; Path=/docs; HttpOnly; SameSite=Lax; Max-Age=86400');
|
|
1327
|
-
return res.redirect('/docs');
|
|
1328
|
-
}
|
|
1329
|
-
return res.status(401).type('html').send(LOGIN_HTML.replace('<!--ERR-->', 'Incorrect password'));
|
|
1330
|
-
});
|
|
1331
|
-
|
|
1332
|
-
// Gate the rest of /docs behind the password (when one is set).
|
|
1333
|
-
router.use((req, res, next) => {
|
|
1334
|
-
const expected = docsHash();
|
|
1335
|
-
if (!expected) return next(); // no password → open
|
|
1336
|
-
if (safeEq(cookieToken(req), expected)) return next();
|
|
1337
|
-
return res.status(401).type('html').send(LOGIN_HTML);
|
|
1338
|
-
});
|
|
1339
|
-
|
|
1340
|
-
// Interactive API documentation. The page is fully self-contained (no external
|
|
1341
|
-
// CDN); the spec is generated by 4bnode and served from this app.
|
|
1342
|
-
router.get('/openapi.json', (req, res) => {
|
|
1343
|
-
res.set('Cache-Control', 'no-store');
|
|
1344
|
-
if (!fs.existsSync(SPEC)) return res.status(404).json({ error: 'API spec not generated yet.' });
|
|
1345
|
-
res.sendFile(SPEC);
|
|
1346
|
-
});
|
|
1347
|
-
|
|
1348
|
-
const DOCS_PAGE = ${JSON.stringify(buildDocsHtml("/docs/openapi.json"))};
|
|
1349
|
-
|
|
1350
|
-
router.get('/', (req, res) => {
|
|
1351
|
-
// Inline the spec into the page so a password-protected page doesn't need a
|
|
1352
|
-
// second authenticated request (some browsers won't attach the docs cookie to
|
|
1353
|
-
// the fetch). Falls back to fetching /openapi.json if no spec is inlined.
|
|
1354
|
-
let spec = 'null';
|
|
1355
|
-
try {
|
|
1356
|
-
if (fs.existsSync(SPEC)) {
|
|
1357
|
-
const raw = fs.readFileSync(SPEC, 'utf8').trim();
|
|
1358
|
-
// Escape '<' so a spec value containing '</script>' can't break out of the
|
|
1359
|
-
// inline <script>. The '\\u003c' sequence still evaluates to '<' in JS.
|
|
1360
|
-
if (raw) spec = raw.split('<').join(String.fromCharCode(92) + 'u003c');
|
|
1361
|
-
}
|
|
1362
|
-
} catch {}
|
|
1363
|
-
// Never cache the docs page — keeps it from going stale after a regenerate.
|
|
1364
|
-
res.set('Cache-Control', 'no-store');
|
|
1365
|
-
res.type('html').send(DOCS_PAGE.replace('/*__SPEC__*/null', () => spec));
|
|
1366
|
-
});
|
|
1367
|
-
|
|
1368
|
-
export default router;
|
|
1369
|
-
`;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1373
|
-
// Email (nodemailer) — SMTP with presets for popular providers (Brevo, etc.).
|
|
1374
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1375
|
-
export const MAIL_PROVIDERS = {
|
|
1376
|
-
mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
|
|
1377
|
-
brevo: { label: "Brevo", host: "smtp-relay.brevo.com", port: 587, secure: false, hint: "Login email + SMTP key (SMTP & API → SMTP)" },
|
|
1378
|
-
sendgrid: { label: "SendGrid", host: "smtp.sendgrid.net", port: 587, secure: false, user: "apikey", hint: "User is literally 'apikey'; pass is your API key" },
|
|
1379
|
-
gmail: { label: "Gmail", host: "smtp.gmail.com", port: 465, secure: true, hint: "Use an App Password, not your login password" },
|
|
1380
|
-
mailtrap: { label: "Mailtrap (test)", host: "sandbox.smtp.mailtrap.io", port: 587, secure: false, hint: "Sandbox inbox for testing" },
|
|
1381
|
-
smtp: { label: "Custom SMTP", host: "", port: 587, secure: false, hint: "Any SMTP server" },
|
|
1382
|
-
};
|
|
1383
|
-
|
|
1384
|
-
// src/services/mailer.js — a configured nodemailer transport + sendMail helper + template.
|
|
1385
|
-
export function buildMailerService() {
|
|
1386
|
-
return `import nodemailer from 'nodemailer';
|
|
1387
|
-
|
|
1388
|
-
// Transport is configured from SMTP_* env vars (see .env). Works with Brevo,
|
|
1389
|
-
// SendGrid, Mailgun, Gmail, Mailtrap, or any SMTP server.
|
|
1390
|
-
const transporter = nodemailer.createTransport({
|
|
1391
|
-
host: process.env.SMTP_HOST,
|
|
1392
|
-
port: Number(process.env.SMTP_PORT) || 587,
|
|
1393
|
-
secure: process.env.SMTP_SECURE === 'true', // true for port 465
|
|
1394
|
-
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
|
1395
|
-
});
|
|
1396
|
-
|
|
1397
|
-
// Wrap content in a simple, responsive HTML email.
|
|
1398
|
-
export function emailTemplate({ title = '', body = '' } = {}) {
|
|
1399
|
-
return \`<!doctype html>
|
|
1400
|
-
<html>
|
|
1401
|
-
<body style="margin:0;background:#f4f5f7;font-family:Arial,Helvetica,sans-serif;">
|
|
1402
|
-
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:24px 0;">
|
|
1403
|
-
<tr><td align="center">
|
|
1404
|
-
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:10px;overflow:hidden;">
|
|
1405
|
-
<tr><td style="background:#0079ff;padding:18px 28px;color:#fff;font-size:18px;font-weight:bold;">\${title}</td></tr>
|
|
1406
|
-
<tr><td style="padding:28px;color:#333;font-size:15px;line-height:1.6;">\${body}</td></tr>
|
|
1407
|
-
<tr><td style="padding:16px 28px;color:#9aa0ab;font-size:12px;border-top:1px solid #eee;">Sent by your app</td></tr>
|
|
1408
|
-
</table>
|
|
1409
|
-
</td></tr>
|
|
1410
|
-
</table>
|
|
1411
|
-
</body>
|
|
1412
|
-
</html>\`;
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
// Send an email. Pass html or text (or use emailTemplate for html).
|
|
1416
|
-
// attachments: [{ filename, path }] or [{ filename, content }] (nodemailer format).
|
|
1417
|
-
export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments } = {}) {
|
|
1418
|
-
if (!to || !subject) throw new Error('sendMail requires { to, subject }');
|
|
1419
|
-
return transporter.sendMail({
|
|
1420
|
-
from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
|
|
1421
|
-
to,
|
|
1422
|
-
cc,
|
|
1423
|
-
bcc,
|
|
1424
|
-
replyTo,
|
|
1425
|
-
subject,
|
|
1426
|
-
text,
|
|
1427
|
-
html,
|
|
1428
|
-
attachments,
|
|
1429
|
-
});
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
// Verify the SMTP connection/credentials.
|
|
1433
|
-
export function verifyMailer() {
|
|
1434
|
-
return transporter.verify();
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
export default { sendMail, emailTemplate, verifyMailer, transporter };
|
|
1438
|
-
`;
|
|
1439
|
-
}
|
|
1440
|
-
|