@invisibleloop/pulse 0.20.44 → 0.20.46

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.44",
3
+ "version": "0.20.46",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.20.44
1
+ 0.20.46
@@ -343,6 +343,8 @@ If it still fails, it is a framework bug:
343
343
  3. If they agree, call `pulse_report_issue` without `confirmed`. Show the user the returned preview verbatim and ask for approval.
344
344
  4. Only after they approve, call it again with `confirmed: true` and the `previewId`. Never confirm on your own.
345
345
 
346
+ Never report a security vulnerability this way — reports become public issues. If you suspect one (XSS, injection, auth or CSRF bypass, anything exploitable), tell the user to report it privately at https://github.com/invisibleloop/pulse-framework/security/advisories/new.
347
+
346
348
  Do not report: your own spec or API-usage errors, missing features or feature requests, design preferences, or anything you have not isolated to Pulse. Keep secrets, credentials and private user data out of every field.
347
349
 
348
350
  ---
@@ -108,6 +108,8 @@ export function previewId(payload) {
108
108
  return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16)
109
109
  }
110
110
 
111
+ const SECURITY_URL = 'https://github.com/invisibleloop/pulse-framework/security/advisories/new'
112
+
111
113
  export function formatPreview(payload, { transport = 'proxy', endpoint = DEFAULT_ENDPOINT } = {}) {
112
114
  const dest = transport === 'gh'
113
115
  ? `GitHub issue on ${REPO}, filed with your own \`gh\` login`
@@ -116,6 +118,7 @@ export function formatPreview(payload, { transport = 'proxy', endpoint = DEFAULT
116
118
  return [
117
119
  `This report has NOT been sent. It will be published as: ${dest}.`,
118
120
  `Only the fields below are sent — no project files, paths or environment. Local paths and secret-like strings were redacted.`,
121
+ `This becomes a PUBLIC issue. If it is a security vulnerability, do NOT send it — tell the user to report it privately at ${SECURITY_URL}.`,
119
122
  ``,
120
123
  `Title: ${payload.title}`,
121
124
  `Pulse version: ${payload.version}`,
@@ -22,6 +22,9 @@ const REPORTS_DIR = path.join(ROOT, '.pulse', 'reports')
22
22
  const LOAD_REPORTS_DIR = path.join(ROOT, '.pulse', 'load-reports')
23
23
  const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000
24
24
 
25
+ // Report data (notably the audited URL) is untrusted when it reaches HTML.
26
+ const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
27
+
25
28
  // ---------------------------------------------------------------------------
26
29
  // Data — Lighthouse
27
30
  // ---------------------------------------------------------------------------
@@ -349,7 +352,7 @@ function renderPerfTab(slug, reports) {
349
352
  ${staleBanner}
350
353
  <div class="page-header">
351
354
  <div>
352
- <h2 class="page-heading">${latest.url || '/' + slug}</h2>
355
+ <h2 class="page-heading">${esc(latest.url || '/' + slug)}</h2>
353
356
  <p class="page-meta">${count} audit${count !== 1 ? 's' : ''} · last run ${when}</p>
354
357
  </div>
355
358
  <div class="health-badge" style="color:${health[1]};border-color:${health[1]}20;background:${health[1]}10">${health[0]}</div>
@@ -480,7 +483,7 @@ function renderLoadTab(slug, reports) {
480
483
  ${localhostBanner}
481
484
  <div class="page-header">
482
485
  <div>
483
- <h2 class="page-heading">${latest.url || '/' + slug}</h2>
486
+ <h2 class="page-heading">${esc(latest.url || '/' + slug)}</h2>
484
487
  <p class="page-meta">${count} test${count !== 1 ? 's' : ''} · last run ${when}</p>
485
488
  </div>
486
489
  </div>
@@ -517,8 +520,8 @@ function renderIndex(slugs) {
517
520
  : ''
518
521
 
519
522
  return `
520
- <a href="/${slug}" class="index-card">
521
- <span class="index-url">${latest?.url || latestLd?.url || '/' + slug}</span>
523
+ <a href="/${esc(slug)}" class="index-card">
524
+ <span class="index-url">${esc(latest?.url || latestLd?.url || '/' + slug)}</span>
522
525
  <span class="index-gauges">${gauges}</span>
523
526
  ${loadBadge}
524
527
  </a>`
@@ -636,7 +639,7 @@ function renderHTML({ title, content, slugs, activeSlug, activeTab, hasBoth }) {
636
639
  const loadRpts = loadLoadReports(slug)
637
640
  const ldUrl = loadRpts[loadRpts.length - 1]?.url
638
641
  const display = url || ldUrl || '/' + slug
639
- return `<a href="/${slug}" class="sidebar-link${slug === activeSlug ? ' active' : ''}">${display}</a>`
642
+ return `<a href="/${esc(slug)}" class="sidebar-link${slug === activeSlug ? ' active' : ''}">${esc(display)}</a>`
640
643
  }).join('')
641
644
 
642
645
  const tabs = activeSlug && hasBoth ? `
@@ -656,7 +659,7 @@ function renderHTML({ title, content, slugs, activeSlug, activeTab, hasBoth }) {
656
659
  <head>
657
660
  <meta charset="UTF-8">
658
661
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
659
- <title>${title} — Pulse Reports</title>
662
+ <title>${esc(title)} — Pulse Reports</title>
660
663
  <style>${CSS}</style>
661
664
  </head>
662
665
  <body>
@@ -718,6 +721,6 @@ http.createServer((req, res) => {
718
721
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })
719
722
  res.end(renderHTML({ title, content, slugs, activeSlug: slug, activeTab, hasBoth }))
720
723
 
721
- }).listen(PORT, () => {
724
+ }).listen(PORT, '127.0.0.1', () => {
722
725
  console.log(`\n⚡ Pulse report server running at http://localhost:${PORT}\n`)
723
726
  })
@@ -488,7 +488,7 @@ Run these steps in order — do not declare work done without them:
488
488
 
489
489
  ## If Pulse itself is broken
490
490
 
491
- If a documented Pulse feature or tool misbehaves and you have reduced it to a minimal repro that involves only Pulse, tell the user, give them the workaround, and offer to report it with \`pulse_report_issue\`. Preview first, show the user the redacted report verbatim, and send only after they approve. Do not report your own mistakes, spec errors or feature requests.
491
+ If a documented Pulse feature or tool misbehaves and you have reduced it to a minimal repro that involves only Pulse, tell the user, give them the workaround, and offer to report it with \`pulse_report_issue\`. Preview first, show the user the redacted report verbatim, and send only after they approve. Do not report your own mistakes, spec errors or feature requests. Never report a security vulnerability this way (reports are public): tell the user to report it privately at https://github.com/invisibleloop/pulse-framework/security/advisories/new.
492
492
 
493
493
  ## Writing tests
494
494
 
@@ -582,7 +582,7 @@ Run these steps in order — do not declare work done without them:
582
582
 
583
583
  ## If Pulse itself is broken
584
584
 
585
- If a documented Pulse feature or tool misbehaves and you have reduced it to a minimal repro that involves only Pulse, tell the user, give them the workaround, and offer to report it with \`pulse_report_issue\`. Preview first, show the user the redacted report verbatim, and send only after they approve. Do not report your own mistakes, spec errors or feature requests.
585
+ If a documented Pulse feature or tool misbehaves and you have reduced it to a minimal repro that involves only Pulse, tell the user, give them the workaround, and offer to report it with \`pulse_report_issue\`. Preview first, show the user the redacted report verbatim, and send only after they approve. Do not report your own mistakes, spec errors or feature requests. Never report a security vulnerability this way (reports are public): tell the user to report it privately at https://github.com/invisibleloop/pulse-framework/security/advisories/new.
586
586
 
587
587
  ## Writing tests
588
588
 
package/src/mcp/server.js CHANGED
@@ -1081,7 +1081,7 @@ The generated file uses @invisibleloop/pulse/testing (renderSync / render). Do N
1081
1081
  : []
1082
1082
 
1083
1083
  const specName = path.basename(file, '.js')
1084
- const importPath = path.relative(path.dirname(testFile), file).replace(/\.js$/, '.js')
1084
+ const importPath = path.relative(path.dirname(testFile), file)
1085
1085
 
1086
1086
  const lines = []
1087
1087
  lines.push(`import { describe, it } from 'node:test'`)
@@ -1632,6 +1632,8 @@ server.registerTool(
1632
1632
  {
1633
1633
  description: `Report a bug in the Pulse framework itself (not in the user's own code). Use only after you have confirmed the problem is Pulse's — e.g. a documented feature misbehaves, a Pulse tool returns a wrong result, or a component renders incorrectly from valid input — and have a minimal repro. Do not report your own mistakes or missing features.
1634
1634
 
1635
+ Never use this for a security vulnerability — reports become public issues; tell the user to report it privately at https://github.com/invisibleloop/pulse-framework/security/advisories/new.
1636
+
1635
1637
  Two steps. First call (no confirmed): returns the redacted report for you to show the user verbatim — nothing is sent. If the user agrees, call again with the same fields plus confirmed: true and the previewId from step one. Never set confirmed without the user's explicit approval. Do not put secrets, credentials or private user data in any field.`,
1636
1638
  inputSchema: {
1637
1639
  title: z.string().min(8).max(120).describe('One-line summary of the bug'),
package/src/md/index.js CHANGED
@@ -17,6 +17,20 @@ import { highlight } from './highlight.js'
17
17
  // HTML escaping
18
18
  // ---------------------------------------------------------------------------
19
19
 
20
+ /**
21
+ * Remove an ATX heading's optional closing sequence ("## Title ##" → "Title").
22
+ * Done by hand rather than with /\s+#+\s*$/, which backtracks quadratically on
23
+ * long runs of whitespace.
24
+ */
25
+ function stripClosingHashes(text) {
26
+ let end = text.length
27
+ while (end > 0 && /\s/.test(text[end - 1])) end--
28
+ let start = end
29
+ while (start > 0 && text[start - 1] === '#') start--
30
+ if (start === end || start === 0 || !/\s/.test(text[start - 1])) return text
31
+ return text.slice(0, start).trimEnd()
32
+ }
33
+
20
34
  function esc(s) {
21
35
  return String(s)
22
36
  .replace(/&/g, '&amp;')
@@ -147,10 +161,14 @@ function parseBlocks(text) {
147
161
  continue
148
162
  }
149
163
 
150
- // ATX heading (# through ######)
151
- const headMatch = line.match(/^(#{1,6})\s+(.*?)(?:\s+#+\s*)?$/)
152
- if (headMatch) {
153
- blocks.push({ type: 'heading', level: headMatch[1].length, content: headMatch[2] })
164
+ // ATX heading (# through ######). This must accept every line the paragraph
165
+ // loop below treats as "a heading starts here" (/^#{1,6}\s/): if the two
166
+ // disagree, neither claims the line and the parser never advances.
167
+ // The marker is matched on its own, with the text taken from what follows, so
168
+ // there is no overlapping whitespace/content regex to backtrack.
169
+ const headMarker = line.match(/^#{1,6}(?=\s)/)
170
+ if (headMarker) {
171
+ blocks.push({ type: 'heading', level: headMarker[0].length, content: stripClosingHashes(line.slice(headMarker[0].length).trimStart()) })
154
172
  i++; continue
155
173
  }
156
174
 
@@ -219,9 +237,12 @@ function parseBlocks(text) {
219
237
  if (/^\d+[.)]\s/.test(l)) break
220
238
  paraLines.push(l); i++
221
239
  }
222
- if (paraLines.length > 0) {
223
- blocks.push({ type: 'paragraph', content: paraLines.join(' ') })
240
+ if (paraLines.length === 0) {
241
+ // Nothing above claimed this line and the paragraph loop refused it. Never
242
+ // loop without consuming input — treat it as text and move on.
243
+ paraLines.push(lines[i]); i++
224
244
  }
245
+ blocks.push({ type: 'paragraph', content: paraLines.join(' ') })
225
246
  }
226
247
 
227
248
  return blocks
@@ -311,6 +332,8 @@ function markdownToHtml(text) {
311
332
  * @returns {{ html: string, frontmatter: Record<string, string> }}
312
333
  */
313
334
  export function parseMd(source) {
335
+ // Normalise line endings first: CRLF files otherwise leave a stray "\r" on every line.
336
+ source = String(source).replace(/\r\n?/g, '\n')
314
337
  const { frontmatter, body } = extractFrontmatter(source)
315
338
  return { html: markdownToHtml(body), frontmatter }
316
339
  }
@@ -869,7 +869,8 @@ function viewErrorFallback(err) {
869
869
  }
870
870
 
871
871
  function isValidEmail(value) {
872
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
872
+ // 254 is the RFC 5321 maximum; the cap also bounds the regex's worst case.
873
+ return value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
873
874
  }
874
875
 
875
876
  // ---------------------------------------------------------------------------
@@ -1659,9 +1659,15 @@ function matchRoute(router, pathname) {
1659
1659
  */
1660
1660
  function routeToRegex(route) {
1661
1661
  const params = []
1662
+ // Split on :params so every literal segment can be regex-escaped ("." in
1663
+ // /feed.xml must not match any character).
1662
1664
  const pattern = route
1663
- .replace(/:([^/]+)/g, (_, name) => { params.push(name); return '([^/]+)' })
1664
- .replace(/\//g, '\\/')
1665
+ .split(/(:[^/]+)/)
1666
+ .map(part => {
1667
+ if (part.startsWith(':')) { params.push(part.slice(1)); return '([^/]+)' }
1668
+ return part.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
1669
+ })
1670
+ .join('')
1665
1671
 
1666
1672
  return {
1667
1673
  pattern: new RegExp(`^${pattern}\\/?$`),
@@ -2221,7 +2227,7 @@ function streamErrorScript(err, dev, nonce = '') {
2221
2227
  `</div>`
2222
2228
  : `<div style="padding:2rem;text-align:center;font-family:system-ui,sans-serif;color:#666">Something went wrong.</div>`
2223
2229
 
2224
- return `<script nonce="${nonce}">(function(){var r=document.getElementById('pulse-root');if(r)r.innerHTML=${JSON.stringify(inner)};})()</script>`
2230
+ return `<script nonce="${nonce}">(function(){var r=document.getElementById('pulse-root');if(r)r.innerHTML=${JSON.stringify(inner).replace(/</g, '\\u003c')};})()</script>`
2225
2231
  }
2226
2232
 
2227
2233
  function errorPage500() {
@@ -64,14 +64,15 @@ function parseAttrs(str) {
64
64
  }
65
65
 
66
66
  function decodeEntities(str) {
67
+ // &amp; must be decoded last: decoding it first turns "&amp;lt;" into "<".
67
68
  return str
68
- .replace(/&amp;/g, '&')
69
69
  .replace(/&lt;/g, '<')
70
70
  .replace(/&gt;/g, '>')
71
71
  .replace(/&quot;/g, '"')
72
72
  .replace(/&#0?39;/g, "'")
73
73
  .replace(/&apos;/g, "'")
74
74
  .replace(/&nbsp;/g, ' ')
75
+ .replace(/&amp;/g, '&')
75
76
  }
76
77
 
77
78
  /**