@alxia/janus 0.1.1 → 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.
@@ -1,12 +1,12 @@
1
1
  # Errors
2
2
 
3
- This page covers `janusErrors()`: the plugin that answers every refusal
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 plugin answers.
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 an `onError` hook: it answers the errors of the routes
46
- declared **after** it. Put it first. A `JanusError` thrown by a route
47
- declared before it is the app's 500 `{"error":"internal"}`.
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
- It answers only a `JanusError`. Anything else goes on to the app's next
50
- `onError`, or its 500.
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 plugin then adds 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 client
150
+ ## On the wire
126
151
 
127
- The refusals are in the type of every route after the plugin, so
128
- `@alxia/client` reads them. A 401 from a route behind
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 }`; narrow on the key:
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 client(app).get('/me');
167
+ const me = await app.request('/me');
168
+ const body = await me.json();
143
169
  if (me.status === 401) {
144
- if ('error' in me.data) console.log('sign in first'); // the session's own 401
145
- else console.log(me.data.code); // a JanusErrorCode
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(me.data.code); // 'STORE_FAILED' and the like: try again later
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): Alxia<Empty, Empty, '', Reply<JanusErrorStatus, JanusErrorBody>>;
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
- `Alxia`, `Empty`, `Reply` and `BaseContext` are `@alxia/core`'s.
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 plugin added: see
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 refusal is in the type of the guarded routes, with the body
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 plugin added
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 plugin added — a tenant, a member,
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
- [`definePlugin`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/writing-a-plugin.md#a-plugin-that-needs-an-earlier-one)
230
- plugin: an app that does not give it before the guard cannot use it.
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
- .use(tenancy) // derives tenant and member
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: the plugin reads "tenant", which this app's context does not give: use the plugin that adds it first
260
- // | the plugin reads "member", which this app's context does not give: use the plugin that adds it first
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
- `the plugin reads "member", which this app's context gives with another type`
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
  > &
@@ -1,6 +1,6 @@
1
1
  # Sessions
2
2
 
3
- This page covers `session()`: the plugin that reads who a request belongs
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`, the 401 is in the type of every route after it, so a
72
- client generated from the app reads it:
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 in the routes' type | no | yes | yes |
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 plugin
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 plugins still looks the session up once: the plugins
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
- plugin that signs in, or signs out, keeps its own `Set-Cookie`: the
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. Without `janusErrors()`, it is the
264
- app's 500. See [Errors](errors.md).
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
- Empty, '', Reply<401, UnauthenticatedBody>
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
- Empty, '', never
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
- Empty, '', Reply<401, UnauthenticatedBody>
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 plugin
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 plugin's `device` options ([below](#devices)).
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 plugin never
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
- Nothing scheduled yet.
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.** `use(session(accounts))` hands the routes
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