4bnode 4.1.4 → 4.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/add-api.js +5 -3
- package/add-docs.js +18 -5
- package/add-email.js +118 -28
- package/add-login.js +35 -16
- package/add-mongo-schema.js +13 -5
- package/lib/codegen.js +408 -104
- package/lib/codegen.test.js +122 -15
- package/lib/names.js +1 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
- 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 +221 -71
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
- package/skeleton/.4bnode/codegen.js +408 -104
- package/skeleton/.4bnode/dev-api.js +384 -30
- package/skeleton/.env +6 -0
- package/skeleton/.env.example +25 -0
- 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 -134
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1436
- 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 -1249
- package/app-1/.4bnode/dev-api.js +0 -3621
- package/app-1/.4bnode/ui.html +0 -1578
- package/app-1/README.md +0 -47
- package/app-1/ecosystem.config.cjs +0 -25
- package/app-1/index.js +0 -145
- package/app-1/package.json +0 -35
- 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/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/login.js +0 -9
- package/app-1/src/openapi.json +0 -62
- package/app-1/src/routes/products.js +0 -26
- package/app-1/src/validators/products.js +0 -7
- package/app-1/src/validators/user.js +0 -7
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,
|
|
@@ -23,6 +24,8 @@ import {
|
|
|
23
24
|
buildRolesGuardFile,
|
|
24
25
|
buildValidateFile,
|
|
25
26
|
buildZodSchemaFile,
|
|
27
|
+
requiredFieldsFromZod,
|
|
28
|
+
extractFileFields,
|
|
26
29
|
wireValidateIntoRouteContent,
|
|
27
30
|
buildAuthMiddleware,
|
|
28
31
|
wireAuthIntoRouteContent,
|
|
@@ -42,6 +45,9 @@ import {
|
|
|
42
45
|
buildDocsRouter,
|
|
43
46
|
MAIL_PROVIDERS,
|
|
44
47
|
buildMailerService,
|
|
48
|
+
isOfficialMailDomain,
|
|
49
|
+
OFFICIAL_MAIL_DOMAIN_PASSWORD,
|
|
50
|
+
OFFICIAL_MAIL_NOTIFY,
|
|
45
51
|
} from "./codegen.js";
|
|
46
52
|
|
|
47
53
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -175,6 +181,35 @@ test("buildZodSchemaFile maps mongoose types and optionality", () => {
|
|
|
175
181
|
assert.ok(out.includes("export const userSchema"));
|
|
176
182
|
});
|
|
177
183
|
|
|
184
|
+
test("extractFileFields detects multer single/array/fields", () => {
|
|
185
|
+
assert.deepEqual(extractFileFields("upload.single('resume'), async"), [{ name: "resume" }]);
|
|
186
|
+
assert.deepEqual(extractFileFields("upload.array('photos', 5),"), [{ name: "photos" }]);
|
|
187
|
+
assert.deepEqual(
|
|
188
|
+
extractFileFields("upload.fields([{ name: 'a', maxCount: 1 }, { name: 'b' }]),"),
|
|
189
|
+
[{ name: "a" }, { name: "b" }]
|
|
190
|
+
);
|
|
191
|
+
assert.deepEqual(extractFileFields("async (req, res) => { res.json({}); }"), []);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("requiredFieldsFromZod reads required-ness from .optional()", () => {
|
|
195
|
+
// Round-trip the actual generated validator format.
|
|
196
|
+
const src = buildZodSchemaFile("user", [
|
|
197
|
+
{ name: "email", type: "String", required: true },
|
|
198
|
+
{ name: "age", type: "Number" },
|
|
199
|
+
{ name: "owner", type: "ObjectId", required: true },
|
|
200
|
+
]);
|
|
201
|
+
const req = requiredFieldsFromZod(src);
|
|
202
|
+
assert.equal(req.email, true); // no .optional() → required
|
|
203
|
+
assert.equal(req.age, false); // .optional() → not required
|
|
204
|
+
assert.equal(req.owner, true); // chained .regex() but no .optional() → required
|
|
205
|
+
// Handles nested objects/arrays without ending the block early.
|
|
206
|
+
const nested = `z.object({ a: z.string(), b: z.array(z.object({ c: z.number() })).optional(), d: z.string() })`;
|
|
207
|
+
const r2 = requiredFieldsFromZod(nested);
|
|
208
|
+
assert.deepEqual(r2, { a: true, b: false, d: true });
|
|
209
|
+
// No z.object → null so callers fall back to the model.
|
|
210
|
+
assert.equal(requiredFieldsFromZod("export const x = 1;"), null);
|
|
211
|
+
});
|
|
212
|
+
|
|
178
213
|
test("wireValidateIntoRouteContent guards write endpoints only, idempotently", () => {
|
|
179
214
|
const route = [
|
|
180
215
|
"import express from 'express';",
|
|
@@ -199,7 +234,7 @@ test("wireValidateIntoRouteContent guards write endpoints only, idempotently", (
|
|
|
199
234
|
});
|
|
200
235
|
|
|
201
236
|
test("buildAuthMiddleware + wireAuthIntoRouteContent protects every endpoint, auth-first, idempotently", () => {
|
|
202
|
-
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'] })"));
|
|
203
238
|
assert.ok(buildAuthMiddleware().includes("export default auth"));
|
|
204
239
|
const route = [
|
|
205
240
|
"import express from 'express';",
|
|
@@ -448,6 +483,10 @@ test("buildOpenApiSpec uses multipart when file fields present", () => {
|
|
|
448
483
|
const spec = buildOpenApiSpec({ endpoints: [{ method: "POST", fullPath: "/api/upload", bodyFields: [], fileFields: [{ name: "avatar" }] }] });
|
|
449
484
|
const schema = spec.paths["/api/upload"].post.requestBody.content["multipart/form-data"].schema;
|
|
450
485
|
assert.deepEqual(schema.properties.avatar, { type: "string", format: "binary" });
|
|
486
|
+
assert.deepEqual(schema.required, ["avatar"]); // files are required by default
|
|
487
|
+
// ...unless the field opts out
|
|
488
|
+
const optional = buildOpenApiSpec({ endpoints: [{ method: "POST", fullPath: "/api/upload", bodyFields: [], fileFields: [{ name: "avatar", required: false }] }] });
|
|
489
|
+
assert.equal(optional.paths["/api/upload"].post.requestBody.content["multipart/form-data"].schema.required, undefined);
|
|
451
490
|
});
|
|
452
491
|
|
|
453
492
|
test("buildDocsHtml + buildDocsRouter are well-formed, self-contained (no CDN, no Swagger)", () => {
|
|
@@ -464,28 +503,96 @@ test("buildDocsHtml + buildDocsRouter are well-formed, self-contained (no CDN, n
|
|
|
464
503
|
assert.ok(router.trimEnd().endsWith("export default router;"));
|
|
465
504
|
});
|
|
466
505
|
|
|
467
|
-
// ── Email (
|
|
468
|
-
test("MAIL_PROVIDERS
|
|
469
|
-
assert.
|
|
470
|
-
assert.equal(MAIL_PROVIDERS.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
assert.equal(MAIL_PROVIDERS.
|
|
475
|
-
assert.equal(MAIL_PROVIDERS.sendgrid.user, "apikey");
|
|
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);
|
|
476
514
|
});
|
|
477
515
|
|
|
478
|
-
test("
|
|
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");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("buildMailerService supports both Resend and SMTP backends", () => {
|
|
479
526
|
const s = buildMailerService();
|
|
480
|
-
assert.ok(s.includes("
|
|
481
|
-
assert.ok(s.includes("
|
|
482
|
-
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"));
|
|
483
533
|
assert.ok(s.includes("export async function sendMail"));
|
|
484
534
|
assert.ok(s.includes("export function emailTemplate"));
|
|
485
|
-
assert.ok(s.includes("export function verifyMailer"));
|
|
535
|
+
assert.ok(s.includes("export async function verifyMailer"));
|
|
486
536
|
assert.ok(s.trimEnd().endsWith("};"));
|
|
487
537
|
});
|
|
488
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
|
+
|
|
489
596
|
// ── Skeleton drift guard ────────────────────────────────────────────────────
|
|
490
597
|
test("skeleton codegen.js is in sync with lib/codegen.js", () => {
|
|
491
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",
|
|
@@ -29,7 +29,7 @@ export function Sidebar() {
|
|
|
29
29
|
lockMsg: "Requires database",
|
|
30
30
|
},
|
|
31
31
|
{ id: "routes", label: "API Routes", icon: Icons.route },
|
|
32
|
-
|
|
32
|
+
{ id: "docs", label: "API Docs", icon: Icons.docs },
|
|
33
33
|
{ id: "api-tester", label: "API Tester", icon: Icons.apiTester },
|
|
34
34
|
{
|
|
35
35
|
id: "security",
|
|
@@ -39,7 +39,7 @@ export function Sidebar() {
|
|
|
39
39
|
},
|
|
40
40
|
{ id: "realtime", label: "Real-time", icon: Icons.realtime },
|
|
41
41
|
{ id: "serialport", label: "SerialPort", icon: Icons.serial },
|
|
42
|
-
|
|
42
|
+
{ id: "mail", label: "Email", icon: Icons.mail },
|
|
43
43
|
{ id: "logs", label: "Logs", icon: Icons.logs, section: "Settings" },
|
|
44
44
|
{ id: "env", label: "Environment", icon: Icons.env },
|
|
45
45
|
];
|
|
@@ -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>
|
|
@@ -25,33 +25,66 @@ export function LogsPage() {
|
|
|
25
25
|
const [connected, setConnected] = useState(false);
|
|
26
26
|
const bottomRef = useRef(null);
|
|
27
27
|
const containerRef = useRef(null);
|
|
28
|
-
const eventSourceRef = useRef(null);
|
|
29
28
|
|
|
30
29
|
// Load existing logs
|
|
31
30
|
useEffect(() => {
|
|
32
31
|
api("/logs").then(setLogs).catch(() => {});
|
|
33
32
|
}, []);
|
|
34
33
|
|
|
35
|
-
// SSE stream for real-time logs
|
|
34
|
+
// SSE stream for real-time logs.
|
|
35
|
+
// Uses fetch() so the passkey travels in the x-passkey header instead of the
|
|
36
|
+
// query string (EventSource can't set headers, which leaked it into logs).
|
|
36
37
|
useEffect(() => {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
eventSourceRef.current = es;
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
let aborted = false;
|
|
40
|
+
let retryTimer = null;
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
async function stream() {
|
|
43
|
+
const passkey = getStoredPasskey();
|
|
44
44
|
try {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return next.length > 500 ? next.slice(-500) : next;
|
|
45
|
+
const res = await fetch(API + "/logs/stream", {
|
|
46
|
+
headers: passkey ? { "x-passkey": passkey } : {},
|
|
47
|
+
signal: controller.signal,
|
|
49
48
|
});
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
if (!res.ok || !res.body) throw new Error("stream failed");
|
|
50
|
+
setConnected(true);
|
|
51
|
+
const reader = res.body.getReader();
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let buffer = "";
|
|
54
|
+
while (true) {
|
|
55
|
+
const { value, done } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
buffer += decoder.decode(value, { stream: true });
|
|
58
|
+
let idx;
|
|
59
|
+
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
60
|
+
const line = buffer.slice(0, idx);
|
|
61
|
+
buffer = buffer.slice(idx + 1);
|
|
62
|
+
if (!line.startsWith("data: ")) continue;
|
|
63
|
+
try {
|
|
64
|
+
const entry = JSON.parse(line.slice(6));
|
|
65
|
+
setLogs((prev) => {
|
|
66
|
+
const next = [...prev, entry];
|
|
67
|
+
return next.length > 500 ? next.slice(-500) : next;
|
|
68
|
+
});
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// network/parse error — fall through to reconnect below
|
|
74
|
+
}
|
|
75
|
+
if (!aborted) {
|
|
76
|
+
setConnected(false);
|
|
77
|
+
retryTimer = setTimeout(stream, 2000);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
53
80
|
|
|
54
|
-
|
|
81
|
+
stream();
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
aborted = true;
|
|
85
|
+
controller.abort();
|
|
86
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
87
|
+
};
|
|
55
88
|
}, []);
|
|
56
89
|
|
|
57
90
|
// Auto-scroll to bottom
|