@alxia/react-router 0.36.0 → 0.37.0

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.
@@ -285,6 +285,11 @@ when the app is served over HTTPS, and `false` for plain HTTP in development.
285
285
  A wrong value in production would send the token over HTTP, so the package
286
286
  leaves that choice to you in plain sight.
287
287
 
288
+ `sameSite` (`'lax'` by default, `'strict'` or `'none'`) sets the `SameSite`
289
+ attribute of every cookie written, `destroy` and the challenge included. Use
290
+ `'strict'` for an app that nobody reaches from a link on another site while
291
+ signed in; `'none'` needs `secure: true` and throws a `TypeError` without it.
292
+
288
293
  | `secure` | session cookie | challenge cookie |
289
294
  | --- | --- | --- |
290
295
  | `true` | `__Host-my-app_session`, `Secure` | `__Secure-my-app_challenge`, `Secure` |
@@ -301,6 +306,8 @@ The bytes are, in this order:
301
306
  Name=value; Max-Age=n; Path=/; HttpOnly[; Secure]; SameSite=Lax
302
307
  ```
303
308
 
309
+ (`SameSite=Strict` or `None` with the `sameSite` option.)
310
+
304
311
  The session cookie's value is `<expiry in ms>.<token>`, percent-encoded. A
305
312
  session set by an app that wrote its own cookies in this format is read as it
306
313
  is, so adopting the package does not sign anyone out.
@@ -357,7 +364,7 @@ sets. It clears the challenge blind: a client navigation fetches
357
364
  request never shows the challenge, yet a `Set-Cookie` with that path removes
358
365
  it. With `deadSession`, it also removes a session cookie the API refused.
359
366
 
360
- `serializeCookie(name, value, { maxAge, path?, secure })`,
367
+ `serializeCookie(name, value, { maxAge, path?, secure, sameSite? })`,
361
368
  `readCookie(request, name)` and `secondsUntil(expiresAt, now?)` are exported
362
369
  for the cookies of your own (a language, a preference) that must be written in
363
370
  the same bytes. A name that is not an RFC 6265 token, or a path with a `;` or a
@@ -365,6 +372,48 @@ control character, throws a `TypeError`; a `maxAge` that is not finite is 0.
365
372
  `readCookie` returns the first non-empty cookie of that name, and `null` when
366
373
  its value is not valid percent-encoding.
367
374
 
375
+ ### A signed session without Janus
376
+
377
+ An app with one local operator, whose password the server checks itself, has
378
+ no API token to keep. `signedSession` writes a stateless cookie instead: the
379
+ value is `<expiresAt ms>.<nonce>.<mac>`, the MAC an HMAC-SHA256 of
380
+ `<expiresAt>.<nonce>`, so the expiry cannot be moved without the secret.
381
+
382
+ ```ts
383
+ // app/operator.server.ts
384
+ import { signedSession } from '@alxia/react-router/session';
385
+
386
+ export const operator = signedSession({
387
+ secret: process.env.SESSION_SECRET ?? '',
388
+ name: 'my-app_operator',
389
+ maxAge: 8 * 60 * 60,
390
+ secure: !import.meta.env.DEV,
391
+ sameSite: 'strict',
392
+ });
393
+ ```
394
+
395
+ ```ts
396
+ // in the sign-in action, once the password was checked
397
+ const headers = new Headers();
398
+ headers.append('Set-Cookie', await operator.commit());
399
+
400
+ // in a loader
401
+ if (!(await operator.valid(request))) throw redirect('/sign-in');
402
+
403
+ // sign-out
404
+ headers.append('Set-Cookie', operator.destroy());
405
+ ```
406
+
407
+ The cookie is `HttpOnly`, `Path=/`, has no `Domain`, is named `__Host-…` when
408
+ `secure`, and its `Max-Age` is `maxAge`. `commit` and `valid` are async (Web Crypto, so the subpath keeps
409
+ importing `react-router` alone). `valid` resolves to `false`, and never
410
+ throws, for a missing cookie, a malformed value, an expiry that is not digits,
411
+ a MAC of the wrong length or that does not match (compared in constant time)
412
+ and an expiry in the past. `secret` is at least 32 characters, from the
413
+ environment; changing it signs everyone out. The session is not revocable
414
+ before its expiry: keep `maxAge` short, and check the password again for
415
+ anything sensitive.
416
+
368
417
  ### Safari and `http://localhost`
369
418
 
370
419
  Safari (WebKit) drops a `Secure` cookie set over `http://localhost`, where
