@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/README.md +31 -14
- package/dist/errors.d.ts +24 -7
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +79 -43
- package/dist/index.js.map +7 -6
- package/dist/permission.d.ts +9 -6
- package/dist/permission.d.ts.map +1 -1
- package/dist/refused.d.ts +9 -0
- package/dist/refused.d.ts.map +1 -0
- package/dist/session.d.ts +23 -21
- package/dist/session.d.ts.map +1 -1
- package/docs/README.md +9 -2
- package/docs/guide/errors.md +77 -23
- package/docs/guide/permissions.md +31 -18
- package/docs/guide/sessions.md +41 -24
- package/docs/guide/sign-in-and-out.md +13 -13
- package/docs/roadmap.md +12 -2
- package/docs/troubleshooting.md +92 -57
- package/package.json +6 -4
|
@@ -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,17 @@ 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. Their deprecated
|
|
15
|
+
plugin form, `app.plugin(session(…))`, was removed with alxia 0.5.
|
|
16
|
+
- **Refusals as problems.** On an app with `@alxia/core`'s
|
|
17
|
+
`alxia({ errors: 'problem' })`, `janusErrors()`, a required `session()`
|
|
18
|
+
and `permission()` answer RFC 9457 problems — `janusErrors()`'s with its
|
|
19
|
+
`code` as an extension — and keep their bodies otherwise
|
|
20
|
+
([guide](guide/errors.md#as-problems)).
|
|
11
21
|
|
|
12
22
|
## Next
|
|
13
23
|
|
|
@@ -31,7 +41,7 @@ Nothing scheduled yet.
|
|
|
31
41
|
|
|
32
42
|
### 0.1.0
|
|
33
43
|
|
|
34
|
-
- **The user in the context.** `
|
|
44
|
+
- **The user in the context.** `plugin(session(accounts))` hands the routes
|
|
35
45
|
after it `user`, `session` and `auth`, typed by the user schema and
|
|
36
46
|
narrowed by `type`; with `required: true`, an anonymous request is a typed
|
|
37
47
|
401 and `user` is never `null`. A required `session()` behind an open one
|
package/docs/troubleshooting.md
CHANGED
|
@@ -11,7 +11,6 @@ symptom, under [Traps](#traps).
|
|
|
11
11
|
- [`'user' is possibly 'null'`](#user-is-possibly-null)
|
|
12
12
|
- [`Type '"admin"' is not assignable to type '"user"'`](#type-admin-is-not-assignable-to-type-user)
|
|
13
13
|
- [`Type 'SecondFactorRequired' is missing the following properties from type 'SessionOpened<User<…>>': token, session, user`](#type-secondfactorrequired-is-missing-the-following-properties-from-type-sessionopeneduser-token-session-user)
|
|
14
|
-
- [`Property 'error' is missing in type '{ code: JanusErrorCode; … }'`](#property-error-is-missing-in-type--code-januserrorcode--)
|
|
15
14
|
|
|
16
15
|
**Types: permissions**
|
|
17
16
|
|
|
@@ -21,8 +20,8 @@ symptom, under [Traps](#traps).
|
|
|
21
20
|
- [`Type '() => { locked: boolean; }' is not assignable to type 'undefined'`](#type----locked-boolean--is-not-assignable-to-type-undefined)
|
|
22
21
|
- [`Property 'doctorId' is missing in type '{ … }' but required in type '{ readonly doctorId: string | null; }'`](#property-doctorid-is-missing-in-type----but-required-in-type--readonly-doctorid-string--null-)
|
|
23
22
|
- [`Property 'tenant' does not exist on type 'BaseContext'`](#property-tenant-does-not-exist-on-type-basecontext)
|
|
24
|
-
- [`
|
|
25
|
-
- [`
|
|
23
|
+
- [`Property 'tenant' is missing in type 'BaseContext & Empty' but required in type '{ tenant: Tenant; }'`](#property-tenant-is-missing-in-type-basecontext--empty-but-required-in-type--tenant-tenant-)
|
|
24
|
+
- [`Types of property 'tenant' are incompatible`](#types-of-property-tenant-are-incompatible)
|
|
26
25
|
- [`the plugin's load reads its context as any: annotate what it reads, or leave it unannotated`](#the-plugins-load-reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated)
|
|
27
26
|
|
|
28
27
|
**Runtime**
|
|
@@ -31,8 +30,11 @@ symptom, under [Traps](#traps).
|
|
|
31
30
|
- [`TypeError: signIn: a device was given, but janus() has no devices — pass devices: { keys }`](#typeerror-signin-a-device-was-given-but-janus-has-no-devices--pass-devices--keys-)
|
|
32
31
|
- [`Warning: janusErrors(): report failed: …`](#warning-januserrors-report-failed-)
|
|
33
32
|
- [`500 {"error":"internal"}`, with a `JanusError` in the log](#500-errorinternal-with-a-januserror-in-the-log)
|
|
33
|
+
- [`500 {"error":"internal"}` when the identity store is down, and `janusErrors()` is there](#500-errorinternal-when-the-identity-store-is-down-and-januserrors-is-there)
|
|
34
34
|
- [`503 {"code":"STORE_FAILED"}`](#503-codestore_failed)
|
|
35
35
|
- [`401 {"error":"unauthenticated"}` from the sign-in route](#401-errorunauthenticated-from-the-sign-in-route)
|
|
36
|
+
- [`401 {"error":"unauthenticated"}` on a path that does not exist](#401-errorunauthenticated-on-a-path-that-does-not-exist)
|
|
37
|
+
- [`logger()` or `secureHeaders()` never shows the reply of `janusErrors()`](#logger-or-secureheaders-never-shows-the-reply-of-januserrors)
|
|
36
38
|
- [`401 {"code":"CREDENTIALS_INVALID","retryAfter":900}` with the right password](#401-codecredentials_invalidretryafter900-with-the-right-password)
|
|
37
39
|
|
|
38
40
|
**Traps**
|
|
@@ -57,7 +59,7 @@ error TS2339: Property 'user' does not exist on type 'Context<Empty, "/profile",
|
|
|
57
59
|
**Why:** `session()` adds `user` and `session` to the routes declared
|
|
58
60
|
**after** it. The route before it is not authenticated at all.
|
|
59
61
|
|
|
60
|
-
**Fix:** declare the route after the
|
|
62
|
+
**Fix:** declare the route after the middleware:
|
|
61
63
|
|
|
62
64
|
```ts
|
|
63
65
|
alxia()
|
|
@@ -86,7 +88,7 @@ the route where it is and call the unbound `sendSession(ctx, accounts, …)`,
|
|
|
86
88
|
```ts
|
|
87
89
|
alxia()
|
|
88
90
|
.use(session(accounts))
|
|
89
|
-
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
91
|
+
.post('/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
|
|
90
92
|
const signedIn = await accounts.signIn(body);
|
|
91
93
|
return reply.ok({ id: auth.send(signedIn).id });
|
|
92
94
|
});
|
|
@@ -106,7 +108,7 @@ only at run time, an anonymous request may reach the route with
|
|
|
106
108
|
`user: null`.
|
|
107
109
|
|
|
108
110
|
**Fix:** answer the anonymous case, or require the session with a literal
|
|
109
|
-
`true`, so the
|
|
111
|
+
`true`, so the middleware answers it with a 401 and `user` is never `null`:
|
|
110
112
|
|
|
111
113
|
```ts
|
|
112
114
|
alxia().use(session(accounts, { required: true })).get('/me', ({ user, reply }) => reply(200, user.email));
|
|
@@ -150,30 +152,6 @@ if (result.status === 'secondFactor') return reply.ok({ challenge: result.challe
|
|
|
150
152
|
return reply.ok({ id: auth.send(result).id });
|
|
151
153
|
```
|
|
152
154
|
|
|
153
|
-
### `Property 'error' is missing in type '{ code: JanusErrorCode; … }'`
|
|
154
|
-
|
|
155
|
-
**When:** a client reads a 401 from a route behind
|
|
156
|
-
`session(accounts, { required: true })` and `janusErrors()` as the session's
|
|
157
|
-
body only.
|
|
158
|
-
|
|
159
|
-
```text
|
|
160
|
-
error TS2322: Type '{ error: "unauthenticated"; } | { code: JanusErrorCode; issues?: …; minLength?: number; attemptsLeft?: number; retryAfter?: number; }' is not assignable to type '{ error: "unauthenticated"; }'.
|
|
161
|
-
Property 'error' is missing in type '{ code: JanusErrorCode; … }' but required in type '{ error: "unauthenticated"; }'.
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
**Why:** two 401s are possible there: the session's own
|
|
165
|
-
`{ error: 'unauthenticated' }`, and janus's `{ code }` — a refused code
|
|
166
|
-
or password — through `janusErrors()`.
|
|
167
|
-
|
|
168
|
-
**Fix:** narrow on the key ([Errors](guide/errors.md#on-the-client)):
|
|
169
|
-
|
|
170
|
-
```ts
|
|
171
|
-
if (me.status === 401) {
|
|
172
|
-
if ('error' in me.data) console.log('sign in first');
|
|
173
|
-
else console.log(me.data.code);
|
|
174
|
-
}
|
|
175
|
-
```
|
|
176
|
-
|
|
177
155
|
## Types: permissions
|
|
178
156
|
|
|
179
157
|
### `Property 'object' does not exist on type 'Context<…>'`
|
|
@@ -271,17 +249,17 @@ byParam('id', (id) => db.records.findOne({ id }, { projection: { title: 1, docto
|
|
|
271
249
|
error TS2339: Property 'tenant' does not exist on type 'BaseContext'.
|
|
272
250
|
```
|
|
273
251
|
|
|
274
|
-
**When:** `load`, `subject` or `ctx` reads something a `derive` or a
|
|
252
|
+
**When:** `load`, `subject` or `ctx` reads something a `derive` or a middleware
|
|
275
253
|
before the guard added — a tenant, a member — and its parameter is not
|
|
276
254
|
annotated: `load: (ctx) => ctx.tenant.records.get(…)`.
|
|
277
255
|
|
|
278
256
|
**Why:** an unannotated callback is typed with the request's `BaseContext`
|
|
279
|
-
— the request, the URL, the path parameters — not with what other
|
|
257
|
+
— the request, the URL, the path parameters — not with what other middlewares
|
|
280
258
|
added. `permission()` is built before it is used, so it cannot see the app
|
|
281
259
|
it will be used on.
|
|
282
260
|
|
|
283
261
|
**Fix:** annotate the parameter with what it reads. The guard infers it,
|
|
284
|
-
and the app that
|
|
262
|
+
and the app that mounts it must then give it, before the guard:
|
|
285
263
|
|
|
286
264
|
```ts
|
|
287
265
|
import type { BaseContext } from '@alxia/core';
|
|
@@ -290,51 +268,55 @@ const byTenant = permission(access, 'view', 'record', ({ tenant, pathParams }: B
|
|
|
290
268
|
tenant.records.get(pathParams['id'] ?? '') ?? null,
|
|
291
269
|
);
|
|
292
270
|
|
|
293
|
-
app.
|
|
271
|
+
app.plugin(tenancy).use(byTenant); // tenancy derives tenant
|
|
294
272
|
```
|
|
295
273
|
|
|
296
274
|
See [Reading the app's context](guide/permissions.md#reading-the-apps-context).
|
|
297
275
|
|
|
298
|
-
### `
|
|
276
|
+
### `Property 'tenant' is missing in type 'BaseContext & Empty' but required in type '{ tenant: Tenant; }'`
|
|
299
277
|
|
|
300
278
|
```text
|
|
301
279
|
error TS2769: No overload matches this call.
|
|
302
280
|
…
|
|
303
|
-
|
|
304
|
-
|
|
281
|
+
Type 'BaseContext & Empty' is not assignable to type 'MiddlewareContext<{ tenant: Tenant; }>'.
|
|
282
|
+
Property 'tenant' is missing in type 'BaseContext & Empty' but required in type '{ tenant: Tenant; }'.
|
|
305
283
|
```
|
|
306
284
|
|
|
307
285
|
**When:** a callback of the guard is annotated to read `tenant`, and the
|
|
308
286
|
guard is used on an app — or in a group — whose context has no `tenant` at
|
|
309
287
|
that point: `alxia().use(byTenant)`, or `use(byTenant)` before
|
|
310
|
-
`
|
|
288
|
+
`plugin(tenancy)`.
|
|
311
289
|
|
|
312
290
|
**Why:** an annotated `load`, `subject` or `ctx` makes the guard require
|
|
313
|
-
what it reads, and `use` checks the app's context against it, so the
|
|
291
|
+
what it reads, and `app.use` checks the app's context against it, so the
|
|
314
292
|
callback never runs without it.
|
|
315
293
|
|
|
316
|
-
**Fix:**
|
|
294
|
+
**Fix:** mount what adds `tenant` first:
|
|
317
295
|
|
|
318
296
|
```ts
|
|
319
|
-
app.
|
|
297
|
+
app.plugin(tenancy).use(byTenant);
|
|
320
298
|
```
|
|
321
299
|
|
|
322
300
|
More on this message in
|
|
323
|
-
[`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-does-not-give-
|
|
301
|
+
[`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-does-not-give-add-the-plugin-or-middleware-that-gives-it-first).
|
|
324
302
|
|
|
325
|
-
### `
|
|
303
|
+
### `Types of property 'tenant' are incompatible`
|
|
326
304
|
|
|
327
305
|
```text
|
|
328
306
|
error TS2769: No overload matches this call.
|
|
329
307
|
…
|
|
330
|
-
Type '{ tenant: Tenant; }' is not assignable to type '
|
|
308
|
+
Type 'BaseContext & Empty & { tenant: Tenant | null; }' is not assignable to type 'MiddlewareContext<{ tenant: Tenant; }>'.
|
|
309
|
+
Type 'BaseContext & Empty & { tenant: Tenant | null; }' is not assignable to type '{ tenant: Tenant; }'.
|
|
310
|
+
Types of property 'tenant' are incompatible.
|
|
311
|
+
Type 'Tenant | null' is not assignable to type 'Tenant'.
|
|
312
|
+
Type 'null' is not assignable to type 'Tenant'.
|
|
331
313
|
```
|
|
332
314
|
|
|
333
315
|
**When:** the app gives a `tenant`, but of a type that does not fit the one
|
|
334
316
|
the callback's parameter is annotated with: a `Tenant | null` where `load`
|
|
335
317
|
reads `Tenant`, or a tenant of another shape.
|
|
336
318
|
|
|
337
|
-
**Why:** `use` checks each key the guard reads against the app's context;
|
|
319
|
+
**Why:** `app.use` checks each key the guard reads against the app's context;
|
|
338
320
|
a narrower type passes, a wider or different one does not.
|
|
339
321
|
|
|
340
322
|
**Fix:** annotate the callback with the type the app gives, and handle it
|
|
@@ -354,8 +336,8 @@ More on this message in
|
|
|
354
336
|
```text
|
|
355
337
|
error TS2769: No overload matches this call.
|
|
356
338
|
…
|
|
357
|
-
|
|
358
|
-
|
|
339
|
+
Type 'BaseContext & Empty' is not assignable to type 'MiddlewareContext<{ readonly '~any': "the plugin's load reads its context as any: annotate what it reads, or leave it unannotated"; }>'.
|
|
340
|
+
Property ''~any'' is missing in type 'BaseContext & Empty' but required in type '{ readonly '~any': "the plugin's load reads its context as any: annotate what it reads, or leave it unannotated"; }'.
|
|
359
341
|
```
|
|
360
342
|
|
|
361
343
|
The same message names `subject` or `ctx` when that callback is the one at
|
|
@@ -370,7 +352,7 @@ reads, so the guard would require nothing, and an app without a `tenant`
|
|
|
370
352
|
would be accepted, and throw on every guarded request. The guard is
|
|
371
353
|
refused instead.
|
|
372
354
|
|
|
373
|
-
**Fix:** annotate what the callback reads, and
|
|
355
|
+
**Fix:** annotate what the callback reads, and mount what adds it
|
|
374
356
|
first:
|
|
375
357
|
|
|
376
358
|
```ts
|
|
@@ -378,7 +360,7 @@ const byTenant = permission(access, 'view', 'record', ({ tenant, pathParams }: B
|
|
|
378
360
|
tenant.records.get(pathParams['id'] ?? '') ?? null,
|
|
379
361
|
);
|
|
380
362
|
|
|
381
|
-
app.
|
|
363
|
+
app.plugin(tenancy).use(byTenant);
|
|
382
364
|
```
|
|
383
365
|
|
|
384
366
|
Or leave it unannotated when it reads only the request, as `byParam`'s
|
|
@@ -450,21 +432,35 @@ one failing.
|
|
|
450
432
|
**When:** a `janus()` refusal — `NotFoundError`, a refused password —
|
|
451
433
|
thrown by a route is answered 500 instead of its own status.
|
|
452
434
|
|
|
453
|
-
**Why:**
|
|
454
|
-
|
|
435
|
+
**Why:** `janusErrors()` is a try/catch middleware: it answers what is thrown
|
|
436
|
+
**behind** it. The error was thrown by something declared before it, or by
|
|
437
|
+
`session()` or a route while `janusErrors()` was given to `use` after them.
|
|
455
438
|
|
|
456
|
-
**Fix:**
|
|
439
|
+
**Fix:** give `janusErrors()` to `use` first, before `session()`:
|
|
457
440
|
|
|
458
441
|
```ts
|
|
459
442
|
const app = alxia()
|
|
460
|
-
.use(janusErrors())
|
|
461
|
-
.
|
|
462
|
-
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
443
|
+
.use(janusErrors(), session(accounts))
|
|
444
|
+
.post('/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
|
|
463
445
|
const signedIn = await accounts.signIn(body); // a refusal is now its 401
|
|
464
446
|
return reply.ok({ id: auth.send(signedIn).id });
|
|
465
447
|
});
|
|
466
448
|
```
|
|
467
449
|
|
|
450
|
+
### `500 {"error":"internal"}` when the identity store is down, and `janusErrors()` is there
|
|
451
|
+
|
|
452
|
+
**When:** the store behind `session()` is unreachable, and the answer is a 500
|
|
453
|
+
instead of the 503 below, though the app uses `janusErrors()`.
|
|
454
|
+
|
|
455
|
+
**Why:** `janusErrors()` was given to `use` **after** `session()`. The session
|
|
456
|
+
is read before it, so the `STORE_FAILED` it throws is not behind it.
|
|
457
|
+
|
|
458
|
+
**Fix:** put it before:
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
alxia().use(janusErrors(), session(accounts)); // not use(session(accounts), janusErrors())
|
|
462
|
+
```
|
|
463
|
+
|
|
468
464
|
### `503 {"code":"STORE_FAILED"}`
|
|
469
465
|
|
|
470
466
|
**When:** every authenticated request, while the identity or relation store
|
|
@@ -473,7 +469,7 @@ is unreachable.
|
|
|
473
469
|
**Why:** intended. A store that cannot answer makes `authenticate` and
|
|
474
470
|
`can` throw `STORE_FAILED`, and `janusErrors()` answers it 503 — never a
|
|
475
471
|
401, which would send every signed-in user to the sign-in page, nor a 403.
|
|
476
|
-
Routes declared before `session()` still answer.
|
|
472
|
+
Routes declared before `session()` still answer. This holds when `janusErrors()` is before `session()`.
|
|
477
473
|
|
|
478
474
|
**Fix:** the store. Pass `report` to `janusErrors()` to hear about it:
|
|
479
475
|
|
|
@@ -498,7 +494,7 @@ the session for the routes after it, or in a `group`
|
|
|
498
494
|
alxia()
|
|
499
495
|
.use(janusErrors())
|
|
500
496
|
.use(session(accounts)) // the sign-in routes
|
|
501
|
-
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
497
|
+
.post('/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
|
|
502
498
|
const signedIn = await accounts.signIn(body);
|
|
503
499
|
return reply.ok({ id: auth.send(signedIn).id });
|
|
504
500
|
})
|
|
@@ -506,6 +502,45 @@ alxia()
|
|
|
506
502
|
.get('/me', ({ user, reply }) => reply.ok({ name: user.name }));
|
|
507
503
|
```
|
|
508
504
|
|
|
505
|
+
### `401 {"error":"unauthenticated"}` on a path that does not exist
|
|
506
|
+
|
|
507
|
+
**When:** an anonymous request to a missing path is answered 401, where it
|
|
508
|
+
used to be a 404.
|
|
509
|
+
|
|
510
|
+
**Why:** a required `session()` given to the app's `use` runs on every
|
|
511
|
+
request, a request no route matches included, and answers before the 404.
|
|
512
|
+
The same holds for `permission()` on the app.
|
|
513
|
+
|
|
514
|
+
**Fix:** scope the guard with a `group`, or a path, so it guards only some routes:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
alxia()
|
|
518
|
+
.use(janusErrors(), session(accounts)) // open: runs on every request
|
|
519
|
+
.group('/account', (account) =>
|
|
520
|
+
account
|
|
521
|
+
.use(session(accounts, { required: true })) // required: only /account/*
|
|
522
|
+
.get('/me', ({ user, reply }) => reply.ok({ name: user.name })),
|
|
523
|
+
);
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### `logger()` or `secureHeaders()` never shows the reply of `janusErrors()`
|
|
527
|
+
|
|
528
|
+
**When:** `janusErrors()` is declared before `logger()`, `secureHeaders()`
|
|
529
|
+
or another observer: the janus error is answered as janus says, but the
|
|
530
|
+
log line shows a 500, or the response lacks the security headers.
|
|
531
|
+
|
|
532
|
+
**Why:** an observer settles `next()`: it reads the response the error
|
|
533
|
+
would be answered with — a 500 — and the error goes on to `janusErrors()`
|
|
534
|
+
outside it, which answers it. The observer inside saw the 500, not the
|
|
535
|
+
reply.
|
|
536
|
+
|
|
537
|
+
**Fix:** observers first, then the error-handling middleware, so that
|
|
538
|
+
they see its reply:
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
alxia().use(logger()).use(janusErrors(), session(accounts));
|
|
542
|
+
```
|
|
543
|
+
|
|
509
544
|
### `401 {"code":"CREDENTIALS_INVALID","retryAfter":900}` with the right password
|
|
510
545
|
|
|
511
546
|
**When:** signing in after many failed attempts at the same login; the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/janus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Identities and sessions for alxia on @nxgt/janus: the user typed in the context, the session cookie kept and renewed, a typed 401",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,14 +41,16 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/
|
|
45
|
-
"@alxia/
|
|
44
|
+
"@alxia/core": "^0.5.0",
|
|
45
|
+
"@alxia/i18n": "^0.3.0",
|
|
46
|
+
"@alxia/language": "^0.3.0",
|
|
47
|
+
"@nxgt/i18n": "^2.0.0",
|
|
46
48
|
"@nxgt/janus": "^0.17.1",
|
|
47
49
|
"@types/bun": "^1.4.2",
|
|
48
50
|
"zod": "^4.6.5"
|
|
49
51
|
},
|
|
50
52
|
"peerDependencies": {
|
|
51
|
-
"@alxia/core": "^0.
|
|
53
|
+
"@alxia/core": "^0.5.0",
|
|
52
54
|
"@nxgt/janus": "^0.17.1",
|
|
53
55
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
54
56
|
}
|