@invisibleloop/pulse 0.17.2 → 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.
@@ -113,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
113
113
  ### Security
114
114
 
115
115
  - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
116
+ - **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
117
+ - **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
118
+
119
+ ### Server-side forms & error pages
120
+
121
+ - **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
122
+ - On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
123
+ - **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
116
124
 
117
125
  ### Tests
118
126
 
package/CLAUDE.md CHANGED
@@ -41,7 +41,8 @@ A spec is a plain JS object. Every property is optional except `route` and `view
41
41
 
42
42
  ```js
43
43
  export const mySpec = {
44
- route: '/path', // URL pattern, supports :params
44
+ route: '/path', // URL pattern, supports :params. Use '*' for the custom
45
+ // not-found page — rendered with status 404 when no route matches
45
46
 
46
47
  meta: {
47
48
  title: 'Page Title',
@@ -121,6 +122,19 @@ export const mySpec = {
121
122
  }
122
123
  },
123
124
 
125
+ // Server-side form handling — POST to this route, no client JS required.
126
+ // CSRF protection is automatic: include ${server.csrf} inside the <form>.
127
+ // { redirect: '/path' } → 303 POST-redirect-GET
128
+ // anything else → page re-renders; return value appears as server.form
129
+ // (optional status field sets the response status, e.g. 422)
130
+ submit: async (ctx) => {
131
+ const data = await ctx.formData()
132
+ if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
133
+ await saveSignup(data)
134
+ return { redirect: '/thanks' }
135
+ },
136
+ // csrf: false, // opt out ONLY for endpoints with their own auth (e.g. signed webhooks)
137
+
124
138
  // Streaming SSR — split view into shell (instant) + deferred segments
125
139
  stream: {
126
140
  shell: ['header', 'nav'],
@@ -141,6 +155,42 @@ export const mySpec = {
141
155
  export default mySpec // required for hydration imports
142
156
  ```
143
157
 
158
+ ## Server-Side Forms (no client JS)
159
+
160
+ A spec with `submit` accepts POST on its route — the form works with JavaScript disabled. **Always include `${server.csrf}` inside the form** (CSRF is enforced; POSTs without the token get 403):
161
+
162
+ ```js
163
+ view: (state, server) => `
164
+ <main id="main-content">
165
+ ${server.form?.errors?.email ? alert({ variant: 'error', message: server.form.errors.email }) : ''}
166
+ <form method="POST">
167
+ ${server.csrf}
168
+ ${input({ label: 'Email', name: 'email', type: 'email', required: true, value: server.form?.values?.email ?? '' })}
169
+ ${button({ label: 'Sign up', type: 'submit' })}
170
+ </form>
171
+ </main>`,
172
+ ```
173
+
174
+ - `server.form` is the return value of `submit()` on a re-render (validation errors, submitted values) — `undefined` on plain GET.
175
+ - **Progressive enhancement:** the same form can also carry `data-action="signup"` — hydrated visitors get the async action (no page reload), no-JS visitors fall back to the POST. Echo submitted values back into inputs via `value=` so a failed validation doesn't wipe the form.
176
+ - Always `return { redirect }` after a successful mutation (POST-redirect-GET) — never render success directly from the POST, or refresh resubmits the form.
177
+ - Multi-instance deployments: set a stable `secret` in `createServer` so CSRF tokens validate across instances.
178
+
179
+ ## Custom 404 Page
180
+
181
+ Create a spec with `route: '*'` — it renders through the normal pipeline (layout, styles, hydration, validation) with status 404 whenever no route matches:
182
+
183
+ ```js
184
+ // src/pages/not-found.js
185
+ export default {
186
+ route: '*',
187
+ meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
188
+ view: () => `<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>`,
189
+ }
190
+ ```
191
+
192
+ Without a `'*'` spec, the framework's plain default 404 is served. 500s are customised via `createServer`'s `onError` option.
193
+
144
194
  ## HTML Event Binding
145
195
 
146
196
  Pulse binds to DOM attributes — no JSX, no templates.
@@ -230,6 +280,8 @@ await createServer(
230
280
  // spec.serverTimeout overrides per-page
231
281
  shutdownTimeout: 30000, // ms to wait for in-flight requests before force-exit on SIGTERM/SIGINT
232
282
  healthCheck: '/healthz', // built-in health endpoint path, or false to disable
283
+ secret: process.env.PULSE_SECRET, // HMAC secret for CSRF tokens — set a stable value
284
+ // when running multiple instances (default: random per boot)
233
285
  csp: { // extra sources merged into the framework's default CSP
234
286
  'style-src': ['https://fonts.googleapis.com'],
235
287
  'font-src': ['https://fonts.gstatic.com'],
@@ -113,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
113
113
  ### Security
114
114
 
115
115
  - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
116
+ - **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
117
+ - **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
118
+
119
+ ### Server-side forms & error pages
120
+
121
+ - **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
122
+ - On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
123
+ - **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
116
124
 
117
125
  ### Tests
118
126
 
package/docs/server.js CHANGED
@@ -45,9 +45,11 @@ await createServer(
45
45
  new URL('./src/pages/markdown.js', import.meta.url),
46
46
  new URL('./src/pages/store.js', import.meta.url),
47
47
  new URL('./src/pages/routing.js', import.meta.url),
48
+ new URL('./src/pages/error-pages.js', import.meta.url),
48
49
  new URL('./src/pages/streaming.js', import.meta.url),
49
50
  new URL('./src/pages/caching.js', import.meta.url),
50
51
  new URL('./src/pages/guard.js', import.meta.url),
52
+ new URL('./src/pages/forms.js', import.meta.url),
51
53
  new URL('./src/pages/raw-responses.js', import.meta.url),
52
54
  new URL('./src/pages/server-api.js', import.meta.url),
53
55
  new URL('./src/pages/extending.js', import.meta.url),
@@ -41,9 +41,11 @@ export const NAV = [
41
41
  { label: 'Markdown', href: '/markdown' },
42
42
  { label: 'Global Store', href: '/store' },
43
43
  { label: 'Routing', href: '/routing' },
44
+ { label: 'Error Pages', href: '/error-pages' },
44
45
  { label: 'Streaming SSR', href: '/streaming' },
45
46
  { label: 'Caching', href: '/caching' },
46
47
  { label: 'Guard', href: '/guard' },
48
+ { label: 'Forms', href: '/forms' },
47
49
  { label: 'Raw Responses', href: '/raw-responses' },
48
50
  { label: 'Server API', href: '/server-api' },
49
51
  { label: 'Extending Pulse', href: '/extending' },
@@ -20,6 +20,8 @@ export default {
20
20
  ${h1('Actions')}
21
21
  ${lead('Actions handle async operations with an enforced lifecycle. The order of steps — capture inputs, validate, run, succeed or fail — is fixed. Skipping validation or running async work before showing a loading state is not possible within the action structure.')}
22
22
 
23
+ ${callout('tip', 'Actions run client-side and need hydration. For forms that must also work with JavaScript disabled, pair the action with a server-side <a href="/forms"><code>submit</code> handler</a> — the same form serves both paths.')}
24
+
23
25
  ${section('lifecycle', 'The action lifecycle')}
24
26
  <p>When a form with <code>data-action</code> is submitted, Pulse runs the action through a fixed sequence of steps:</p>
25
27
  ${codeBlock(highlight(`onStart(state, formData)
@@ -0,0 +1,98 @@
1
+ import { renderLayout, h1, lead, section, codeBlock, callout, table } from '../lib/layout.js'
2
+ import { prevNext } from '../lib/nav.js'
3
+ import { highlight } from '../lib/highlight.js'
4
+
5
+ const { prev, next } = prevNext('/error-pages')
6
+
7
+ export default {
8
+ route: '/error-pages',
9
+ meta: {
10
+ title: 'Error Pages — Pulse Docs',
11
+ description: 'Custom 404 pages in Pulse. A spec with route "*" renders through the normal pipeline with status 404 whenever no route matches.',
12
+ styles: ['/docs.css'],
13
+ },
14
+ view: () => renderLayout({
15
+ currentHref: '/error-pages',
16
+ prev,
17
+ next,
18
+ content: `
19
+ ${h1('Error Pages')}
20
+ ${lead('A spec with <code>route: \'*\'</code> is your site\'s custom 404 page. It renders through the normal pipeline — layout, styles, components, hydration — with status <strong>404</strong> whenever no route matches. Without one, visitors to a bad URL get the framework\'s unbranded default.')}
21
+
22
+ ${section('not-found', 'The custom 404 page')}
23
+ <p>Create a spec like any other page, with <code>'*'</code> as the route. Use the shared <code>layout()</code> so the page carries the site's nav and footer:</p>
24
+ ${codeBlock(highlight(`import { layout } from '../components/layout.js'
25
+
26
+ export default {
27
+ route: '*',
28
+ meta: {
29
+ title: 'Page not found',
30
+ styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
31
+ },
32
+ view: () => layout({
33
+ content: \`
34
+ <h1>Page not found</h1>
35
+ <p>That page doesn't exist. <a href="/">Back home</a></p>
36
+ \`,
37
+ }),
38
+ }`, 'js', ), 'src/pages/not-found.js')}
39
+ <p>The filename doesn't matter — <code>not-found.js</code> is just a readable convention. The <code>route: '*'</code> property is what registers it as the fallback.</p>
40
+
41
+ ${callout('tip', 'Every site should have one. The framework\'s default 404 is a bare unstyled page with no navigation — a dead end for visitors and a missed signal for search engines crawling old URLs.')}
42
+
43
+ ${section('behaviour', 'How it behaves')}
44
+ ${table(
45
+ ['Aspect', 'Behaviour'],
46
+ [
47
+ ['Status code', 'Always <strong>404</strong> — correct for SEO; crawlers de-index dead URLs'],
48
+ ['Pipeline', 'Full normal render: <code>meta</code>, styles, components, hydration if it has mutations/actions'],
49
+ ['Validation', 'Validated at startup like every spec — a broken 404 page fails fast'],
50
+ ['Matching', 'Only when no other route matches. It is a fallback, not a route — it never shadows real pages'],
51
+ ['Client-side navigation', 'A bad link falls back to a full page load, which renders the custom 404'],
52
+ ['Caching', '404 responses are never stored in the in-process page cache'],
53
+ ]
54
+ )}
55
+
56
+ ${section('useful-404', 'Making the 404 useful')}
57
+ <p>The best 404 pages help the visitor recover. Since the page is a normal spec, it can use any component — search, popular links, even server data:</p>
58
+ ${codeBlock(highlight(`export default {
59
+ route: '*',
60
+ meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
61
+ server: {
62
+ popular: async () => getPopularPages(), // suggest somewhere to go
63
+ },
64
+ view: (state, server) => layout({
65
+ content: \`
66
+ <h1>Page not found</h1>
67
+ <p>Try one of these instead:</p>
68
+ \${list({ items: (server.popular ?? []).map(p => \`<a href="\${p.href}">\${p.title}</a>\`) })}
69
+ \`,
70
+ }),
71
+ }`, 'js'))}
72
+
73
+ ${section('server-errors', '500s and other errors')}
74
+ <p>Server errors are handled by <code>createServer</code>'s <code>onError</code> option, not a spec — when a render throws, the spec pipeline itself may be the thing that is broken, so the handler works at the HTTP level:</p>
75
+ ${codeBlock(highlight(`await createServer(pages, {
76
+ onError: (err, req, res) => {
77
+ console.error(err)
78
+ if (res.headersSent) return
79
+ res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' })
80
+ res.end('<h1>Something went wrong</h1><p><a href="/">Back home</a></p>')
81
+ },
82
+ })`, 'js'))}
83
+ <p>For errors thrown inside a <em>view</em> specifically, prefer <code>onViewError</code> on the spec — it returns a 200 with fallback HTML instead of a 500, keeping the page alive when one piece of data is malformed.</p>
84
+
85
+ ${callout('note', 'Three layers, three jobs: <code>route: \'*\'</code> for URLs that don\'t exist, <code>onViewError</code> for views that throw on bad data, <code>onError</code> for everything else that goes wrong server-side.')}
86
+
87
+ ${section('reference', 'Reference')}
88
+ ${table(
89
+ ['Property', 'Where', 'Notes'],
90
+ [
91
+ ['<code>route: \'*\'</code>', 'spec', 'Custom not-found page — rendered with status 404 when no route matches'],
92
+ ['<code>onViewError</code>', 'spec', 'Fallback HTML when the view throws — returns 200'],
93
+ ['<code>onError</code>', '<code>createServer</code> option', 'HTTP-level handler for unhandled server errors (500s)'],
94
+ ]
95
+ )}
96
+ `,
97
+ }),
98
+ }
@@ -0,0 +1,128 @@
1
+ import { renderLayout, h1, lead, section, sub, codeBlock, callout, table } from '../lib/layout.js'
2
+ import { prevNext } from '../lib/nav.js'
3
+ import { highlight } from '../lib/highlight.js'
4
+
5
+ const { prev, next } = prevNext('/forms')
6
+
7
+ export default {
8
+ route: '/forms',
9
+ meta: {
10
+ title: 'Forms — Pulse Docs',
11
+ description: 'Server-side form handling in Pulse. spec.submit processes POST requests without client JavaScript, with automatic CSRF protection and POST-redirect-GET.',
12
+ styles: ['/docs.css'],
13
+ },
14
+ view: () => renderLayout({
15
+ currentHref: '/forms',
16
+ prev,
17
+ next,
18
+ content: `
19
+ ${h1('Forms')}
20
+ ${lead('A spec with a <code>submit</code> handler accepts POST requests on its own route — the form works with JavaScript disabled. CSRF protection is automatic, and successful submissions follow POST-redirect-GET. Client-side <code>actions</code> become an enhancement, not a requirement.')}
21
+
22
+ ${section('basics', 'Basic usage')}
23
+ <p>Add a <code>submit</code> function to the spec and render a plain <code>&lt;form method="POST"&gt;</code>. The route accepts POST automatically — no <code>methods</code> declaration needed.</p>
24
+ ${codeBlock(highlight(`export default {
25
+ route: '/contact',
26
+
27
+ submit: async (ctx) => {
28
+ const data = await ctx.formData()
29
+ if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
30
+ await sendMessage(data)
31
+ return { redirect: '/contact?sent=1' } // 303 POST-redirect-GET
32
+ },
33
+
34
+ view: (state, server) => \`
35
+ <main id="main-content">
36
+ \${server.form?.errors?.email ? \`<p class="error">\${escHtml(server.form.errors.email)}</p>\` : ''}
37
+ <form method="POST">
38
+ \${server.csrf}
39
+ \${input({ label: 'Email', name: 'email', type: 'email', required: true,
40
+ value: server.form?.values?.email ?? '' })}
41
+ \${button({ label: 'Send', type: 'submit' })}
42
+ </form>
43
+ </main>
44
+ \`,
45
+ }`, 'js'))}
46
+ <p>The flow: the browser POSTs the form, <code>submit(ctx)</code> runs server-side, and either redirects (success) or re-renders the page with the returned value available to the view as <code>server.form</code> (validation failure).</p>
47
+
48
+ ${callout('warning', '<strong><code>\${server.csrf}</code> must be inside every POST form.</strong> It renders the hidden CSRF token input — submissions without a valid token are rejected with <strong>403</strong> before <code>submit</code> runs.')}
49
+
50
+ ${section('return-values', 'Return values')}
51
+ ${table(
52
+ ['Return', 'Response', 'Use for'],
53
+ [
54
+ ['<code>{ redirect: \'/path\' }</code>', '<strong>303 See Other</strong>', 'Successful submissions — POST-redirect-GET means refresh never resubmits'],
55
+ ['<code>{ errors, values, … }</code>', 'Page re-renders (200), value exposed as <code>server.form</code>', 'Validation failures — show errors, echo values back into inputs'],
56
+ ['<code>{ status: 422, errors, … }</code>', 'Same re-render with status <strong>422</strong>', 'Validation failures where the status code matters (API clients, tests)'],
57
+ ['<code>undefined</code>', 'Page re-renders (200), <code>server.form</code> is <code>{}</code>', 'Fire-and-forget side effects'],
58
+ ]
59
+ )}
60
+ <p><strong>Always redirect after a successful mutation.</strong> Rendering a success message directly from the POST means a browser refresh resubmits the form. Redirect to a URL that shows the confirmation instead (e.g. <code>/contact?sent=1</code>).</p>
61
+
62
+ ${section('server-form', 'server.form — errors and echoed values')}
63
+ <p>On a re-render, whatever <code>submit</code> returned is available to the view as <code>server.form</code>. On a plain GET it is <code>undefined</code> — always use optional chaining.</p>
64
+ ${codeBlock(highlight(`// In submit — return errors AND the submitted values:
65
+ return { errors: { email: 'Invalid email' }, values: data }
66
+
67
+ // In the view — show the error, echo the value back so the form isn't wiped:
68
+ \${server.form?.errors?.email ? \`<p class="error">\${escHtml(server.form.errors.email)}</p>\` : ''}
69
+ \${input({ name: 'email', label: 'Email', value: server.form?.values?.email ?? '' })}`, 'js'))}
70
+ ${callout('warning', 'Escape user-submitted values before interpolating them into HTML — <code>server.form.values</code> is raw user input. Component props like <code>input({ value })</code> are auto-escaped; manual interpolation needs <code>escHtml()</code>.')}
71
+
72
+ ${section('csrf', 'CSRF protection')}
73
+ <p>Every <code>submit</code> spec gets CSRF protection automatically — there is nothing to configure. Under the hood Pulse uses an HMAC double-submit pattern:</p>
74
+ ${table(
75
+ ['Piece', 'Where it lives', 'What it does'],
76
+ [
77
+ ['Random id', '<code>pulse_csrf</code> cookie (HttpOnly, SameSite=Lax)', 'Set on the first GET of the form page'],
78
+ ['Token', 'Hidden <code>_csrf</code> input via <code>\${server.csrf}</code>', '<code>HMAC-SHA256(secret, id)</code> — embedded into the form server-side'],
79
+ ['Verification', 'On every POST, before <code>submit</code> runs', 'Recomputes the HMAC from the cookie and compares in constant time — mismatch is 403'],
80
+ ]
81
+ )}
82
+ <p>An attacker on another origin can cause the cookie to be sent, but cannot read it or compute the HMAC — so they cannot forge the pair.</p>
83
+
84
+ ${sub('multi-instance', 'Multiple server instances')}
85
+ <p>The HMAC secret defaults to a random value per server boot. Behind a load balancer, pin it so tokens issued by one instance validate on another:</p>
86
+ ${codeBlock(highlight(`await createServer(pages, {
87
+ secret: process.env.PULSE_SECRET, // stable across instances and restarts
88
+ })`, 'js'))}
89
+
90
+ ${sub('opt-out', 'Opting out')}
91
+ <p>Set <code>csrf: false</code> on the spec to disable protection — only for endpoints with their own authentication, such as signed webhooks:</p>
92
+ ${codeBlock(highlight(`export default {
93
+ route: '/webhooks/stripe',
94
+ csrf: false, // Stripe signs its requests — verify the signature instead
95
+ submit: async (ctx) => {
96
+ verifyStripeSignature(ctx.headers['stripe-signature'], await ctx.text())
97
+ // ...
98
+ },
99
+ view: () => '<main id="main-content">ok</main>',
100
+ }`, 'js'))}
101
+ ${callout('warning', 'Never set <code>csrf: false</code> to "fix" a 403. The 403 means the form is missing <code>\${server.csrf}</code> — add the field, don\'t remove the protection.')}
102
+
103
+ ${section('progressive-enhancement', 'Progressive enhancement')}
104
+ <p>The same form can serve both worlds: <code>method="POST"</code> is the no-JS fallback, <code>data-action</code> is the hydrated path. With JavaScript enabled the runtime intercepts the submit and runs the async action (no page reload); without it the browser POSTs to <code>submit</code>.</p>
105
+ ${codeBlock(highlight(`<form method="POST" data-action="send">
106
+ \${server.csrf}
107
+ \${input({ label: 'Email', name: 'email', type: 'email', required: true })}
108
+ \${button({ label: 'Send', type: 'submit' })}
109
+ </form>`, 'html'))}
110
+ <p>Keep the two paths consistent: <code>submit</code> and the action's <code>run</code> should call the same underlying function so validation and side effects cannot drift apart.</p>
111
+
112
+ ${section('caching', 'Interaction with caching')}
113
+ <p>Pages with <code>submit</code> are automatically excluded from the in-process page cache — the CSRF token is per-visitor, and a cached page would serve one visitor's token to everyone. POST responses are never cached.</p>
114
+
115
+ ${section('reference', 'Reference')}
116
+ ${table(
117
+ ['Property', 'Type', 'Notes'],
118
+ [
119
+ ['<code>submit</code>', '<code>async (ctx) =&gt; { redirect } | object | void</code>', 'Handles POST on the route. Guard runs first.'],
120
+ ['<code>csrf</code>', '<code>boolean</code>', 'Default <code>true</code>. Set <code>false</code> only for self-authenticated endpoints.'],
121
+ ['<code>server.csrf</code>', 'string (view)', 'Hidden <code>_csrf</code> input — required inside every POST form.'],
122
+ ['<code>server.form</code>', 'object | undefined (view)', 'Return value of <code>submit</code> on a re-render; <code>undefined</code> on GET.'],
123
+ ['<code>secret</code>', 'string (createServer option)', 'Stable HMAC secret for multi-instance deployments.'],
124
+ ]
125
+ )}
126
+ `,
127
+ }),
128
+ }
@@ -105,6 +105,7 @@ export default {
105
105
  // return nothing to let a GET request proceed to the view
106
106
  }`, 'js'))}
107
107
  ${callout('note', 'To use <code>guard</code> as a POST handler, the spec must declare <code>methods: [\'GET\', \'POST\']</code>. Without it, POST requests are rejected with 405 before guard runs.')}
108
+ ${callout('tip', 'For HTML form submissions, prefer <a href="/forms"><code>spec.submit</code></a> — it accepts POST automatically, gets CSRF protection for free, and re-renders the page with validation errors. Guard-based POST handling is best for API-style endpoints.')}
108
109
 
109
110
  ${section('reference', 'Reference')}
110
111
  ${table(
@@ -80,15 +80,13 @@ server: {
80
80
  }`, 'js'))}
81
81
 
82
82
  ${section('not-found', '404 handling')}
83
- <p>If no spec matches the incoming request path, Pulse returns a 404 response. The response body is a minimal HTML page. To customise the 404 page, use the <code>onError</code> option in <code>createServer</code>:</p>
84
- ${codeBlock(highlight(`createServer(specs, {
85
- onError: (err, req, res) => {
86
- if (err.status === 404) {
87
- res.writeHead(404, { 'Content-Type': 'text/html' })
88
- res.end('<h1>Not found</h1>')
89
- }
90
- }
91
- })`, 'js'))}
83
+ <p>If no spec matches the incoming request path, Pulse returns a 404. To customise it, create a spec with <code>route: '*'</code> — it renders through the normal pipeline (layout, styles, components) with status 404:</p>
84
+ ${codeBlock(highlight(`export default {
85
+ route: '*',
86
+ meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
87
+ view: () => \`<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>\`,
88
+ }`, 'js'), 'src/pages/not-found.js')}
89
+ <p>See <a href="/error-pages">Error Pages</a> for the full behaviour, including 500 handling.</p>
92
90
 
93
91
  ${section('conventions', 'File naming conventions')}
94
92
  <p>While Pulse does not auto-discover files, the recommended convention maps file names to routes:</p>
@@ -48,8 +48,9 @@ await createServer(entries, options)`, 'js'))}
48
48
  ['<code>shutdownTimeout</code>', '<code>number</code>', '<code>30000</code>', 'Milliseconds to wait for in-flight requests to finish during graceful shutdown before force-exiting. See <a href="#graceful-shutdown">Graceful shutdown</a>.'],
49
49
  ['<code>healthCheck</code>', '<code>string | false</code>', '<code>"/healthz"</code>', 'Path for the built-in health check endpoint. Returns <code>{ status: "ok", uptime }</code>. Set to <code>false</code> to disable. The endpoint bypasses <code>onRequest</code> so load balancers always get a response.'],
50
50
  ['<code>resolveBrand</code>', '<code>async (host) => any</code>', '<code>undefined</code>', 'Multi-brand support. Called once per host (cached 60s). Result is attached to <code>ctx.brand</code> and available in <code>guard</code>, <code>server</code>, and <code>meta</code> functions.'],
51
+ ['<code>secret</code>', '<code>string</code>', 'random per boot', 'HMAC secret for CSRF tokens (<a href="/forms">Forms</a>). Set a stable value when running multiple instances so tokens issued by one validate on another.'],
51
52
  ['<code>onRequest</code>', '<code>function</code>', '<code>undefined</code>', 'Called on every request before routing. Return <code>false</code> to short-circuit Pulse handling.'],
52
- ['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>.'],
53
+ ['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>. See <a href="/error-pages">Error Pages</a>.'],
53
54
  ]
54
55
  )}
55
56
 
@@ -113,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
113
113
  ### Security
114
114
 
115
115
  - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
116
+ - **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
117
+ - **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
118
+
119
+ ### Server-side forms & error pages
120
+
121
+ - **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
122
+ - On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
123
+ - **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
116
124
 
117
125
  ### Tests
118
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
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.17.2
1
+ 0.17.3
@@ -113,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
113
113
  ### Security
114
114
 
115
115
  - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
116
+ - **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
117
+ - **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
118
+
119
+ ### Server-side forms & error pages
120
+
121
+ - **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
122
+ - On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
123
+ - **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
116
124
 
117
125
  ### Tests
118
126
 
@@ -126,6 +126,27 @@ Convention: name dynamic-route files [param].js inside a subfolder:
126
126
 
127
127
  This is purely a human readability convention. Pulse does not process [ ] in filenames.
128
128
 
129
+ ## Custom 404 page
130
+
131
+ Create a spec with `route: '*'` — it renders through the normal pipeline (layout, styles, hydration) with status 404 whenever no route matches:
132
+
133
+ ```js
134
+ // src/pages/not-found.js
135
+ import { layout } from '../components/layout.js'
136
+
137
+ export default {
138
+ route: '*',
139
+ meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
140
+ view: () => layout({
141
+ content: `<h1>Page not found</h1><p>That page doesn't exist. <a href="/">Back home</a></p>`,
142
+ }),
143
+ }
144
+ ```
145
+
146
+ - Use the shared `layout()` so the 404 carries the site's nav and footer.
147
+ - **Every site should have one** — without it, visitors to a bad URL get the framework's unbranded default 404.
148
+ - 500 errors are customised via `createServer`'s `onError` option, not a spec.
149
+
129
150
  ## Canonical URLs
130
151
 
131
152
  Pulse auto-derives a canonical URL from every request and emits `<link rel="canonical">` in the `<head>`. No config needed in most cases.
@@ -102,6 +102,40 @@ actions: {
102
102
  ```
103
103
  `_storeUpdate` is stripped from local page state — it is only forwarded to the store. The rest of `onSuccess` merges into the page's own state as normal.
104
104
 
105
+ ## Server-side forms — `spec.submit` (works without client JS)
106
+
107
+ A spec with a `submit` handler accepts POST on its own route. The form works with JavaScript disabled — hydrated `actions` become an enhancement, not a requirement.
108
+
109
+ ```js
110
+ export default {
111
+ route: '/contact',
112
+ submit: async (ctx) => {
113
+ const data = await ctx.formData()
114
+ if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
115
+ if (!isValid(data.email)) return { status: 422, errors: { email: 'Invalid email' }, values: data }
116
+ await sendMessage(data)
117
+ return { redirect: '/contact?sent=1' } // 303 POST-redirect-GET
118
+ },
119
+ view: (state, server) => `
120
+ <main id="main-content">
121
+ ${server.form?.errors?.email ? `<p class="error">${escHtml(server.form.errors.email)}</p>` : ''}
122
+ <form method="POST">
123
+ ${server.csrf}
124
+ ${input({ label: 'Email', name: 'email', type: 'email', required: true, value: server.form?.values?.email ?? '' })}
125
+ ${button({ label: 'Send', type: 'submit' })}
126
+ </form>
127
+ </main>`,
128
+ }
129
+ ```
130
+
131
+ **Rules:**
132
+ - `${server.csrf}` **must be inside every POST form** — CSRF protection is automatic and POSTs without a valid token are rejected with 403. Opt out with `csrf: false` only for endpoints with their own authentication (e.g. signed webhooks).
133
+ - `{ redirect }` → 303 See Other. Always redirect after a successful mutation — rendering success directly from the POST means refresh resubmits the form.
134
+ - Any other return value re-renders the page with it exposed as `server.form` — put `errors` and the submitted `values` there, and echo values back into inputs so failed validation doesn't wipe what the user typed. An optional `status` field sets the response status (e.g. 422).
135
+ - Escape user-submitted values before interpolating them into HTML (`escHtml`).
136
+ - **Progressive enhancement:** add `data-action="send"` to the same form — hydrated visitors get the async action with no page reload; no-JS visitors fall back to the POST.
137
+ - Multi-instance deployments: pass a stable `secret` to `createServer` (e.g. from an env var) so CSRF tokens issued by one instance validate on another.
138
+
105
139
  ## Server context — redirects, cookies, POST bodies
106
140
 
107
141
  The `ctx` object is available in `guard`, `server.*` fetchers, and `meta` functions.
@@ -46,10 +46,31 @@ export default {
46
46
  // Resolved server-side before render. Passed to view() as second arg.
47
47
  posts: async () => fetchPostsFromDb(),
48
48
  },
49
+ // Server-side form handling — POST works with JavaScript disabled.
50
+ // { redirect } → 303 PRG; anything else → re-render with the value as server.form.
51
+ // CSRF enforced automatically: the view MUST include ${server.csrf} inside the <form>.
52
+ submit: async (ctx) => {
53
+ const data = await ctx.formData()
54
+ if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
55
+ await save(data)
56
+ return { redirect: '/thanks' }
57
+ },
49
58
  view: (state, serverState) => `<h1>${state.title}</h1>`,
50
59
  }
51
60
  ```
52
61
 
62
+ ## Server-side forms (`submit`)
63
+
64
+ - A spec with `submit` accepts POST on its own route — plain `<form method="POST">`, no client JS needed.
65
+ - **Always put `${server.csrf}` inside the form** — it renders the hidden CSRF input. POSTs without a valid token are rejected with 403. (`csrf: false` opts out — only for endpoints with their own auth.)
66
+ - `server.form` holds the return value of `submit()` on a re-render: show `server.form?.errors`, echo `server.form?.values` back into inputs via `value=` so failed validation doesn't wipe the form.
67
+ - After a successful mutation always `return { redirect: '/path' }` (POST-redirect-GET) — never render success from the POST itself. An optional `status` field on a re-render return sets the response status (e.g. 422).
68
+ - Progressive enhancement: the same form can also have `data-action="name"` — hydrated visitors get the async action, no-JS visitors use the POST.
69
+
70
+ ## Custom not-found page
71
+
72
+ A spec with `route: '*'` renders with **status 404** whenever no route matches — same pipeline as any page (layout, styles, hydration, validation). Every site should have one; the default framework 404 is unbranded.
73
+
53
74
  ## Streaming SSR — shell + deferred segments
54
75
 
55
76
  Use streaming when a page has slow data and you want the shell to paint instantly while slower content streams in.
@@ -36,7 +36,7 @@ export async function renderToString(spec, ctx = {}, pageState = {}) {
36
36
  const serverState = await resolveServerState(spec, ctx)
37
37
 
38
38
  // Merge declared store keys into server state — store keys lose to page-level keys
39
- const mergedServerState = mergeStoreKeys(spec, serverState, ctx.store)
39
+ const mergedServerState = applyCtxExtras(mergeStoreKeys(spec, serverState, ctx.store), ctx)
40
40
 
41
41
  const tData = performance.now()
42
42
 
@@ -105,7 +105,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
105
105
 
106
106
  // Await only the fetchers the shell segments need
107
107
  const shellServerState = await awaitFetchersForSegments(spec, shell, fetcherCache)
108
- const mergedShellState = mergeStoreKeys(spec, shellServerState, ctx.store)
108
+ const mergedShellState = applyCtxExtras(mergeStoreKeys(spec, shellServerState, ctx.store), ctx)
109
109
 
110
110
  // Write shell immediately — deferred fetchers may still be in-flight
111
111
  const shellHtml = renderNamedSegments(spec, shell, clientState, mergedShellState)
@@ -122,7 +122,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
122
122
  // Promise.all runs them concurrently — the fastest segment wins.
123
123
  await Promise.all(deferred.map(async (key) => {
124
124
  const segServerState = await awaitFetchersForSegments(spec, [key], fetcherCache)
125
- const mergedSegState = mergeStoreKeys(spec, segServerState, ctx.store)
125
+ const mergedSegState = applyCtxExtras(mergeStoreKeys(spec, segServerState, ctx.store), ctx)
126
126
  const segHtml = renderNamedSegments(spec, [key], clientState, mergedSegState)
127
127
 
128
128
  // Inline script replaces the placeholder with the rendered content
@@ -145,7 +145,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
145
145
  const allServerState = fetcherCache.size > 0
146
146
  ? Object.fromEntries(await Promise.all([...fetcherCache.entries()].map(async ([k, p]) => [k, await p])))
147
147
  : {}
148
- const mergedAllState = mergeStoreKeys(spec, allServerState, ctx.store)
148
+ const mergedAllState = applyCtxExtras(mergeStoreKeys(spec, allServerState, ctx.store), ctx)
149
149
  if (spec.hydrate && Object.keys(mergedAllState).length > 0) {
150
150
  controller.enqueue(encode(
151
151
  `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedAllState)};</script>`
@@ -218,7 +218,7 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
218
218
 
219
219
  // 2. Await shell fetchers then send shell HTML
220
220
  const shellServerState = await awaitFetchersForSegments(spec, shell, fetcherCache)
221
- const mergedShellState = mergeStoreKeys(spec, shellServerState, ctx.store)
221
+ const mergedShellState = applyCtxExtras(mergeStoreKeys(spec, shellServerState, ctx.store), ctx)
222
222
  let shellHtml = renderNamedSegments(spec, shell, clientState, mergedShellState)
223
223
 
224
224
  // Append <pulse-deferred> placeholders so the client knows where to inject deferred content
@@ -231,7 +231,7 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
231
231
  // 3. Stream each deferred segment as soon as its own fetchers resolve
232
232
  await Promise.all(deferred.map(async (key) => {
233
233
  const segServerState = await awaitFetchersForSegments(spec, [key], fetcherCache)
234
- const mergedSegState = mergeStoreKeys(spec, segServerState, ctx.store)
234
+ const mergedSegState = applyCtxExtras(mergeStoreKeys(spec, segServerState, ctx.store), ctx)
235
235
  const segHtml = renderNamedSegments(spec, [key], clientState, mergedSegState)
236
236
  writeLine({ type: 'deferred', id: key, html: segHtml })
237
237
  }))
@@ -240,7 +240,7 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
240
240
  const allServerState = fetcherCache.size > 0
241
241
  ? Object.fromEntries(await Promise.all([...fetcherCache.entries()].map(async ([k, p]) => [k, await p])))
242
242
  : {}
243
- const mergedAll = mergeStoreKeys(spec, allServerState, ctx.store)
243
+ const mergedAll = applyCtxExtras(mergeStoreKeys(spec, allServerState, ctx.store), ctx)
244
244
 
245
245
  writeLine({
246
246
  type: 'done',
@@ -426,6 +426,29 @@ function mergeStoreKeys(spec, serverState, storeState) {
426
426
  return { ...slice, ...serverState }
427
427
  }
428
428
 
429
+ /**
430
+ * Merge per-request extras set by the server into the view's server state:
431
+ *
432
+ * ctx._form → serverState.form — the return value of spec.submit() on a
433
+ * POST re-render (validation errors, submitted values, etc.)
434
+ * ctx._csrfField → serverState.csrf — ready-to-embed hidden <input> for CSRF;
435
+ * views include it inside <form method="POST"> as ${server.csrf}
436
+ *
437
+ * Only set by the server for specs that declare `submit` — other pages see
438
+ * neither key. Explicit server fetcher keys with the same names win.
439
+ *
440
+ * @param {Object} serverState
441
+ * @param {Object} ctx
442
+ * @returns {Object}
443
+ */
444
+ function applyCtxExtras(serverState, ctx) {
445
+ if (ctx?._form === undefined && ctx?._csrfField === undefined) return serverState
446
+ const extras = {}
447
+ if (ctx._form !== undefined) extras.form = ctx._form
448
+ if (ctx._csrfField !== undefined) extras.csrf = ctx._csrfField
449
+ return { ...extras, ...serverState }
450
+ }
451
+
429
452
  // ---------------------------------------------------------------------------
430
453
  // Internal — server state resolution
431
454
  // ---------------------------------------------------------------------------
@@ -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
- // Build route table — let so it can be swapped on hot reload
601
- let router = buildRouter(specs)
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
- const match = matchRoute(router, pathname)
679
+ let match = matchRoute(router, pathname)
680
+ let responseStatus = 200
666
681
 
667
682
  if (!match) {
668
- res.writeHead(404, { 'Content-Type': 'text/html', ...SECURITY_HEADERS, ...httpsHeaders(req) })
669
- res.end(notFoundHtml(pathname))
670
- return
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; opt in to other methods via spec.methods.
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
- if (stream) {
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
- router = buildRouter(newSpecs)
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
- const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
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(200, headers)
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, '&#39;')
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 () => {
@@ -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.)
@@ -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/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
- /** URL pattern. Supports :param segments. */
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.