@c9up/aurora 0.1.25 → 0.1.27

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/README.md CHANGED
@@ -22,6 +22,56 @@ providers: [
22
22
  ]
23
23
  ```
24
24
 
25
+ ```ts
26
+ // config/aurora.ts
27
+ export default {
28
+ pages: {
29
+ root: new URL('../resources/pages', import.meta.url).pathname,
30
+ },
31
+ root: {
32
+ tag: 'main',
33
+ class: 'min-h-screen',
34
+ },
35
+ shared: async (ctx) => ({
36
+ auth: { user: ctx.auth?.user ?? null },
37
+ flash: ctx.session?.flashMessages?.all?.() ?? {},
38
+ errors: ctx.session?.flashMessages?.get?.('errors') ?? {},
39
+ }),
40
+ }
41
+ ```
42
+
43
+ Controllers stay thin, like Adonis/Inertia controllers:
44
+
45
+ ```ts
46
+ export default class DashboardController {
47
+ async show({ aurora }) {
48
+ return aurora.render('dashboard/show', {
49
+ stats: await loadStats(),
50
+ })
51
+ }
52
+ }
53
+ ```
54
+
55
+ ## Dev live-reload (HMR)
56
+
57
+ Pages are dynamic-imported per request; aurora dev-busts the page module by mtime, but a page's **transitive** imports (components/layouts/services) stay cached until you restart. Enable graph-aware SSR HMR — the AdonisJS way — with [`hot-hook`](https://github.com/Julien-R44/hot-hook):
58
+
59
+ ```bash
60
+ pnpm add -D hot-hook @hot-hook/runner
61
+ ```
62
+
63
+ ```jsonc
64
+ // package.json
65
+ {
66
+ "scripts": {
67
+ "dev": "hot-runner --node-args=--import=tsx --node-args=--import=hot-hook/register bin/server.ts"
68
+ },
69
+ "hotHook": { "boundaries": ["./resources/pages/*.js"] }
70
+ }
71
+ ```
72
+
73
+ Editing a page or any component it imports now hot-reloads the SSR with no restart. **Point `boundaries` at page entries only** (`./resources/pages/*.js`, not `**/*.js`) — hot-hook requires boundary files to be dynamically imported, so a statically-imported component matched by the glob forces a full reload. aurora itself needs no change.
74
+
25
75
  ## Entry points
26
76
 
27
77
  - `@c9up/aurora` — main API: reactive primitives (`signal`/`effect`/`html`/`component`/`hydrate`) plus the client toolkit — `WebStorage`/`persistedSignal`, reactive browser signals (`prefersDark`/`online`/`windowSize`/…), SPA navigation (`navigate`/`queryParam`), `cookie`/`clipboard`/`share`, the `HttpClient` fetch wrapper, `createRpcClient()` (JSON-RPC 2.0), `command()` (async action + reactive loading/data/error), `form()` (reactive form controller; optional rune validation + rosetta i18n), `urlFor()` (isomorphic named-route URLs, paired with Ream's `router.namedManifest()`), and `cn()` (zero-dependency Tailwind v4 class merge — `clsx` + `tailwind-merge` reimplemented)
@@ -30,6 +80,48 @@ providers: [
30
80
  - `@c9up/aurora/relay` — realtime adapter
31
81
  - `@c9up/aurora/ssr` — server-side rendering
32
82
  - `@c9up/aurora/hydrate` — client hydration
83
+ - `@c9up/aurora/server` — server-only helpers and types (`AuroraManager`,
84
+ `Pages`, `renderPage`, `serveAssets`, `SharedProps`, `SharedPropsResolver`)
85
+
86
+ ## Adonis / Inertia parity
87
+
88
+ Implemented in Aurora:
89
+
90
+ - provider + IoC service;
91
+ - `ctx.aurora.render(name, props, options)`;
92
+ - named page rendering from routes/controllers;
93
+ - shared props per request via `shared`;
94
+ - root tag/class customization;
95
+ - named-route manifest for `urlFor()`;
96
+ - asset/version marker in page data;
97
+ - SSR + hydration payload.
98
+
99
+ Still intentionally tracked as remaining work:
100
+
101
+ - `@adonisjs/vite`-equivalent frontend integration: asset bundling, dev manifest and browser-asset HMR (SSR page-module HMR is available today via hot-hook — see [Dev live-reload](#dev-live-reload-hmr));
102
+ - full Inertia navigation semantics: preserve state, history encryption, version
103
+ mismatch handling and client-side visit lifecycle;
104
+ - generated page-name types.
105
+
106
+ ## Security notes
107
+
108
+ - `renderPage()` isolates SSR cookies and route manifests per request. Pages can
109
+ read `cookieState()` and `urlFor()` across async boundaries without leaking
110
+ another concurrent request's state.
111
+ - `renderPage()` and `AuroraManager.render()` support Adonis/Inertia-style
112
+ shared props via `shared`, plus root element customization via `rootTag`,
113
+ `rootClass` or `config.aurora.root`.
114
+ - `HttpClient` does not send managed bearer/default `Authorization` headers to
115
+ cross-origin absolute URLs by default. Set `allowCrossOriginAuth: true` only
116
+ when the external origin is intentional and trusted, or pass an explicit
117
+ per-request `Authorization` header.
118
+ - `wireLiveEvents()` accepts an `authorize(ctx, body)` hook. Use it to bind live
119
+ event POSTs to the same auth/CSRF/owner policy as the page that mounted the
120
+ live session.
121
+ - `redirect()`, `replace()` and `navigate()` reject `javascript:`, `vbscript:`
122
+ and `data:` URLs.
123
+ - `auroraRoute()` is the legacy low-level route helper. Prefer the provider /
124
+ `ctx.aurora.render()` pipeline for Adonis-style applications.
33
125
 
34
126
  ## License
35
127
 
@@ -12,10 +12,25 @@
12
12
  * the app can also instantiate one manually for tests.
13
13
  */
14
14
  import { Pages, type PagesConfig } from "./Pages.js";
15
- import { type RenderHttpContext, type RenderPageOptions } from "./server/renderPage.js";
15
+ import { type RenderHttpContext, type RenderPageOptions, type SharedProps, type SharedPropsResolver } from "./server/renderPage.js";
16
16
  import { type AssetsHttpContext } from "./server/serveAssets.js";
17
17
  export interface AuroraManagerConfig {
18
18
  pages: PagesConfig;
19
+ /**
20
+ * Shared props injected into every `render()` call, matching Adonis/Inertia's
21
+ * request-level shared data model. Per-call `options.shared` is merged over it.
22
+ */
23
+ shared?: SharedProps | SharedPropsResolver;
24
+ /**
25
+ * Default root tag/class for rendered pages. Mirrors Inertia's root template
26
+ * customization while keeping controllers thin.
27
+ */
28
+ root?: {
29
+ tag?: string;
30
+ class?: string;
31
+ };
32
+ /** Default asset/version marker serialized into the page payload. */
33
+ assetsVersion?: string;
19
34
  /**
20
35
  * Filesystem path to aurora's pre-built `dist/`. Defaults to the
21
36
  * dist directory shipped with the installed `@c9up/aurora` package.
@@ -67,6 +82,12 @@ export declare class AuroraManager {
67
82
  readonly cometDistRoot: string | null;
68
83
  /** App-level importmap overrides from `config/aurora.ts`, merged on render. */
69
84
  readonly importmap: Record<string, string>;
85
+ /** App-level shared props from config/aurora.ts. */
86
+ readonly shared?: SharedProps | SharedPropsResolver;
87
+ /** App-level root element defaults from config/aurora.ts. */
88
+ readonly root?: AuroraManagerConfig["root"];
89
+ /** App-level asset/version marker. */
90
+ readonly assetsVersion?: string;
70
91
  constructor(config: AuroraManagerConfig);
71
92
  /**
72
93
  * SSR + hydrate + ship the document. The importmap layers, last wins:
@@ -50,6 +50,12 @@ export class AuroraManager {
50
50
  cometDistRoot;
51
51
  /** App-level importmap overrides from `config/aurora.ts`, merged on render. */
52
52
  importmap;
53
+ /** App-level shared props from config/aurora.ts. */
54
+ shared;
55
+ /** App-level root element defaults from config/aurora.ts. */
56
+ root;
57
+ /** App-level asset/version marker. */
58
+ assetsVersion;
53
59
  constructor(config) {
54
60
  this.assetsPrefix = normalizePrefix(config.assetsPrefix ?? "/__assets");
55
61
  this.auroraAssetPath = `${this.assetsPrefix}/aurora`;
@@ -57,6 +63,9 @@ export class AuroraManager {
57
63
  this.cometAssetPath = `${this.assetsPrefix}/comet`;
58
64
  this.cometDistRoot = config.cometDistRoot ?? resolveCometDist();
59
65
  this.importmap = config.importmap ?? {};
66
+ this.shared = config.shared;
67
+ this.root = config.root;
68
+ this.assetsVersion = config.assetsVersion;
60
69
  // Pages serve their compiled JS from the same prefix unless the app
61
70
  // pins an explicit urlPrefix.
62
71
  this.pages = new Pages({
@@ -75,6 +84,10 @@ export class AuroraManager {
75
84
  render(ctx, name, props, options) {
76
85
  return renderPage(ctx, this.pages, name, props, {
77
86
  ...options,
87
+ rootTag: options?.rootTag ?? this.root?.tag,
88
+ rootClass: options?.rootClass ?? this.root?.class,
89
+ assetsVersion: options?.assetsVersion ?? this.assetsVersion,
90
+ shared: mergeSharedResolvers(this.shared, options?.shared),
78
91
  importmap: {
79
92
  "@c9up/aurora": `${this.auroraAssetPath}/index.js`,
80
93
  // The browser-facing subpath (RPC client) needs an explicit entry —
@@ -120,3 +133,16 @@ export class AuroraManager {
120
133
  : null;
121
134
  }
122
135
  }
136
+ function mergeSharedResolvers(base, override) {
137
+ if (!base)
138
+ return override;
139
+ if (!override)
140
+ return base;
141
+ return async (ctx) => ({
142
+ ...(await resolveShared(ctx, base)),
143
+ ...(await resolveShared(ctx, override)),
144
+ });
145
+ }
146
+ async function resolveShared(ctx, shared) {
147
+ return typeof shared === "function" ? shared(ctx) : shared;
148
+ }
package/dist/browser.d.ts CHANGED
@@ -153,6 +153,12 @@ export interface CookieOptions {
153
153
  /** Restrict to HTTPS. */
154
154
  secure?: boolean;
155
155
  }
156
+ type CookieStoreReader = () => Record<string, string> | undefined;
157
+ /**
158
+ * @internal Server-side hook used by `renderPage()` to provide a request-scoped
159
+ * cookie store without importing Node built-ins from this browser-safe module.
160
+ */
161
+ export declare function setCookieStoreReader(reader: CookieStoreReader | undefined): void;
156
162
  /**
157
163
  * Install the SSR cookie seed (`name → value`). Called by `renderPage` from its
158
164
  * `cookies` allowlist; the hydrate bootstrap does NOT call it (the browser reads
@@ -228,3 +234,4 @@ export interface ShareData {
228
234
  * user cancels — never throws.
229
235
  */
230
236
  export declare function share(data: ShareData): Promise<boolean>;
237
+ export {};
package/dist/browser.js CHANGED
@@ -10,7 +10,7 @@ import { effect, onCleanup, signal } from "./reactive.js";
10
10
  /** Navigate to `url` with a full page load. No-op during SSR. */
11
11
  export function redirect(url) {
12
12
  if (typeof window !== "undefined") {
13
- window.location.href = url;
13
+ window.location.href = safeNavigationUrl(url);
14
14
  }
15
15
  }
16
16
  /**
@@ -19,7 +19,7 @@ export function redirect(url) {
19
19
  */
20
20
  export function replace(url) {
21
21
  if (typeof window !== "undefined") {
22
- window.location.replace(url);
22
+ window.location.replace(safeNavigationUrl(url));
23
23
  }
24
24
  }
25
25
  /** Reload the current page. No-op during SSR. */
@@ -278,6 +278,22 @@ export function forward() {
278
278
  if (typeof window !== "undefined")
279
279
  window.history.forward();
280
280
  }
281
+ /**
282
+ * Drop every C0 control character (U+0000–U+001F).
283
+ *
284
+ * Written as a scan rather than a regex: a character class over control
285
+ * characters is exactly what `noControlCharactersInRegex` flags, and the rule
286
+ * is right in general — here the stripping is the point, so the loop states it
287
+ * without needing a suppression.
288
+ */
289
+ function stripControlChars(value) {
290
+ let out = "";
291
+ for (const char of value) {
292
+ if (char.charCodeAt(0) > 0x1f)
293
+ out += char;
294
+ }
295
+ return out;
296
+ }
281
297
  /**
282
298
  * SPA navigation: push `url` onto history WITHOUT a full page reload (contrast
283
299
  * {@link redirect}, which reloads). Emits a `popstate` event so reactive URL
@@ -287,9 +303,23 @@ export function forward() {
287
303
  export function navigate(url) {
288
304
  if (typeof window === "undefined")
289
305
  return;
290
- window.history.pushState({}, "", url);
306
+ window.history.pushState({}, "", safeNavigationUrl(url));
291
307
  window.dispatchEvent(new Event("popstate"));
292
308
  }
309
+ function safeNavigationUrl(url) {
310
+ // Browsers strip ASCII tab/newline/CR from ANYWHERE in a URL and trim leading
311
+ // control chars + whitespace before resolving the scheme, so `java\tscript:`
312
+ // (or a leading NUL) is evaluated as `javascript:`. A guard that only
313
+ // `trimStart()`s is trivially bypassed — mirror the browser and strip every
314
+ // C0 control char before comparing the scheme.
315
+ const normalized = stripControlChars(url).trimStart().toLowerCase();
316
+ if (normalized.startsWith("javascript:") ||
317
+ normalized.startsWith("vbscript:") ||
318
+ normalized.startsWith("data:")) {
319
+ throw new Error(`[aurora] blocked unsafe navigation URL: ${url}`);
320
+ }
321
+ return url;
322
+ }
293
323
  /**
294
324
  * A {@link Signal} bound to a single URL query parameter. Reading reflects the
295
325
  * current value (`null` when absent); writing updates the URL via `pushState`
@@ -331,13 +361,20 @@ export function queryParam(key) {
331
361
  * no view of the request cookies and renders default UI state → a flash /
332
362
  * mismatch on hydration (the classic collapsed-sidebar flicker).
333
363
  *
334
- * Module-global by necessity (the page factory reads it ambiently). It is set
335
- * synchronously immediately before the synchronous render, so read your cookie
336
- * signals at the TOP of a page (before any `await`) to avoid a cross-request
337
- * race under concurrent async page factories. In the browser it is unused —
364
+ * Fallback module-global for manual/server tests. `renderPage()` installs a
365
+ * request-scoped reader backed by AsyncLocalStorage, so concurrent SSR renders
366
+ * do not share this mutable object. In the browser it is unused
338
367
  * {@link cookie.get} reads `document.cookie` directly there.
339
368
  */
340
369
  let cookieSeed = {};
370
+ let cookieStoreReader;
371
+ /**
372
+ * @internal Server-side hook used by `renderPage()` to provide a request-scoped
373
+ * cookie store without importing Node built-ins from this browser-safe module.
374
+ */
375
+ export function setCookieStoreReader(reader) {
376
+ cookieStoreReader = reader;
377
+ }
341
378
  /**
342
379
  * Install the SSR cookie seed (`name → value`). Called by `renderPage` from its
343
380
  * `cookies` allowlist; the hydrate bootstrap does NOT call it (the browser reads
@@ -358,8 +395,12 @@ export function getCookieStore() {
358
395
  */
359
396
  export const cookie = {
360
397
  get(name) {
361
- if (typeof document === "undefined")
398
+ if (typeof document === "undefined") {
399
+ const scoped = cookieStoreReader?.();
400
+ if (scoped && Object.hasOwn(scoped, name))
401
+ return scoped[name];
362
402
  return cookieSeed[name] ?? null;
403
+ }
363
404
  const prefix = `${encodeURIComponent(name)}=`;
364
405
  for (const part of document.cookie.split("; ")) {
365
406
  if (part.startsWith(prefix)) {
package/dist/form.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * `html\`\`` and never hand-roll field state or try/catch.
6
6
  *
