@alxia/janus 0.1.2 → 0.2.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.
- package/README.md +26 -14
- package/dist/errors.d.ts +17 -7
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -31
- package/dist/index.js.map +5 -5
- package/dist/permission.d.ts +6 -5
- package/dist/permission.d.ts.map +1 -1
- package/dist/session.d.ts +21 -21
- package/dist/session.d.ts.map +1 -1
- package/docs/README.md +2 -2
- package/docs/guide/errors.md +52 -23
- package/docs/guide/permissions.md +15 -11
- package/docs/guide/sessions.md +31 -19
- package/docs/guide/sign-in-and-out.md +13 -13
- package/docs/roadmap.md +6 -2
- package/docs/troubleshooting.md +91 -56
- package/package.json +6 -4
package/docs/guide/errors.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Errors
|
|
2
2
|
|
|
3
|
-
This page covers `janusErrors()`: the
|
|
3
|
+
This page covers `janusErrors()`: the middleware that answers every refusal
|
|
4
4
|
`@nxgt/janus` throws — a wrong password, a login taken, a store down —
|
|
5
5
|
with its status and a body a client can act on, typed on the routes after
|
|
6
6
|
it; and `bodyOf` and `statusOf`, the two functions it is made of.
|
|
7
7
|
|
|
8
8
|
```ts
|
|
9
|
-
import { alxia } from '@alxia/core';
|
|
9
|
+
import { alxia, validate } from '@alxia/core';
|
|
10
10
|
import { janusErrors, session } from '@alxia/janus';
|
|
11
11
|
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
12
12
|
import { z } from 'zod';
|
|
@@ -23,13 +23,13 @@ const SignUp = z.object({ email: z.string(), name: z.string(), password: z.strin
|
|
|
23
23
|
const app = alxia()
|
|
24
24
|
.use(janusErrors())
|
|
25
25
|
.use(session(accounts))
|
|
26
|
-
.post('/signup', { body: SignUp }, async ({ body, auth, reply }) => {
|
|
26
|
+
.post('/signup', validate({ body: SignUp }), async ({ body, auth, reply }) => {
|
|
27
27
|
const signedUp = await accounts.signUp(body); // throws a JanusError when it refuses
|
|
28
28
|
return reply.created({ id: auth.send(signedUp).id });
|
|
29
29
|
});
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
A route needs no `try`: `signUp` throws, and the
|
|
32
|
+
A route needs no `try`: `signUp` throws, and the middleware answers.
|
|
33
33
|
|
|
34
34
|
```text
|
|
35
35
|
400 {"code":"PASSWORD_TOO_SHORT","minLength":12}
|
|
@@ -42,12 +42,36 @@ A route needs no `try`: `signUp` throws, and the plugin answers.
|
|
|
42
42
|
|
|
43
43
|
## Where to put it
|
|
44
44
|
|
|
45
|
-
`janusErrors()` is
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
`janusErrors()` is a try/catch middleware: it answers a `JanusError` thrown
|
|
46
|
+
**behind** it, by `session()`, by `permission()` or by a route. Give it to
|
|
47
|
+
`use` before `session()`:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const app = alxia().use(janusErrors(), session(accounts));
|
|
51
|
+
```
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
`
|
|
53
|
+
Declared after `session()`, it never sees what `session()` throws: a store
|
|
54
|
+
down while the session is read (`STORE_FAILED`) is the app's 500
|
|
55
|
+
`{"error":"internal"}`, not the 503. The same goes for a route declared
|
|
56
|
+
before it: a `JanusError` it throws is not behind `janusErrors()`.
|
|
57
|
+
|
|
58
|
+
It answers only a `JanusError`. Anything else goes on, thrown, to the
|
|
59
|
+
middlewares before it, then the app's `onError`, or its 500.
|
|
60
|
+
|
|
61
|
+
An observer that settles `next()` (`logger()`, `secureHeaders()`,
|
|
62
|
+
`telemetry()`, `createI18n()`) reads the response the error would be
|
|
63
|
+
answered with, and lets the error go on: `janusErrors()` answers it
|
|
64
|
+
wherever it stands, `use(janusErrors()).use(i18n).use(session())`
|
|
65
|
+
included. Give the observers to `use` first, then `janusErrors()`, so
|
|
66
|
+
that they see its reply rather than the 500.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { logger } from '@alxia/logger';
|
|
70
|
+
|
|
71
|
+
const app = alxia()
|
|
72
|
+
.use(logger()) // sees the 503 janusErrors() answers
|
|
73
|
+
.use(janusErrors(), session(accounts));
|
|
74
|
+
```
|
|
51
75
|
|
|
52
76
|
## Statuses
|
|
53
77
|
|
|
@@ -95,7 +119,7 @@ bodyOf(new NotFoundError('user')); // { code: 'NOT_FOUND' }
|
|
|
95
119
|
`CREDENTIALS_INVALID` is one code for an unknown login, a wrong password and
|
|
96
120
|
a login throttled for too many attempts: a client cannot tell a login exists
|
|
97
121
|
from it. `retryAfter` is set only once `janus()`'s throttle trips — ten
|
|
98
|
-
passwords in fifteen minutes by default — and the
|
|
122
|
+
passwords in fifteen minutes by default — and the middleware then adds the
|
|
99
123
|
`Retry-After` header.
|
|
100
124
|
|
|
101
125
|
## Reporting the 5xx
|
|
@@ -105,6 +129,7 @@ const app = alxia().use(
|
|
|
105
129
|
janusErrors({
|
|
106
130
|
report: (error, ctx) => console.error(`${ctx.route}: ${error.code}`, error.cause),
|
|
107
131
|
}),
|
|
132
|
+
session(accounts),
|
|
108
133
|
);
|
|
109
134
|
```
|
|
110
135
|
|
|
@@ -122,35 +147,39 @@ or rejects is a process warning, and the 503 is sent all the same:
|
|
|
122
147
|
|
|
123
148
|
The 4xx are not reported: they are the client's.
|
|
124
149
|
|
|
125
|
-
## On the
|
|
150
|
+
## On the wire
|
|
126
151
|
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
Every route after the middleware may answer these refusals: declare them in
|
|
153
|
+
your OpenAPI document, and the client you generate from it (with
|
|
154
|
+
`@nxgt/openapi-codegen`, say) reads them typed. A 401 from a route behind
|
|
129
155
|
`session(accounts, { required: true })` is either the session's
|
|
130
|
-
`{ error: 'unauthenticated' }` or janus's `{ code }`;
|
|
156
|
+
`{ error: 'unauthenticated' }` or janus's `{ code }`; a caller narrows on
|
|
157
|
+
the key:
|
|
131
158
|
|
|
132
159
|
```ts
|
|
133
|
-
import { client } from '@alxia/client';
|
|
134
160
|
import { alxia } from '@alxia/core';
|
|
135
161
|
import { janusErrors, session } from '@alxia/janus';
|
|
136
162
|
|
|
137
163
|
const app = alxia()
|
|
138
|
-
.use(janusErrors())
|
|
139
|
-
.use(session(accounts, { required: true }))
|
|
164
|
+
.use(janusErrors(), session(accounts, { required: true }))
|
|
140
165
|
.get('/me', ({ user, reply }) => reply(200, { name: user.name }));
|
|
141
166
|
|
|
142
|
-
const me = await
|
|
167
|
+
const me = await app.request('/me');
|
|
168
|
+
const body = await me.json();
|
|
143
169
|
if (me.status === 401) {
|
|
144
|
-
if ('error' in
|
|
145
|
-
else console.log(
|
|
170
|
+
if ('error' in body) console.log('sign in first'); // the session's own 401
|
|
171
|
+
else console.log(body.code); // a JanusErrorCode
|
|
146
172
|
}
|
|
147
|
-
if (me.status === 503) console.log(
|
|
173
|
+
if (me.status === 503) console.log(body.code); // 'STORE_FAILED' and the like: try again later
|
|
148
174
|
```
|
|
149
175
|
|
|
150
176
|
## Signatures
|
|
151
177
|
|
|
152
178
|
```ts
|
|
153
|
-
function janusErrors(options?: JanusErrorsOptions):
|
|
179
|
+
function janusErrors(options?: JanusErrorsOptions): JanusErrors;
|
|
180
|
+
|
|
181
|
+
// a middleware: answers the JanusErrors thrown behind it
|
|
182
|
+
type JanusErrors = Middleware<object, Promise<Next | Reply<JanusErrorStatus, JanusErrorBody>>> & MiddlewareMark;
|
|
154
183
|
|
|
155
184
|
interface JanusErrorsOptions {
|
|
156
185
|
readonly report?: (error: JanusError, ctx: BaseContext) => unknown;
|
|
@@ -170,7 +199,7 @@ function statusOf(code: JanusErrorCode): JanusErrorStatus; // @nxgt/janus's
|
|
|
170
199
|
```
|
|
171
200
|
|
|
172
201
|
`JanusError`, `JanusErrorCode` and `JanusErrorStatus` are `@nxgt/janus`'s;
|
|
173
|
-
`
|
|
202
|
+
`Middleware`, `MiddlewareMark`, `Next`, `Reply` and `BaseContext` are `@alxia/core`'s.
|
|
174
203
|
|
|
175
204
|
The permission guard's own refusals — `{ error: 'forbidden' }` and the
|
|
176
205
|
like — are not `JanusError`s; see [Permissions](permissions.md#refusals).
|
|
@@ -45,6 +45,11 @@ const app = alxia()
|
|
|
45
45
|
);
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
The guard lives in a `group`: given to the app's own `use`, it would also run
|
|
49
|
+
on a request no route matches, and answer it 401 or 404 before the 404 it
|
|
50
|
+
should get. `janusErrors()` is given to `use` before `session()`, so it
|
|
51
|
+
answers what `session()` and the guard throw.
|
|
52
|
+
|
|
48
53
|
`object` is what `findRecord` answered, typed as such. Granting access is
|
|
49
54
|
`@nxgt/janus`'s:
|
|
50
55
|
|
|
@@ -64,7 +69,7 @@ await access.grant({ type: 'record', id: 'r1' }, 'owners', user); // user: what
|
|
|
64
69
|
| `options.ctx` | `(ctx: BaseContext, object: O) => …` | the context a condition of the permission reads; **required exactly when the permission has one**, refused otherwise |
|
|
65
70
|
|
|
66
71
|
Each callback reads the request's `BaseContext`, and, when its parameter is
|
|
67
|
-
annotated, what an earlier
|
|
72
|
+
annotated, what an earlier middleware added: see
|
|
68
73
|
[Reading the app's context](#reading-the-apps-context).
|
|
69
74
|
|
|
70
75
|
It runs once per request, before the route:
|
|
@@ -83,7 +88,7 @@ and answer it 401 or 404.
|
|
|
83
88
|
|
|
84
89
|
## Refusals
|
|
85
90
|
|
|
86
|
-
Each
|
|
91
|
+
Each guarded route may answer these refusals, with the body
|
|
87
92
|
`PermissionRefusedBody`:
|
|
88
93
|
|
|
89
94
|
| The request | Answered |
|
|
@@ -206,7 +211,7 @@ request throws a `TypeError` and answers 500
|
|
|
206
211
|
`subject` names another — an API key's service account, a user read from a
|
|
207
212
|
header set by a gateway. It reads the `BaseContext`, so the request, the
|
|
208
213
|
URL and the path parameters, not the route's validated headers — and, when
|
|
209
|
-
its parameter is annotated, what an earlier
|
|
214
|
+
its parameter is annotated, what an earlier middleware added
|
|
210
215
|
([Reading the app's context](#reading-the-apps-context)). `null` is
|
|
211
216
|
anonymous, a 401:
|
|
212
217
|
|
|
@@ -222,12 +227,12 @@ permission(access, 'view', 'record', byParam('id', findRecord), {
|
|
|
222
227
|
## Reading the app's context
|
|
223
228
|
|
|
224
229
|
`load`, `subject` and `ctx` read `BaseContext` — the request, the URL, the
|
|
225
|
-
path parameters. To read what an earlier
|
|
230
|
+
path parameters. To read what an earlier middleware added — a tenant, a member,
|
|
226
231
|
a feature flag — annotate the callback's parameter. `permission()` infers
|
|
227
232
|
what each one reads from its annotation, while `access`, `permission`,
|
|
228
233
|
`type` and the object stay inferred as before, and the guard is a
|
|
229
|
-
[
|
|
230
|
-
|
|
234
|
+
[middleware that requires an earlier one](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/writing-a-plugin.md#a-plugin-that-needs-an-earlier-one):
|
|
235
|
+
an app that does not give it before the guard cannot `use` it.
|
|
231
236
|
|
|
232
237
|
```ts
|
|
233
238
|
import { alxia, type BaseContext } from '@alxia/core';
|
|
@@ -250,20 +255,20 @@ const edit = permission(
|
|
|
250
255
|
|
|
251
256
|
const app = alxia()
|
|
252
257
|
.use(janusErrors())
|
|
253
|
-
.
|
|
258
|
+
.plugin(tenancy) // derives tenant and member
|
|
254
259
|
.group('/records/:id', (record) =>
|
|
255
260
|
record.use(edit).put('/', ({ object, reply }) => reply(200, { title: object.title })),
|
|
256
261
|
);
|
|
257
262
|
|
|
258
263
|
alxia().use(edit);
|
|
259
|
-
// error, one message per missing key:
|
|
260
|
-
// |
|
|
264
|
+
// error, one message per missing key: Property 'tenant' is missing in type 'BaseContext & Empty' but required in type '{ tenant: Tenant; }'
|
|
265
|
+
// | Property 'member' is missing in type 'BaseContext & Empty' but required in type '{ member: { type: 'user'; id: string } | null; }'
|
|
261
266
|
```
|
|
262
267
|
|
|
263
268
|
What the guard requires is the union of what the three annotations read.
|
|
264
269
|
An annotation may be `BaseContext & { member: Member }` or `{ member: Member }`
|
|
265
270
|
alone. An app whose `member` has a type that does not fit is refused too —
|
|
266
|
-
`
|
|
271
|
+
`Types of property 'member' are incompatible`
|
|
267
272
|
— while a narrower one passes. A key `BaseContext` already has, annotated
|
|
268
273
|
with a type it does not give — `({ pathParams }: { pathParams: string })` —
|
|
269
274
|
is refused the same way.
|
|
@@ -318,7 +323,6 @@ function permission<
|
|
|
318
323
|
...options: OptionsArgs<C, T, P, O, SubjectCtx, CheckCtx>
|
|
319
324
|
): Alxia<
|
|
320
325
|
RequiresOf<LoadCtx & SubjectCtx & CheckCtx> & { object: O },
|
|
321
|
-
Empty,
|
|
322
326
|
'',
|
|
323
327
|
Reply<401, PermissionRefusedBody> | Reply<404, PermissionRefusedBody> | Reply<403, PermissionRefusedBody>
|
|
324
328
|
> &
|
package/docs/guide/sessions.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sessions
|
|
2
2
|
|
|
3
|
-
This page covers `session()`: the
|
|
3
|
+
This page covers `session()`: the middleware that reads who a request belongs
|
|
4
4
|
to, hands the routes after it a typed `user` and `session` and a bound
|
|
5
5
|
`auth`, refuses an anonymous request when asked to, and sends a renewed
|
|
6
6
|
session's cookie again.
|
|
@@ -27,7 +27,9 @@ const app = alxia()
|
|
|
27
27
|
|
|
28
28
|
`session(accounts)` calls `accounts.authenticate(request)` once per request, for
|
|
29
29
|
the routes declared **after** it. A route declared before it is not
|
|
30
|
-
touched, and has no `user`, `session` or `auth`.
|
|
30
|
+
touched, and has no `user`, `session` or `auth`. A request no route matches
|
|
31
|
+
is not a route, so `session()` runs on it, wherever it is declared: with
|
|
32
|
+
`required: true`, `GET /nothing` is a 401 before it is a 404.
|
|
31
33
|
|
|
32
34
|
## Where the session is read from
|
|
33
35
|
|
|
@@ -68,8 +70,9 @@ const app = alxia()
|
|
|
68
70
|
);
|
|
69
71
|
```
|
|
70
72
|
|
|
71
|
-
With `required: true`,
|
|
72
|
-
|
|
73
|
+
With `required: true`, every route after it answers this 401 to an
|
|
74
|
+
anonymous request; declare it in the OpenAPI document a client is
|
|
75
|
+
generated from:
|
|
73
76
|
|
|
74
77
|
```text
|
|
75
78
|
401 {"error":"unauthenticated"}
|
|
@@ -143,7 +146,7 @@ A patient's session on `/staff/me` is a 401, as if they had sent none. A
|
|
|
143
146
|
| `user` | the user, typed by its schema and narrowed by `type`, or `null` | the user, or `null` | the user |
|
|
144
147
|
| `session` | `Session` or `null` | `Session` or `null` | `Session` |
|
|
145
148
|
| `auth` | `RequestAuth`: `send`, `signOut`, `device` | the same | the same |
|
|
146
|
-
| a 401
|
|
149
|
+
| a 401 to an anonymous request | no | yes | yes |
|
|
147
150
|
|
|
148
151
|
`Session` is `@nxgt/janus`'s: `id`, `userId`, `authenticatedAt`,
|
|
149
152
|
`expiresAt`, `revokedAt`, `createdAt`. The token is never in it.
|
|
@@ -164,11 +167,11 @@ is signed in — [Signing in and out](sign-in-and-out.md) is its page.
|
|
|
164
167
|
|
|
165
168
|
A sign-in route is called by someone who is not signed in yet, so it sits
|
|
166
169
|
behind a `session()` that is **not** required: behind `required: true` it
|
|
167
|
-
answers `401 {"error":"unauthenticated"}` before it runs. Use the
|
|
170
|
+
answers `401 {"error":"unauthenticated"}` before it runs. Use the middleware
|
|
168
171
|
twice — once open, for the sign-in routes; once required, for the rest:
|
|
169
172
|
|
|
170
173
|
```ts
|
|
171
|
-
import { alxia } from '@alxia/core';
|
|
174
|
+
import { alxia, validate } from '@alxia/core';
|
|
172
175
|
import { janusErrors, session } from '@alxia/janus';
|
|
173
176
|
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
174
177
|
import { z } from 'zod';
|
|
@@ -185,7 +188,7 @@ const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
|
185
188
|
const app = alxia()
|
|
186
189
|
.use(janusErrors())
|
|
187
190
|
.use(session(accounts)) // open: user may be null
|
|
188
|
-
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
191
|
+
.post('/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
|
|
189
192
|
const signedIn = await accounts.signIn(body);
|
|
190
193
|
return reply.ok({ id: auth.send(signedIn).id });
|
|
191
194
|
})
|
|
@@ -202,10 +205,13 @@ The second `session()` replaces the first's `user`, `session` and `auth`
|
|
|
202
205
|
for the routes after it, with its own types: `user` is never `null` under
|
|
203
206
|
`/account`, and still may be on `/greeting`, outside the group. Without the
|
|
204
207
|
`group`, `.use(session(accounts, { required: true }))` on the app itself works
|
|
205
|
-
the same for every route declared after it
|
|
208
|
+
the same for every route declared after it, and for a request no route
|
|
209
|
+
matches: an anonymous request to a missing path is then a 401, not a 404.
|
|
210
|
+
Scope a required session with a `group` to guard only some routes: a path-scoped
|
|
211
|
+
`use('/api', session(…))` does not compile, since `session` adds `user`.
|
|
206
212
|
|
|
207
|
-
A route behind both
|
|
208
|
-
of one instance share a request's lookup, for the same `type`. A different
|
|
213
|
+
A route behind both middlewares still looks the session up once: the
|
|
214
|
+
middlewares of one instance share a request's lookup, for the same `type`. A different
|
|
209
215
|
`type` is its own lookup.
|
|
210
216
|
|
|
211
217
|
## A renewed session is sent again
|
|
@@ -219,7 +225,7 @@ conditions:
|
|
|
219
225
|
`Authorization: Bearer` is never handed a cookie; it keeps its token,
|
|
220
226
|
which is still the same one;
|
|
221
227
|
- **the route did not set a session cookie itself.** A route behind the
|
|
222
|
-
|
|
228
|
+
route that signs in, or signs out, keeps its own `Set-Cookie`: the
|
|
223
229
|
session it replaced does not undo it.
|
|
224
230
|
|
|
225
231
|
```ts
|
|
@@ -260,11 +266,17 @@ writes it with `accounts.cookie.serialize`, so they are set in one place.
|
|
|
260
266
|
A store that cannot answer makes `authenticate` throw `STORE_FAILED`.
|
|
261
267
|
`session()` does not catch it: the request fails, and `janusErrors()`
|
|
262
268
|
answers it **503** `{ "code": "STORE_FAILED" }` — never a 401 that would
|
|
263
|
-
send every user to the sign-in page.
|
|
264
|
-
app's
|
|
269
|
+
send every user to the sign-in page. `janusErrors()` must be given to `use`
|
|
270
|
+
**before** `session()`, or it is not behind it to see the error: the app's
|
|
271
|
+
500 answers instead. See [Errors](errors.md).
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const app = alxia().use(janusErrors(), session(accounts, { required: true }));
|
|
275
|
+
```
|
|
265
276
|
|
|
266
277
|
Routes declared before `session()` do not call the store, and keep
|
|
267
|
-
answering.
|
|
278
|
+
answering. A request no route matches does run it: `session()` on the app is
|
|
279
|
+
given to every request.
|
|
268
280
|
|
|
269
281
|
## Testing it
|
|
270
282
|
|
|
@@ -303,7 +315,7 @@ function session<A extends Auth<{ readonly type: string }>, const T extends User
|
|
|
303
315
|
options: SessionOptions<T> & { readonly required: true },
|
|
304
316
|
): Alxia<
|
|
305
317
|
{ readonly user: UserOf<A, T>; readonly session: Session; readonly auth: RequestAuth },
|
|
306
|
-
|
|
318
|
+
'', Reply<401, UnauthenticatedBody>
|
|
307
319
|
>;
|
|
308
320
|
|
|
309
321
|
function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
|
|
@@ -311,7 +323,7 @@ function session<A extends Auth<{ readonly type: string }>, const T extends User
|
|
|
311
323
|
options?: SessionOptions<T> & { readonly required?: false },
|
|
312
324
|
): Alxia<
|
|
313
325
|
{ readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth },
|
|
314
|
-
|
|
326
|
+
'', never
|
|
315
327
|
>;
|
|
316
328
|
|
|
317
329
|
function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
|
|
@@ -319,7 +331,7 @@ function session<A extends Auth<{ readonly type: string }>, const T extends User
|
|
|
319
331
|
options?: SessionOptions<T>,
|
|
320
332
|
): Alxia<
|
|
321
333
|
{ readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth },
|
|
322
|
-
|
|
334
|
+
'', Reply<401, UnauthenticatedBody>
|
|
323
335
|
>;
|
|
324
336
|
|
|
325
337
|
interface SessionOptions<T extends string> {
|
|
@@ -346,5 +358,5 @@ type UserOfAuth<A> = A extends Auth<infer U> ? U : never;
|
|
|
346
358
|
exported. `Alxia`, `Empty` and `Reply` are `@alxia/core`'s; `Session` and
|
|
347
359
|
`SharedApi` are `@nxgt/janus`'s.
|
|
348
360
|
|
|
349
|
-
Next: [Signing in and out](sign-in-and-out.md) sets the cookie this
|
|
361
|
+
Next: [Signing in and out](sign-in-and-out.md) sets the cookie this middleware
|
|
350
362
|
reads.
|
|
@@ -6,7 +6,7 @@ cookie that tells `janus()` a sign-in comes from a device it has seen
|
|
|
6
6
|
before — and the unbound functions for code outside a route.
|
|
7
7
|
|
|
8
8
|
```ts
|
|
9
|
-
import { alxia } from '@alxia/core';
|
|
9
|
+
import { alxia, validate } from '@alxia/core';
|
|
10
10
|
import { janusErrors, session } from '@alxia/janus';
|
|
11
11
|
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
12
12
|
import { z } from 'zod';
|
|
@@ -24,11 +24,11 @@ const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
|
24
24
|
const app = alxia()
|
|
25
25
|
.use(janusErrors()) // a refused sign-in answered 401, a login taken 409
|
|
26
26
|
.use(session(accounts)) // not required: the people signing in are anonymous
|
|
27
|
-
.post('/signup', { body: SignUp }, async ({ body, auth, reply }) => {
|
|
27
|
+
.post('/signup', validate({ body: SignUp }), async ({ body, auth, reply }) => {
|
|
28
28
|
const signedUp = await accounts.signUp(body);
|
|
29
29
|
return reply.created({ id: auth.send(signedUp).id });
|
|
30
30
|
})
|
|
31
|
-
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
31
|
+
.post('/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
|
|
32
32
|
const signedIn = await accounts.signIn(body);
|
|
33
33
|
return reply.ok({ id: auth.send(signedIn).id });
|
|
34
34
|
})
|
|
@@ -60,12 +60,12 @@ It takes a `SessionOpened` — anything holding a `token`, a `session` and
|
|
|
60
60
|
a `user`, what `signUp`, `signIn` and the other flows of `janus()` that open
|
|
61
61
|
a session answer — so it works after a sign-in by code, by link, or after
|
|
62
62
|
a second factor is confirmed too. With a `deviceToken`, it sets the device
|
|
63
|
-
cookie as well, with the
|
|
63
|
+
cookie as well, with the middleware's `device` options ([below](#devices)).
|
|
64
64
|
|
|
65
65
|
The cookie's attributes — `name` (`janus-session`), `domain`, `path`
|
|
66
66
|
(`'/'`), `sameSite` (`'lax'`), `secure` (`true`) — are set once, in
|
|
67
67
|
`janus({ cookie })`, and `session()` reads and renews the same cookie. A
|
|
68
|
-
route that sends a session keeps its own `Set-Cookie`: the
|
|
68
|
+
route that sends a session keeps its own `Set-Cookie`: the middleware never
|
|
69
69
|
overwrites it with the session the request came in with.
|
|
70
70
|
|
|
71
71
|
### A user type
|
|
@@ -74,7 +74,7 @@ With several user types, the flow is the type's; `ctx.auth.send` is the
|
|
|
74
74
|
same:
|
|
75
75
|
|
|
76
76
|
```ts
|
|
77
|
-
import { alxia } from '@alxia/core';
|
|
77
|
+
import { alxia, validate } from '@alxia/core';
|
|
78
78
|
import { janusErrors, session } from '@alxia/janus';
|
|
79
79
|
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
80
80
|
import { z } from 'zod';
|
|
@@ -93,7 +93,7 @@ const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
|
93
93
|
const app = alxia()
|
|
94
94
|
.use(janusErrors())
|
|
95
95
|
.use(session(accounts))
|
|
96
|
-
.post('/patients/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
96
|
+
.post('/patients/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
|
|
97
97
|
const signedIn = await accounts.patient.signIn(body);
|
|
98
98
|
return reply.ok({ id: auth.send(signedIn).id });
|
|
99
99
|
});
|
|
@@ -107,7 +107,7 @@ challenge, and `send` refuses the union at compile time
|
|
|
107
107
|
Switch on `status` first:
|
|
108
108
|
|
|
109
109
|
```ts
|
|
110
|
-
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
110
|
+
.post('/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
|
|
111
111
|
const result = await accounts.signIn(body);
|
|
112
112
|
if (result.status === 'secondFactor') {
|
|
113
113
|
return reply.ok({ status: result.status, challenge: result.challenge });
|
|
@@ -151,7 +151,7 @@ client keeps in a long-lived cookie and presents at its next sign-in.
|
|
|
151
151
|
`ctx.auth.device` reads it, `ctx.auth.send` writes it back:
|
|
152
152
|
|
|
153
153
|
```ts
|
|
154
|
-
import { alxia } from '@alxia/core';
|
|
154
|
+
import { alxia, validate } from '@alxia/core';
|
|
155
155
|
import { janusErrors, session } from '@alxia/janus';
|
|
156
156
|
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
157
157
|
import { z } from 'zod';
|
|
@@ -169,7 +169,7 @@ const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
|
169
169
|
const app = alxia()
|
|
170
170
|
.use(janusErrors())
|
|
171
171
|
.use(session(accounts))
|
|
172
|
-
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
172
|
+
.post('/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
|
|
173
173
|
const signedIn = await accounts.signIn(body, { device: auth.device });
|
|
174
174
|
return reply.ok({
|
|
175
175
|
id: auth.send(signedIn).id,
|
|
@@ -203,7 +203,7 @@ const device = { name: 'shop-device', domain: 'example.com' };
|
|
|
203
203
|
|
|
204
204
|
alxia()
|
|
205
205
|
.use(session(accounts, { device }))
|
|
206
|
-
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
206
|
+
.post('/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
|
|
207
207
|
const signedIn = await accounts.signIn(body, { device: auth.device });
|
|
208
208
|
return reply.ok({ id: auth.send(signedIn).id });
|
|
209
209
|
});
|
|
@@ -241,7 +241,7 @@ helper shared by several routes, a test, or a route declared **before**
|
|
|
241
241
|
any `session()`, which then looks up no session at all:
|
|
242
242
|
|
|
243
243
|
```ts
|
|
244
|
-
import { alxia } from '@alxia/core';
|
|
244
|
+
import { alxia, validate } from '@alxia/core';
|
|
245
245
|
import { deviceOf, janusErrors, sendSession, session, signOut } from '@alxia/janus';
|
|
246
246
|
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
247
247
|
import { z } from 'zod';
|
|
@@ -258,7 +258,7 @@ const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
|
258
258
|
|
|
259
259
|
const app = alxia()
|
|
260
260
|
.use(janusErrors())
|
|
261
|
-
.post('/signin', { body: SignIn }, async (ctx) => {
|
|
261
|
+
.post('/signin', validate({ body: SignIn }), async (ctx) => {
|
|
262
262
|
const signedIn = await accounts.signIn(ctx.body, { device: deviceOf(ctx) });
|
|
263
263
|
return ctx.reply.ok({ id: sendSession(ctx, accounts, signedIn).id });
|
|
264
264
|
})
|
package/docs/roadmap.md
CHANGED
|
@@ -7,7 +7,11 @@ number on it. Every release, with each change it made, is in
|
|
|
7
7
|
|
|
8
8
|
## Now
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
- **Middlewares, not hooks.** `session()`, `permission()` and `janusErrors()`
|
|
11
|
+
are middlewares given to `use`. `janusErrors()` is a try/catch: it answers
|
|
12
|
+
the refusals thrown behind it, so it goes to `use` before `session()`. A
|
|
13
|
+
required `session()` on the app answers an anonymous request to a missing
|
|
14
|
+
path with its 401: scope it with a `group`. Lands in 0.4.
|
|
11
15
|
|
|
12
16
|
## Next
|
|
13
17
|
|
|
@@ -31,7 +35,7 @@ Nothing scheduled yet.
|
|
|
31
35
|
|
|
32
36
|
### 0.1.0
|
|
33
37
|
|
|
34
|
-
- **The user in the context.** `
|
|
38
|
+
- **The user in the context.** `plugin(session(accounts))` hands the routes
|
|
35
39
|
after it `user`, `session` and `auth`, typed by the user schema and
|
|
36
40
|
narrowed by `type`; with `required: true`, an anonymous request is a typed
|
|
37
41
|
401 and `user` is never `null`. A required `session()` behind an open one
|