@invisibleloop/pulse 0.17.1 → 0.17.3
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/.claude/commands/build-page.md +1 -1
- package/.claude/commands/verify.md +6 -4
- package/.claude/pulse-checklist.md +10 -0
- package/CLAUDE.md +56 -2
- package/docs/.claude/pulse-checklist.md +10 -0
- package/docs/public/pulse-ui.css +8 -2
- package/docs/server.js +2 -0
- package/docs/src/lib/nav.js +2 -0
- package/docs/src/pages/actions.js +2 -0
- package/docs/src/pages/error-pages.js +98 -0
- package/docs/src/pages/forms.js +128 -0
- package/docs/src/pages/guard.js +1 -0
- package/docs/src/pages/routing.js +7 -9
- package/docs/src/pages/server-api.js +2 -1
- package/examples/.claude/pulse-checklist.md +10 -0
- package/examples/public/pulse-ui.css +52 -2
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +8 -2
- package/scripts/build.js +1 -204
- package/scripts/css-purge.js +241 -0
- package/scripts/css-purge.test.js +94 -0
- package/src/agent/checklist.md +10 -0
- package/src/agent/commands/verify.md +6 -4
- package/src/agent/guide-routing.md +21 -0
- package/src/agent/guide-server.md +34 -0
- package/src/agent/guide-spec.md +25 -0
- package/src/agent/guide-styles.md +3 -1
- package/src/agent/skills/build-page/SKILL.md +1 -1
- package/src/agent/skills/verify/SKILL.md +2 -2
- package/src/agent/workflow.md +15 -3
- package/src/mcp/mcp.test.js +55 -1
- package/src/mcp/server.js +82 -5
- package/src/runtime/ssr.js +30 -7
- package/src/server/index.js +119 -16
- package/src/server/server.test.js +200 -0
- package/src/spec/schema.js +20 -3
- package/src/spec/schema.test.js +33 -0
- package/src/ui/drop-cap.js +1 -1
- package/src/ui/gallery.js +5 -1
- package/src/ui/ui.test.js +34 -0
- package/types/schema.d.ts +31 -2
- package/types/server.d.ts +7 -0
package/src/server/index.js
CHANGED
|
@@ -367,12 +367,14 @@ function buildCacheKey(spec, ctx) {
|
|
|
367
367
|
* - spec.stream.deferred — deferred segments inject content via inline <script nonce="...">
|
|
368
368
|
* tags; caching bakes in one nonce while subsequent CSP headers
|
|
369
369
|
* carry a different nonce, causing a violation
|
|
370
|
+
* - spec.submit — form pages embed a per-visitor CSRF token; caching
|
|
371
|
+
* would serve one visitor's token to everyone
|
|
370
372
|
*
|
|
371
373
|
* In all these cases the cached HTML would contain a nonce that doesn't match
|
|
372
374
|
* the nonce-free CSP used for cached responses, causing a CSP violation.
|
|
373
375
|
*/
|
|
374
376
|
function isCacheablePage(spec) {
|
|
375
|
-
return !spec.server && !spec.store?.length && !spec.hydrate && !spec.stream?.deferred?.length
|
|
377
|
+
return !spec.server && !spec.store?.length && !spec.hydrate && !spec.stream?.deferred?.length && !spec.submit
|
|
376
378
|
}
|
|
377
379
|
|
|
378
380
|
// ---------------------------------------------------------------------------
|
|
@@ -542,6 +544,8 @@ export async function createServer(entries, options = {}) {
|
|
|
542
544
|
shutdownTimeout = 30000, // ms to wait for in-flight requests before force-exit
|
|
543
545
|
healthCheck = '/healthz', // path for health check endpoint, or false to disable
|
|
544
546
|
csp = {}, // extra CSP sources: { 'style-src': ['https://fonts.googleapis.com'] }
|
|
547
|
+
secret = null, // HMAC secret for CSRF tokens — set a stable value when running
|
|
548
|
+
// multiple instances; defaults to a random per-boot secret
|
|
545
549
|
onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
|
|
546
550
|
onRequest,
|
|
547
551
|
agentMode = false, // show agent-active indicator in banner
|
|
@@ -585,8 +589,8 @@ export async function createServer(entries, options = {}) {
|
|
|
585
589
|
const routeErrors = []
|
|
586
590
|
if (!spec.route || typeof spec.route !== 'string') {
|
|
587
591
|
routeErrors.push('spec.route is required and must be a string (e.g. "/contact")')
|
|
588
|
-
} else if (!spec.route.startsWith('/')) {
|
|
589
|
-
routeErrors.push('spec.route must start with "/" (e.g. "/contact")')
|
|
592
|
+
} else if (!spec.route.startsWith('/') && spec.route !== '*') {
|
|
593
|
+
routeErrors.push('spec.route must start with "/" (e.g. "/contact"), or be "*" for the custom not-found page')
|
|
590
594
|
}
|
|
591
595
|
const allErrors = [...errors, ...routeErrors]
|
|
592
596
|
if (!valid || routeErrors.length > 0) {
|
|
@@ -597,8 +601,18 @@ export async function createServer(entries, options = {}) {
|
|
|
597
601
|
}
|
|
598
602
|
}
|
|
599
603
|
|
|
600
|
-
//
|
|
601
|
-
|
|
604
|
+
// Custom not-found page — a spec with route '*' renders (with status 404)
|
|
605
|
+
// whenever no route matches. It goes through the normal render pipeline, so
|
|
606
|
+
// it gets the site's layout, styles, hydration, and validation like any page.
|
|
607
|
+
let notFoundSpec = specs.find(s => s.route === '*') || null
|
|
608
|
+
|
|
609
|
+
// Build route table — let so it can be swapped on hot reload.
|
|
610
|
+
// The '*' spec is excluded: it is not a route, it is the fallback.
|
|
611
|
+
let router = buildRouter(specs.filter(s => s.route !== '*'))
|
|
612
|
+
|
|
613
|
+
// CSRF secret — per-boot random unless pinned via options.secret. Tokens are
|
|
614
|
+
// tied to a cookie, so a restart only means in-flight forms re-render once.
|
|
615
|
+
const csrfSecret = secret || crypto.randomBytes(32).toString('hex')
|
|
602
616
|
|
|
603
617
|
// Resolve the logger module once at startup rather than dynamically importing
|
|
604
618
|
// it on every request's `finish` event. `logRequest` stays null until loaded.
|
|
@@ -662,20 +676,32 @@ export async function createServer(entries, options = {}) {
|
|
|
662
676
|
}
|
|
663
677
|
|
|
664
678
|
// Match route
|
|
665
|
-
|
|
679
|
+
let match = matchRoute(router, pathname)
|
|
680
|
+
let responseStatus = 200
|
|
666
681
|
|
|
667
682
|
if (!match) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
683
|
+
// Custom not-found page — a '*' spec renders through the normal pipeline
|
|
684
|
+
// with status 404, so the site's branded 404 gets layout, styles, and
|
|
685
|
+
// hydration like any other page. Client-side navigation requests still
|
|
686
|
+
// get the plain 404 (navigate.js falls back to a full page load, which
|
|
687
|
+
// then renders the custom page).
|
|
688
|
+
if (notFoundSpec && (req.method === 'GET' || req.method === 'HEAD') && req.headers['x-pulse-navigate'] !== 'true') {
|
|
689
|
+
match = { spec: notFoundSpec, params: {} }
|
|
690
|
+
responseStatus = 404
|
|
691
|
+
} else {
|
|
692
|
+
res.writeHead(404, { 'Content-Type': 'text/html', ...SECURITY_HEADERS, ...httpsHeaders(req) })
|
|
693
|
+
res.end(notFoundHtml(pathname))
|
|
694
|
+
return
|
|
695
|
+
}
|
|
671
696
|
}
|
|
672
697
|
|
|
673
698
|
// Method gating — raw response specs accept any HTTP method.
|
|
674
|
-
// Page specs default to GET/HEAD only;
|
|
699
|
+
// Page specs default to GET/HEAD only; spec.submit adds POST automatically;
|
|
700
|
+
// opt in to other methods via spec.methods.
|
|
675
701
|
if (!match.spec.contentType) {
|
|
676
702
|
const allowed = match.spec.methods
|
|
677
703
|
? match.spec.methods.map(m => m.toUpperCase())
|
|
678
|
-
: ['GET', 'HEAD']
|
|
704
|
+
: (typeof match.spec.submit === 'function' ? ['GET', 'HEAD', 'POST'] : ['GET', 'HEAD'])
|
|
679
705
|
if (!allowed.includes(req.method)) {
|
|
680
706
|
res.writeHead(405, {
|
|
681
707
|
'Content-Type': 'text/plain',
|
|
@@ -738,6 +764,57 @@ export async function createServer(entries, options = {}) {
|
|
|
738
764
|
}
|
|
739
765
|
}
|
|
740
766
|
|
|
767
|
+
// -----------------------------------------------------------------------
|
|
768
|
+
// CSRF — specs with a `submit` handler get token protection automatically
|
|
769
|
+
// (opt out per spec with csrf: false, e.g. for token-authenticated routes).
|
|
770
|
+
// Double-submit pattern: an HttpOnly cookie holds a random id; the form
|
|
771
|
+
// carries HMAC(secret, id) in a hidden _csrf field; POST verifies the pair.
|
|
772
|
+
// Views embed the field inside <form method="POST"> as ${server.csrf}.
|
|
773
|
+
// -----------------------------------------------------------------------
|
|
774
|
+
if (typeof spec.submit === 'function' && spec.csrf !== false) {
|
|
775
|
+
let csrfId = ctx.cookies.pulse_csrf
|
|
776
|
+
if (!csrfId) {
|
|
777
|
+
csrfId = crypto.randomBytes(16).toString('base64url')
|
|
778
|
+
ctx.setCookie('pulse_csrf', csrfId, { httpOnly: true, sameSite: 'Lax', path: '/' })
|
|
779
|
+
}
|
|
780
|
+
ctx._csrfField = `<input type="hidden" name="_csrf" value="${csrfToken(csrfSecret, csrfId)}">`
|
|
781
|
+
|
|
782
|
+
if (req.method === 'POST') {
|
|
783
|
+
const form = await ctx.formData()
|
|
784
|
+
const submitted = typeof form?._csrf === 'string' ? form._csrf : ''
|
|
785
|
+
const cookieId = ctx.cookies.pulse_csrf
|
|
786
|
+
if (!cookieId || !safeEqual(submitted, csrfToken(csrfSecret, cookieId))) {
|
|
787
|
+
res.writeHead(403, mergeCtxHeaders(ctx, {
|
|
788
|
+
'Content-Type': 'text/plain',
|
|
789
|
+
...SECURITY_HEADERS, ...httpsHeaders(req),
|
|
790
|
+
}))
|
|
791
|
+
res.end('Invalid or missing CSRF token. Reload the page and resubmit the form.')
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// -----------------------------------------------------------------------
|
|
798
|
+
// Server-side form handling — spec.submit handles POST without client JS.
|
|
799
|
+
// { redirect: '/path' } → 303 See Other (POST-redirect-GET)
|
|
800
|
+
// anything else → re-render the page; the return value appears
|
|
801
|
+
// in the view as serverState.form (errors,
|
|
802
|
+
// submitted values, etc.). Optional status field
|
|
803
|
+
// sets the response status (e.g. 422).
|
|
804
|
+
// -----------------------------------------------------------------------
|
|
805
|
+
if (req.method === 'POST' && typeof spec.submit === 'function') {
|
|
806
|
+
const result = await spec.submit(ctx)
|
|
807
|
+
if (result?.redirect) {
|
|
808
|
+
res.writeHead(303, mergeCtxHeaders(ctx, { Location: result.redirect, ...SECURITY_HEADERS, ...httpsHeaders(req) }))
|
|
809
|
+
res.end()
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
ctx._form = result ?? {}
|
|
813
|
+
if (typeof result?.status === 'number') responseStatus = result.status
|
|
814
|
+
// POST re-renders always use the buffered path — the request body is
|
|
815
|
+
// consumed and streaming a form response has no benefit.
|
|
816
|
+
}
|
|
817
|
+
|
|
741
818
|
// Derive the canonical base URL from the request.
|
|
742
819
|
// Canonical path follows the trailingSlash mode so the <link> is consistent with redirects.
|
|
743
820
|
// spec.meta.canonical (string or function) is resolved inside each handler, where serverState
|
|
@@ -776,7 +853,12 @@ export async function createServer(entries, options = {}) {
|
|
|
776
853
|
return
|
|
777
854
|
}
|
|
778
855
|
|
|
779
|
-
|
|
856
|
+
// Non-200 renders (custom 404) and POST re-renders always use the buffered
|
|
857
|
+
// string path — they are small, must carry an explicit status, and POST
|
|
858
|
+
// bodies are already consumed.
|
|
859
|
+
if (responseStatus !== 200 || req.method === 'POST') {
|
|
860
|
+
await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath, responseStatus)
|
|
861
|
+
} else if (stream) {
|
|
780
862
|
await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
|
|
781
863
|
} else {
|
|
782
864
|
await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
|
|
@@ -865,7 +947,8 @@ export async function createServer(entries, options = {}) {
|
|
|
865
947
|
server,
|
|
866
948
|
shutdown,
|
|
867
949
|
updateSpecs(newSpecs) {
|
|
868
|
-
|
|
950
|
+
notFoundSpec = newSpecs.find(s => s.route === '*') || null
|
|
951
|
+
router = buildRouter(newSpecs.filter(s => s.route !== '*'))
|
|
869
952
|
}
|
|
870
953
|
}
|
|
871
954
|
}
|
|
@@ -943,9 +1026,12 @@ async function handleNavStreamResponse(spec, ctx, req, res) {
|
|
|
943
1026
|
* Render to a complete string then send — simpler, easier to cache.
|
|
944
1027
|
* Checks the in-process page cache before rendering; stores result after.
|
|
945
1028
|
*/
|
|
946
|
-
async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null) {
|
|
1029
|
+
async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, status = 200) {
|
|
947
1030
|
const cacheKey = buildCacheKey(spec, ctx)
|
|
948
|
-
|
|
1031
|
+
// Never cache POST re-renders or non-200 responses (custom 404s) — POST output
|
|
1032
|
+
// varies per submission and a cached 404 body would be served with a 200 later.
|
|
1033
|
+
const cacheable = status === 200 && ctx.method !== 'POST' && isCacheablePage(spec)
|
|
1034
|
+
const ttl = cacheable ? pageCacheTtl(spec, dev, defaultCache) : 0
|
|
949
1035
|
|
|
950
1036
|
let html, serverTimingValue, fromCache = false
|
|
951
1037
|
|
|
@@ -984,7 +1070,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
984
1070
|
if (encoding) headers['Content-Encoding'] = encoding
|
|
985
1071
|
if (serverTimingValue) headers['Server-Timing'] = serverTimingValue
|
|
986
1072
|
|
|
987
|
-
res.writeHead(
|
|
1073
|
+
res.writeHead(status, headers)
|
|
988
1074
|
res.end(buf)
|
|
989
1075
|
}
|
|
990
1076
|
|
|
@@ -1585,6 +1671,23 @@ function escHtml(str) {
|
|
|
1585
1671
|
.replace(/'/g, ''')
|
|
1586
1672
|
}
|
|
1587
1673
|
|
|
1674
|
+
/**
|
|
1675
|
+
* Derive the CSRF token for a cookie id: HMAC-SHA256(secret, id), base64url.
|
|
1676
|
+
* The token goes in the form's hidden _csrf field; the id stays in an HttpOnly
|
|
1677
|
+
* cookie. An attacker on another origin can trigger the cookie being sent but
|
|
1678
|
+
* cannot read it or compute the HMAC, so they cannot forge the pair.
|
|
1679
|
+
*/
|
|
1680
|
+
function csrfToken(secret, id) {
|
|
1681
|
+
return crypto.createHmac('sha256', secret).update(String(id)).digest('base64url')
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/** Constant-time string comparison — length mismatch returns false without throwing. */
|
|
1685
|
+
function safeEqual(a, b) {
|
|
1686
|
+
const ba = Buffer.from(String(a))
|
|
1687
|
+
const bb = Buffer.from(String(b))
|
|
1688
|
+
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb)
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1588
1691
|
function notFoundHtml(pathname) {
|
|
1589
1692
|
return `<!DOCTYPE html>
|
|
1590
1693
|
<html lang="en">
|
|
@@ -1246,6 +1246,206 @@ await test('health check fires before route matching — shadows a user spec at
|
|
|
1246
1246
|
|
|
1247
1247
|
// ---------------------------------------------------------------------------
|
|
1248
1248
|
|
|
1249
|
+
console.log('\nCustom 404 page (route: "*")\n')
|
|
1250
|
+
|
|
1251
|
+
const notFoundPageSpec = {
|
|
1252
|
+
route: '*',
|
|
1253
|
+
meta: { title: 'Lost?' },
|
|
1254
|
+
view: () => '<main id="main-content"><h1>Custom not found</h1><a href="/">Home</a></main>',
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
await test('unknown route renders the "*" spec with status 404', async () => {
|
|
1258
|
+
await withServer([helloSpec, notFoundPageSpec], { stream: false }, async (port) => {
|
|
1259
|
+
const { status, body } = await get(port, '/nope')
|
|
1260
|
+
assert(status === 404, `Expected 404, got ${status}`)
|
|
1261
|
+
assert(body.includes('Custom not found'), `Expected custom 404 content, got: ${body.slice(0, 200)}`)
|
|
1262
|
+
assert(body.includes('<title>Lost?</title>'), `Expected custom 404 title, got: ${body.slice(0, 300)}`)
|
|
1263
|
+
})
|
|
1264
|
+
})
|
|
1265
|
+
|
|
1266
|
+
await test('custom 404 renders in streaming mode too (forced string path)', async () => {
|
|
1267
|
+
await withServer([helloSpec, notFoundPageSpec], { stream: true }, async (port) => {
|
|
1268
|
+
const { status, body } = await get(port, '/still-nope')
|
|
1269
|
+
assert(status === 404, `Expected 404, got ${status}`)
|
|
1270
|
+
assert(body.includes('Custom not found'), `Expected custom 404 content`)
|
|
1271
|
+
})
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
await test('known routes are unaffected by the "*" spec', async () => {
|
|
1275
|
+
await withServer([helloSpec, notFoundPageSpec], { stream: false }, async (port) => {
|
|
1276
|
+
const { status, body } = await get(port, '/hello')
|
|
1277
|
+
assert(status === 200, `Expected 200, got ${status}`)
|
|
1278
|
+
assert(body.includes('Hello world'), 'Expected normal page content')
|
|
1279
|
+
})
|
|
1280
|
+
})
|
|
1281
|
+
|
|
1282
|
+
await test('custom 404 responses are not cached as 200s', async () => {
|
|
1283
|
+
await withServer([helloSpec, notFoundPageSpec], { stream: false, defaultCache: 3600, dev: false }, async (port) => {
|
|
1284
|
+
const a = await get(port, '/missing')
|
|
1285
|
+
const b = await get(port, '/missing')
|
|
1286
|
+
assert(a.status === 404 && b.status === 404, `Expected both 404, got ${a.status}/${b.status}`)
|
|
1287
|
+
})
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1290
|
+
await test('without a "*" spec the default 404 still works', async () => {
|
|
1291
|
+
await withServer([helloSpec], { stream: false }, async (port) => {
|
|
1292
|
+
const { status, body } = await get(port, '/nope')
|
|
1293
|
+
assert(status === 404 && body.includes('404'), 'Expected default 404 page')
|
|
1294
|
+
})
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
// ---------------------------------------------------------------------------
|
|
1298
|
+
|
|
1299
|
+
console.log('\nServer-side forms (spec.submit) + CSRF\n')
|
|
1300
|
+
|
|
1301
|
+
function requestFull(port, method, path, { headers = {}, body = null } = {}) {
|
|
1302
|
+
return new Promise((resolve, reject) => {
|
|
1303
|
+
const req = http.request({ method, hostname: 'localhost', port, path, headers }, (res) => {
|
|
1304
|
+
let data = ''
|
|
1305
|
+
res.on('data', c => { data += c })
|
|
1306
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
|
|
1307
|
+
})
|
|
1308
|
+
req.on('error', reject)
|
|
1309
|
+
if (body) req.write(body)
|
|
1310
|
+
req.end()
|
|
1311
|
+
})
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function postForm(port, path, fields, cookie = '') {
|
|
1315
|
+
const body = new URLSearchParams(fields).toString()
|
|
1316
|
+
return requestFull(port, 'POST', path, {
|
|
1317
|
+
headers: {
|
|
1318
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1319
|
+
'Content-Length': Buffer.byteLength(body),
|
|
1320
|
+
...(cookie ? { Cookie: cookie } : {}),
|
|
1321
|
+
},
|
|
1322
|
+
body,
|
|
1323
|
+
})
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/** GET the form page and extract the CSRF cookie + token for a valid POST. */
|
|
1327
|
+
async function getCsrfPair(port, path) {
|
|
1328
|
+
const res = await get(port, path)
|
|
1329
|
+
const setCookie = [].concat(res.headers['set-cookie'] || []).find(c => c.startsWith('pulse_csrf='))
|
|
1330
|
+
const cookie = setCookie ? setCookie.split(';')[0] : ''
|
|
1331
|
+
const token = res.body.match(/name="_csrf" value="([^"]+)"/)?.[1] || ''
|
|
1332
|
+
return { cookie, token, body: res.body }
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const formSpec = {
|
|
1336
|
+
route: '/signup',
|
|
1337
|
+
meta: { title: 'Sign up' },
|
|
1338
|
+
submit: async (ctx) => {
|
|
1339
|
+
const data = await ctx.formData()
|
|
1340
|
+
if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
|
|
1341
|
+
if (data.email === 'taken@example.com') return { status: 422, errors: { email: 'Already registered' }, values: data }
|
|
1342
|
+
return { redirect: '/signup?done=1' }
|
|
1343
|
+
},
|
|
1344
|
+
view: (state, server) => `
|
|
1345
|
+
<main id="main-content">
|
|
1346
|
+
<h1>Sign up</h1>
|
|
1347
|
+
${server.form?.errors?.email ? `<p class="error">${server.form.errors.email}</p>` : ''}
|
|
1348
|
+
<form method="POST">
|
|
1349
|
+
${server.csrf ?? ''}
|
|
1350
|
+
<label for="email">Email</label>
|
|
1351
|
+
<input id="email" name="email" value="${server.form?.values?.email ?? ''}">
|
|
1352
|
+
<button type="submit">Go</button>
|
|
1353
|
+
</form>
|
|
1354
|
+
</main>`,
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
await test('GET on a submit page sets the CSRF cookie and embeds the hidden field', async () => {
|
|
1358
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1359
|
+
const { cookie, token, body } = await getCsrfPair(port, '/signup')
|
|
1360
|
+
assert(cookie.startsWith('pulse_csrf='), `Expected pulse_csrf cookie, got: ${cookie}`)
|
|
1361
|
+
assert(token.length > 20, `Expected a CSRF token in the form, got: ${token}`)
|
|
1362
|
+
assert(body.includes('name="_csrf"'), 'Expected hidden _csrf input in the form')
|
|
1363
|
+
})
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
await test('CSRF field is present in streaming mode too', async () => {
|
|
1367
|
+
await withServer([formSpec], { stream: true }, async (port) => {
|
|
1368
|
+
const { token } = await getCsrfPair(port, '/signup')
|
|
1369
|
+
assert(token.length > 20, 'Expected CSRF token in streamed HTML')
|
|
1370
|
+
})
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
await test('POST without a CSRF token is rejected with 403', async () => {
|
|
1374
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1375
|
+
const { status } = await postForm(port, '/signup', { email: 'a@b.com' })
|
|
1376
|
+
assert(status === 403, `Expected 403, got ${status}`)
|
|
1377
|
+
})
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
await test('POST with a forged token is rejected with 403', async () => {
|
|
1381
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1382
|
+
const { cookie } = await getCsrfPair(port, '/signup')
|
|
1383
|
+
const { status } = await postForm(port, '/signup', { email: 'a@b.com', _csrf: 'forged-token-value' }, cookie)
|
|
1384
|
+
assert(status === 403, `Expected 403, got ${status}`)
|
|
1385
|
+
})
|
|
1386
|
+
})
|
|
1387
|
+
|
|
1388
|
+
await test('valid POST follows POST-redirect-GET with 303', async () => {
|
|
1389
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1390
|
+
const { cookie, token } = await getCsrfPair(port, '/signup')
|
|
1391
|
+
const { status, headers } = await postForm(port, '/signup', { email: 'a@b.com', _csrf: token }, cookie)
|
|
1392
|
+
assert(status === 303, `Expected 303, got ${status}`)
|
|
1393
|
+
assert(headers.location === '/signup?done=1', `Expected PRG redirect, got ${headers.location}`)
|
|
1394
|
+
})
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
await test('validation failure re-renders the page with server.form (errors + values)', async () => {
|
|
1398
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1399
|
+
const { cookie, token } = await getCsrfPair(port, '/signup')
|
|
1400
|
+
const { status, body } = await postForm(port, '/signup', { email: '', _csrf: token }, cookie)
|
|
1401
|
+
assert(status === 200, `Expected 200 re-render, got ${status}`)
|
|
1402
|
+
assert(body.includes('Email is required'), `Expected error message in re-render, got: ${body.slice(0, 400)}`)
|
|
1403
|
+
})
|
|
1404
|
+
})
|
|
1405
|
+
|
|
1406
|
+
await test('submit can set a custom response status (422) and values are echoed back', async () => {
|
|
1407
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1408
|
+
const { cookie, token } = await getCsrfPair(port, '/signup')
|
|
1409
|
+
const { status, body } = await postForm(port, '/signup', { email: 'taken@example.com', _csrf: token }, cookie)
|
|
1410
|
+
assert(status === 422, `Expected 422, got ${status}`)
|
|
1411
|
+
assert(body.includes('Already registered'), 'Expected error in re-render')
|
|
1412
|
+
assert(body.includes('value="taken@example.com"'), 'Expected submitted value echoed into the input')
|
|
1413
|
+
})
|
|
1414
|
+
})
|
|
1415
|
+
|
|
1416
|
+
await test('csrf: false opts a submit route out of CSRF protection', async () => {
|
|
1417
|
+
const webhookish = {
|
|
1418
|
+
route: '/hook',
|
|
1419
|
+
csrf: false,
|
|
1420
|
+
submit: async () => ({ redirect: '/hook?ok=1' }),
|
|
1421
|
+
view: () => '<main id="main-content">hook</main>',
|
|
1422
|
+
}
|
|
1423
|
+
await withServer([webhookish], { stream: false }, async (port) => {
|
|
1424
|
+
const { status, headers } = await postForm(port, '/hook', { x: '1' })
|
|
1425
|
+
assert(status === 303 && headers.location === '/hook?ok=1', `Expected 303 without CSRF, got ${status}`)
|
|
1426
|
+
})
|
|
1427
|
+
})
|
|
1428
|
+
|
|
1429
|
+
await test('POST to a page without submit is still 405', async () => {
|
|
1430
|
+
await withServer([helloSpec], { stream: false }, async (port) => {
|
|
1431
|
+
const { status } = await postForm(port, '/hello', { x: '1' })
|
|
1432
|
+
assert(status === 405, `Expected 405, got ${status}`)
|
|
1433
|
+
})
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1436
|
+
await test('submit pages are excluded from the in-process page cache', async () => {
|
|
1437
|
+
// Two different clients must get different CSRF tokens — a cached page would
|
|
1438
|
+
// serve client A's token (and cookie pairing) to client B.
|
|
1439
|
+
await withServer([formSpec], { stream: false, defaultCache: 3600 }, async (port) => {
|
|
1440
|
+
const a = await getCsrfPair(port, '/signup')
|
|
1441
|
+
const b = await getCsrfPair(port, '/signup')
|
|
1442
|
+
assert(a.token && b.token && a.token !== b.token,
|
|
1443
|
+
`Expected per-visitor CSRF tokens, got identical values (page was cached)`)
|
|
1444
|
+
})
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1447
|
+
// ---------------------------------------------------------------------------
|
|
1448
|
+
|
|
1249
1449
|
console.log('\nMalformed request handling\n')
|
|
1250
1450
|
|
|
1251
1451
|
await test('malformed percent-encoding in path returns 404, not 500', async () => {
|
package/src/spec/schema.js
CHANGED
|
@@ -137,13 +137,30 @@ export function validateSpec(spec) {
|
|
|
137
137
|
return { valid: false, errors: ['Spec must be a plain object'] }
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
// route
|
|
140
|
+
// route — '*' is the custom not-found page, rendered with status 404 when no route matches
|
|
141
141
|
if (spec.route === undefined) {
|
|
142
142
|
warnings.push('spec.route is not set — the server cannot register this page without a route')
|
|
143
143
|
} else if (typeof spec.route !== 'string') {
|
|
144
144
|
errors.push('spec.route must be a string (e.g. "/contact")')
|
|
145
|
-
} else if (!spec.route.startsWith('/')) {
|
|
146
|
-
errors.push('spec.route must start with "/" (e.g. "/contact")')
|
|
145
|
+
} else if (!spec.route.startsWith('/') && spec.route !== '*') {
|
|
146
|
+
errors.push('spec.route must start with "/" (e.g. "/contact"), or be "*" for the custom not-found page')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// submit — server-side form handler: POST to the route is handled without client JS
|
|
150
|
+
if (spec.submit !== undefined) {
|
|
151
|
+
if (typeof spec.submit !== 'function') {
|
|
152
|
+
errors.push('spec.submit must be a function — async (ctx) => ({ redirect } | { ...form state }). It handles POST requests to this route server-side.')
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// csrf — opt out of CSRF protection on the submit handler (default: enforced)
|
|
157
|
+
if (spec.csrf !== undefined) {
|
|
158
|
+
if (typeof spec.csrf !== 'boolean') {
|
|
159
|
+
errors.push('spec.csrf must be a boolean — set csrf: false only to disable CSRF protection for spec.submit (e.g. for token-authenticated endpoints)')
|
|
160
|
+
}
|
|
161
|
+
if (spec.csrf === false && typeof spec.submit === 'function') {
|
|
162
|
+
warnings.push('spec.csrf is false — POST submissions to this route are NOT CSRF-protected. Only do this for endpoints with their own auth (e.g. signed webhooks).')
|
|
163
|
+
}
|
|
147
164
|
}
|
|
148
165
|
|
|
149
166
|
// contentType — marks a raw content spec (RSS, sitemap, JSON API, etc.)
|
package/src/spec/schema.test.js
CHANGED
|
@@ -412,6 +412,39 @@ test('does not warn when meta.theme is a function', () => {
|
|
|
412
412
|
assert(!warnings.some(w => w.includes('meta.theme')), 'Unexpected meta.theme warning when theme is a function')
|
|
413
413
|
})
|
|
414
414
|
|
|
415
|
+
// --- route '*', submit, csrf ------------------------------------------------
|
|
416
|
+
|
|
417
|
+
test('route "*" (custom not-found page) is valid', () => {
|
|
418
|
+
const { valid, errors } = validateSpec({ route: '*', view: () => '<main id="main-content">404</main>' })
|
|
419
|
+
assert(valid, `Expected valid, got: ${errors.join('; ')}`)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('non-"/" routes other than "*" are still rejected', () => {
|
|
423
|
+
const { valid } = validateSpec({ route: 'about', view: () => '' })
|
|
424
|
+
assert(!valid, 'Expected route without leading / to be invalid')
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test('submit must be a function', () => {
|
|
428
|
+
const { valid, errors } = validateSpec({ route: '/x', view: () => '', submit: 'nope' })
|
|
429
|
+
assert(!valid && errors.some(e => e.includes('submit')), 'Expected submit type error')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('a valid submit function passes', () => {
|
|
433
|
+
const { valid } = validateSpec({ route: '/x', view: () => '', submit: async () => ({ redirect: '/' }) })
|
|
434
|
+
assert(valid, 'Expected spec with submit function to be valid')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test('csrf must be a boolean', () => {
|
|
438
|
+
const { valid, errors } = validateSpec({ route: '/x', view: () => '', csrf: 'off' })
|
|
439
|
+
assert(!valid && errors.some(e => e.includes('csrf')), 'Expected csrf type error')
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
test('csrf: false with submit warns about unprotected POSTs', () => {
|
|
443
|
+
const { valid, warnings } = validateSpec({ route: '/x', view: () => '', submit: async () => {}, csrf: false })
|
|
444
|
+
assert(valid, 'csrf: false is valid')
|
|
445
|
+
assert(warnings.some(w => w.includes('CSRF')), 'Expected a warning about disabled CSRF protection')
|
|
446
|
+
})
|
|
447
|
+
|
|
415
448
|
// ---------------------------------------------------------------------------
|
|
416
449
|
|
|
417
450
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
package/src/ui/drop-cap.js
CHANGED
|
@@ -24,6 +24,6 @@ export function dropCap({
|
|
|
24
24
|
const classes = ['ui-drop-cap', cls].filter(Boolean).join(' ')
|
|
25
25
|
|
|
26
26
|
return `<p class="${e(classes)}">
|
|
27
|
-
<span class="ui-drop-cap-letter" style="
|
|
27
|
+
<span class="ui-drop-cap-letter" style="font-size:${+(lines * 1.2).toFixed(2)}em" aria-hidden="true">${e(letter)}</span>${e(content)}
|
|
28
28
|
</p>`
|
|
29
29
|
}
|
package/src/ui/gallery.js
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* @param {'sm'|'md'|'lg'} opts.gap - Gap between images (default: 'md')
|
|
12
12
|
* @param {boolean} opts.rounded - Rounded corners on images
|
|
13
13
|
* @param {string} opts.ratio - CSS aspect-ratio for grid/strip cells e.g. '4/3', '1/1', '16/9'
|
|
14
|
+
* @param {boolean} opts.lazy - loading="lazy" on images (default: true). Set false for
|
|
15
|
+
* above-the-fold galleries or when full-page screenshots
|
|
16
|
+
* during development need every image rendered eagerly.
|
|
14
17
|
* @param {string} opts.class
|
|
15
18
|
*/
|
|
16
19
|
|
|
@@ -26,6 +29,7 @@ export function gallery({
|
|
|
26
29
|
gap = 'md',
|
|
27
30
|
rounded = false,
|
|
28
31
|
ratio = '4/3',
|
|
32
|
+
lazy = true,
|
|
29
33
|
class: cls = '',
|
|
30
34
|
} = {}) {
|
|
31
35
|
if (!LAYOUTS.has(layout)) layout = 'grid'
|
|
@@ -42,7 +46,7 @@ export function gallery({
|
|
|
42
46
|
].filter(Boolean).join(' ')
|
|
43
47
|
|
|
44
48
|
const items = images.map(img => {
|
|
45
|
-
const imgTag = `<img src="${e(img.src)}" alt="${e(img.alt || '')}" class="ui-gallery-img" loading="lazy" decoding="async">`
|
|
49
|
+
const imgTag = `<img src="${e(img.src)}" alt="${e(img.alt || '')}" class="ui-gallery-img"${lazy ? ' loading="lazy"' : ''} decoding="async">`
|
|
46
50
|
const cap = img.caption ? `<figcaption class="ui-gallery-caption">${e(img.caption)}</figcaption>` : ''
|
|
47
51
|
const inner = img.href
|
|
48
52
|
? `<a href="${e(img.href)}" class="ui-gallery-link">${imgTag}</a>`
|
package/src/ui/ui.test.js
CHANGED
|
@@ -25,6 +25,8 @@ import { pricing } from './pricing.js'
|
|
|
25
25
|
import { accordion } from './accordion.js'
|
|
26
26
|
import { nav } from './nav.js'
|
|
27
27
|
import { appBadge } from './app-badge.js'
|
|
28
|
+
import { dropCap } from './drop-cap.js'
|
|
29
|
+
import { gallery } from './gallery.js'
|
|
28
30
|
import { container } from './container.js'
|
|
29
31
|
import { section } from './section.js'
|
|
30
32
|
import { grid } from './grid.js'
|
|
@@ -1319,4 +1321,36 @@ test('uiImage: escapes src to prevent XSS', () => {
|
|
|
1319
1321
|
assert.doesNotMatch(html, /<script>/)
|
|
1320
1322
|
})
|
|
1321
1323
|
|
|
1324
|
+
test('dropCap: inline style carries only the dynamic font-size — alignment lives in CSS', () => {
|
|
1325
|
+
// Regression: a relative inline line-height (lines * 0.8 → 2.4 for 3 lines)
|
|
1326
|
+
// made the float box ~3x the glyph height, so serif caps floated below the
|
|
1327
|
+
// opening line instead of top-aligning. line-height/float/margin now live in
|
|
1328
|
+
// .ui-drop-cap-letter (pulse-ui.css) with a fixed ~cap-height value.
|
|
1329
|
+
const html = dropCap({ letter: 'O', content: 'nce upon a time.' })
|
|
1330
|
+
assert.match(html, /class="ui-drop-cap-letter"/)
|
|
1331
|
+
assert.match(html, /style="font-size:3\.6em"/)
|
|
1332
|
+
assert.doesNotMatch(html, /line-height/)
|
|
1333
|
+
assert.doesNotMatch(html, /float:left/)
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
test('dropCap: lines option scales the font-size', () => {
|
|
1337
|
+
const html = dropCap({ letter: 'O', content: 'nce.', lines: 5 })
|
|
1338
|
+
assert.match(html, /style="font-size:6em"/)
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
test('gallery: images are lazy by default', () => {
|
|
1342
|
+
const html = gallery({ images: [{ src: '/a.jpg', alt: 'A' }] })
|
|
1343
|
+
assert.match(html, /loading="lazy"/)
|
|
1344
|
+
})
|
|
1345
|
+
|
|
1346
|
+
test('gallery: lazy:false renders eager images', () => {
|
|
1347
|
+
const html = gallery({ images: [{ src: '/a.jpg', alt: 'A' }], lazy: false })
|
|
1348
|
+
assert.doesNotMatch(html, /loading="lazy"/)
|
|
1349
|
+
})
|
|
1350
|
+
|
|
1351
|
+
test('dropCap: escapes letter and content', () => {
|
|
1352
|
+
const html = dropCap({ letter: '<', content: '<script>alert(1)</script>' })
|
|
1353
|
+
assert.doesNotMatch(html, /<script>/)
|
|
1354
|
+
})
|
|
1355
|
+
|
|
1322
1356
|
console.log('✓ All UI component tests passed')
|
package/types/schema.d.ts
CHANGED
|
@@ -65,6 +65,16 @@ export interface RequestContext {
|
|
|
65
65
|
// Guard return value
|
|
66
66
|
// ---------------------------------------------------------------------------
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Return value of spec.submit().
|
|
70
|
+
* { redirect } → 303 POST-redirect-GET. Any other object re-renders the page
|
|
71
|
+
* with the value exposed as serverState.form; an optional status field sets
|
|
72
|
+
* the HTTP response status (e.g. 422 for validation failures).
|
|
73
|
+
*/
|
|
74
|
+
export type SubmitResult =
|
|
75
|
+
| { redirect: string }
|
|
76
|
+
| { status?: number; [key: string]: unknown }
|
|
77
|
+
|
|
68
78
|
export type GuardResult =
|
|
69
79
|
| { redirect: string }
|
|
70
80
|
| { status: number; json?: unknown; body?: string; headers?: Record<string, string> }
|
|
@@ -131,7 +141,10 @@ export interface MetaConfig {
|
|
|
131
141
|
// ---------------------------------------------------------------------------
|
|
132
142
|
|
|
133
143
|
export interface PulseSpec<S extends object = Record<string, unknown>> {
|
|
134
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* URL pattern. Supports :param segments.
|
|
146
|
+
* Use '*' for the custom not-found page — rendered with status 404 when no route matches.
|
|
147
|
+
*/
|
|
135
148
|
route: string
|
|
136
149
|
|
|
137
150
|
/**
|
|
@@ -194,9 +207,25 @@ export interface PulseSpec<S extends object = Record<string, unknown>> {
|
|
|
194
207
|
/** Global store keys this page subscribes to. Appear in view's server arg. */
|
|
195
208
|
store?: string[]
|
|
196
209
|
|
|
197
|
-
/** HTTP methods this page accepts. Default ['GET', 'HEAD']. */
|
|
210
|
+
/** HTTP methods this page accepts. Default ['GET', 'HEAD'] (plus POST when submit is set). */
|
|
198
211
|
methods?: Array<'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS'>
|
|
199
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Server-side form handler — POST to this route works without client JS.
|
|
215
|
+
* Return { redirect } for a 303 POST-redirect-GET; any other value re-renders
|
|
216
|
+
* the page with the value exposed to the view as serverState.form (an optional
|
|
217
|
+
* status field sets the response status, e.g. 422).
|
|
218
|
+
* CSRF protection is enforced automatically: the view must include
|
|
219
|
+
* ${server.csrf} inside the <form> or POSTs are rejected with 403.
|
|
220
|
+
*/
|
|
221
|
+
submit?: (ctx: RequestContext) => Promise<SubmitResult | void> | SubmitResult | void
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Set false to disable CSRF protection on the submit handler.
|
|
225
|
+
* Only for endpoints with their own authentication (e.g. signed webhooks).
|
|
226
|
+
*/
|
|
227
|
+
csrf?: boolean
|
|
228
|
+
|
|
200
229
|
/**
|
|
201
230
|
* Called before server data fetchers on every request.
|
|
202
231
|
* Return { redirect } to redirect, { status, json } for a custom response,
|
package/types/server.d.ts
CHANGED
|
@@ -72,6 +72,13 @@ export interface ServerOptions {
|
|
|
72
72
|
/** Called on every request before routing. Return false to short-circuit Pulse handling. */
|
|
73
73
|
onRequest?: (req: IncomingMessage, res: ServerResponse) => false | void | unknown
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* HMAC secret for CSRF tokens (spec.submit forms). Defaults to a random
|
|
77
|
+
* per-boot value — set a stable secret (e.g. from an env var) when running
|
|
78
|
+
* multiple instances so tokens issued by one instance validate on another.
|
|
79
|
+
*/
|
|
80
|
+
secret?: string | null
|
|
81
|
+
|
|
75
82
|
/**
|
|
76
83
|
* Extra CSP sources to merge into the framework's default Content-Security-Policy.
|
|
77
84
|
* Use this to allow external stylesheets, fonts, or API origins.
|