7
7
  * Validation is OPTIONAL and **agnostic**: pass a `validate` function returning a
8
- * `{ field: message }` map, OR any object with a `.validate(values)` method
8
+ * `{ field: message }` map, OR any object with a `.validateResult(values)` method
9
9
  * (e.g. a `@c9up/rune` schema) — aurora never imports a validator, it only
10
10
  * duck-types `.validate`. With no `validate`, the form simply never reports field
11
11
  * errors. Node-free — part of the client barrel.
@@ -31,15 +31,25 @@
31
31
  import { type ReadSignal } from "./reactive.js";
32
32
  /** A field-keyed error map: `{ email: "Invalid", … }`. Absent key ⇒ no error. */
33
33
  export type FieldErrors<T> = Partial<Record<keyof T, string>>;
34
- /** Anything `.validate()`-shaped (a `@c9up/rune` schema satisfies this). */
34
+ /** The synchronous, never-throwing outcome a form schema hands back. */
35
+ export interface FormValidationOutcome {
36
+ valid: boolean;
37
+ errors?: ReadonlyArray<{
38
+ field?: string;
39
+ message: string;
40
+ }>;
41
+ }
42
+ /**
43
+ * Anything schema-shaped (a `@c9up/rune` schema satisfies this).
44
+ *
45
+ * Both spellings are accepted, and `validateResult` wins when present: rune
46
+ * reserves `validate()` for the VineJS contract (async, throwing), and reading
47
+ * `.valid` off a Promise yields `undefined` — the form would then report itself
48
+ * invalid with no error to show.
49
+ */
35
50
  export interface FormSchema<T> {
36
- validate(values: T): {
37
- valid: boolean;
38
- errors?: ReadonlyArray<{
39
- field?: string;
40
- message: string;
41
- }>;
42
- };
51
+ validate?(values: T): FormValidationOutcome;
52
+ validateResult?(values: T): FormValidationOutcome;
43
53
  }
