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/lib/codegen.js
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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) {
|
|
@@ -514,8 +569,10 @@ const authLimiter = rateLimit({
|
|
|
514
569
|
standardHeaders: true,
|
|
515
570
|
legacyHeaders: false,
|
|
516
571
|
});
|
|
517
|
-
//
|
|
518
|
-
|
|
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') {
|
|
519
576
|
app.use('/api/auth', authLimiter); // strict limiter first, on auth routes
|
|
520
577
|
app.use(apiLimiter); // generous global limiter for everything else
|
|
521
578
|
}`
|
|
@@ -796,6 +853,7 @@ export const PACKAGE_VERSIONS = {
|
|
|
796
853
|
mongoose: "^8.8.0",
|
|
797
854
|
multer: "^1.4.5-lts.1",
|
|
798
855
|
nodemailer: "^6.9.0",
|
|
856
|
+
resend: "^4.0.0",
|
|
799
857
|
};
|
|
800
858
|
|
|
801
859
|
// Add packages to package.json (dependencies, or devDependencies when dev:true).
|
|
@@ -1293,37 +1351,70 @@ const DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
1293
1351
|
const SPEC = path.join(DIR, 'openapi.json');
|
|
1294
1352
|
const AUTH = path.join(DIR, 'docs-auth.json');
|
|
1295
1353
|
|
|
1296
|
-
// Optional password protection. If src/docs-auth.json holds
|
|
1354
|
+
// Optional password protection. If src/docs-auth.json holds { hash, secret }, the
|
|
1297
1355
|
// whole /docs surface requires a single password (NO username) via a small login
|
|
1298
|
-
// page + cookie. Set/remove the password from the dashboard
|
|
1299
|
-
// (mtime-cached), so changes apply without a restart.
|
|
1300
|
-
let _authCache = { mtimeMs: -1, hash: null };
|
|
1301
|
-
function
|
|
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() {
|
|
1302
1360
|
try {
|
|
1303
1361
|
const { mtimeMs } = fs.statSync(AUTH);
|
|
1304
1362
|
if (mtimeMs !== _authCache.mtimeMs) {
|
|
1305
1363
|
const data = JSON.parse(fs.readFileSync(AUTH, 'utf8'));
|
|
1306
|
-
_authCache = { mtimeMs, hash: (data && data.hash) || null };
|
|
1364
|
+
_authCache = { mtimeMs, hash: (data && data.hash) || null, secret: (data && data.secret) || null };
|
|
1307
1365
|
}
|
|
1308
|
-
} catch { _authCache = { mtimeMs: -1, hash: null }; }
|
|
1309
|
-
return _authCache
|
|
1366
|
+
} catch { _authCache = { mtimeMs: -1, hash: null, secret: null }; }
|
|
1367
|
+
return _authCache;
|
|
1310
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; }
|
|
1311
1374
|
function safeEq(a, b) {
|
|
1312
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; }
|
|
1313
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
|
+
}
|
|
1314
1405
|
function cookieToken(req) {
|
|
1315
1406
|
const m = (req.headers.cookie || '').match(/(?:^|; )docs_auth=([^;]+)/);
|
|
1316
|
-
return m ? m[1] : null;
|
|
1407
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
1317
1408
|
}
|
|
1318
1409
|
const LOGIN_HTML = ${JSON.stringify(buildDocsLoginHtml())};
|
|
1319
1410
|
|
|
1320
1411
|
// Password submit — reachable without auth.
|
|
1321
1412
|
router.post('/login', (req, res) => {
|
|
1322
|
-
const
|
|
1323
|
-
if (!
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
res.setHeader('Set-Cookie', 'docs_auth=' +
|
|
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);
|
|
1327
1418
|
return res.redirect('/docs');
|
|
1328
1419
|
}
|
|
1329
1420
|
return res.status(401).type('html').send(LOGIN_HTML.replace('<!--ERR-->', 'Incorrect password'));
|
|
@@ -1331,9 +1422,9 @@ router.post('/login', (req, res) => {
|
|
|
1331
1422
|
|
|
1332
1423
|
// Gate the rest of /docs behind the password (when one is set).
|
|
1333
1424
|
router.use((req, res, next) => {
|
|
1334
|
-
const
|
|
1335
|
-
if (!
|
|
1336
|
-
if (
|
|
1425
|
+
const { hash } = docsAuth();
|
|
1426
|
+
if (!hash) return next(); // no password → open
|
|
1427
|
+
if (validSession(cookieToken(req))) return next();
|
|
1337
1428
|
return res.status(401).type('html').send(LOGIN_HTML);
|
|
1338
1429
|
});
|
|
1339
1430
|
|
|
@@ -1370,29 +1461,57 @@ export default router;
|
|
|
1370
1461
|
}
|
|
1371
1462
|
|
|
1372
1463
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1373
|
-
// Email
|
|
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).
|
|
1374
1467
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1375
1468
|
export const MAIL_PROVIDERS = {
|
|
1376
|
-
|
|
1377
|
-
|
|
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" },
|
|
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." },
|
|
1382
1471
|
};
|
|
1383
1472
|
|
|
1384
|
-
//
|
|
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.
|
|
1385
1486
|
export function buildMailerService() {
|
|
1386
|
-
return
|
|
1387
|
-
|
|
1388
|
-
//
|
|
1389
|
-
//
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
+
}
|
|
1396
1515
|
|
|
1397
1516
|
// Wrap content in a simple, responsive HTML email.
|
|
1398
1517
|
export function emailTemplate({ title = '', body = '' } = {}) {
|
|
@@ -1413,28 +1532,43 @@ export function emailTemplate({ title = '', body = '' } = {}) {
|
|
|
1413
1532
|
}
|
|
1414
1533
|
|
|
1415
1534
|
// Send an email. Pass html or text (or use emailTemplate for html).
|
|
1416
|
-
// attachments: [{ filename, path }] or [{ filename, content }]
|
|
1535
|
+
// attachments: [{ filename, path }] or [{ filename, content }].
|
|
1417
1536
|
export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments } = {}) {
|
|
1418
1537
|
if (!to || !subject) throw new Error('sendMail requires { to, subject }');
|
|
1419
|
-
|
|
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({
|
|
1420
1556
|
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,
|
|
1557
|
+
to, cc, bcc, replyTo, subject, text, html, attachments,
|
|
1429
1558
|
});
|
|
1430
1559
|
}
|
|
1431
1560
|
|
|
1432
|
-
// Verify the SMTP
|
|
1433
|
-
export function verifyMailer() {
|
|
1434
|
-
|
|
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();
|
|
1435
1569
|
}
|
|
1436
1570
|
|
|
1437
|
-
export default { sendMail, emailTemplate, verifyMailer
|
|
1571
|
+
export default { sendMail, emailTemplate, verifyMailer };
|
|
1438
1572
|
`;
|
|
1439
1573
|
}
|
|
1440
1574
|
|
package/lib/codegen.test.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
toCamelCase,
|
|
13
13
|
toPascalCase,
|
|
14
14
|
sanitizeName,
|
|
15
|
+
sanitizeFieldName,
|
|
15
16
|
addImportToContent,
|
|
16
17
|
insertCodeIntoContent,
|
|
17
18
|
buildInsertCode,
|
|
@@ -44,6 +45,9 @@ import {
|
|
|
44
45
|
buildDocsRouter,
|
|
45
46
|
MAIL_PROVIDERS,
|
|
46
47
|
buildMailerService,
|
|
48
|
+
isOfficialMailDomain,
|
|
49
|
+
OFFICIAL_MAIL_DOMAIN_PASSWORD,
|
|
50
|
+
OFFICIAL_MAIL_NOTIFY,
|
|
47
51
|
} from "./codegen.js";
|
|
48
52
|
|
|
49
53
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -230,7 +234,7 @@ test("wireValidateIntoRouteContent guards write endpoints only, idempotently", (
|
|
|
230
234
|
});
|
|
231
235
|
|
|
232
236
|
test("buildAuthMiddleware + wireAuthIntoRouteContent protects every endpoint, auth-first, idempotently", () => {
|
|
233
|
-
assert.ok(buildAuthMiddleware().includes("jwt.verify(token, process.env.JWT_SECRET)"));
|
|
237
|
+
assert.ok(buildAuthMiddleware().includes("jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] })"));
|
|
234
238
|
assert.ok(buildAuthMiddleware().includes("export default auth"));
|
|
235
239
|
const route = [
|
|
236
240
|
"import express from 'express';",
|
|
@@ -499,28 +503,96 @@ test("buildDocsHtml + buildDocsRouter are well-formed, self-contained (no CDN, n
|
|
|
499
503
|
assert.ok(router.trimEnd().endsWith("export default router;"));
|
|
500
504
|
});
|
|
501
505
|
|
|
502
|
-
// ── Email (
|
|
503
|
-
test("MAIL_PROVIDERS
|
|
504
|
-
assert.
|
|
505
|
-
assert.equal(MAIL_PROVIDERS.
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
assert.equal(MAIL_PROVIDERS.
|
|
510
|
-
|
|
506
|
+
// ── Email (Resend + SMTP) ────────────────────────────────────────────────────
|
|
507
|
+
test("MAIL_PROVIDERS has exactly IntraApp(Postmaster) + Custom SMTP", () => {
|
|
508
|
+
assert.deepEqual(Object.keys(MAIL_PROVIDERS).sort(), ["resend", "smtp"]);
|
|
509
|
+
assert.equal(MAIL_PROVIDERS.resend.label, "IntraApp(Postmaster)");
|
|
510
|
+
assert.equal(MAIL_PROVIDERS.resend.type, "resend");
|
|
511
|
+
assert.equal(MAIL_PROVIDERS.resend.apiKeyOnly, true);
|
|
512
|
+
assert.equal(MAIL_PROVIDERS.smtp.type, "smtp");
|
|
513
|
+
assert.equal(MAIL_PROVIDERS.smtp.port, 587);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("official 4brains.in mail domain is gated", () => {
|
|
517
|
+
assert.ok(isOfficialMailDomain("alok@4brains.in"));
|
|
518
|
+
assert.ok(isOfficialMailDomain(" ALOK@4Brains.IN "));
|
|
519
|
+
assert.ok(!isOfficialMailDomain("alok@gmail.com"));
|
|
520
|
+
assert.ok(!isOfficialMailDomain("alok@sub.4brains.in.evil.com"));
|
|
521
|
+
assert.equal(OFFICIAL_MAIL_DOMAIN_PASSWORD, "99321239");
|
|
522
|
+
assert.equal(OFFICIAL_MAIL_NOTIFY, "pawan@4brains.in");
|
|
511
523
|
});
|
|
512
524
|
|
|
513
|
-
test("buildMailerService
|
|
525
|
+
test("buildMailerService supports both Resend and SMTP backends", () => {
|
|
514
526
|
const s = buildMailerService();
|
|
515
|
-
assert.ok(s.includes("
|
|
516
|
-
assert.ok(s.includes("
|
|
517
|
-
assert.ok(s.includes("
|
|
527
|
+
assert.ok(s.includes("MAIL_PROVIDER"), "provider switch");
|
|
528
|
+
assert.ok(s.includes("await import('resend')") && s.includes("new Resend("), "Resend backend");
|
|
529
|
+
assert.ok(s.includes("await import('nodemailer')") && s.includes("createTransport"), "SMTP backend");
|
|
530
|
+
assert.ok(s.includes("process.env.RESEND_API_KEY") && s.includes("process.env.SMTP_HOST"));
|
|
531
|
+
// Runtime gate: 4brains.in from-address is blocked unless authorized.
|
|
532
|
+
assert.ok(s.includes("4brains") && s.includes("OFFICIAL_MAIL_AUTHORIZED"));
|
|
518
533
|
assert.ok(s.includes("export async function sendMail"));
|
|
519
534
|
assert.ok(s.includes("export function emailTemplate"));
|
|
520
|
-
assert.ok(s.includes("export function verifyMailer"));
|
|
535
|
+
assert.ok(s.includes("export async function verifyMailer"));
|
|
521
536
|
assert.ok(s.trimEnd().endsWith("};"));
|
|
522
537
|
});
|
|
523
538
|
|
|
539
|
+
// ── Security hardening ──────────────────────────────────────────────────────
|
|
540
|
+
test("buildReadCode paginates and never returns password", () => {
|
|
541
|
+
const out = buildReadCode("User");
|
|
542
|
+
assert.ok(out.includes(".select('-password')"), "list/detail exclude password");
|
|
543
|
+
assert.ok(out.includes("req.query.page") && out.includes("req.query.limit"), "paginated");
|
|
544
|
+
assert.ok(out.includes("countDocuments()"), "returns total");
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("create/update DTOs drop mass-assignable privilege fields", () => {
|
|
548
|
+
const fields = ["name", "email", "role", "isAdmin", "__proto__", "password"];
|
|
549
|
+
for (const code of [buildInsertCode("User", fields, "req.body"), buildUpdateCode("User", fields, "req.body")]) {
|
|
550
|
+
assert.ok(code.includes("name: req.body.name"), "keeps normal fields");
|
|
551
|
+
assert.ok(code.includes("email: req.body.email"), "keeps normal fields");
|
|
552
|
+
assert.ok(!/\brole:\s*req\.body\.role\b/.test(code), "drops role");
|
|
553
|
+
assert.ok(!/isAdmin:\s*req\.body\.isAdmin/.test(code), "drops isAdmin");
|
|
554
|
+
assert.ok(!code.includes("__proto__: req.body"), "drops __proto__");
|
|
555
|
+
assert.ok(code.includes("password: req.body.password"), "keeps password (hashed below)");
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("generateMongooseModel marks password select:false", () => {
|
|
560
|
+
const out = generateMongooseModel("User", [
|
|
561
|
+
{ name: "email", type: "String" },
|
|
562
|
+
{ name: "password", type: "String", required: true },
|
|
563
|
+
]);
|
|
564
|
+
assert.ok(/password:\s*\{[^}]*select:\s*false/.test(out), "password hidden by default");
|
|
565
|
+
assert.ok(!/email:\s*\{[^}]*select:\s*false/.test(out), "non-password fields untouched");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("errorHandler hides 5xx internals in production", () => {
|
|
569
|
+
const src = buildErrorHandlerFile();
|
|
570
|
+
assert.ok(src.includes("err.expose"), "honors err.expose");
|
|
571
|
+
assert.ok(src.includes("status >= 400 && status < 500"), "only 4xx messages pass in prod");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("buildAuthMiddleware pins algorithm and rejects non-access tokens", () => {
|
|
575
|
+
const src = buildAuthMiddleware();
|
|
576
|
+
assert.ok(src.includes("algorithms: ['HS256']"), "algorithm pinned");
|
|
577
|
+
assert.ok(src.includes("decoded.type") && src.includes("!== 'access'"), "type checked");
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("buildDocsRouter never sets the cookie to the stored hash", () => {
|
|
581
|
+
const src = buildDocsRouter();
|
|
582
|
+
assert.ok(src.includes("signSession()"), "signed session token");
|
|
583
|
+
assert.ok(src.includes("createHmac"), "HMAC-signed cookie");
|
|
584
|
+
assert.ok(!src.includes("docs_auth=' + expected"), "cookie is not the hash");
|
|
585
|
+
assert.ok(src.includes("scrypt$"), "supports salted scrypt");
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("sanitizeFieldName rejects prototype keys and bad identifiers", () => {
|
|
589
|
+
assert.equal(sanitizeFieldName(" email "), "email");
|
|
590
|
+
assert.equal(sanitizeFieldName("_id"), "_id");
|
|
591
|
+
for (const bad of ["__proto__", "constructor", "prototype", "a b", "a-b", "1x", "a } = req.body"]) {
|
|
592
|
+
assert.throws(() => sanitizeFieldName(bad), new RegExp("Invalid field name|reserved"), `should reject ${bad}`);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
524
596
|
// ── Skeleton drift guard ────────────────────────────────────────────────────
|
|
525
597
|
test("skeleton codegen.js is in sync with lib/codegen.js", () => {
|
|
526
598
|
const src = fs.readFileSync(path.join(root, "lib", "codegen.js"), "utf8");
|
package/lib/names.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Name helpers live in lib/codegen.js (the pure, project-syncable module) so the
|
|
2
2
|
// CLI and the in-project dashboard share one implementation. Re-exported here to
|
|
3
3
|
// keep the existing `./lib/names.js` import path stable across the codebase.
|
|
4
|
-
export { toCamelCase, toPascalCase, sanitizeName } from "./codegen.js";
|
|
4
|
+
export { toCamelCase, toPascalCase, sanitizeName, sanitizeFieldName } from "./codegen.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "4bnode",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.6",
|
|
4
4
|
"description": "4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -511,7 +511,9 @@ export function ApiTesterPage() {
|
|
|
511
511
|
if (ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight + 2, 600) + 'px'; }
|
|
512
512
|
}, [body, bodyType, activeTab]);
|
|
513
513
|
|
|
514
|
-
// ── Environments / variables + auth (persisted per-browser in
|
|
514
|
+
// ── Environments / variables + auth (persisted per-browser in sessionStorage) ──
|
|
515
|
+
// sessionStorage (not localStorage) because vars can hold captured tokens and
|
|
516
|
+
// auth carries bearer tokens / basic-auth passwords / api-key values.
|
|
515
517
|
const [variables, setVariables] = useState([]);
|
|
516
518
|
const [showVars, setShowVars] = useState(false);
|
|
517
519
|
const [autoToken, setAutoToken] = useState(true);
|
|
@@ -519,16 +521,16 @@ export function ApiTesterPage() {
|
|
|
519
521
|
|
|
520
522
|
useEffect(() => {
|
|
521
523
|
try {
|
|
522
|
-
const v = JSON.parse(
|
|
524
|
+
const v = JSON.parse(sessionStorage.getItem('_4bnode_vars') || '[]');
|
|
523
525
|
if (Array.isArray(v)) setVariables(v);
|
|
524
|
-
const a = JSON.parse(
|
|
526
|
+
const a = JSON.parse(sessionStorage.getItem('_4bnode_auth') || 'null');
|
|
525
527
|
if (a && typeof a === 'object') setAuth(prev => ({ ...prev, ...a }));
|
|
526
528
|
const at = localStorage.getItem('_4bnode_autotoken');
|
|
527
529
|
if (at !== null) setAutoToken(at === '1');
|
|
528
530
|
} catch {}
|
|
529
531
|
}, []);
|
|
530
|
-
useEffect(() => { try {
|
|
531
|
-
useEffect(() => { try {
|
|
532
|
+
useEffect(() => { try { sessionStorage.setItem('_4bnode_vars', JSON.stringify(variables)); } catch {} }, [variables]);
|
|
533
|
+
useEffect(() => { try { sessionStorage.setItem('_4bnode_auth', JSON.stringify(auth)); } catch {} }, [auth]);
|
|
532
534
|
useEffect(() => { try { localStorage.setItem('_4bnode_autotoken', autoToken ? '1' : '0'); } catch {} }, [autoToken]);
|
|
533
535
|
|
|
534
536
|
// Replace {{name}} tokens with variable values.
|
|
@@ -615,18 +617,18 @@ export function ApiTesterPage() {
|
|
|
615
617
|
const [history, setHistory] = useState([]);
|
|
616
618
|
const [showHistory, setShowHistory] = useState(false);
|
|
617
619
|
useEffect(() => {
|
|
618
|
-
try { const h = JSON.parse(
|
|
620
|
+
try { const h = JSON.parse(sessionStorage.getItem('_4bnode_history') || '[]'); if (Array.isArray(h)) setHistory(h); } catch {}
|
|
619
621
|
}, []);
|
|
620
622
|
function pushHistory(entry) {
|
|
621
623
|
setHistory(prev => {
|
|
622
624
|
const next = [entry, ...prev].slice(0, 50);
|
|
623
|
-
try {
|
|
625
|
+
try { sessionStorage.setItem('_4bnode_history', JSON.stringify(next)); } catch {}
|
|
624
626
|
return next;
|
|
625
627
|
});
|
|
626
628
|
}
|
|
627
629
|
function clearHistory() {
|
|
628
630
|
setHistory([]);
|
|
629
|
-
try {
|
|
631
|
+
try { sessionStorage.removeItem('_4bnode_history'); } catch {}
|
|
630
632
|
}
|
|
631
633
|
function loadHistory(h) {
|
|
632
634
|
setMethod(h.method);
|
|
@@ -809,12 +811,54 @@ export function ApiTesterPage() {
|
|
|
809
811
|
toast('Response updated', 'success');
|
|
810
812
|
}
|
|
811
813
|
|
|
814
|
+
// Deep-clone + strip secrets so nothing sensitive leaves the browser in a share.
|
|
815
|
+
function redactForShare(tests, responses) {
|
|
816
|
+
const REDACTED = '[redacted]';
|
|
817
|
+
const SECRET_REQ_HEADERS = ['authorization', 'cookie', 'x-api-key', 'x-passkey', 'proxy-authorization'];
|
|
818
|
+
const SECRET_RESP_HEADERS = ['set-cookie', 'authorization'];
|
|
819
|
+
const redactHeaders = (hdrs, names) => {
|
|
820
|
+
if (Array.isArray(hdrs)) {
|
|
821
|
+
return hdrs.map(h => (h && typeof h === 'object')
|
|
822
|
+
? (names.includes(String(h.key || '').toLowerCase()) ? { ...h, value: REDACTED } : { ...h })
|
|
823
|
+
: h);
|
|
824
|
+
}
|
|
825
|
+
if (hdrs && typeof hdrs === 'object') {
|
|
826
|
+
const out = {};
|
|
827
|
+
Object.keys(hdrs).forEach(k => { out[k] = names.includes(k.toLowerCase()) ? REDACTED : hdrs[k]; });
|
|
828
|
+
return out;
|
|
829
|
+
}
|
|
830
|
+
return hdrs;
|
|
831
|
+
};
|
|
832
|
+
const safeTests = (tests || []).map(t => {
|
|
833
|
+
const copy = { ...t };
|
|
834
|
+
if (copy.headers) copy.headers = redactHeaders(copy.headers, SECRET_REQ_HEADERS);
|
|
835
|
+
if (copy.auth && typeof copy.auth === 'object') {
|
|
836
|
+
const a = { ...copy.auth };
|
|
837
|
+
if ('token' in a) a.token = REDACTED;
|
|
838
|
+
if ('password' in a) a.password = REDACTED;
|
|
839
|
+
if ('apiKeyValue' in a) a.apiKeyValue = REDACTED;
|
|
840
|
+
copy.auth = a;
|
|
841
|
+
}
|
|
842
|
+
return copy;
|
|
843
|
+
});
|
|
844
|
+
const safeResponses = {};
|
|
845
|
+
Object.keys(responses || {}).forEach(id => {
|
|
846
|
+
safeResponses[id] = (responses[id] || []).map(r => {
|
|
847
|
+
const copy = { ...r };
|
|
848
|
+
if (copy.headers) copy.headers = redactHeaders(copy.headers, SECRET_RESP_HEADERS);
|
|
849
|
+
return copy;
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
return { tests: safeTests, responses: safeResponses };
|
|
853
|
+
}
|
|
854
|
+
|
|
812
855
|
async function shareCollection() {
|
|
813
856
|
if (savedTests.length === 0) { toast('No saved requests to share', 'error'); return; }
|
|
814
857
|
try {
|
|
858
|
+
const safe = redactForShare(savedTests, savedResponses);
|
|
815
859
|
const result = await api('/api-tester/share-collection', {
|
|
816
860
|
method: 'POST',
|
|
817
|
-
body: { tests:
|
|
861
|
+
body: { tests: safe.tests, responses: safe.responses },
|
|
818
862
|
});
|
|
819
863
|
const shareUrl = window.location.origin + '/_dev/shared/' + result.id;
|
|
820
864
|
copyToClipboard(shareUrl);
|
|
@@ -1286,6 +1330,7 @@ export function ApiTesterPage() {
|
|
|
1286
1330
|
</button>`}
|
|
1287
1331
|
<button className=${`btn btn-sm ${shareNeeded ? 'btn-warning' : 'btn-primary'}`}
|
|
1288
1332
|
onClick=${shareCollection} disabled=${savedTests.length === 0}
|
|
1333
|
+
title="Secrets (auth tokens, passwords, and auth/cookie/api-key headers) are stripped before sharing"
|
|
1289
1334
|
style=${shareNeeded ? { animation: 'pulse 2s infinite', background: 'var(--warning)', color: '#000' } : {}}>
|
|
1290
1335
|
<${LuShare2} size=${14} /> ${shareNeeded ? 'Update & Share' : lastSharedAt ? 'Re-share' : 'Share'}
|
|
1291
1336
|
</button>
|