@alxia/janus 0.1.2 → 0.3.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/dist/session.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Alxia, type Empty, type Reply } from '@alxia/core';
1
+ import { type Empty, type Middleware, type Next, type Problem, type Reply } from '@alxia/core';
2
2
  import type { Session } from '@nxgt/janus';
3
3
  import { type DeviceCookieOptions } from './device';
4
4
  import type { Auth, RequestAuth, UserOfAuth } from './types';
@@ -19,49 +19,51 @@ export interface SessionOptions<T extends string> {
19
19
  export interface UnauthenticatedBody {
20
20
  readonly error: 'unauthenticated';
21
21
  }
22
+ /** The 401 a required session answers under `alxia({ errors: 'problem' })`. */
23
+ export type UnauthenticatedProblem = Problem<401>;
22
24
  type UserOf<A, T> = Extract<UserOfAuth<A>, {
23
25
  readonly type: T;
24
26
  }>;
27
+ /** What a session gives the routes after it. */
28
+ type Given<User, S> = {
29
+ readonly user: User;
30
+ readonly session: S;
31
+ readonly auth: RequestAuth;
32
+ };
33
+ /** What `session()` makes: a middleware that gives `Added`, or answers `Refused`. */
34
+ export type SessionMiddleware<Added extends object, Refused = never> = Middleware<Empty, Promise<Refused | Next<Added>>>;
25
35
  /**
26
- * Who a request belongs to, as a plugin: `auth.authenticate(request)`, and
27
- * the routes declared after it read `user` and `session` — typed by the
28
- * user schema, narrowed by `type`.
36
+ * Who a request belongs to, as a middleware: `auth.authenticate(request)`,
37
+ * and the routes declared after it read `user` and `session` — typed by
38
+ * the user schema, narrowed by `type`. Given to `app.use`, it runs on a
39
+ * request no route matches too: a required session answers it 401 before
40
+ * its 404.
29
41
  *
30
42
  * **An outage is not anonymous**: a store that cannot answer throws
31
- * `STORE_FAILED`, which `janusErrors()` answers 503, never 401.
43
+ * `STORE_FAILED`, which `janusErrors()` — given to `use` before it —
44
+ * answers 503, never 401.
32
45
  *
33
46
  * A session renewed in passing is sent again as a cookie, after the route —
34
47
  * only to a request that presented it as one (a `Bearer` client is never
35
48
  * handed a cookie), and never over a session cookie the route set itself.
36
49
  *
37
50
  * ```ts
38
- * app.use(session(accounts, { required: true })).get('/me', ({ user, reply }) => reply.ok(user));
51
+ * app.use(janusErrors(), session(accounts, { required: true }))
52
+ * .get('/me', ({ user, reply }) => reply.ok(user));
39
53
  * ```
40
54
  */
41
55
  export declare function session<A extends Auth<{
42
56
  readonly type: string;
43
57
  }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options: SessionOptions<T> & {
44
58
  readonly required: true;
45
- }): Alxia<{
46
- readonly user: UserOf<A, T>;
47
- readonly session: Session;
48
- readonly auth: RequestAuth;
49
- }, Empty, '', Reply<401, UnauthenticatedBody>>;
59
+ }): SessionMiddleware<Given<UserOf<A, T>, Session>, Reply<401, UnauthenticatedBody | UnauthenticatedProblem>>;
50
60
  export declare function session<A extends Auth<{
51
61
  readonly type: string;
52
62
  }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options?: SessionOptions<T> & {
53
63
  readonly required?: false;
54
- }): Alxia<{
55
- readonly user: UserOf<A, T> | null;
56
- readonly session: Session | null;
57
- readonly auth: RequestAuth;
58
- }, Empty, '', never>;
64
+ }): SessionMiddleware<Given<UserOf<A, T> | null, Session | null>>;
59
65
  export declare function session<A extends Auth<{
60
66
  readonly type: string;
61
- }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options?: SessionOptions<T>): Alxia<{
62
- readonly user: UserOf<A, T> | null;
63
- readonly session: Session | null;
64
- readonly auth: RequestAuth;
65
- }, Empty, '', Reply<401, UnauthenticatedBody>>;
67
+ }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options?: SessionOptions<T>): SessionMiddleware<Given<UserOf<A, T> | null, Session | null>, Reply<401, UnauthenticatedBody | UnauthenticatedProblem>>;
66
68
  export {};
