@alxia/react-router 0.27.0 → 0.28.1
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 +6 -1
- package/dist/chunks/{index-3jfeew2y.js → index-fpy7f4bz.js} +5 -58
- package/dist/chunks/index-fpy7f4bz.js.map +11 -0
- package/dist/chunks/index-xvx63t7n.js +59 -0
- package/dist/chunks/index-xvx63t7n.js.map +13 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +20 -2
- package/dist/session/index.js.map +3 -3
- package/dist/session/model/index.js +5 -3
- package/dist/session/model/index.js.map +1 -1
- package/dist/session/react/index.d.ts +3 -2
- package/dist/session/react/index.d.ts.map +1 -1
- package/dist/session/react/index.js +199 -17
- package/dist/session/react/index.js.map +8 -3
- package/dist/session/react/step-up/answers.d.ts +9 -0
- package/dist/session/react/step-up/answers.d.ts.map +1 -0
- package/dist/session/react/step-up/dialog-props.d.ts +27 -0
- package/dist/session/react/step-up/dialog-props.d.ts.map +1 -0
- package/dist/session/react/step-up/index.d.ts +6 -0
- package/dist/session/react/step-up/index.d.ts.map +1 -0
- package/dist/session/react/step-up/phase.d.ts +8 -0
- package/dist/session/react/step-up/phase.d.ts.map +1 -0
- package/dist/session/react/step-up/use-step-up-replay.d.ts +54 -0
- package/dist/session/react/step-up/use-step-up-replay.d.ts.map +1 -0
- package/dist/session/react/step-up/use-step-up.d.ts +51 -0
- package/dist/session/react/step-up/use-step-up.d.ts.map +1 -0
- package/dist/session/step-up-types.d.ts +25 -0
- package/dist/session/step-up-types.d.ts.map +1 -1
- package/dist/session/step-up.d.ts.map +1 -1
- package/docs/guide/session.md +137 -2
- package/docs/guide/subscriptions.md +18 -4
- package/docs/troubleshooting.md +24 -4
- package/package.json +4 -4
- package/dist/chunks/index-3jfeew2y.js.map +0 -15
package/docs/guide/session.md
CHANGED
|
@@ -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
|
|
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
|
|
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,116 @@ 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
|
+
`via` is kept in every phase once a challenge was granted (`code`, `expired`,
|
|
1241
|
+
`failed`, `confirmed`) and is `undefined` before, so a dialog can tell a mailed
|
|
1242
|
+
code from an authenticator-app code.
|
|
1243
|
+
|
|
1244
|
+
### `useStepUp`, `asksStepUp` and `stepUpDialogPhase`
|
|
1245
|
+
|
|
1246
|
+
`useStepUp({ request, confirm, onConfirmed })` is the part under it, for a host
|
|
1247
|
+
that decides itself what to do once the code is accepted. `start()` asks for a
|
|
1248
|
+
challenge, `submit(code)` posts it, `release()` forgets everything.
|
|
1249
|
+
|
|
1250
|
+
```tsx
|
|
1251
|
+
import { useStepUp } from '@alxia/react-router/session/react';
|
|
1252
|
+
|
|
1253
|
+
const stepUp = useStepUp({ request, confirm, onConfirmed: () => revalidate() });
|
|
1254
|
+
// stepUp.phase: 'asking' | 'failed' | 'code' | 'expired' | 'confirmed'
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
`asksStepUp(answer)` is true for a refusal carrying `stepUp` (what opens the
|
|
1258
|
+
dialog), and `stepUpDialogPhase(state)` is what the dialog shows for a
|
|
1259
|
+
`stepUpReducer` state:
|
|
1260
|
+
|
|
1261
|
+
```ts
|
|
1262
|
+
import { asksStepUp, stepUpDialogPhase } from '@alxia/react-router/session/react';
|
|
1263
|
+
import { INITIAL_STEP_UP } from '@alxia/react-router/session/model';
|
|
1264
|
+
|
|
1265
|
+
asksStepUp({ ok: false, stepUp: true }); // true
|
|
1266
|
+
stepUpDialogPhase(INITIAL_STEP_UP); // 'asking'
|
|
1267
|
+
```
|
|
1268
|
+
|
|
1134
1269
|
## The types and the rest of `/session/model`
|
|
1135
1270
|
|
|
1136
1271
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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/troubleshooting.md
CHANGED
|
@@ -142,6 +142,24 @@ a loader, a message React Router or the browser prints, or an error from
|
|
|
142
142
|
|
|
143
143
|
## Thrown or printed
|
|
144
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
|
+
|
|
145
163
|
### `alxia-react-router: … is running on Node, and alxia's server runs on Bun. …`
|
|
146
164
|
|
|
147
165
|
**When:** `bun run dev`, `vite preview`, or a `react-router build` that
|
|
@@ -2909,10 +2927,12 @@ blank line.
|
|
|
2909
2927
|
### Events arrive in bursts, or the stream closes after a few seconds
|
|
2910
2928
|
|
|
2911
2929
|
**Why:** a proxy or CDN between the app and the browser buffers the response,
|
|
2912
|
-
or closes an idle connection (Bun's `idleTimeout`,
|
|
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)
|
|
2913
2932
|
because `heartbeatMs` is longer than it.
|
|
2914
2933
|
|
|
2915
|
-
**Fix:**
|
|
2916
|
-
|
|
2917
|
-
|
|
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
|
|
2918
2938
|
route's responses.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/react-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.1",
|
|
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",
|
|
@@ -136,9 +136,9 @@
|
|
|
136
136
|
},
|
|
137
137
|
"devDependencies": {
|
|
138
138
|
"@alxia/compress": "^0.3.12",
|
|
139
|
-
"@alxia/core": "^0.17.
|
|
139
|
+
"@alxia/core": "^0.17.1",
|
|
140
140
|
"@alxia/openapi": "^0.6.12",
|
|
141
|
-
"@nxgt/graphql-client": "^0.
|
|
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.17.
|
|
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
|
-
}
|