@alxia/react-router 0.26.0 → 0.28.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.
Files changed (53) hide show
  1. package/README.md +20 -5
  2. package/dist/assets.d.ts +3 -1
  3. package/dist/assets.d.ts.map +1 -1
  4. package/dist/chunks/{index-3jfeew2y.js → index-fpy7f4bz.js} +5 -58
  5. package/dist/chunks/index-fpy7f4bz.js.map +11 -0
  6. package/dist/chunks/index-xvx63t7n.js +59 -0
  7. package/dist/chunks/index-xvx63t7n.js.map +13 -0
  8. package/dist/index.js +12 -8
  9. package/dist/index.js.map +5 -5
  10. package/dist/react-router.d.ts +8 -1
  11. package/dist/react-router.d.ts.map +1 -1
  12. package/dist/server.d.ts +6 -0
  13. package/dist/server.d.ts.map +1 -1
  14. package/dist/session/index.d.ts +1 -1
  15. package/dist/session/index.d.ts.map +1 -1
  16. package/dist/session/index.js +20 -2
  17. package/dist/session/index.js.map +3 -3
  18. package/dist/session/model/index.js +5 -3
  19. package/dist/session/model/index.js.map +1 -1
  20. package/dist/session/react/index.d.ts +3 -2
  21. package/dist/session/react/index.d.ts.map +1 -1
  22. package/dist/session/react/index.js +199 -17
  23. package/dist/session/react/index.js.map +8 -3
  24. package/dist/session/react/step-up/answers.d.ts +9 -0
  25. package/dist/session/react/step-up/answers.d.ts.map +1 -0
  26. package/dist/session/react/step-up/dialog-props.d.ts +27 -0
  27. package/dist/session/react/step-up/dialog-props.d.ts.map +1 -0
  28. package/dist/session/react/step-up/index.d.ts +6 -0
  29. package/dist/session/react/step-up/index.d.ts.map +1 -0
  30. package/dist/session/react/step-up/phase.d.ts +8 -0
  31. package/dist/session/react/step-up/phase.d.ts.map +1 -0
  32. package/dist/session/react/step-up/use-step-up-replay.d.ts +54 -0
  33. package/dist/session/react/step-up/use-step-up-replay.d.ts.map +1 -0
  34. package/dist/session/react/step-up/use-step-up.d.ts +51 -0
  35. package/dist/session/react/step-up/use-step-up.d.ts.map +1 -0
  36. package/dist/session/step-up-types.d.ts +25 -0
  37. package/dist/session/step-up-types.d.ts.map +1 -1
  38. package/dist/session/step-up.d.ts.map +1 -1
  39. package/dist/vite/compress.d.ts +18 -0
  40. package/dist/vite/compress.d.ts.map +1 -0
  41. package/dist/vite/index.d.ts +10 -0
  42. package/dist/vite/index.d.ts.map +1 -1
  43. package/dist/vite/index.js +93 -12
  44. package/dist/vite/index.js.map +5 -4
  45. package/docs/guide/beside-the-pages.md +22 -0
  46. package/docs/guide/customising-the-server.md +2 -0
  47. package/docs/guide/how-it-works.md +6 -0
  48. package/docs/guide/session.md +133 -2
  49. package/docs/guide/subscriptions.md +18 -4
  50. package/docs/roadmap.md +6 -0
  51. package/docs/troubleshooting.md +50 -4
  52. package/package.json +6 -6
  53. package/dist/chunks/index-3jfeew2y.js.map +0 -15
@@ -113,6 +113,7 @@ Every option is optional:
113
113
  | `beforeAll(app)` | runs first, on a new app. What it declares applies to the client's files too: a rate limit, a guard on everything, a logger that should see every asset. It returns the app, which `configure` then receives |
114
114
  | `getLoadContext(ctx, context)` | sets the app's own keys on React Router's provider, `ctx` typed by `configure`'s app |
