@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.
@@ -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
- - [`the plugin reads "tenant", which this app's context does not give: use the plugin that adds it first`](#the-plugin-reads-tenant-which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first)
25
- - [`the plugin reads "tenant", which this app's context gives with another type`](#the-plugin-reads-tenant-which-this-apps-context-gives-with-another-type)
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 plugin:
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 plugin answers it with a 401 and `user` is never `null`:
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 plugin
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 uses it must then give it, before the guard:
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.use(tenancy).use(byTenant); // tenancy derives tenant
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
- ### `the plugin reads "tenant", which this app's context does not give: use the plugin that adds it first`
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
- Types of property ''~requires'' are incompatible.
304
- Type '{ tenant: Tenant; }' is not assignable to type '"the plugin reads \"tenant\", which this app's context does not give: use the plugin that adds it first"'.
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
- `use(tenancy)`.
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:** use the plugin that adds `tenant` first:
294
+ **Fix:** mount what adds `tenant` first:
317
295
 
318
296
  ```ts
319
- app.use(tenancy).use(byTenant);
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-use-the-plugin-that-adds-it-first).
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
- ### `the plugin reads "tenant", which this app's context gives with another type`
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 '"the plugin reads \"tenant\", which this app's context gives with another 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
- Types of property ''~requires'' are incompatible.
358
- Type '{ readonly '~any': "the plugin's load reads its context as any: annotate what it reads, or leave it unannotated"; }' is not assignable to type '"the plugin's load reads its context as any: annotate what it reads, or leave it unannotated"'.
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 use the plugin that adds it
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.use(tenancy).use(byTenant);
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:** the route is declared before `use(janusErrors())`, which answers
454
- only the routes after it.
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:** use `janusErrors()` first:
439
+ **Fix:** give `janusErrors()` to `use` first, before `session()`:
457
440
 
458
441
  ```ts
459
442
  const app = alxia()
460
- .use(janusErrors())
461
- .use(session(accounts))
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.1.1",
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/client": "^0.2.0",
45
- "@alxia/core": "^0.2.0",
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.2.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
  }