@@ -82,6 +82,10 @@ a loader, a message React Router or the browser prints, or an error from
82
82
  - [`TypeError: sessionCookies(): the … name "…" already carries a prefix. Pass the base name: the package adds __Host- (session) or __Secure- (challenge) when secure is true.`](#typeerror-sessioncookies-the--name--already-carries-a-prefix-pass-the-base-name-the-package-adds-__host--session-or-__secure--challenge-when-secure-is-true)
83
83
  - [`TypeError: sessionCookies(): challengeSeconds must be a positive integer; got ….`](#typeerror-sessioncookies-challengeseconds-must-be-a-positive-integer-got-)
84
84
  - [`TypeError: sessionCookies(): challengePath must start with "/" and hold no ";" or control character; got ….`](#typeerror-sessioncookies-challengepath-must-start-with--and-hold-no--or-control-character-got-)
85
+ - [`TypeError: sessionCookies(): sameSite must be "lax", "strict" or "none"; got ….`](#typeerror-sessioncookies-samesite-must-be-lax-strict-or-none-got-)
86
+ - [`TypeError: sessionCookies(): sameSite "none" needs secure: true, since a browser drops a SameSite=None cookie without Secure.`](#typeerror-sessioncookies-samesite-none-needs-secure-true-since-a-browser-drops-a-samesitenone-cookie-without-secure)
87
+ - [`TypeError: signedSession(): secret must be a string of at least 32 characters.`](#typeerror-signedsession-secret-must-be-a-string-of-at-least-32-characters)
88
+ - [`TypeError: signedSession(): the name "…" must be an RFC 6265 token without a __Host- or __Secure- prefix; …` or `maxAge must be a positive integer of seconds; got ….`](#typeerror-signedsession-the-name--must-be-an-rfc-6265-token-without-a-__host--or-__secure--prefix--or-maxage-must-be-a-positive-integer-of-seconds-got-)
85
89
  - [`TypeError: signInFlow(): paths.… must be a path of this site with no query or fragment, starting with a single "/"; got ….`](#typeerror-signinflow-paths-must-be-a-path-of-this-site-with-no-query-or-fragment-starting-with-a-single--got-)
86
90
  - [`TypeError: signInPath(): options.path must be a path of this site with no query or fragment, starting with a single "/"; got ….`](#typeerror-signinpath-optionspath-must-be-a-path-of-this-site-with-no-query-or-fragment-starting-with-a-single--got-)
87
91
  - [`TypeError: signInFlow(): json must come from jsonActions(), which carries the site check every step runs; this object has none (a hand-made double?). Pass the app's jsonActions({ origin }) instance, or a spread copy of it.`](#typeerror-signinflow-json-must-come-from-jsonactions-which-carries-the-site-check-every-step-runs-this-object-has-none-a-hand-made-double-pass-the-apps-jsonactions-origin--instance-or-a-spread-copy-of-it)
@@ -1822,6 +1826,42 @@ TypeError: sessionCookies(): challengePath must start with "/" and hold no ";" o
1822
1826
 
1823
1827
  **Fix:** pass the path of the sign-in pages with its leading slash. The default is `'/sign-in'`, which covers `/sign-in` and the pages under it; not `/sign-in/code` alone, since a client navigation fetches `/sign-in/code.data`, which that path would not match.
1824
1828
 
1829
+ ### `TypeError: sessionCookies(): sameSite must be "lax", "strict" or "none"; got ….`
1830
+
1831
+ ```
1832
+ TypeError: sessionCookies(): sameSite must be "lax", "strict" or "none"; got "sometimes".
1833
+ ```
1834
+
1835
+ **When:** the module that calls `sessionCookies`, `signedSession` or `serializeCookie` is imported (the message starts with that function's name).
1836
+
1837
+ **Why:** `sameSite` is one of the three values, in lower case.
1838
+
1839
+ **Fix:** pass `'lax'` (the default), `'strict'` or `'none'`.
1840
+
1841
+ ### `TypeError: sessionCookies(): sameSite "none" needs secure: true, since a browser drops a SameSite=None cookie without Secure.`
1842
+
1843
+ **When:** `sameSite: 'none'` with `secure: false` (development over plain HTTP).
1844
+
1845
+ **Why:** a browser drops a `SameSite=None` cookie that is not `Secure`.
1846
+
1847
+ **Fix:** use `'lax'` or `'strict'` in development, for example `sameSite: import.meta.env.DEV ? 'lax' : 'none'`.
1848
+
1849
+ ### `TypeError: signedSession(): secret must be a string of at least 32 characters.`
1850
+
1851
+ **When:** the module that calls `signedSession` is imported, often because the secret's environment variable is unset and `?? ''` gave an empty string.
1852
+
1853
+ **Why:** an HMAC key shorter than 32 characters is guessable.
1854
+
1855
+ **Fix:** set the variable to a random value of 32 characters or more (`openssl rand -base64 32`). Changing it later signs every operator out.
1856
+
1857
+ ### `TypeError: signedSession(): the name "…" must be an RFC 6265 token without a __Host- or __Secure- prefix; …` or `maxAge must be a positive integer of seconds; got ….`
1858
+
1859
+ **When:** the module that calls `signedSession` is imported.
1860
+
1861
+ **Why:** the name is not a cookie name or already carries a prefix (the package adds `__Host-` when `secure`), or `maxAge` is not a positive whole number of seconds.
1862
+
1863
+ **Fix:** pass a base name such as `'my-app_operator'` and `maxAge: 8 * 60 * 60`.
1864
+
1825
1865
  ### `TypeError: signInFlow(): paths.… must be a path of this site with no query or fragment, starting with a single "/"; got ….`
1826
1866
 
1827
1867
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/react-router",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`), an API's refusals worded for the page (`/errors`), the BFF session: cookies, sign-in, step-up and mail links (`/session`), its page hooks (`/session/react`) and what it shares with the page (`/session/model`), a GraphQL subscription relayed to the browser over SSE (`/subscription`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -141,9 +141,9 @@
141
141
  ]
142
142
  },
143
143
  "devDependencies": {
144
- "@alxia/compress": "^0.3.13",
145
- "@alxia/core": "^0.18.1",
146
- "@alxia/openapi": "^0.6.13",
144
+ "@alxia/compress": "^0.3.14",
145
+ "@alxia/core": "^0.19.0",
146
+ "@alxia/openapi": "^0.6.14",
147
147
  "@nxgt/graphql-client": "^0.6.0",
148
148
  "@nxgt/janus": "^0.29.0",
149
149
  "@react-router/dev": "^8.5.0",
@@ -167,7 +167,7 @@
167
167
  "zod": "^4.2.0"
168
168
  },
169
169
  "peerDependencies": {
170
- "@alxia/core": "^0.18.1",
170
+ "@alxia/core": "^0.19.0",
171
171
  "isbot": "^5.0.0",
172
172
  "react": "^19.0.0",
173
173
  "react-dom": "^19.0.0",