@alxia/janus 0.1.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.
@@ -0,0 +1,68 @@
1
+ # Roadmap
2
+
3
+ What `@alxia/janus` gives an app, and what is coming. This page is a
4
+ direction, not a commitment: the version something shipped in is the only
5
+ number on it. Every release, with each change it made, is in
6
+ [`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/janus/CHANGELOG.md).
7
+
8
+ ## Now
9
+
10
+ Nothing scheduled yet.
11
+
12
+ ## Next
13
+
14
+ Nothing scheduled yet.
15
+
16
+ ## Later
17
+
18
+ Nothing scheduled yet.
19
+
20
+ ## Not planned
21
+
22
+ - **A runtime dependency.** `@alxia/janus` declares no dependency, only
23
+ peers: it is built on `@alxia/core`'s public API, `@nxgt/janus`, and
24
+ Bun's own cookie parser.
25
+ - **Identities of its own.** Users, passwords, sessions and permissions are
26
+ `@nxgt/janus`'s, with your database behind its stores: this package only
27
+ wires them into an app — the context, the cookies, the answers — so a
28
+ flow `@nxgt/janus` adds is usable here without a release of this one.
29
+
30
+ ## Shipped
31
+
32
+ ### 0.1.0
33
+
34
+ - **The user in the context.** `use(session(accounts))` hands the routes
35
+ after it `user`, `session` and `auth`, typed by the user schema and
36
+ narrowed by `type`; with `required: true`, an anonymous request is a typed
37
+ 401 and `user` is never `null`. A required `session()` behind an open one
38
+ shares its lookup.
39
+ - **Signing in and out on the context.** `ctx.auth.send(signedIn)` sets the
40
+ session cookie — and the device cookie, with a `deviceToken` — and answers
41
+ the user; `ctx.auth.signOut()` revokes the session and clears it;
42
+ `ctx.auth.device` reads the device cookie, named by
43
+ `session(accounts, { device })`. `sendSession`, `signOut` and `deviceOf`
44
+ stay, unbound, for code outside a route.
45
+ - **The session cookie kept and renewed.** The token stays out of the body;
46
+ a session renewed in passing is sent again, only to a client that
47
+ presented it as a cookie, and never over one the route set itself.
48
+ - **Devices remembered.** `ctx.auth.device` and `ctx.auth.send` read and
49
+ write the device cookie under the name `@nxgt/janus-hono` uses, so a
50
+ device one remembers the other does too.
51
+ - **Janus's refusals answered.** `janusErrors()` answers every `JanusError`
52
+ with its status and a body holding only what a client can act on, typed
53
+ on the routes after it, with `Retry-After` for a throttled sign-in; an
54
+ outage is a 503, never a 401.
55
+ - **A permission guard.** `permission(access, permission, type, load)`
56
+ loads the object once, checks it with `@nxgt/janus/permissions`, and
57
+ hands it to the routes as `object`; anonymous, not found and denied are
58
+ a typed 401, 404 and 403, and a condition's context is required by the
59
+ types exactly when the permission has one.
60
+ - **A guard that reads what an earlier plugin added.** Annotate `load`,
61
+ `subject` or `ctx`'s parameter —
62
+ `({ tenant }: BaseContext & { tenant: Tenant }) => …` — and it reads a
63
+ tenant, a member or a flag an earlier plugin derived; an app that does
64
+ not give it cannot use the guard. Unannotated, they read the request
65
+ alone and the guard requires nothing.
66
+ - **No silent `any`.** A `load`, `subject` or `ctx` annotated `any` would
67
+ require nothing and turn the check off; the guard is refused on every
68
+ app instead, with a message naming the callback.
@@ -0,0 +1,600 @@
1
+ # Troubleshooting
2
+
3
+ Each entry is headed by the text you see: a TypeScript error, an exception
4
+ in the log, or a response. A trap that prints nothing is headed by its
5
+ symptom, under [Traps](#traps).
6
+
7
+ **Types: sessions and cookies**
8
+
9
+ - [`Property 'user' does not exist on type 'Context<…>'`](#property-user-does-not-exist-on-type-context)
10
+ - [`Property 'auth' does not exist on type 'Context<…>'`](#property-auth-does-not-exist-on-type-context)
11
+ - [`'user' is possibly 'null'`](#user-is-possibly-null)
12
+ - [`Type '"admin"' is not assignable to type '"user"'`](#type-admin-is-not-assignable-to-type-user)
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
+
16
+ **Types: permissions**
17
+
18
+ - [`Property 'object' does not exist on type 'Context<…>'`](#property-object-does-not-exist-on-type-context)
19
+ - [`Argument of type '"delete"' is not assignable to parameter of type 'CheckableOf<…>'`](#argument-of-type-delete-is-not-assignable-to-parameter-of-type-checkableof)
20
+ - [`Expected 5 arguments, but got 4.`](#expected-5-arguments-but-got-4)
21
+ - [`Type '() => { locked: boolean; }' is not assignable to type 'undefined'`](#type----locked-boolean--is-not-assignable-to-type-undefined)
22
+ - [`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
+ - [`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)
26
+ - [`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
+
28
+ **Runtime**
29
+
30
+ - [`TypeError: permission(): no user in the context — use session(auth) before it, or pass { subject }`](#typeerror-permission-no-user-in-the-context--use-sessionauth-before-it-or-pass--subject-)
31
+ - [`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
+ - [`Warning: janusErrors(): report failed: …`](#warning-januserrors-report-failed-)
33
+ - [`500 {"error":"internal"}`, with a `JanusError` in the log](#500-errorinternal-with-a-januserror-in-the-log)
34
+ - [`503 {"code":"STORE_FAILED"}`](#503-codestore_failed)
35
+ - [`401 {"error":"unauthenticated"}` from the sign-in route](#401-errorunauthenticated-from-the-sign-in-route)
36
+ - [`401 {"code":"CREDENTIALS_INVALID","retryAfter":900}` with the right password](#401-codecredentials_invalidretryafter900-with-the-right-password)
37
+
38
+ **Traps**
39
+
40
+ - [Signed in, and the next request is anonymous](#signed-in-and-the-next-request-is-anonymous)
41
+ - [A client with a token never gets a renewed cookie](#a-client-with-a-token-never-gets-a-renewed-cookie)
42
+ - [A user of another type gets a 401](#a-user-of-another-type-gets-a-401)
43
+ - [Every guarded route answers 404](#every-guarded-route-answers-404)
44
+ - [Routes that have nothing to do with the object answer 401 or 404](#routes-that-have-nothing-to-do-with-the-object-answer-401-or-404)
45
+
46
+ ## Types: sessions and cookies
47
+
48
+ ### `Property 'user' does not exist on type 'Context<…>'`
49
+
50
+ **When:** a route reads `user` or `session`, and is declared before
51
+ `use(session(accounts))`.
52
+
53
+ ```text
54
+ error TS2339: Property 'user' does not exist on type 'Context<Empty, "/profile", Empty>'.
55
+ ```
56
+
57
+ **Why:** `session()` adds `user` and `session` to the routes declared
58
+ **after** it. The route before it is not authenticated at all.
59
+
60
+ **Fix:** declare the route after the plugin:
61
+
62
+ ```ts
63
+ alxia()
64
+ .use(session(accounts, { required: true }))
65
+ .get('/profile', ({ user, reply }) => reply(200, { name: user.name }));
66
+ ```
67
+
68
+ ### `Property 'auth' does not exist on type 'Context<…>'`
69
+
70
+ **When:** a sign-in or sign-out route calls `ctx.auth.send`,
71
+ `ctx.auth.signOut` or reads `ctx.auth.device`, and is declared before
72
+ `use(session(accounts))`.
73
+
74
+ ```text
75
+ error TS2339: Property 'auth' does not exist on type 'Context<Empty, "/signin", Empty>'.
76
+ ```
77
+
78
+ **Why:** `ctx.auth` is added by `session()`, to the routes declared
79
+ **after** it, like `user`.
80
+
81
+ **Fix:** use a `session()` that is not required before the route — or keep
82
+ the route where it is and call the unbound `sendSession(ctx, accounts, …)`,
83
+ `signOut(ctx, accounts)` and `deviceOf(ctx)`
84
+ ([Signing in and out](guide/sign-in-and-out.md#outside-a-route-sendsession-signout-deviceof)):
85
+
86
+ ```ts
87
+ alxia()
88
+ .use(session(accounts))
89
+ .post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
90
+ const signedIn = await accounts.signIn(body);
91
+ return reply.ok({ id: auth.send(signedIn).id });
92
+ });
93
+ ```
94
+
95
+ ### `'user' is possibly 'null'`
96
+
97
+ **When:** reading a field of `user` behind `session(accounts)` without
98
+ `required: true`.
99
+
100
+ ```text
101
+ error TS18047: 'user' is possibly 'null'.
102
+ ```
103
+
104
+ **Why:** without `required`, or with `required` given a `boolean` known
105
+ only at run time, an anonymous request may reach the route with
106
+ `user: null`.
107
+
108
+ **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`:
110
+
111
+ ```ts
112
+ alxia().use(session(accounts, { required: true })).get('/me', ({ user, reply }) => reply(200, user.email));
113
+ ```
114
+
115
+ ### `Type '"admin"' is not assignable to type '"user"'`
116
+
117
+ **When:** `session(accounts, { type })` names a type `janus()` does not have.
118
+ With a single `user` schema, the only type is `'user'`.
119
+
120
+ ```text
121
+ error TS2769: No overload matches this call.
122
+ Overload 1 of 3, '(auth: Janus<…>, options: SessionOptions<…> & { …; }): Al…', gave the following error.
123
+ Type '"admin"' is not assignable to type '"user"'.
124
+ ```
125
+
126
+ **Why:** `type` narrows `user` to one of `janus()`'s user types; the types
127
+ are the keys of `janus({ users })`.
128
+
129
+ **Fix:** use one of those keys, or declare the type in `janus({ users: { … } })`.
130
+
131
+ ### `Type 'SecondFactorRequired' is missing the following properties from type 'SessionOpened<User<…>>': token, session, user`
132
+
133
+ **When:** calling `ctx.auth.send` or `sendSession` with what `signIn`
134
+ answered, in a `janus()` given a `secondFactor`.
135
+
136
+ ```text
137
+ error TS2345: Argument of type 'SignInResult<User<"user", { email: string; }>>' is not assignable to parameter of type 'SessionOpened<User<"user", { email: string; }>>'.
138
+ Type 'SecondFactorRequired' is missing the following properties from type 'SessionOpened<User<"user", { email: string; }>>': token, session, user
139
+ ```
140
+
141
+ **Why:** with a second factor, `signIn` answers either a session or a
142
+ challenge, and a challenge has no session to put in a cookie.
143
+
144
+ **Fix:** switch on `status` first
145
+ ([Signing in and out](guide/sign-in-and-out.md#a-second-factor)):
146
+
147
+ ```ts
148
+ const result = await accounts.signIn(body);
149
+ if (result.status === 'secondFactor') return reply.ok({ challenge: result.challenge });
150
+ return reply.ok({ id: auth.send(result).id });
151
+ ```
152
+
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
+ ## Types: permissions
178
+
179
+ ### `Property 'object' does not exist on type 'Context<…>'`
180
+
181
+ **When:** a route reads `object` and is not behind `permission()`.
182
+
183
+ ```text
184
+ error TS2339: Property 'object' does not exist on type 'Context<Empty & { readonly user: …; readonly session: Session; }, "/records/:id", Empty>'.
185
+ ```
186
+
187
+ **Why:** the guard adds `object` to the routes declared after it, in the
188
+ app or group it is used in.
189
+
190
+ **Fix:** declare the route in the guard's group, after it:
191
+
192
+ ```ts
193
+ app.group('/records/:id', (record) =>
194
+ record
195
+ .use(permission(access, 'view', 'record', byParam('id', findRecord)))
196
+ .get('/', ({ object, reply }) => reply(200, object)),
197
+ );
198
+ ```
199
+
200
+ ### `Argument of type '"delete"' is not assignable to parameter of type 'CheckableOf<…>'`
201
+
202
+ **When:** `permission()` names a permission the model does not define for
203
+ that type.
204
+
205
+ ```text
206
+ error TS2345: Argument of type '"delete"' is not assignable to parameter of type 'CheckableOf<{ readonly subjects: readonly "user"[]; readonly types: { readonly record: { … }; }; }, "record">'.
207
+ ```
208
+
209
+ **Why:** the permission is checked against the model's `permits` for
210
+ `type`.
211
+
212
+ **Fix:** add it to the model's `permits`, or use one that is there.
213
+
214
+ ### `Expected 5 arguments, but got 4.`
215
+
216
+ **When:** `permission()` names a permission whose rule has a `when(…)`
217
+ condition, and is given no options.
218
+
219
+ ```text
220
+ error TS2554: Expected 5 arguments, but got 4.
221
+ ```
222
+
223
+ **Why:** a condition needs a context to decide, and only the app can build
224
+ it from the request and the object.
225
+
226
+ **Fix:** pass `ctx` ([Permissions](guide/permissions.md#a-condition-optionsctx)):
227
+
228
+ ```ts
229
+ permission(access, 'edit', 'record', byParam('id', findRecord), {
230
+ ctx: (_ctx, object) => ({ locked: object.locked }),
231
+ });
232
+ ```
233
+
234
+ ### `Type '() => { locked: boolean; }' is not assignable to type 'undefined'`
235
+
236
+ **When:** passing `ctx` to `permission()` for a permission with no
237
+ condition.
238
+
239
+ ```text
240
+ error TS2322: Type '() => { locked: boolean; }' is not assignable to type 'undefined'.
241
+ ```
242
+
243
+ **Why:** `ctx` would never be read: the types refuse it rather than let it
244
+ look like it matters.
245
+
246
+ **Fix:** drop `ctx`, or check the permission that has the condition.
247
+
248
+ ### `Property 'doctorId' is missing in type '{ … }' but required in type '{ readonly doctorId: string | null; }'`
249
+
250
+ **When:** the model has a `fromField('doctorId', …)` relation on the type,
251
+ and `load` answers an object without that field.
252
+
253
+ ```text
254
+ error TS2345: Argument of type '(ctx: BaseContext) => Awaitable<{ id: string; title: string; } | null>' is not assignable to parameter of type '(ctx: BaseContext) => Awaitable<ObjectData<…, "record"> | null>'.
255
+ …
256
+ Property 'doctorId' is missing in type '{ id: string; title: string; }' but required in type '{ readonly doctorId: string | null; }'.
257
+ ```
258
+
259
+ **Why:** `can` reads that field from the loaded object to find who is
260
+ related through it, so the types require it on what `load` answers.
261
+
262
+ **Fix:** load the field, as a `string` or `null`:
263
+
264
+ ```ts
265
+ byParam('id', (id) => db.records.findOne({ id }, { projection: { title: 1, doctorId: 1 } }));
266
+ ```
267
+
268
+ ### `Property 'tenant' does not exist on type 'BaseContext'`
269
+
270
+ ```text
271
+ error TS2339: Property 'tenant' does not exist on type 'BaseContext'.
272
+ ```
273
+
274
+ **When:** `load`, `subject` or `ctx` reads something a `derive` or a plugin
275
+ before the guard added — a tenant, a member — and its parameter is not
276
+ annotated: `load: (ctx) => ctx.tenant.records.get(…)`.
277
+
278
+ **Why:** an unannotated callback is typed with the request's `BaseContext`
279
+ — the request, the URL, the path parameters — not with what other hooks
280
+ added. `permission()` is built before it is used, so it cannot see the app
281
+ it will be used on.
282
+
283
+ **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:
285
+
286
+ ```ts
287
+ import type { BaseContext } from '@alxia/core';
288
+
289
+ const byTenant = permission(access, 'view', 'record', ({ tenant, pathParams }: BaseContext & { tenant: Tenant }) =>
290
+ tenant.records.get(pathParams['id'] ?? '') ?? null,
291
+ );
292
+
293
+ app.use(tenancy).use(byTenant); // tenancy derives tenant
294
+ ```
295
+
296
+ See [Reading the app's context](guide/permissions.md#reading-the-apps-context).
297
+
298
+ ### `the plugin reads "tenant", which this app's context does not give: use the plugin that adds it first`
299
+
300
+ ```text
301
+ error TS2769: No overload matches this call.
302
+ …
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"'.
305
+ ```
306
+
307
+ **When:** a callback of the guard is annotated to read `tenant`, and the
308
+ guard is used on an app — or in a group — whose context has no `tenant` at
309
+ that point: `alxia().use(byTenant)`, or `use(byTenant)` before
310
+ `use(tenancy)`.
311
+
312
+ **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
314
+ callback never runs without it.
315
+
316
+ **Fix:** use the plugin that adds `tenant` first:
317
+
318
+ ```ts
319
+ app.use(tenancy).use(byTenant);
320
+ ```
321
+
322
+ 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).
324
+
325
+ ### `the plugin reads "tenant", which this app's context gives with another type`
326
+
327
+ ```text
328
+ error TS2769: No overload matches this call.
329
+ …
330
+ Type '{ tenant: Tenant; }' is not assignable to type '"the plugin reads \"tenant\", which this app's context gives with another type"'.
331
+ ```
332
+
333
+ **When:** the app gives a `tenant`, but of a type that does not fit the one
334
+ the callback's parameter is annotated with: a `Tenant | null` where `load`
335
+ reads `Tenant`, or a tenant of another shape.
336
+
337
+ **Why:** `use` checks each key the guard reads against the app's context;
338
+ a narrower type passes, a wider or different one does not.
339
+
340
+ **Fix:** annotate the callback with the type the app gives, and handle it
341
+ inside — a `null` tenant loads nothing, a 404:
342
+
343
+ ```ts
344
+ permission(access, 'view', 'record', ({ tenant, pathParams }: BaseContext & { tenant: Tenant | null }) =>
345
+ tenant?.records.get(pathParams['id'] ?? '') ?? null,
346
+ );
347
+ ```
348
+
349
+ More on this message in
350
+ [`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-gives-with-another-type).
351
+
352
+ ### `the plugin's load reads its context as any: annotate what it reads, or leave it unannotated`
353
+
354
+ ```text
355
+ error TS2769: No overload matches this call.
356
+ …
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"'.
359
+ ```
360
+
361
+ The same message names `subject` or `ctx` when that callback is the one at
362
+ fault.
363
+
364
+ **When:** `load`, `subject` or `ctx` has its parameter annotated `any` —
365
+ `(ctx: any) => records.get(ctx.tenant.id)` — and the guard is used, on any
366
+ app, whatever its context gives.
367
+
368
+ **Why:** an `any` parameter reads any key and says nothing of what it
369
+ reads, so the guard would require nothing, and an app without a `tenant`
370
+ would be accepted, and throw on every guarded request. The guard is
371
+ refused instead.
372
+
373
+ **Fix:** annotate what the callback reads, and use the plugin that adds it
374
+ first:
375
+
376
+ ```ts
377
+ const byTenant = permission(access, 'view', 'record', ({ tenant, pathParams }: BaseContext & { tenant: Tenant }) =>
378
+ tenant.records.get(pathParams['id'] ?? '') ?? null,
379
+ );
380
+
381
+ app.use(tenancy).use(byTenant);
382
+ ```
383
+
384
+ Or leave it unannotated when it reads only the request, as `byParam`'s
385
+ `load` does.
386
+
387
+ More on this message in
388
+ [`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugins--reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated).
389
+
390
+ ## Runtime
391
+
392
+ ### `TypeError: permission(): no user in the context — use session(auth) before it, or pass { subject }`
393
+
394
+ **When:** a request reaches a `permission()` guard; it is answered
395
+ `500 {"error":"internal"}`.
396
+
397
+ **Why:** with no `subject` option, the guard reads the `user` `session()`
398
+ derived, and no `session()` was used before it. It throws rather than
399
+ treat everyone as anonymous.
400
+
401
+ **Fix:** use `session(accounts)` before the guard, or name the subject:
402
+
403
+ ```ts
404
+ app
405
+ .use(session(accounts))
406
+ .group('/records/:id', (record) =>
407
+ record.use(permission(access, 'view', 'record', byParam('id', findRecord))).get('/', ({ object, reply }) => reply(200, object)),
408
+ );
409
+ ```
410
+
411
+ ### `TypeError: signIn: a device was given, but janus() has no devices — pass devices: { keys }`
412
+
413
+ **When:** every sign-in or sign-up passing `{ device: ctx.auth.device }` or
414
+ `{ device: deviceOf(ctx) }` is answered `500 {"error":"internal"}` (the
415
+ message starts with `signUp:` for a sign-up).
416
+
417
+ **Why:** `ctx.auth.device` and `deviceOf` are `null` for a browser with no
418
+ device cookie, and `janus()` reads `device: null` as a device given — "this client holds no
419
+ token yet". Without `janus({ devices })` it has nothing to sign one with.
420
+
421
+ **Fix:** configure `devices`, or leave `device` out:
422
+
423
+ ```ts
424
+ const accounts = janus({
425
+ user: z.object({ email: z.email() }),
426
+ password: { login: 'email' },
427
+ store: createMemoryStores(),
428
+ hasher: scryptHasher(),
429
+ devices: { keys: [{ id: '2026-09', key: Bun.env['DEVICES_KEY'] ?? '' }] }, // openssl rand -base64 32
430
+ });
431
+ ```
432
+
433
+ ### `Warning: janusErrors(): report failed: …`
434
+
435
+ **When:** a 5xx was answered, and the `report` function given to
436
+ `janusErrors()` threw or rejected.
437
+
438
+ ```text
439
+ (node:13837) Warning: janusErrors(): report failed: Error: boom
440
+ ```
441
+
442
+ **Why:** `report` cannot change the answer: the 503 was sent all the same,
443
+ and the failure is a process warning.
444
+
445
+ **Fix:** make `report` safe — the logger or error tracker it calls is the
446
+ one failing.
447
+
448
+ ### `500 {"error":"internal"}`, with a `JanusError` in the log
449
+
450
+ **When:** a `janus()` refusal — `NotFoundError`, a refused password —
451
+ thrown by a route is answered 500 instead of its own status.
452
+
453
+ **Why:** the route is declared before `use(janusErrors())`, which answers
454
+ only the routes after it.
455
+
456
+ **Fix:** use `janusErrors()` first:
457
+
458
+ ```ts
459
+ const app = alxia()
460
+ .use(janusErrors())
461
+ .use(session(accounts))
462
+ .post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
463
+ const signedIn = await accounts.signIn(body); // a refusal is now its 401
464
+ return reply.ok({ id: auth.send(signedIn).id });
465
+ });
466
+ ```
467
+
468
+ ### `503 {"code":"STORE_FAILED"}`
469
+
470
+ **When:** every authenticated request, while the identity or relation store
471
+ is unreachable.
472
+
473
+ **Why:** intended. A store that cannot answer makes `authenticate` and
474
+ `can` throw `STORE_FAILED`, and `janusErrors()` answers it 503 — never a
475
+ 401, which would send every signed-in user to the sign-in page, nor a 403.
476
+ Routes declared before `session()` still answer.
477
+
478
+ **Fix:** the store. Pass `report` to `janusErrors()` to hear about it:
479
+
480
+ ```ts
481
+ janusErrors({ report: (error, ctx) => console.error(ctx.route, error.code, error.cause) });
482
+ ```
483
+
484
+ ### `401 {"error":"unauthenticated"}` from the sign-in route
485
+
486
+ **When:** every sign-in, sign-up or sign-out of a request with no session
487
+ is answered `401 {"error":"unauthenticated"}`, and the route never runs.
488
+
489
+ **Why:** the route is declared after `session(accounts, { required: true })`,
490
+ which answers an anonymous request before any route behind it — and
491
+ whoever signs in is anonymous.
492
+
493
+ **Fix:** declare it behind a `session()` that is not required, and require
494
+ the session for the routes after it, or in a `group`
495
+ ([Sessions](guide/sessions.md#where-the-sign-in-routes-go)):
496
+
497
+ ```ts
498
+ alxia()
499
+ .use(janusErrors())
500
+ .use(session(accounts)) // the sign-in routes
501
+ .post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
502
+ const signedIn = await accounts.signIn(body);
503
+ return reply.ok({ id: auth.send(signedIn).id });
504
+ })
505
+ .use(session(accounts, { required: true })) // everything after
506
+ .get('/me', ({ user, reply }) => reply.ok({ name: user.name }));
507
+ ```
508
+
509
+ ### `401 {"code":"CREDENTIALS_INVALID","retryAfter":900}` with the right password
510
+
511
+ **When:** signing in after many failed attempts at the same login; the
512
+ response carries `Retry-After: 900`.
513
+
514
+ **Why:** `janus()` throttles password guessing by default: past ten
515
+ passwords tried at one login in fifteen minutes, `signIn` refuses until the
516
+ next window, the right password included. Nothing is locked.
517
+
518
+ **Fix:** wait `retryAfter` seconds, and show it to the user. The throttle is
519
+ `janus({ signIn: { throttle } })`'s, in `@nxgt/janus`.
520
+
521
+ ## Traps
522
+
523
+ ### Signed in, and the next request is anonymous
524
+
525
+ **When:** the sign-in answers 200 with a `Set-Cookie`, and the next request
526
+ from a browser reaches `session()` with no cookie.
527
+
528
+ **Why:** the session cookie is `Secure` by default (`janus()`'s
529
+ `cookie.secure`), and is set on `Path=/` for the host that answered.
530
+ A browser does not keep a `Secure` cookie sent over plain `http://` from
531
+ a host other than `localhost`, nor send a cookie to another host.
532
+
533
+ **Fix:** serve over HTTPS; for a plain-HTTP development host, turn it off
534
+ there only:
535
+
536
+ ```ts
537
+ const accounts = janus({
538
+ user: z.object({ email: z.email() }),
539
+ password: { login: 'email' },
540
+ store: createMemoryStores(),
541
+ hasher: scryptHasher(),
542
+ cookie: { secure: Bun.env['NODE_ENV'] === 'production' },
543
+ });
544
+ ```
545
+
546
+ For an API on another subdomain, set `cookie.domain`.
547
+
548
+ ### A client with a token never gets a renewed cookie
549
+
550
+ **When:** a client sending `Authorization: Bearer` or `X-Session-Token`
551
+ never receives a `Set-Cookie`, even when its session was renewed.
552
+
553
+ **Why:** intended. `session()` sends a renewed session again only to a
554
+ request that presented it as a cookie: the token itself does not change, so
555
+ a bearer client keeps using it. Neither does a route that set its own
556
+ session cookie get a second one.
557
+
558
+ **Fix:** none needed. A bearer client that wants the new expiry reads
559
+ `session.expiresAt` from a route that answers it.
560
+
561
+ ### A user of another type gets a 401
562
+
563
+ **When:** a signed-in user is answered `401 {"error":"unauthenticated"}`
564
+ on routes behind `session(accounts, { type, required: true })`.
565
+
566
+ **Why:** a user of another type than `type` is anonymous there, by design.
567
+ So is a request whose **first** credential is lapsed: `Authorization:
568
+ Bearer` is read before `X-Session-Token`, then the cookie, and a stale
569
+ bearer beside a live cookie is anonymous.
570
+
571
+ **Fix:** sign in as that type, or stop sending the stale header.
572
+
573
+ ### Every guarded route answers 404
574
+
575
+ **When:** a signed-in user allowed on the object gets
576
+ `404 {"error":"not_found"}` from every route behind `permission()`.
577
+
578
+ **Why:** `byParam(name, …)` reads a path parameter of that name; a group
579
+ whose path has no such parameter — `/records/:recordId` with
580
+ `byParam('id', …)` — answers `null`, a 404. The name is a string: a typo
581
+ compiles.
582
+
583
+ **Fix:** use the parameter's name as declared in the path:
584
+
585
+ ```ts
586
+ app.group('/records/:recordId', (record) =>
587
+ record.use(permission(access, 'view', 'record', byParam('recordId', findRecord))).get('/', ({ object, reply }) => reply(200, object)),
588
+ );
589
+ ```
590
+
591
+ ### Routes that have nothing to do with the object answer 401 or 404
592
+
593
+ **When:** after adding `permission()`, routes such as `/health` answer 401
594
+ or 404.
595
+
596
+ **Why:** the guard applies to every route declared after it in the app or
597
+ group it is used in. Used at the top of the app, it guards them all.
598
+
599
+ **Fix:** use it inside the `group` of the routes about the object
600
+ ([Permissions](guide/permissions.md#scope-it-with-group)).