67
69
  //# sourceMappingURL=session.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
package/docs/README.md CHANGED
@@ -10,7 +10,14 @@ a realistic example for each.
10
10
  | --- | --- |
11
11
  | [Sessions](guide/sessions.md) | reading who a request belongs to, requiring a session, narrowing to one user type, placing the sign-in routes before the required ones, knowing where the token is read from and when a renewed cookie is sent, or testing a signed-in route |
12
12
  | [Signing in and out](guide/sign-in-and-out.md) | setting the session cookie with `ctx.auth.send` after a sign-up or a sign-in, handling a second factor, signing out, remembering devices with the device cookie, or doing any of it outside a route |
13
- | [Errors](guide/errors.md) | answering janus's refusals with their status and a safe body, knowing which code is which status, reporting the 5xx, or reading a refusal on the client |
14
- | [Permissions](guide/permissions.md) | guarding routes with a permission on an object, loading it by a path parameter, passing a condition's context, checking for another subject than the session's user, or reading what an earlier plugin added |
13
+ | [Errors](guide/errors.md) | answering janus's refusals with their status and a safe body, knowing which code is which status, reporting the 5xx, or reading a refusal on the wire |
14
+ | [Permissions](guide/permissions.md) | guarding routes with a permission on an object, loading it by a path parameter, passing a condition's context, checking for another subject than the session's user, or reading what an earlier middleware added |
15
15
  | [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a request is anonymous, refused or a 404 when you did not expect it |
16
16
  | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
17
+
18
+ ## Recipes
19
+
20
+ A task that crosses packages, in [the repository's recipes](https://github.com/softistx/alxia/blob/develop/docs/recipes/README.md), each with a complete example:
21
+
22
+ - [Authenticate requests](https://github.com/softistx/alxia/blob/develop/docs/recipes/authentication.md): a bearer token or a cookie session, a role check, the user typed in every handler
23
+ - [Answer errors consistently](https://github.com/softistx/alxia/blob/develop/docs/recipes/errors.md): problem details, `HttpError`, a `try`/`catch` middleware
@@ -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,37 @@ 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
+ ```
52
+
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()`.
48
57
 
49
- It answers only a `JanusError`. Anything else goes on to the app's next
50
- `onError`, or its 500.
58
+ It answers only a `JanusError`. Anything else goes on, thrown, to the
59
+ middlewares before it — a try/catch of your own around `await next()` — and,
60
+ when none answers it, to the route boundary: an `HttpError`'s status, else a 500.
61
+
62
+ An observer that settles `next()` (`logger()`, `secureHeaders()`,
63
+ `telemetry()`, `createI18n()`) reads the response the error would be
64
+ answered with, and lets the error go on: `janusErrors()` answers it
65
+ wherever it stands, `use(janusErrors()).use(i18n).use(session())`
66
+ included. Give the observers to `use` first, then `janusErrors()`, so
67
+ that they see its reply rather than the 500.
68
+
69
+ ```ts
70
+ import { logger } from '@alxia/logger';
71
+
72
+ const app = alxia()
73
+ .use(logger()) // sees the 503 janusErrors() answers
74
+ .use(janusErrors(), session(accounts));
75
+ ```
51
76
 
52
77
  ## Statuses
53
78
 
@@ -95,7 +120,7 @@ bodyOf(new NotFoundError('user')); // { code: 'NOT_FOUND' }
95
120
  `CREDENTIALS_INVALID` is one code for an unknown login, a wrong password and
96
121
  a login throttled for too many attempts: a client cannot tell a login exists
97
122
  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
123
+ passwords in fifteen minutes by default — and the middleware then adds the
99
124
  `Retry-After` header.
100
125
 
101
126
  ## Reporting the 5xx
@@ -105,6 +130,7 @@ const app = alxia().use(
105
130
  janusErrors({
106
131
  report: (error, ctx) => console.error(`${ctx.route}: ${error.code}`, error.cause),
107
132
  }),
133
+ session(accounts),
108
134
  );
109
135
  ```
110
136
 
@@ -122,35 +148,63 @@ or rejects is a process warning, and the 503 is sent all the same:
122
148
 
123
149
  The 4xx are not reported: they are the client's.
124
150
 
125
- ## On the client
151
+ ## On the wire
126
152
 
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
153
+ Every route after the middleware may answer these refusals: declare them in
154
+ your OpenAPI document, and the client you generate from it (with
155
+ `@nxgt/openapi-codegen`, say) reads them typed. A 401 from a route behind
129
156
  `session(accounts, { required: true })` is either the session's
130
- `{ error: 'unauthenticated' }` or janus's `{ code }`; narrow on the key:
157
+ `{ error: 'unauthenticated' }` or janus's `{ code }`; a caller narrows on
158
+ the key:
131
159
 
132
160
  ```ts
133
- import { client } from '@alxia/client';
134
161
  import { alxia } from '@alxia/core';
135
162
  import { janusErrors, session } from '@alxia/janus';
136
163
 
137
164
  const app = alxia()
138
- .use(janusErrors())
139
- .use(session(accounts, { required: true }))
165
+ .use(janusErrors(), session(accounts, { required: true }))
140
166
  .get('/me', ({ user, reply }) => reply(200, { name: user.name }));
141
167
 
142
- const me = await client(app).get('/me');
168
+ const me = await app.request('/me');
169
+ const body = await me.json();
143
170
  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
171
+ if ('error' in body) console.log('sign in first'); // the session's own 401
172
+ else console.log(body.code); // a JanusErrorCode
146
173
  }
147
- if (me.status === 503) console.log(me.data.code); // 'STORE_FAILED' and the like: try again later
174
+ if (me.status === 503) console.log(body.code); // 'STORE_FAILED' and the like: try again later
175
+ ```
176
+
177
+ ### As problems
178
+
179
+ On an app that answers its errors as RFC 9457 problems, every answer of
180
+ this package is one, sent as `application/problem+json`, with the
181
+ `Retry-After` header kept: `janusErrors()`'s carries `bodyOf(error)` as
182
+ its extensions, `code` first among them (`JanusErrorProblem`); a
183
+ required `session()`'s 401 (`UnauthenticatedProblem`) and
184
+ `permission()`'s 401, 404 and 403 (`PermissionRefusedProblem`) carry
185
+ their five members alone. A caller narrows on `code` rather than on
186
+ `error`:
187
+
188
+ ```text
189
+ 503 {"type":"about:blank","title":"Service Unavailable","status":503,"detail":"Service Unavailable","instance":"/me","code":"STORE_FAILED"}
190
+ 401 {"type":"about:blank","title":"Unauthorized","status":401,"detail":"The request has no session","instance":"/me"}
191
+ 403 {"type":"about:blank","title":"Forbidden","status":403,"detail":"The view permission on this record is not granted","instance":"/records/r1"}
148
192
  ```
149
193
 
194
+ Each reads the format of the app serving the request, with
195
+ `@alxia/core`'s `errorFormat(ctx)`
196
+ ([core's errors guide](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/errors.md)).
197
+
150
198
  ## Signatures
151
199
 
152
200
  ```ts
153
- function janusErrors(options?: JanusErrorsOptions): Alxia<Empty, Empty, '', Reply<JanusErrorStatus, JanusErrorBody>>;
201
+ function janusErrors(options?: JanusErrorsOptions): JanusErrors;
202
+
203
+ // a middleware: answers the JanusErrors thrown behind it
204
+ type JanusErrors = Middleware<object, Promise<Next | Reply<JanusErrorStatus, JanusErrorBody | JanusErrorProblem>>>;
205
+
206
+ // under alxia({ errors: 'problem' })
207
+ type JanusErrorProblem = Problem<JanusErrorStatus, JanusErrorBody>;
154
208
 
155
209
  interface JanusErrorsOptions {
156
210
  readonly report?: (error: JanusError, ctx: BaseContext) => unknown;
@@ -170,7 +224,7 @@ function statusOf(code: JanusErrorCode): JanusErrorStatus; // @nxgt/janus's
170
224
  ```
171
225
 
172
226
  `JanusError`, `JanusErrorCode` and `JanusErrorStatus` are `@nxgt/janus`'s;
173
- `Alxia`, `Empty`, `Reply` and `BaseContext` are `@alxia/core`'s.
227
+ `Middleware`, `Next`, `Reply`, `Problem` and `BaseContext` are `@alxia/core`'s.
174
228
 
175
229
  The permission guard's own refusals — `{ error: 'forbidden' }` and the
176
230
  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 |
@@ -93,6 +98,11 @@ Each refusal is in the type of the guarded routes, with the body
93
98
  | `can` answers `false` | `403 {"error":"forbidden"}` |
94
99
  | allowed | the route runs, `object` set |
95
100
 
101
+ On an app with `alxia({ errors: 'problem' })`, each refusal is an RFC 9457
102
+ problem instead, sent as `application/problem+json` — `"title":"Forbidden",
103
+ "detail":"The view permission on this record is not granted"` — typed
104
+ `PermissionRefusedProblem` ([Errors](errors.md#as-problems)).
105
+
96
106
  **A failure throws.** A relation store that cannot answer throws
97
107
  `STORE_FAILED`, never a 403; a check that walks past `maxDepth` throws
98
108
  `PERMISSION_DEPTH`. Both are `JanusError`s: [`janusErrors()`](errors.md)
@@ -206,7 +216,7 @@ request throws a `TypeError` and answers 500
206
216
  `subject` names another — an API key's service account, a user read from a
207
217
  header set by a gateway. It reads the `BaseContext`, so the request, the
208
218
  URL and the path parameters, not the route's validated headers — and, when
209
- its parameter is annotated, what an earlier plugin added
219
+ its parameter is annotated, what an earlier middleware added
210
220
  ([Reading the app's context](#reading-the-apps-context)). `null` is
211
221
  anonymous, a 401:
212
222
 
@@ -222,12 +232,12 @@ permission(access, 'view', 'record', byParam('id', findRecord), {
222
232
  ## Reading the app's context
223
233
 
224
234
  `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,
235
+ path parameters. To read what an earlier middleware added — a tenant, a member,
226
236
  a feature flag — annotate the callback's parameter. `permission()` infers
227
237
  what each one reads from its annotation, while `access`, `permission`,
228
238
  `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.
239
+ [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):
240
+ an app that does not give it before the guard cannot `use` it.
231
241
 
232
242
  ```ts
233
243
  import { alxia, type BaseContext } from '@alxia/core';
@@ -250,20 +260,20 @@ const edit = permission(
250
260
 
251
261
  const app = alxia()
252
262
  .use(janusErrors())
253
- .use(tenancy) // derives tenant and member
263
+ .plugin(tenancy) // derives tenant and member
254
264
  .group('/records/:id', (record) =>
255
265
  record.use(edit).put('/', ({ object, reply }) => reply(200, { title: object.title })),
256
266
  );
257
267
 
258
268
  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
269
+ // error, one message per missing key: Property 'tenant' is missing in type 'BaseContext & Empty' but required in type '{ tenant: Tenant; }'
270
+ // | Property 'member' is missing in type 'BaseContext & Empty' but required in type '{ member: { type: 'user'; id: string } | null; }'
261
271
  ```
262
272
 
263
273
  What the guard requires is the union of what the three annotations read.
264
274
  An annotation may be `BaseContext & { member: Member }` or `{ member: Member }`
265
275
  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`
276
+ `Types of property 'member' are incompatible`
267
277
  — while a narrower one passes. A key `BaseContext` already has, annotated
268
278
  with a type it does not give — `({ pathParams }: { pathParams: string })` —
269
279
  is refused the same way.
@@ -316,13 +326,13 @@ function permission<
316
326
  type: T,
317
327
  load: (ctx: BaseContext & LoadCtx) => Awaitable<O | null>,
318
328
  ...options: OptionsArgs<C, T, P, O, SubjectCtx, CheckCtx>
319
- ): Alxia<
320
- RequiresOf<LoadCtx & SubjectCtx & CheckCtx> & { object: O },
321
- Empty,
322
- '',
323
- Reply<401, PermissionRefusedBody> | Reply<404, PermissionRefusedBody> | Reply<403, PermissionRefusedBody>
324
- > &
325
- Requiring<RequiresOf<LoadCtx & SubjectCtx & CheckCtx>>;
329
+ ): Middleware<
330
+ RequiresOf<LoadCtx & SubjectCtx & CheckCtx>,
331
+ Promise<
332
+ | Reply<401 | 404 | 403, PermissionRefusedBody | PermissionRefusedProblem>
333
+ | Next<{ object: O }>
334
+ >
335
+ >;
326
336
 
327
337
  function byParam<O>(name: string, find: (id: string) => Awaitable<O | null>): (ctx: BaseContext) => Awaitable<O | null>;
328
338
 
@@ -340,10 +350,13 @@ interface PermissionRefusedBody {
340
350
  readonly error: 'unauthenticated' | 'not_found' | 'forbidden';
341
351
  }
342
352
 
353
+ // a refusal under @alxia/core's alxia({ errors: 'problem' })
354
+ type PermissionRefusedProblem = Problem<401 | 403 | 404>;
355
+
343
356
  type Awaitable<V> = V | Promise<V>;
344
357
  ```
345
358
 
346
- `RequiresOf` and `Requiring` are `@alxia/core`'s. `LoadCtx`, `SubjectCtx`
359
+ `RequiresOf`, `Middleware`, `Next`, `Reply` and `Problem` are `@alxia/core`'s. `LoadCtx`, `SubjectCtx`
347
360
  and `CheckCtx` are inferred from the types `load`, `subject` and `ctx`'s
348
361
  parameters are annotated with, `BaseContext` when they are not. `RequiresOf<X>` is what `X` adds to `BaseContext` — `{ tenant: Tenant }`
349
362
  for `BaseContext & { tenant: Tenant }` — and `Empty` when it adds nothing;
@@ -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
 
@@ -301,27 +313,32 @@ test('the session reads the user, and anonymous is a 401', async () => {
301
313
  function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
302
314
  auth: A,
303
315
  options: SessionOptions<T> & { readonly required: true },
304
- ): Alxia<
316
+ ): SessionMiddleware<
305
317
  { readonly user: UserOf<A, T>; readonly session: Session; readonly auth: RequestAuth },
306
- Empty, '', Reply<401, UnauthenticatedBody>
318
+ Reply<401, UnauthenticatedBody | UnauthenticatedProblem>
307
319
  >;
308
320
 
309
321
  function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
310
322
  auth: A,
311
323
  options?: SessionOptions<T> & { readonly required?: false },
312
- ): Alxia<
313
- { readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth },
314
- Empty, '', never
324
+ ): SessionMiddleware<
325
+ { readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth }
315
326
  >;
316
327
 
317
328
  function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
318
329
  auth: A,
319
330
  options?: SessionOptions<T>,
320
- ): Alxia<
331
+ ): SessionMiddleware<
321
332
  { readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth },
322
- Empty, '', Reply<401, UnauthenticatedBody>
333
+ Reply<401, UnauthenticatedBody | UnauthenticatedProblem>
323
334
  >;
324
335
 
336
+ // a middleware that gives Added, or answers Refused
337
+ type SessionMiddleware<Added extends object, Refused = never> = Middleware<Empty, Promise<Refused | Next<Added>>>;
338
+
339
+ // the 401 under @alxia/core's alxia({ errors: 'problem' })
340
+ type UnauthenticatedProblem = Problem<401>;
341
+
325
342
  interface SessionOptions<T extends string> {
326
343
  readonly type?: T;
327
344
  readonly required?: boolean;
@@ -343,8 +360,8 @@ type UserOfAuth<A> = A extends Auth<infer U> ? U : never;
343
360
  `RequestAuth` and `DeviceCookieOptions` are shown in
344
361
  [Signing in and out](sign-in-and-out.md#signatures).
345
362
  `UserOf<A, T>` is `Extract<UserOfAuth<A>, { readonly type: T }>`; it is not
346
- exported. `Alxia`, `Empty` and `Reply` are `@alxia/core`'s; `Session` and
363
+ exported. `Middleware`, `Next`, `Empty`, `Reply` and `Problem` are `@alxia/core`'s; `Session` and
347
364
  `SharedApi` are `@nxgt/janus`'s.
348
365
 
349
- Next: [Signing in and out](sign-in-and-out.md) sets the cookie this plugin
366
+ Next: [Signing in and out](sign-in-and-out.md) sets the cookie this middleware
350
367
  reads.