@c9up/aurora 0.1.29 → 0.1.30

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.
@@ -31,12 +31,34 @@ export interface RenderResponse {
31
31
  status(code: number): RenderResponse;
32
32
  header(name: string, value: string): RenderResponse;
33
33
  send(body: string): void;
34
+ /**
35
+ * Per-request CSP nonce, when a security layer set one.
36
+ *
37
+ * `@c9up/blackhole` seeds it on the response (the AdonisJS idiom,
38
+ * `response.nonce`) whenever the policy uses `@nonce`. Optional, and read
39
+ * structurally: aurora stays free of any dependency on it, and a host that
40
+ * sets no policy renders exactly as before.
41
+ */
42
+ nonce?: string;
34
43
  }
35
44
  export interface RenderHttpContext {
36
45
  request: unknown;
37
46
  response: RenderResponse;
47
+ /** Per-request bag; blackhole also seeds `cspNonce` here. */
48
+ store?: {
49
+ get(key: string): unknown;
50
+ };
38
51
  }
39
52
  export interface RenderPageOptions {
53
+ /**
54
+ * CSP nonce for the inline scripts this page emits.
55
+ *
56
+ * Normally left unset: it is read from `response.nonce` (what blackhole
57
+ * seeds) or from the request store. Pass it only when the security layer
58
+ * puts it somewhere else. It must be the SAME nonce the policy header
59
+ * names, otherwise the browser blocks the scripts anyway.
60
+ */
61
+ nonce?: string;
40
62
  /**
41
63
  * Importmap entries injected into `<head>`. Defaults to mapping
42
64
  * `@c9up/aurora` to `/__assets/aurora/index.js`. Override to point
@@ -73,17 +73,23 @@ async function renderPageInScope(ctx, pages, name, props, options) {
73
73
  const rootClass = options.rootClass;
74
74
  const lang = options.lang ?? "en";
75
75
  const pageUrl = pages.urlFor(name);
76
+ // A CSP that names a nonce blocks every inline script that lacks it, and the
77
+ // page then renders but never hydrates — the HTML is byte-identical, so only
78
+ // a real browser shows the failure. Reading it here is what lets a default
79
+ // policy stay strict instead of being turned off.
80
+ const nonce = resolveNonce(ctx, options.nonce);
81
+ const nonceAttr = nonce === undefined ? "" : ` nonce="${escapeAttr(nonce)}"`;
76
82
  const doc = `<!doctype html>
77
83
  <html lang="${escapeAttr(lang)}">
78
84
  <head>
79
85
  <meta charset="utf-8" />
80
86
  <meta name="viewport" content="width=device-width,initial-scale=1" />
81
- <script type="importmap">${escapeJsonForScript({ imports: importmap })}</script>
87
+ <script${nonceAttr} type="importmap">${escapeJsonForScript({ imports: importmap })}</script>
82
88
  ${options.headExtra ?? ""}
83
89
  </head>
84
90
  <body>
85
91
  <${rootTag}${rootAttrs(rootId, rootClass)}>${body}</${rootTag}>
86
- <script id="aurora-page-data" type="application/json">${escapeJsonForScript({
92
+ <script${nonceAttr} id="aurora-page-data" type="application/json">${escapeJsonForScript({
87
93
  name,
88
94
  props: pageProps,
89
95
  url: pageUrl,
@@ -91,7 +97,7 @@ ${options.headExtra ?? ""}
91
97
  routes: options.routes ?? {},
92
98
  version: options.assetsVersion ?? null,
93
99
  })}</script>
94
- <script type="module">
100
+ <script${nonceAttr} type="module">
95
101
  import { hydrate, setRouteManifest } from '@c9up/aurora'
96
102
  import Page from ${JSON.stringify(pageUrl)}
97
103
  const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
@@ -103,6 +109,26 @@ hydrate(document.getElementById(data.rootId), () => Page(data.props))
103
109
  ctx.response.header("content-type", "text/html; charset=utf-8");
104
110
  ctx.response.send(doc);
105
111
  }
112
+ /**
113
+ * The nonce to stamp on inline scripts, or `undefined` when there is none.
114
+ *
115
+ * Explicit option first, then `response.nonce` (what AdonisJS exposes and what
116
+ * blackhole seeds), then the `cspNonce` a middleware may have left in the
117
+ * per-request store. Never generated here: a nonce aurora invented would not
118
+ * appear in the policy header, so it would block the page rather than unblock
119
+ * it.
120
+ */
121
+ function resolveNonce(ctx, explicit) {
122
+ if (typeof explicit === "string" && explicit.length > 0)
123
+ return explicit;
124
+ const fromResponse = ctx.response.nonce;
125
+ if (typeof fromResponse === "string" && fromResponse.length > 0)
126
+ return fromResponse;
127
+ const fromStore = ctx.store?.get("cspNonce");
128
+ if (typeof fromStore === "string" && fromStore.length > 0)
129
+ return fromStore;
130
+ return undefined;
131
+ }
106
132
  async function resolveSharedProps(ctx, shared) {
107
133
  if (!shared)
108
134
  return {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,13 +37,33 @@ export interface RenderResponse {
37
37
  status(code: number): RenderResponse;
38
38
  header(name: string, value: string): RenderResponse;
39
39
  send(body: string): void;
40
+ /**
41
+ * Per-request CSP nonce, when a security layer set one.
42
+ *
43
+ * `@c9up/blackhole` seeds it on the response (the AdonisJS idiom,
44
+ * `response.nonce`) whenever the policy uses `@nonce`. Optional, and read
45
+ * structurally: aurora stays free of any dependency on it, and a host that
46
+ * sets no policy renders exactly as before.
47
+ */
48
+ nonce?: string;
40
49
  }
41
50
  export interface RenderHttpContext {
42
51
  request: unknown;
43
52
  response: RenderResponse;
53
+ /** Per-request bag; blackhole also seeds `cspNonce` here. */
54
+ store?: { get(key: string): unknown };
44
55
  }
45
56
 
46
57
  export interface RenderPageOptions {
58
+ /**
59
+ * CSP nonce for the inline scripts this page emits.
60
+ *
61
+ * Normally left unset: it is read from `response.nonce` (what blackhole
62
+ * seeds) or from the request store. Pass it only when the security layer
63
+ * puts it somewhere else. It must be the SAME nonce the policy header
64
+ * names, otherwise the browser blocks the scripts anyway.
65
+ */
66
+ nonce?: string;
47
67
  /**
48
68
  * Importmap entries injected into `<head>`. Defaults to mapping
49
69
  * `@c9up/aurora` to `/__assets/aurora/index.js`. Override to point
@@ -203,25 +223,34 @@ async function renderPageInScope<P>(
203
223
  const lang = options.lang ?? "en";
204
224
  const pageUrl = pages.urlFor(name);
205
225
 
226
+ // A CSP that names a nonce blocks every inline script that lacks it, and the
227
+ // page then renders but never hydrates — the HTML is byte-identical, so only
228
+ // a real browser shows the failure. Reading it here is what lets a default
229
+ // policy stay strict instead of being turned off.
230
+ const nonce = resolveNonce(ctx, options.nonce);
231
+ const nonceAttr = nonce === undefined ? "" : ` nonce="${escapeAttr(nonce)}"`;
232
+
206
233
  const doc = `<!doctype html>
207
234
  <html lang="${escapeAttr(lang)}">
208
235
  <head>
209
236
  <meta charset="utf-8" />
210
237
  <meta name="viewport" content="width=device-width,initial-scale=1" />
211
- <script type="importmap">${escapeJsonForScript({ imports: importmap })}</script>
238
+ <script${nonceAttr} type="importmap">${escapeJsonForScript({ imports: importmap })}</script>
212
239
  ${options.headExtra ?? ""}
213
240
  </head>
214
241
  <body>
215
242
  <${rootTag}${rootAttrs(rootId, rootClass)}>${body}</${rootTag}>
216
- <script id="aurora-page-data" type="application/json">${escapeJsonForScript({
217
- name,
218
- props: pageProps,
219
- url: pageUrl,
220
- rootId,
221
- routes: options.routes ?? {},
222
- version: options.assetsVersion ?? null,
223
- })}</script>
224
- <script type="module">
243
+ <script${nonceAttr} id="aurora-page-data" type="application/json">${escapeJsonForScript(
244
+ {
245
+ name,
246
+ props: pageProps,
247
+ url: pageUrl,
248
+ rootId,
249
+ routes: options.routes ?? {},
250
+ version: options.assetsVersion ?? null,
251
+ },
252
+ )}</script>
253
+ <script${nonceAttr} type="module">
225
254
  import { hydrate, setRouteManifest } from '@c9up/aurora'
226
255
  import Page from ${JSON.stringify(pageUrl)}
227
256
  const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
@@ -235,6 +264,28 @@ hydrate(document.getElementById(data.rootId), () => Page(data.props))
235
264
  ctx.response.send(doc);
236
265
  }
237
266
 
267
+ /**
268
+ * The nonce to stamp on inline scripts, or `undefined` when there is none.
269
+ *
270
+ * Explicit option first, then `response.nonce` (what AdonisJS exposes and what
271
+ * blackhole seeds), then the `cspNonce` a middleware may have left in the
272
+ * per-request store. Never generated here: a nonce aurora invented would not
273
+ * appear in the policy header, so it would block the page rather than unblock
274
+ * it.
275
+ */
276
+ function resolveNonce(
277
+ ctx: RenderHttpContext,
278
+ explicit?: string,
279
+ ): string | undefined {
280
+ if (typeof explicit === "string" && explicit.length > 0) return explicit;
281
+ const fromResponse = ctx.response.nonce;
282
+ if (typeof fromResponse === "string" && fromResponse.length > 0)
283
+ return fromResponse;
284
+ const fromStore = ctx.store?.get("cspNonce");
285
+ if (typeof fromStore === "string" && fromStore.length > 0) return fromStore;
286
+ return undefined;
287
+ }
288
+
238
289
  async function resolveSharedProps(
239
290
  ctx: RenderHttpContext,
240
291
  shared: RenderPageOptions["shared"],