@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.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/device.d.ts +21 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +15 -0
- package/dist/lookup.d.ts +9 -0
- package/dist/lookup.d.ts.map +1 -0
- package/dist/permission-options.d.ts +45 -0
- package/dist/permission-options.d.ts.map +1 -0
- package/dist/permission.d.ts +52 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/send.d.ts +34 -0
- package/dist/send.d.ts.map +1 -0
- package/dist/session.d.ts +67 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/README.md +16 -0
- package/docs/guide/errors.md +176 -0
- package/docs/guide/permissions.md +362 -0
- package/docs/guide/sessions.md +350 -0
- package/docs/guide/sign-in-and-out.md +340 -0
- package/docs/roadmap.md +68 -0
- package/docs/troubleshooting.md +600 -0
- package/package.json +55 -0
package/docs/roadmap.md
ADDED
|
@@ -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)).
|