115
115
  | `build`, `mode`, `client` | override what the plugin wires; see [Escape hatches](#escape-hatches) |
116
+ | `precompressed` | `false` serves the client's files as they are, without their `.br` and `.gz` copies; see [Compressed](beside-the-pages.md#compressed) |
116
117
  | `listen` | `listen`'s options for `bun build/server/index.js`: `port`, `hostname`, `idleTimeout`, `maxRequestBodySize`, `tls`, `onListen`. A `port` or `hostname` given here wins over `PORT` and `HOST`; an `onListen` here, told `@alxia/core`'s `ListenInfo` — `url`, `routes`, `table`, `dev` — wins over the option below |
117
118
  | `onListen(server)` | called once the built server listens and its `SIGINT` and `SIGTERM` handlers are in place, in place of the `alxia listening on …` line (and, in dev, of the route table); a signal sent from then on runs the `onStop` hooks |
118
119
 
@@ -282,6 +283,7 @@ bun run build && bun server.ts
282
283
  | `mode` | React Router's server mode, `production` by default |
283
284
  | `getLoadContext(ctx, context)` | as `createServer`'s |
284
285
  | `client` | the client build's folder, a path or a `file:` URL, served before the catch-all in `production` |
286
+ | `precompressed` | `true` by default: serves the client's `.br` and `.gz` copies to a browser that accepts them; `false` serves the files as they are |
285
287
 
286
288
  `reactRouter()` goes through `app.plugin`, as `@alxia/graphql`'s `graphql(app, …)`
287
289
  does: that is how it knows the app's context type. A `HEAD` is handed to
@@ -77,6 +77,12 @@ service worker when the app has an `app/service-worker.ts` (`worker: false`
77
77
  turns it off): `build/client/service-worker.js`, with the precache list read
78
78
  from the client build ([the PWA guide](pwa.md#zero-config-alxia-builds-it)).
79
79
 
80
+ Then it writes the compressed copies of the client build, `.br` and `.gz`
81
+ beside each compressible file, which the server serves to a browser that
82
+ accepts them ([the client's files](beside-the-pages.md#compressed)).
83
+ `precompress: false` turns it off, `precompress: { minSize }` moves the 1 kB
84
+ threshold.
85
+
80
86
  React Router's `serverBundles` split the server build in several, and
81
87
  alxia serves one: the plugin refuses them. With `ssr: false`, a
82
88
  single-page app, the plugin does nothing but build the service worker (when
@@ -13,7 +13,8 @@ It covers three subpaths:
13
13
  `react-router` and `/errors`' files, nothing else, knows no API client and
14
14
  adds no peer.
15
15
  - `@alxia/react-router/session/react`, the page's hooks around those flows:
16
- signing out, a mail link's parameter and a one-code field. They import
16
+ signing out, a mail link's parameter, a one-code field and the step-up
17
+ with the replay of the refused action. They import
17
18
  `react` and `react-router`, both already peers.
18
19
  - `@alxia/react-router/session/model`, the part that imports nothing and is
19
20
  safe in a component as well as in a loader: redirects that stay on the
@@ -664,6 +665,30 @@ export async function action({ context, request }: Route.ActionArgs) {
664
665
  }
665
666
  ```
666
667
 
668
+ To serve the step-up to every page from one resource route, `route(run, {
669
+ request, language })` is that whole action: it reads the same-site JSON body
670
+ and runs `run.request()` for `{ intent: 'request-step-up' }` or
671
+ `run.confirm({ challenge, code })` for `{ intent: 'confirm-step-up',
672
+ challenge, code, via? }`. Any other intent is a 400.
673
+
674
+ ```ts
675
+ // app/routes/step-up.tsx: any page posts the step-up intents here
676
+ export async function action({ context, request }: Route.ActionArgs) {
677
+ const { language } = alxiaOf(context);
678
+ const caller = await sessionCaller(request);
679
+ return stepUp.route(
680
+ {
681
+ request: async () => {
682
+ const { requestStepUp } = await execute(RequestStepUpMutation, {}, caller);
683
+ return { challenge: requestStepUp.challenge, via: toVia(requestStepUp.method) };
684
+ },
685
+ confirm: (input) => execute(ConfirmStepUpMutation, input, caller),
686
+ },
687
+ { request, language },
688
+ );
689
+ }
690
+ ```
691
+
667
692
  `request(run, { request, language })` answers `{ ok: true,
668
693
  intent: 'request-step-up', challenge, via }`. `confirm(run, { request,
669
694
  language, via? })` answers `{ ok: true, intent: 'confirm-step-up' }`. Both
@@ -991,7 +1016,7 @@ out of any `Referer`.
991
1016
 
992
1017
  ## The page's hooks
993
1018
 
994
- `@alxia/react-router/session/react` holds the three client pieces every app
1019
+ `@alxia/react-router/session/react` holds the client pieces every app
995
1020
  writes around the flows above. It imports `react` and `react-router` (peers
996
1021
  the package already has) and nothing else; every hook renders on the server.
997
1022
 
@@ -1131,6 +1156,112 @@ The package takes no form library as a peer: react-hook-form would also need
1131
1156
  its resolver for the schema, and a library-agnostic core serves every form
1132
1157
  library at the price of three lines.
1133
1158
 
1159
+ ### `useStepUpReplay`
1160
+
1161
+ A sensitive action the API refuses with `step-up-required` answers with
1162
+ `stepUp` set. `useStepUpReplay` opens the step-up on that answer, posts the
1163
+ `request-step-up` and `confirm-step-up` intents, and sends the refused action
1164
+ again once the session is confirmed. The dialog stays, saying so, until the
1165
+ replayed action is answered; an answer that asks for a step-up again reopens
1166
+ it. Cancelling forgets the step-up: a code accepted or a challenge granted
1167
+ after `cancel()` is ignored and replays nothing. It is generic over your own
1168
+ `post`s, so each intent keeps the fields your action types.
1169
+
1170
+ An action that carries a one-time code (`spendsCode`) is not replayed after a
1171
+ step-up by the second factor: that code was just spent, so the dialog closes
1172
+ and `confirmedNotice` is true ("confirmed, type a new code").
1173
+
1174
+ `route()` answers both intents from one action, so a fetcher on it has their
1175
+ union as data. `requestAnswerOf(data)` and `confirmAnswerOf(data)` narrow it on
1176
+ `intent` (and give `undefined` for any other answer), so each channel is typed
1177
+ from the one action.
1178
+
1179
+ ```tsx
1180
+ import { useRef } from 'react';
1181
+ import { useFetcher } from 'react-router';
1182
+ import {
1183
+ confirmAnswerOf,
1184
+ requestAnswerOf,
1185
+ useStepUpReplay,
1186
+ } from '@alxia/react-router/session/react';
1187
+ import type { action as stepUpAction } from './step-up'; // the route with `stepUp.route`
1188
+
1189
+ type DeleteItem = { intent: 'delete-item'; id: string };
1190
+
1191
+ export function useDeleteItem() {
1192
+ const fetcher = useFetcher<{ ok: boolean; stepUp?: boolean }>();
1193
+ const request = useFetcher<typeof stepUpAction>({ key: 'step-up-request' });
1194
+ const confirm = useFetcher<typeof stepUpAction>({ key: 'step-up-confirm' });
1195
+ const last = useRef<DeleteItem | null>(null);
1196
+
1197
+ const post = (action: DeleteItem) => {
1198
+ last.current = action;
1199
+ void fetcher.submit(action, { method: 'post', encType: 'application/json' });
1200
+ };
1201
+ const toStepUp = (body: Record<string, unknown>) =>
1202
+ void request.submit(body, {
1203
+ method: 'post',
1204
+ action: '/step-up',
1205
+ encType: 'application/json',
1206
+ });
1207
+
1208
+ const stepUp = useStepUpReplay({
1209
+ answer: fetcher.state === 'idle' ? fetcher.data : undefined,
1210
+ action: last.current ?? undefined,
1211
+ replay: () => {
1212
+ if (last.current) post(last.current);
1213
+ },
1214
+ request: {
1215
+ post: () => toStepUp({ intent: 'request-step-up' }),
1216
+ answer: requestAnswerOf(request.data),
1217
+ busy: request.state !== 'idle',
1218
+ reset: () => request.reset(),
1219
+ },
1220
+ confirm: {
1221
+ post: (fields) =>
1222
+ void confirm.submit(
1223
+ { intent: 'confirm-step-up', ...fields },
1224
+ { method: 'post', action: '/step-up', encType: 'application/json' },
1225
+ ),
1226
+ answer: confirmAnswerOf(confirm.data),
1227
+ busy: confirm.state !== 'idle',
1228
+ reset: () => confirm.reset(),
1229
+ },
1230
+ });
1231
+ return { post, ...stepUp };
1232
+ }
1233
+ // Pass `dialogProps` to your step-up dialog: phase, via, props, message,
1234
+ // busy, confirming, onCancel, onRetry, onSubmit, onResend.
1235
+ ```
1236
+
1237
+ `dialogProps` is `dialogPropsOf(stepUp, open, cancel)`, which is plain and
1238
+ exported, for a host that builds them itself.
1239
+
1240
+ ### `useStepUp`, `asksStepUp` and `stepUpDialogPhase`
1241
+
1242
+ `useStepUp({ request, confirm, onConfirmed })` is the part under it, for a host
1243
+ that decides itself what to do once the code is accepted. `start()` asks for a
1244
+ challenge, `submit(code)` posts it, `release()` forgets everything.
1245
+
1246
+ ```tsx
1247
+ import { useStepUp } from '@alxia/react-router/session/react';
1248
+
1249
+ const stepUp = useStepUp({ request, confirm, onConfirmed: () => revalidate() });
1250
+ // stepUp.phase: 'asking' | 'failed' | 'code' | 'expired' | 'confirmed'
1251
+ ```
1252
+
1253
+ `asksStepUp(answer)` is true for a refusal carrying `stepUp` (what opens the
1254
+ dialog), and `stepUpDialogPhase(state)` is what the dialog shows for a
1255
+ `stepUpReducer` state:
1256
+
1257
+ ```ts
1258
+ import { asksStepUp, stepUpDialogPhase } from '@alxia/react-router/session/react';
1259
+ import { INITIAL_STEP_UP } from '@alxia/react-router/session/model';
1260
+
1261
+ asksStepUp({ ok: false, stepUp: true }); // true
1262
+ stepUpDialogPhase(INITIAL_STEP_UP); // 'asking'
1263
+ ```
1264
+
1134
1265
  ## The types and the rest of `/session/model`
1135
1266
 
1136
1267
  The rest of `@alxia/react-router/session/model`, beside `safeNext`,
@@ -113,10 +113,24 @@ adapter or reverse proxy needs to buffer it.
113
113
 
114
114
  ## Keep the stream alive
115
115
 
116
- Bun closes an idle connection after `idleTimeout` seconds (10 by default).
117
- The heartbeat covers it as long as `heartbeatMs` is shorter, and so is a proxy's
118
- read timeout. Behind nginx, keep `proxy_buffering off` for the route (the
119
- `X-Accel-Buffering: no` header does it per response).
116
+ Bun closes a connection that has been idle for `idleTimeout` seconds, 10 by
117
+ default, which is shorter than the proxy's 15 second heartbeat (`heartbeatMs`)
118
+ and than the API's own 12 second ping. Left alone, an idle subscription is
119
+ reset at about 8 to 10 seconds, before the first heartbeat. Set `idleTimeout`
120
+ above both in the server's `listen` options:
121
+
122
+ ```ts
123
+ // app/server.ts
124
+ export default createServer({
125
+ listen: { idleTimeout: 30 }, // seconds
126
+ });
127
+ ```
128
+
129
+ The same goes for the API's server (`app.listen({ idleTimeout: 30 })` with
130
+ `@alxia/graphql`), and for a proxy's read timeout, which must be longer than
131
+ `heartbeatMs` too. Calling `server.timeout(request, 0)` from a resolver does
132
+ not lift it for the stream. Behind nginx, keep `proxy_buffering off` for the
133
+ route (the `X-Accel-Buffering: no` header does it per response).
120
134
 
121
135
  ## Cross-site requests
122
136
 
package/docs/roadmap.md CHANGED
@@ -7,6 +7,12 @@ number on it. Every release, with each change it made, is in
7
7
 
8
8
  ## Now
9
9
 
10
+ - **The client build, served compressed.** `react-router build` writes `.br`
11
+ and `.gz` copies beside the compressible files of `build/client`, and the
12
+ server sends the one a browser accepts, with `Vary: Accept-Encoding` and
13
+ the same cache headers. See [the client's
14
+ files](guide/beside-the-pages.md#compressed).
15
+
10
16
  - **`@alxia/react-router/session/react`: the page's hooks around the session
11
17
  flows.** `useSignOut`, `useLinkParam` (a mail link's parameter, read once and optionally dropped
12
18
  from the address bar, or with `live: true` following the URL) and `useCodeField` /
@@ -25,6 +25,7 @@ a loader, a message React Router or the browser prints, or an error from
25
25
  - [``You made a POST request to "/" but did not provide an `action` for route "root", so there is no way to handle the request.``](#you-made-a-post-request-to--but-did-not-provide-an-action-for-route-root-so-there-is-no-way-to-handle-the-request)
26
26
  - [`TypeError: reactRouter(): client is …, which is not a directory. …`](#typeerror-reactrouter-client-is--which-is-not-a-directory-)
27
27
  - [`TypeError: reactRouter(): … cannot be served at a path of its own name; rename it. …`](#typeerror-reactrouter--cannot-be-served-at-a-path-of-its-own-name-rename-it-)
28
+ - [The client's files are served uncompressed](#the-clients-files-are-served-uncompressed)
28
29
  - [`Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'none'"`](#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-default-src-none)
29
30
  - [`Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`](#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-script-src-self-nonce-)
30
31
  - [`A tree hydrated but some attributes of the server rendered HTML didn't match the client properties`](#a-tree-hydrated-but-some-attributes-of-the-server-rendered-html-didnt-match-the-client-properties)
@@ -141,6 +142,24 @@ a loader, a message React Router or the browser prints, or an error from
141
142
 
142
143
  ## Thrown or printed
143
144
 
145
+ ### `Not a step-up intent` (a 400 from `stepUp.route`)
146
+
147
+ **When:** a page posts to the route that serves `stepUpSteps().route` with an
148
+ `intent` other than `request-step-up` or `confirm-step-up`, or a
149
+ `confirm-step-up` whose `challenge` or `code` is not a string.
150
+
151
+ **Why:** `route()` answers only those two intents and runs nothing else.
152
+
153
+ **Fix:** post the intents with the fields `useStepUp` gives your `confirm.post`
154
+ (`challenge`, `code`, `via`), as JSON:
155
+
156
+ ```ts
157
+ confirm.submit(
158
+ { intent: 'confirm-step-up', challenge, code, via },
159
+ { method: 'post', action: '/step-up', encType: 'application/json' },
160
+ );
161
+ ```
162
+
144
163
  ### `alxia-react-router: … is running on Node, and alxia's server runs on Bun. …`
145
164
 
146
165
  **When:** `bun run dev`, `vite preview`, or a `react-router build` that
@@ -565,6 +584,31 @@ export async function loader({ request }: Route.LoaderArgs) {
565
584
 
566
585
  The rows stay, `error` is `true`, and `loadMore` retries.
567
586
 
587
+ ### The client's files are served uncompressed
588
+
589
+ A response for a script, a stylesheet or an SVG has no `Content-Encoding`,
590
+ though the request sent `Accept-Encoding: br, gzip`.
591
+
592
+ **When:** the server answers from `build/client`, and no `.br` or `.gz`
593
+ sits beside the file.
594
+
595
+ **Why:** the server never compresses on a request: it serves the copies
596
+ `react-router build` writes. They are missing when `alxia({ precompress:
597
+ false })` is set, when the build did not go through `alxia()` (a
598
+ `build/client` copied from a build before the option, or made by another
599
+ tool), when the file is under 1 kB or not compressible (an image, say), or
600
+ when `createServer({ precompressed: false })` is set. A compressed copy that
601
+ is not smaller than the file is not written.
602
+
603
+ A single-page app (`ssr: false` in `react-router.config.ts`) has no alxia server: the plugin builds the service worker only and writes no copies, so whatever serves `build/client` has to compress it.
604
+
605
+ **Fix:** build with the plugin, and check the copies exist:
606
+
607
+ ```sh
608
+ bun run build && ls build/client/assets | grep -E '\.(br|gz)$'
609
+ curl -sI -H 'Accept-Encoding: br' http://localhost:3000/assets/entry.js | grep -i content-encoding
610
+ ```
611
+
568
612
  ### `Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'none'"`
569
613
 
570
614
  The browser's console prints it; the page renders on the server, then
@@ -2883,10 +2927,12 @@ blank line.
2883
2927
  ### Events arrive in bursts, or the stream closes after a few seconds
2884
2928
 
2885
2929
  **Why:** a proxy or CDN between the app and the browser buffers the response,
2886
- or closes an idle connection (Bun's `idleTimeout`, a load balancer's timeout)
2930
+ or closes an idle connection (Bun's `idleTimeout`, 10 seconds by default,
2931
+ shorter than the 15 second `heartbeatMs`; a load balancer's timeout)
2887
2932
  because `heartbeatMs` is longer than it.
2888
2933
 
2889
- **Fix:** keep `heartbeatMs` shorter than the shortest idle timeout on the
2890
- path, turn response buffering off for the route (nginx `proxy_buffering off`;
2891
- the route already sends `X-Accel-Buffering: no`), and do not compress the
2934
+ **Fix:** set `listen: { idleTimeout: 30 }` in `createServer` (and
2935
+ `idleTimeout: 30` on the API's `listen`), keep `heartbeatMs` shorter than the
2936
+ shortest idle timeout on the path, turn response buffering off for the
2937
+ route (nginx `proxy_buffering off`; the route already sends `X-Accel-Buffering: no`), and do not compress the
2892
2938
  route's responses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/react-router",
3
- "version": "0.26.0",
3
+ "version": "0.28.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",
@@ -135,10 +135,10 @@
135
135
  ]
136
136
  },
137
137
  "devDependencies": {
138
- "@alxia/compress": "^0.3.11",
139
- "@alxia/core": "^0.16.0",
140
- "@alxia/openapi": "^0.6.11",
141
- "@nxgt/graphql-client": "^0.4.0",
138
+ "@alxia/compress": "^0.3.12",
139
+ "@alxia/core": "^0.17.1",
140
+ "@alxia/openapi": "^0.6.12",
141
+ "@nxgt/graphql-client": "^0.6.0",
142
142
  "@nxgt/janus": "^0.29.0",
143
143
  "@react-router/dev": "^8.5.0",
144
144
  "@types/bun": "^1.4.4",
@@ -161,7 +161,7 @@
161
161
  "zod": "^4.2.0"
162
162
  },
163
163
  "peerDependencies": {
164
- "@alxia/core": "^0.16.0",
164
+ "@alxia/core": "^0.17.1",
165
165
  "isbot": "^5.0.0",
166
166
  "react": "^19.0.0",
167
167
  "react-dom": "^19.0.0",
@@ -1,15 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../src/session/model/safe-next.ts", "../src/session/model/site-path.ts", "../src/session/model/paths.ts", "../src/session/model/second-factor.ts", "../src/session/model/step-up.ts", "../src/session/model/step-up-props.ts"],
4
- "sourcesContent": [
5
- "/** Where a sign-in lands when `next` is missing or unsafe. */\nexport const HOME = '/';\n\n/** The base a `next` is resolved against; `.invalid` never resolves. */\nconst BASE = 'http://next.invalid';\n\n/**\n * The `next` a sign-in may redirect to: a path of this site only. Anything\n * that could leave it (`//host`, `/\\host`, `https://…`, `javascript:…`, a\n * control character a browser would strip, a value over 2000 characters)\n * falls back to `/`. The path is read after normalisation, so `/a/..//host`\n * is refused too.\n *\n * @example\n * safeNext('/orders?page=2'); // '/orders?page=2'\n * safeNext('//evil.test'); // '/'\n */\nexport function safeNext(value: string | null | undefined): string {\n\tif (!value || value.length > 2000) return HOME;\n\tif (!value.startsWith('/') || value.startsWith('//')) return HOME;\n\t// Browsers read `\\` as `/` and drop tabs and newlines in URLs.\n\tif (/[\\\\\\p{Cc}\\s]/u.test(value)) return HOME;\n\ttry {\n\t\tconst url = new URL(value, BASE);\n\t\tconst path = `${url.pathname}${url.search}${url.hash}`;\n\t\t// Dot segments can collapse into `//host` again: check what is answered.\n\t\tif (url.origin !== BASE || path.startsWith('//')) return HOME;\n\t\treturn path;\n\t} catch {\n\t\treturn HOME;\n\t}\n}\n",
6
- "import { safeNext } from './safe-next';\n\n/**\n * A path of this site, or a `TypeError` naming `label`. With no `query`, the\n * path holds no `?` or `#` either, and the message prints it only up to\n * there, since what follows may be a token.\n */\nexport function sitePath(\n\tpath: string,\n\t{\n\t\tlabel,\n\t\tquery = false,\n\t}: { readonly label: string; readonly query?: boolean | undefined },\n): string {\n\tif (safeNext(path) !== path || (!query && /[?#]/.test(path))) {\n\t\tconst shown = query ? path : path.replace(/[?#].*$/s, '?…');\n\t\tthrow new TypeError(\n\t\t\t`${label} must be a path of this site${query ? '' : ' with no query or fragment'}, starting with a single \"/\"; got ${JSON.stringify(shown)}.`,\n\t\t);\n\t}\n\treturn path;\n}\n",
7
- "import { HOME, safeNext } from './safe-next';\nimport { sitePath } from './site-path';\n\n/** The two pages of a sign-in. */\nexport interface SignInPaths {\n\treadonly signIn: string;\n\treadonly secondFactor: string;\n}\n\n/** The defaults: `/sign-in` and `/sign-in/second-factor`. */\nexport const SIGN_IN_PATHS: SignInPaths = {\n\tsignIn: '/sign-in',\n\tsecondFactor: '/sign-in/second-factor',\n};\n\nfunction withNext(\n\tpath: string,\n\tnext: string,\n\tquery?: Record<string, string>,\n): string {\n\tconst params = new URLSearchParams(query);\n\tconst target = safeNext(next);\n\tif (target !== HOME) params.set('next', target);\n\tconst search = params.toString();\n\treturn search ? `${path}?${search}` : path;\n}\n\n/**\n * The sign-in page's URL, coming back to `next` (none for the home page, and\n * `next` goes through `safeNext`). `query` adds parameters, such as\n * `{ expired: '1' }`; `path` replaces `/sign-in` and must be a path of this\n * site with no query or fragment (a `TypeError` otherwise).\n *\n * @example\n * signInPath('/orders'); // '/sign-in?next=%2Forders'\n * signInPath('/', { query: { expired: '1' } }); // '/sign-in?expired=1'\n */\nexport function signInPath(\n\tnext: string,\n\toptions: {\n\t\treadonly query?: Record<string, string> | undefined;\n\t\treadonly path?: string | undefined;\n\t} = {},\n): string {\n\tconst path =\n\t\toptions.path === undefined\n\t\t\t? SIGN_IN_PATHS.signIn\n\t\t\t: sitePath(options.path, { label: 'signInPath(): options.path' });\n\treturn withNext(path, next, options.query);\n}\n\n/** The second-factor page's URL, coming back to `next` as `signInPath` does. */\nexport function secondFactorPath(\n\tnext: string,\n\tpath: string = SIGN_IN_PATHS.secondFactor,\n): string {\n\treturn withNext(path, next);\n}\n",
8
- "/** A code of an authenticator app: six digits. */\nexport const TOTP_CODE: RegExp = /^\\d{6}$/;\n\n/**\n * What a second-factor form sends: `code` is the issued code (an app's, or\n * the one mailed), `recovery` a recovery code. Janus has no name for it, only\n * `confirm` and `recover`.\n */\nexport type SecondFactorKind = 'code' | 'recovery';\n\n/** How a sign-in ended, in `@nxgt/janus`'s words (`SignInResult['status']`). */\nexport type SignInStatus = 'signedIn' | 'secondFactor';\n\n/**\n * How a step-up is confirmed, in `@nxgt/janus`'s words: a code mailed to the\n * current address, or the account's second factor.\n */\nexport type StepUpVia = 'email' | 'secondFactor';\n",
9
- "import type { StepUpVia } from './second-factor';\nimport type {\n\tStepUpConfirmAnswer,\n\tStepUpError,\n\tStepUpRequestAnswer,\n} from './step-up-answers';\n\n/** Where a step-up is. */\nexport type StepUpPhase =\n\t| 'idle'\n\t| 'requesting'\n\t| 'code'\n\t| 'confirming'\n\t| 'done';\n\nexport interface StepUpState {\n\treadonly phase: StepUpPhase;\n\t/** How the challenge held (or the last one) is confirmed. */\n\treadonly via?: StepUpVia | undefined;\n\t/** The challenge held. A secret: never in a URL, a log or loader data. */\n\treadonly challenge?: string | undefined;\n\t/** The tries the last refused code left. */\n\treadonly attemptsLeft?: number | undefined;\n\t/** No try left on an app or recovery code: wait, asking again would fail too. */\n\treadonly locked: boolean;\n\treadonly error?: StepUpError | undefined;\n\t/** The last refusal worded for the page, until the next step or the release. */\n\treadonly message?: string | undefined;\n\t/** The seconds a throttled request asks to wait before asking again. */\n\treadonly retryAfter?: number | undefined;\n\t/** A request or a code is in flight. */\n\treadonly pending: boolean;\n\t/** The last request replaced a challenge that was held. */\n\treadonly resent: boolean;\n}\n\nexport type StepUpAction =\n\t| { readonly type: 'start' }\n\t| { readonly type: 'requested'; readonly answer: StepUpRequestAnswer }\n\t| { readonly type: 'submit' }\n\t| { readonly type: 'confirmed'; readonly answer: StepUpConfirmAnswer }\n\t| { readonly type: 'release' };\n\nexport const INITIAL_STEP_UP: StepUpState = {\n\tphase: 'idle',\n\tlocked: false,\n\tpending: false,\n\tresent: false,\n};\n\nfunction requested(\n\tstate: StepUpState,\n\tanswer: StepUpRequestAnswer,\n): StepUpState {\n\tif (!answer.ok) {\n\t\t// A resend refused leaves the code form up, and says why in it.\n\t\treturn {\n\t\t\t...state,\n\t\t\tphase: state.challenge ? 'code' : 'idle',\n\t\t\terror: answer.error,\n\t\t\tmessage: answer.message,\n\t\t\tretryAfter: answer.retryAfter,\n\t\t\tpending: false,\n\t\t\tresent: false,\n\t\t};\n\t}\n\treturn {\n\t\tphase: 'code',\n\t\tvia: answer.via,\n\t\tchallenge: answer.challenge,\n\t\tlocked: false,\n\t\tpending: false,\n\t\tresent: state.challenge !== undefined,\n\t};\n}\n\n/** A refused code. At 0 tries left a mailed code is over (ask again), an app or recovery code is locked (wait). */\nfunction refused(\n\tstate: StepUpState,\n\tanswer: Extract<StepUpConfirmAnswer, { ok: false }>,\n): StepUpState {\n\tconst none = answer.attemptsLeft === 0;\n\tconst over = answer.error === 'expired' || (none && state.via === 'email');\n\tif (over) {\n\t\treturn {\n\t\t\tphase: 'idle',\n\t\t\t...(state.via ? { via: state.via } : {}),\n\t\t\tlocked: false,\n\t\t\terror: 'expired',\n\t\t\tmessage: answer.message,\n\t\t\tpending: false,\n\t\t\tresent: false,\n\t\t};\n\t}\n\tif (answer.error === 'throttled') {\n\t\t// Asking too often spends nothing: the challenge and its tries stay.\n\t\treturn {\n\t\t\t...state,\n\t\t\tphase: 'code',\n\t\t\terror: 'throttled',\n\t\t\tmessage: answer.message,\n\t\t\tretryAfter: answer.retryAfter,\n\t\t\tpending: false,\n\t\t\tresent: false,\n\t\t};\n\t}\n\treturn {\n\t\t...state,\n\t\tphase: 'code',\n\t\terror: answer.error,\n\t\tmessage: answer.message,\n\t\tretryAfter: undefined,\n\t\tattemptsLeft: answer.attemptsLeft,\n\t\tlocked: answer.locked || none,\n\t\tpending: false,\n\t\tresent: false,\n\t};\n}\n\n/**\n * The step-up's transitions, pure. `start` (a request in flight) forgets the\n * last error but keeps a challenge held, so a refused \"Resend code\" leaves\n * the code form up; a refusal keeps its `message` (and a throttle its\n * `retryAfter`) until the next step; a granted request replaces the challenge; a code accepted\n * spends it; an expired or spent one leaves \"ask again\"; at 0 tries left a\n * mailed code is `expired` and an app or recovery code is `locked`.\n */\nexport function stepUpReducer(\n\tstate: StepUpState,\n\taction: StepUpAction,\n): StepUpState {\n\tswitch (action.type) {\n\t\tcase 'start':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tphase: state.challenge ? 'code' : 'requesting',\n\t\t\t\terror: undefined,\n\t\t\t\tmessage: undefined,\n\t\t\t\tretryAfter: undefined,\n\t\t\t\tpending: true,\n\t\t\t\tresent: false,\n\t\t\t};\n\t\tcase 'requested':\n\t\t\treturn requested(state, action.answer);\n\t\tcase 'submit':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tphase: 'confirming',\n\t\t\t\terror: undefined,\n\t\t\t\tmessage: undefined,\n\t\t\t\tretryAfter: undefined,\n\t\t\t\tpending: true,\n\t\t\t};\n\t\tcase 'confirmed':\n\t\t\treturn action.answer.ok\n\t\t\t\t? {\n\t\t\t\t\t\tphase: 'done',\n\t\t\t\t\t\t...(state.via ? { via: state.via } : {}),\n\t\t\t\t\t\tlocked: false,\n\t\t\t\t\t\tpending: false,\n\t\t\t\t\t\tresent: false,\n\t\t\t\t\t}\n\t\t\t\t: refused(state, action.answer);\n\t\tcase 'release':\n\t\t\treturn INITIAL_STEP_UP;\n\t}\n}\n",
10
- "import type { StepUpVia } from './second-factor';\nimport type { StepUpState } from './step-up';\nimport type { StepUpError } from './step-up-answers';\n\n/**\n * The props of `@nxgt/material`'s `VerificationCode` and `StepUpDialog` that\n * the state decides. The state's `message` is not one: the dialog words its\n * own texts from `error`, `attemptsLeft`, `locked` and `retryAfter`. The callbacks (`onSubmit`, `onResend`) are the app's.\n */\nexport interface StepUpProps {\n\treadonly via?: StepUpVia;\n\treadonly attemptsLeft?: number;\n\treadonly locked: boolean;\n\treadonly error?: StepUpError;\n\t/** The seconds to wait when `error` is `throttled` and the API said how long. */\n\treadonly retryAfter?: number;\n\treadonly pending: boolean;\n\treadonly resent: boolean;\n}\n\n/** The state as the dialog's props: `{ ...stepUpProps(state), onSubmit, onResend }`. */\nexport function stepUpProps(state: StepUpState): StepUpProps {\n\treturn {\n\t\t...(state.via ? { via: state.via } : {}),\n\t\t...(state.attemptsLeft === undefined\n\t\t\t? {}\n\t\t\t: { attemptsLeft: state.attemptsLeft }),\n\t\tlocked: state.locked,\n\t\t...(state.error ? { error: state.error } : {}),\n\t\t...(state.retryAfter === undefined ? {} : { retryAfter: state.retryAfter }),\n\t\tpending: state.pending,\n\t\tresent: state.resent,\n\t};\n}\n"
11
- ],
12
- "mappings": "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",
13
- "debugId": "E77CC5BBAAF44F7C64756E2164756E21",
14
- "names": []
15
- }