4bnode 4.1.4 → 4.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/add-api.js +5 -3
  2. package/add-docs.js +18 -5
  3. package/add-email.js +118 -28
  4. package/add-login.js +35 -16
  5. package/add-mongo-schema.js +13 -5
  6. package/lib/codegen.js +408 -104
  7. package/lib/codegen.test.js +122 -15
  8. package/lib/names.js +1 -1
  9. package/package.json +1 -1
  10. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
  11. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
  12. package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
  13. package/skeleton/.4bnode/assets/js/pages/MailPage.js +221 -71
  14. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
  15. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
  16. package/skeleton/.4bnode/codegen.js +408 -104
  17. package/skeleton/.4bnode/dev-api.js +384 -30
  18. package/skeleton/.env +6 -0
  19. package/skeleton/.env.example +25 -0
  20. package/skeleton/_gitignore +1 -0
  21. package/skeleton/index.js +7 -2
  22. package/skeleton/src/middleware/apiKey.js +14 -1
  23. package/skeleton/src/middleware/errorHandler.js +7 -2
  24. package/app-1/.4bnode/.passkey +0 -1
  25. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  26. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  27. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  28. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  29. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  30. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  31. package/app-1/.4bnode/assets/js/App.js +0 -139
  32. package/app-1/.4bnode/assets/js/Root.js +0 -40
  33. package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
  34. package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
  35. package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
  36. package/app-1/.4bnode/assets/js/context.js +0 -7
  37. package/app-1/.4bnode/assets/js/lib.js +0 -113
  38. package/app-1/.4bnode/assets/js/main.js +0 -5
  39. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
  40. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
  41. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
  42. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
  43. package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
  44. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
  45. package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
  46. package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -134
  47. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
  48. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1436
  49. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
  50. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
  51. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
  52. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
  53. package/app-1/.4bnode/codegen.js +0 -1249
  54. package/app-1/.4bnode/dev-api.js +0 -3621
  55. package/app-1/.4bnode/ui.html +0 -1578
  56. package/app-1/README.md +0 -47
  57. package/app-1/ecosystem.config.cjs +0 -25
  58. package/app-1/index.js +0 -145
  59. package/app-1/package.json +0 -35
  60. package/app-1/public/index.html +0 -292
  61. package/app-1/src/db.js +0 -13
  62. package/app-1/src/docs.js +0 -84
  63. package/app-1/src/middleware/errorHandler.js +0 -15
  64. package/app-1/src/middleware/roles.js +0 -14
  65. package/app-1/src/middleware/validate.js +0 -20
  66. package/app-1/src/models/login.js +0 -9
  67. package/app-1/src/openapi.json +0 -62
  68. package/app-1/src/routes/products.js +0 -26
  69. package/app-1/src/validators/products.js +0 -7
  70. package/app-1/src/validators/user.js +0 -7
@@ -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 (nodemailer) ───────────────────────────────────────────────────────
468
- test("MAIL_PROVIDERS includes Brevo + common providers with host/port", () => {
469
- assert.equal(MAIL_PROVIDERS.brevo.host, "smtp-relay.brevo.com");
470
- assert.equal(MAIL_PROVIDERS.brevo.port, 587);
471
- for (const id of ["brevo", "sendgrid", "mailgun", "gmail", "mailtrap", "smtp"]) {
472
- assert.ok(MAIL_PROVIDERS[id] && MAIL_PROVIDERS[id].label, id);
473
- }
474
- assert.equal(MAIL_PROVIDERS.gmail.secure, true);
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("buildMailerService produces a valid nodemailer module", () => {
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("import nodemailer from 'nodemailer';"));
481
- assert.ok(s.includes("nodemailer.createTransport"));
482
- assert.ok(s.includes("process.env.SMTP_HOST"));
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.4",
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
- // { id: "docs", label: "API Docs", icon: Icons.docs }, // hidden for now
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
- // { id: "mail", label: "Email", icon: Icons.mail }, // hidden for now
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 localStorage) ──
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(localStorage.getItem('_4bnode_vars') || '[]');
524
+ const v = JSON.parse(sessionStorage.getItem('_4bnode_vars') || '[]');
523
525
  if (Array.isArray(v)) setVariables(v);
524
- const a = JSON.parse(localStorage.getItem('_4bnode_auth') || 'null');
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 { localStorage.setItem('_4bnode_vars', JSON.stringify(variables)); } catch {} }, [variables]);
531
- useEffect(() => { try { localStorage.setItem('_4bnode_auth', JSON.stringify(auth)); } catch {} }, [auth]);
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(localStorage.getItem('_4bnode_history') || '[]'); if (Array.isArray(h)) setHistory(h); } catch {}
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 { localStorage.setItem('_4bnode_history', JSON.stringify(next)); } catch {}
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 { localStorage.removeItem('_4bnode_history'); } catch {}
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: savedTests, responses: savedResponses },
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 passkey = getStoredPasskey();
38
- const url = API + "/logs/stream" + (passkey ? "?passkey=" + encodeURIComponent(passkey) : "");
39
- const es = new EventSource(url);
40
- eventSourceRef.current = es;
38
+ const controller = new AbortController();
39
+ let aborted = false;
40
+ let retryTimer = null;
41
41
 
42
- es.onopen = () => setConnected(true);
43
- es.onmessage = (e) => {
42
+ async function stream() {
43
+ const passkey = getStoredPasskey();
44
44
  try {
45
- const entry = JSON.parse(e.data);
46
- setLogs((prev) => {
47
- const next = [...prev, entry];
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
- } catch {}
51
- };
52
- es.onerror = () => setConnected(false);
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
- return () => es.close();
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