@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/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
|
+
- [The error is never seen by a `try`/`catch` around `logger()` or `secureHeaders()`](#the-error-is-never-seen-by-a-trycatch-around-logger-or-secureheaders)
|
|
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,7 +249,7 @@ 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
|
|
|
@@ -281,7 +259,7 @@ 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.2.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.4.0",
|
|
45
|
+
"@alxia/i18n": "^0.2.0",
|
|
46
|
+
"@alxia/language": "^0.2.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.4.0",
|
|
52
54
|
"@nxgt/janus": "^0.17.1",
|
|
53
55
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
54
56
|
}
|