44
54
  /** Validation source — a function, a schema-like object, or omitted. */
45
55
  export type FormValidate<T> = ((values: T) => FieldErrors<T>) | FormSchema<T>;
package/dist/form.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * `html\`\`` and never hand-roll field state or try/catch.
6
6
  *
7
7
  * Validation is OPTIONAL and **agnostic**: pass a `validate` function returning a
8
- * `{ field: message }` map, OR any object with a `.validate(values)` method
8
+ * `{ field: message }` map, OR any object with a `.validateResult(values)` method
9
9
  * (e.g. a `@c9up/rune` schema) — aurora never imports a validator, it only
10
10
  * duck-types `.validate`. With no `validate`, the form simply never reports field
11
11
  * errors. Node-free — part of the client barrel.
@@ -36,7 +36,10 @@ function computeErrors(validate, values) {
36
36
  return {};
37
37
  if (typeof validate === "function")
38
38
  return validate(values);
39
- const result = validate.validate(values);
39
+ const check = validate.validateResult ?? validate.validate;
40
+ if (typeof check !== "function")
41
+ return {};
42
+ const result = check.call(validate, values);
40
43
  if (result.valid)
41
44
  return {};
42
45
  const errors = {};
package/dist/http.d.ts CHANGED
@@ -28,6 +28,13 @@ export interface HttpClientOptions {
28
28
  token?: string | null | (() => string | null | undefined);
29
29
  /** Default `credentials` mode (e.g. `"include"` to send cookies). */
30
30
  credentials?: RequestCredentials;
31
+ /**
32
+ * Allow default bearer/default Authorization headers to be sent to absolute
33
+ * cross-origin URLs. Default `false`: same-origin API clients should not leak
34
+ * credentials if an untrusted value becomes the request URL. Per-request
35
+ * `headers.Authorization` is still treated as explicit caller intent.
36
+ */
37
+ allowCrossOriginAuth?: boolean;
31
38
  /**
32
39
  * Default timeout in ms — the request is aborted (rejecting with a
33
40
  * `TimeoutError`) if it doesn't settle in time. Combined with a per-request
@@ -48,6 +55,11 @@ export interface HttpRequestOptions<T = unknown> {
48
55
  timeout?: number;
49
56
  /** `credentials` mode for this request. */
50
57
  credentials?: RequestCredentials;
58
+ /**
59
+ * Per-request override for sending managed auth headers to cross-origin
60
+ * absolute URLs. Default inherits the client option (`false` by default).
61
+ */
62
+ allowCrossOriginAuth?: boolean;
51
63
  /**
52
64
  * Runtime validator/mapper for the parsed body. When provided, the return
53
65
  * type is whatever it returns — no unchecked cast. When omitted, the parsed
package/dist/http.js CHANGED
@@ -71,6 +71,39 @@ function hasHeader(headers, name) {
71
71
  }
72
72
  return false;
73
73
  }
74
+ /** Delete every case variant of a header from a plain header record. */
75
+ function deleteHeader(headers, name) {
76
+ const lower = name.toLowerCase();
77
+ for (const key of Object.keys(headers)) {
78
+ if (key.toLowerCase() === lower)
79
+ delete headers[key];
80
+ }
81
+ }
82
+ function originOf(value) {
83
+ try {
84
+ if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value))
85
+ return new URL(value).origin;
86
+ if (typeof window !== "undefined")
87
+ return new URL(value, window.location.href).origin;
88
+ return null;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function isCrossOriginAbsoluteUrl(url, baseURL) {
95
+ if (!/^[a-z][a-z\d+\-.]*:\/\//i.test(url))
96
+ return false;
97
+ const targetOrigin = originOf(url);
98
+ if (targetOrigin === null)
99
+ return true;
100
+ const baseOrigin = baseURL ? originOf(baseURL) : null;
101
+ if (baseOrigin !== null)
102
+ return targetOrigin !== baseOrigin;
103
+ if (typeof window !== "undefined")
104
+ return targetOrigin !== window.location.origin;
105
+ return true;
106
+ }
74
107
  /** Merge abort signals into one (whichever fires first wins). `undefined` if none. */
75
108
  function combineSignals(signals) {
76
109
  const present = signals.filter((s) => s !== undefined);
@@ -109,12 +142,14 @@ export class HttpClient {
109
142
  #token;
110
143
  #credentials;
111
144
  #timeout;
145
+ #allowCrossOriginAuth;
112
146
  constructor(options = {}) {
113
147
  this.#baseURL = options.baseURL ?? "";
114
148
  this.#headers = { ...options.headers };
115
149
  this.#token = options.token;
116
150
  this.#credentials = options.credentials;
117
151
  this.#timeout = options.timeout;
152
+ this.#allowCrossOriginAuth = options.allowCrossOriginAuth ?? false;
118
153
  }
119
154
  /** Set a default header for every subsequent request (case-insensitive replace). Chainable. */
120
155
  setHeader(name, value) {
@@ -196,6 +231,7 @@ export class HttpClient {
196
231
  token: options.token ?? this.#token,
197
232
  credentials: options.credentials ?? this.#credentials,
198
233
  timeout: options.timeout ?? this.#timeout,
234
+ allowCrossOriginAuth: options.allowCrossOriginAuth ?? this.#allowCrossOriginAuth,
199
235
  });
200
236
  }
201
237
  #resolveToken(override) {
@@ -220,12 +256,25 @@ export class HttpClient {
220
256
  return `${base}${base.includes("?") ? "&" : "?"}${qs}`;
221
257
  }
222
258
  #send(method, url, body, options) {
259
+ const finalUrl = this.#buildUrl(url, options.query);
260
+ const crossOrigin = isCrossOriginAbsoluteUrl(finalUrl, this.#baseURL);
261
+ const allowCrossOriginAuth = options.allowCrossOriginAuth ?? this.#allowCrossOriginAuth;
262
+ const explicitRequestAuth = options.headers !== undefined &&
263
+ hasHeader(options.headers, "authorization");
223
264
  const headers = {
224
265
  ...this.#headers,
225
266
  ...options.headers,
226
267
  };
268
+ if (crossOrigin &&
269
+ !allowCrossOriginAuth &&
270
+ !explicitRequestAuth &&
271
+ hasHeader(this.#headers, "authorization")) {
272
+ deleteHeader(headers, "authorization");
273
+ }
227
274
  const token = this.#resolveToken(options.token);
228
- if (token != null && !hasHeader(headers, "authorization")) {
275
+ if (token != null &&
276
+ !hasHeader(headers, "authorization") &&
277
+ (!crossOrigin || allowCrossOriginAuth)) {
229
278
  headers.Authorization = `Bearer ${token}`;
230
279
  }
231
280
  let payload;
@@ -246,7 +295,7 @@ export class HttpClient {
246
295
  options.signal,
247
296
  timeout !== undefined ? AbortSignal.timeout(timeout) : undefined,
248
297
  ]);
249
- return fetch(this.#buildUrl(url, options.query), {
298
+ return fetch(finalUrl, {
250
299
  method,
251
300
  headers,
252
301
  body: payload,
package/dist/hydrate.js CHANGED
@@ -257,6 +257,11 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
257
257
  const syntheticRoot = {
258
258
  childNodes: liveNodes,
259
259
  };
260
+ // An attribute interpolating several slots — `class="static ${a} ${b}"` — is
261
+ // ONE attribute value built from all of them plus the static segments in
262
+ // between. Binding each slot on its own would have the last writer win and
263
+ // wipe the statics, which is what render.ts already avoids server-side.
264
+ const multiGroups = new Map();
260
265
  for (let i = 0; i < tpl.slots.length; i++) {
261
266
  const slot = tpl.slots[i];
262
267
  const liveNode = resolvePathLive(syntheticRoot, slot.path, liveNodes);
@@ -270,8 +275,47 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
270
275
  }
271
276
  continue;
272
277
  }
278
+ if (slot.kind === "attr" && slot.staticParts !== undefined) {
279
+ collectMultiAttr(slot, liveNode, result.values[i], multiGroups);
280
+ continue;
281
+ }
273
282
  hydrateSlot(slot, liveNode, result.values[i], cleanups, mountHooks, markerCursor);
274
283
  }
284
+ for (const group of multiGroups.values()) {
285
+ applyMultiAttrGroup(group, cleanups);
286
+ }
287
+ }
288
+ function collectMultiAttr(slot, el, value, groups) {
289
+ if (!slot.staticParts)
290
+ return;
291
+ const key = `${slot.name}::${slot.path.join(".")}`;
292
+ let group = groups.get(key);
293
+ if (!group) {
294
+ group = { el, name: slot.name, staticParts: slot.staticParts, values: [] };
295
+ groups.set(key, group);
296
+ }
297
+ group.values.push(value);
298
+ }
299
+ function applyMultiAttrGroup(group, cleanups) {
300
+ function join() {
301
+ let out = group.staticParts[0] ?? "";
302
+ for (let i = 0; i < group.values.length; i++) {
303
+ const v = group.values[i];
304
+ const resolved = isSignal(v) || typeof v === "function" ? v() : v;
305
+ out += resolved == null || resolved === false ? "" : String(resolved);
306
+ out += group.staticParts[i + 1] ?? "";
307
+ }
308
+ return out;
309
+ }
310
+ const hasReactive = group.values.some((v) => isSignal(v) || typeof v === "function");
311
+ if (hasReactive) {
312
+ // SSR already wrote the joined value; re-joining on every tick is what
313
+ // keeps the statics in place when only one part changes.
314
+ cleanups.push(effect(() => {
315
+ group.el.setAttribute(group.name, join());
316
+ }));
317
+ }
318
+ // Fully static groups need nothing: SSR wrote the final value.
275
319
  }
276
320
  /**
277
321
  * Resolve a slot's path against the LIVE DOM. The first index of the
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export { connectPatches, type LiveStore, liveStore, type RelayBroadcaster, } fro
15
15
  export { buildLiveTransport, type LiveClientOptions, type LiveClientTransport, type LiveHttpPoster, liveClient, type RelaySubscribeClient, } from "./liveClient.js";
16
16
  export { createLiveRegistry, type LiveRegistry, type LiveSessionHandle, } from "./liveRegistry.js";
17
17
  export { createLiveRouter, type LiveMount, type LiveRouter, } from "./liveRouter.js";
18
- export { DEFAULT_LIVE_EVENT_PATH, type LiveHttpContext, type LiveHttpRouter, type WireLiveEventsOptions, wireLiveEvents, } from "./liveServer.js";
18
+ export { DEFAULT_LIVE_EVENT_PATH, type LiveEventBody, type LiveHttpContext, type LiveHttpRouter, type WireLiveEventsOptions, wireLiveEvents, } from "./liveServer.js";
19
19
  export { batch, effect, isSignal, memo, onCleanup, type ReadSignal, type Signal, signal, untrack, } from "./reactive.js";
20
20
  export { type Disposer, render } from "./render.js";
21
21
  export { type AuroraHttpContext, type AuroraResponse, type AuroraRouteConfig, auroraRoute, } from "./route.js";
package/dist/live.d.ts CHANGED
@@ -16,5 +16,5 @@ export { connectPatches, type LiveStore, liveStore, type RelayBroadcaster, } fro
16
16
  export { buildLiveTransport, type LiveClientOptions, type LiveClientTransport, type LiveHttpPoster, liveClient, type RelaySubscribeClient, } from "./liveClient.js";
17
17
  export { createLiveRegistry, type LiveRegistry, type LiveSessionHandle, } from "./liveRegistry.js";
18
18
  export { createLiveRouter, type LiveMount, type LiveRouter, } from "./liveRouter.js";
19
- export { DEFAULT_LIVE_EVENT_PATH, type LiveHttpContext, type LiveHttpRouter, type WireLiveEventsOptions, wireLiveEvents, } from "./liveServer.js";
19
+ export { DEFAULT_LIVE_EVENT_PATH, type LiveEventBody, type LiveHttpContext, type LiveHttpRouter, type WireLiveEventsOptions, wireLiveEvents, } from "./liveServer.js";
20
20
  export { type LiveComponentDefinition, type LiveSession, mountLiveSession, type SlotPatch, } from "./liveSession.js";
@@ -27,6 +27,18 @@ export interface LiveHttpContext {
27
27
  export interface WireLiveEventsOptions {
28
28
  /** Route path for inbound events (must match the client transport). */
29
29
  path?: string;
30
+ /**
31
+ * Optional per-request guard. Return `false` to reject the event with 403.
32
+ * Use this to enforce the same auth/CSRF/owner policy as the page that mounted
33
+ * the live session. When omitted, aurora preserves the framework-agnostic
34
+ * legacy behavior and expects the host route/middleware to guard the endpoint.
35
+ */
36
+ authorize?: (ctx: LiveHttpContext, body: LiveEventBody) => boolean | Promise<boolean>;
37
+ }
38
+ export interface LiveEventBody {
39
+ id: string;
40
+ event: string;
41
+ payload?: unknown;
30
42
  }
31
43
  /** Default inbound-event route — keep the client transport's `path` in sync. */
32
44
  export declare const DEFAULT_LIVE_EVENT_PATH = "/__live/event";
@@ -25,13 +25,27 @@ export const DEFAULT_LIVE_EVENT_PATH = "/__live/event";
25
25
  */
26
26
  export function wireLiveEvents(router, live, options = {}) {
27
27
  const path = options.path ?? DEFAULT_LIVE_EVENT_PATH;
28
- router.post(path, (ctx) => {
28
+ router.post(path, async (ctx) => {
29
29
  const body = ctx.request.body();
30
30
  if (!isLiveEventBody(body)) {
31
31
  ctx.response.status(400);
32
32
  ctx.response.json({ error: "live event requires { id, event }" });
33
33
  return;
34
34
  }
35
+ let authorized = true;
36
+ if (options.authorize) {
37
+ try {
38
+ authorized = await options.authorize(ctx, body);
39
+ }
40
+ catch {
41
+ authorized = false;
42
+ }
43
+ }
44
+ if (!authorized) {
45
+ ctx.response.status(403);
46
+ ctx.response.json({ error: "forbidden live event" });
47
+ return;
48
+ }
35
49
  const handled = live.event(body.id, body.event, body.payload);
36
50
  if (!handled) {
37
51
  ctx.response.status(404);
package/dist/relay.js CHANGED
@@ -263,7 +263,17 @@ function retrieveXsrfToken() {
263
263
  if (typeof document === "undefined")
264
264
  return null;
265
265
  const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
266
- return match ? decodeURIComponent(match[1]) : null;
266
+ if (!match)
267
+ return null;
268
+ try {
269
+ return decodeURIComponent(match[1]);
270
+ }
271
+ catch {
272
+ // A malformed cookie must not break subscribe/unsubscribe handshakes. The
273
+ // server will reject an invalid token normally; the client should not throw
274
+ // before it even sends the request.
275
+ return match[1];
276
+ }
267
277
  }
268
278
  function safeJson(raw) {
269
279
  if (typeof raw !== "string")