@nxgt/janus-hono 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 +205 -0
- package/dist/bind.d.ts +76 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/errors.d.ts +50 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +181 -0
- package/dist/index.js.map +14 -0
- package/dist/permission.d.ts +80 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/provide.d.ts +19 -0
- package/dist/provide.d.ts.map +1 -0
- package/dist/session.d.ts +91 -0
- package/dist/session.d.ts.map +1 -0
- package/docs/README.md +15 -0
- package/docs/guide/permissions.md +225 -0
- package/docs/guide/routes.md +243 -0
- package/docs/roadmap.md +40 -0
- package/docs/troubleshooting.md +324 -0
- package/package.json +62 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# The routes of an application
|
|
2
|
+
|
|
3
|
+
Every flow of `@nxgt/janus` as a Hono route. What this package adds is small on
|
|
4
|
+
purpose — who the request belongs to, the cookie, the statuses — so each route
|
|
5
|
+
below is `janus()`'s own call with Hono around it.
|
|
6
|
+
|
|
7
|
+
## Wiring
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { janusErrors, session } from '@nxgt/janus-hono';
|
|
11
|
+
import { Hono } from 'hono';
|
|
12
|
+
import { auth } from './auth';
|
|
13
|
+
|
|
14
|
+
export const app = new Hono();
|
|
15
|
+
app.onError(janusErrors());
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`janusErrors()` is what turns a `JanusError` thrown anywhere — in a route, or
|
|
19
|
+
in `session()` — into its status. Without it, Hono answers 500 for all of
|
|
20
|
+
them. To log what the server must fix — every error answered 5xx, an outage
|
|
21
|
+
first — pass `report`; it runs before the answer:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
app.onError(janusErrors({ report: (error) => logger.error(error) }));
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
To log every refusal too — why a sign-in failed, say — wrap it:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const answer = janusErrors();
|
|
31
|
+
app.onError((error, c) => {
|
|
32
|
+
// `reason`, `login`, `slot`, `operation` and `cause` are here, never in the body
|
|
33
|
+
logger.error(error);
|
|
34
|
+
return answer(error, c);
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`janusErrors({ fallback })` hands every other error to `fallback`; without one,
|
|
39
|
+
an `HTTPException` answers its own response and anything else is a logged
|
|
40
|
+
500, as Hono does by default.
|
|
41
|
+
|
|
42
|
+
### Binding the instances once
|
|
43
|
+
|
|
44
|
+
Every function of this package takes `auth` or `access` first.
|
|
45
|
+
`bindJanus()` binds them once — the same functions, the same types:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { bindJanus } from '@nxgt/janus-hono';
|
|
49
|
+
|
|
50
|
+
export const j = bindJanus({ auth, access });
|
|
51
|
+
|
|
52
|
+
app.use(j.session(), j.provide());
|
|
53
|
+
app.get('/me', j.session({ required: true }), (c) => c.json(c.var.user));
|
|
54
|
+
app.post('/sign-in', async (c) => {
|
|
55
|
+
const user = j.sendSession(c, await auth.signIn(await c.req.json()));
|
|
56
|
+
return c.json({ id: user.id });
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The rest of this guide writes the unbound form; each call reads the same with
|
|
61
|
+
`j.` and without its first argument.
|
|
62
|
+
|
|
63
|
+
## Who the request belongs to
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
app.get('/', session(auth), (c) =>
|
|
67
|
+
c.text(c.var.user === null ? 'Hello' : `Hello ${c.var.user.name}`),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
app.get('/account', session(auth, { required: true }), (c) =>
|
|
71
|
+
c.json(c.var.user), // typed: never null
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`session()` reads the session token the way `auth.authenticate` does —
|
|
76
|
+
`Authorization: Bearer`, then `X-Session-Token`, then the cookie, **the first
|
|
77
|
+
present wins** — and sets `c.var.user` and `c.var.session`.
|
|
78
|
+
|
|
79
|
+
| The request presents | `c.var.user` | With `required: true` |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| Nothing | `null` | 401, no body; the route never runs |
|
|
82
|
+
| A lapsed, revoked or unknown session | `null` | 401 |
|
|
83
|
+
| A session of an inactive user, or of another type than `type` | `null` | 401 |
|
|
84
|
+
| A live session | the user | the user |
|
|
85
|
+
| A session credential, while the store cannot answer | — `STORE_FAILED` is thrown | — 503 through `janusErrors()` |
|
|
86
|
+
|
|
87
|
+
### For a whole app
|
|
88
|
+
|
|
89
|
+
Hono types a chain, so `app.use(session(auth))` on its own line leaves the
|
|
90
|
+
routes after it untyped. Declare the app with the `Env` instead:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { type SessionEnv, session } from '@nxgt/janus-hono';
|
|
94
|
+
|
|
95
|
+
const app = new Hono<SessionEnv<typeof auth>>();
|
|
96
|
+
app.use(session(auth));
|
|
97
|
+
app.get('/', (c) => c.json({ signedIn: c.var.user !== null }));
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
An app whose every route requires a user of one type says so in both places,
|
|
101
|
+
and `c.var.user` is never `null` in it:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const rota = new Hono<SessionEnv<typeof auth, 'staff', true>>();
|
|
105
|
+
rota.use(session(auth, { type: 'staff', required: true }));
|
|
106
|
+
rota.get('/', (c) => c.json(rotaOf(c.var.user.username)));
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Renewal
|
|
110
|
+
|
|
111
|
+
Every session is renewed by `authenticate` in passing once `renewAfter` has
|
|
112
|
+
passed — `'1d'` by default, `false` for a fixed lifespan — and its expiry
|
|
113
|
+
moves. `session()` sends the cookie again after the route ran, with the
|
|
114
|
+
new `Expires` — to a request that presented the session as a cookie. A client
|
|
115
|
+
using `Authorization: Bearer` is renewed too, keeps its session token, and is never
|
|
116
|
+
handed a cookie. A route that sets the session cookie itself — `sendSession`
|
|
117
|
+
after a sign-in, `signOut` — keeps its own: the renewal is not sent over it.
|
|
118
|
+
|
|
119
|
+
## Sign-up and sign-in
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { sendSession } from '@nxgt/janus-hono';
|
|
123
|
+
|
|
124
|
+
app.post('/sign-up', async (c) => {
|
|
125
|
+
const { email, name, password } = await c.req.json(); // validate the shape yourself
|
|
126
|
+
const user = sendSession(c, auth, await auth.signUp({ email, name, password }));
|
|
127
|
+
return c.json({ id: user.id }, 201);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
app.post('/sign-in', async (c) => {
|
|
131
|
+
const { email, password } = await c.req.json();
|
|
132
|
+
const user = sendSession(c, auth, await auth.signIn({ email, password }));
|
|
133
|
+
return c.json({ id: user.id });
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`sendSession` answers the user it signed in, so a cookie client's route never
|
|
138
|
+
holds the session token: it is in the `Set-Cookie`, and the body cannot leak
|
|
139
|
+
it.
|
|
140
|
+
|
|
141
|
+
The refusals need no `try`: `janusErrors()` answers them.
|
|
142
|
+
|
|
143
|
+
| Thrown | Answered |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `USER_INVALID` | 400 `{ code, issues }`, the fields that failed |
|
|
146
|
+
| `PASSWORD_TOO_SHORT` | 400 `{ code, minLength }` |
|
|
147
|
+
| `LOGIN_TAKEN` | 409 `{ code }` |
|
|
148
|
+
| `CREDENTIALS_INVALID` | 401 `{ code }` — the same for an unknown login and a wrong password |
|
|
149
|
+
| `USER_INACTIVE` | 403 `{ code }` — only told to somebody who gave the right password |
|
|
150
|
+
|
|
151
|
+
A bearer client — a mobile app — keeps its session token itself and sends it
|
|
152
|
+
as `Authorization: Bearer`. Answer it the session token in the body, not a
|
|
153
|
+
cookie — keep what `signIn` answered:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const signedIn = await auth.signIn({ email, password });
|
|
157
|
+
return c.json({
|
|
158
|
+
id: signedIn.user.id,
|
|
159
|
+
token: signedIn.token,
|
|
160
|
+
expiresAt: signedIn.session.expiresAt,
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Sign-out
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { signOut } from '@nxgt/janus-hono';
|
|
168
|
+
|
|
169
|
+
app.post('/sign-out', async (c) => {
|
|
170
|
+
await signOut(c, auth);
|
|
171
|
+
return c.body(null, 204);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
app.post('/sign-out-everywhere', session(auth, { required: true }), async (c) => {
|
|
175
|
+
await auth.signOutEverywhere(c.var.user, { except: c.var.session.id });
|
|
176
|
+
return c.body(null, 204);
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`signOut` clears the cookie whatever it revoked, so a browser holding a stale
|
|
181
|
+
cookie drops it too.
|
|
182
|
+
|
|
183
|
+
## E-mail verification and password reset
|
|
184
|
+
|
|
185
|
+
Sending the e-mail is yours; the one-time token goes in a link.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
app.post('/verify-email', session(auth, { required: true }), async (c) => {
|
|
189
|
+
const { token, email } = await auth.verifyEmail.send(c.var.user);
|
|
190
|
+
await mailer.send(email, `https://app.test/verify?token=${token}`);
|
|
191
|
+
return c.body(null, 202);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
app.post('/verify-email/confirm', async (c) => {
|
|
195
|
+
const { token } = await c.req.json();
|
|
196
|
+
await auth.verifyEmail.confirm(token); // TOKEN_* → 400
|
|
197
|
+
return c.body(null, 204);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
app.post('/reset-password', async (c) => {
|
|
201
|
+
const { email } = await c.req.json();
|
|
202
|
+
const issued = await auth.resetPassword.request(email);
|
|
203
|
+
if (issued !== null) {
|
|
204
|
+
await mailer.send(issued.email, `https://app.test/reset?token=${issued.token}`);
|
|
205
|
+
}
|
|
206
|
+
return c.body(null, 202); // the same answer either way: never say which e-mails exist
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
app.post('/reset-password/confirm', async (c) => {
|
|
210
|
+
const { token, password } = await c.req.json();
|
|
211
|
+
await auth.resetPassword.confirm(token, password); // signs the user out everywhere
|
|
212
|
+
return c.body(null, 204);
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Several user types
|
|
217
|
+
|
|
218
|
+
With `janus({ users: { patient, staff } })`, each type's flows are under its
|
|
219
|
+
name, and `session()` takes the type a route admits:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
app.post('/staff/sign-in', async (c) => {
|
|
223
|
+
const { username, password } = await c.req.json();
|
|
224
|
+
sendSession(c, auth, await auth.staff.signIn({ username, password }));
|
|
225
|
+
return c.body(null, 204);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
app.get('/staff/rota', session(auth, { type: 'staff', required: true }), (c) =>
|
|
229
|
+
c.json(rotaOf(c.var.user.username)), // a staff user: the compiler knows
|
|
230
|
+
);
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A patient's session on `/staff/rota` is anonymous there, and answered 401. The
|
|
234
|
+
session stands, and still authenticates the patient on the routes that admit them.
|
|
235
|
+
One cookie holds one session: a browser signed in as a patient that signs in
|
|
236
|
+
as staff replaces the patient's cookie.
|
|
237
|
+
|
|
238
|
+
## See also
|
|
239
|
+
|
|
240
|
+
- [`@nxgt/janus` — sessions](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sessions.md) — lifespans, renewal, the cookie's attributes
|
|
241
|
+
- [`@nxgt/janus` — errors](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/errors.md) — every code
|
|
242
|
+
- [Guarded routes and writing tuples](permissions.md) — `permission()`, `provide()`
|
|
243
|
+
- [Troubleshooting](../troubleshooting.md)
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
Where `@nxgt/janus-hono` is heading. A direction, not a commitment: there are
|
|
4
|
+
no dates here, and the version something shipped in is the only number.
|
|
5
|
+
|
|
6
|
+
## Now
|
|
7
|
+
|
|
8
|
+
Nothing in progress.
|
|
9
|
+
|
|
10
|
+
## Next
|
|
11
|
+
|
|
12
|
+
Nothing yet.
|
|
13
|
+
|
|
14
|
+
## Later
|
|
15
|
+
|
|
16
|
+
Nothing yet.
|
|
17
|
+
|
|
18
|
+
## Not planned
|
|
19
|
+
|
|
20
|
+
- **A middleware that guesses the object** — `permission()` takes a `load`
|
|
21
|
+
and never picks a path parameter or a table for you: the application knows
|
|
22
|
+
where its objects live. `byParam('id', find)` reads the parameter it is
|
|
23
|
+
named, with the `find` it is given.
|
|
24
|
+
- **Routes of its own** — no `/sign-in` mounted for you. Every application
|
|
25
|
+
names, validates and answers its routes its own way; the flows are one call
|
|
26
|
+
each.
|
|
27
|
+
- **Body validation** — the shape of a request body is the route's to check,
|
|
28
|
+
with Hono's validator or your own. `janus()` validates the fields against
|
|
29
|
+
your schema, not that `password` is a string.
|
|
30
|
+
- **Error classes of its own** — it throws nothing but `@nxgt/janus`'s, which
|
|
31
|
+
is why `@nxgt/janus` is a required peer and never a dependency.
|
|
32
|
+
- **`moduleResolution: "nodenext"`** — like `@nxgt/janus`, the package imports
|
|
33
|
+
without extensions. Use `"moduleResolution": "bundler"`.
|
|
34
|
+
|
|
35
|
+
## Shipped
|
|
36
|
+
|
|
37
|
+
- **The first release** — `session()`,
|
|
38
|
+
`sendSession` (which answers the user), `signOut`, `permission()` and
|
|
39
|
+
`byParam`, `provide()`, `janusErrors({ report, fallback })`, and
|
|
40
|
+
`bindJanus()` binding them to the instances once. — v0.1
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# Troubleshooting `@nxgt/janus-hono`
|
|
2
|
+
|
|
3
|
+
Each entry is headed by what you see: a compiler error, a thrown `TypeError`,
|
|
4
|
+
or a status and its body. Search this page for its words.
|
|
5
|
+
|
|
6
|
+
This package **defines no error class**. What reaches `app.onError` is one of
|
|
7
|
+
`@nxgt/janus`'s errors, and the codes are those of the core — see
|
|
8
|
+
[`@nxgt/janus`'s troubleshooting](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/troubleshooting.md)
|
|
9
|
+
for what causes each.
|
|
10
|
+
|
|
11
|
+
## Index
|
|
12
|
+
|
|
13
|
+
**Types**
|
|
14
|
+
- [`'c.var.user' is possibly 'null'`](#cvaruser-is-possibly-null)
|
|
15
|
+
- [`Property 'user' does not exist on type 'Readonly<ContextVariableMap>'`](#property-user-does-not-exist-on-type-readonlycontextvariablemap)
|
|
16
|
+
- [`Property 'email' does not exist on type 'User<"patient", …> | User<"staff", …>'`](#property-email-does-not-exist-on-type-userpatient---userstaff-)
|
|
17
|
+
- [`Type '"doctor"' is not assignable to type '"patient" | "staff"'`](#type-doctor-is-not-assignable-to-type-patient--staff)
|
|
18
|
+
- [`Argument of type 'string | undefined' is not assignable to parameter of type 'string'`, in `load`](#argument-of-type-string--undefined-is-not-assignable-to-parameter-of-type-string-in-load)
|
|
19
|
+
- [`Expected 5 arguments, but got 4`, on `permission()`](#expected-5-arguments-but-got-4-on-permission)
|
|
20
|
+
- [`Type '{ id: string; }' is not assignable to type 'Awaitable<ObjectData<…> | null>'`](#type--id-string--is-not-assignable-to-type-awaitableobjectdata--null)
|
|
21
|
+
- [`Property 'access' does not exist on type 'Readonly<ContextVariableMap & …>'`](#property-access-does-not-exist-on-type-readonlycontextvariablemap--)
|
|
22
|
+
|
|
23
|
+
**Runtime**
|
|
24
|
+
- [`500 Internal Server Error` for every refusal](#500-internal-server-error-for-every-refusal)
|
|
25
|
+
- [`TypeError: permission(): c.var.object is already set`](#typeerror-permission-cvarobject-is-already-set)
|
|
26
|
+
- [`TypeError: permission(): c.var.user is not set`](#typeerror-permission-cvaruser-is-not-set)
|
|
27
|
+
- [`403` where the user should be allowed](#403-where-the-user-should-be-allowed)
|
|
28
|
+
- [`401` with an empty body, for a signed-in user](#401-with-an-empty-body-for-a-signed-in-user)
|
|
29
|
+
- [`503 {"code":"STORE_FAILED"}` on every route](#503-codestore_failed-on-every-route)
|
|
30
|
+
- [`400 {"code":"HASH_UNSUPPORTED"}` on sign-in](#400-codehash_unsupported-on-sign-in)
|
|
31
|
+
- [The browser never sends the cookie back](#the-browser-never-sends-the-cookie-back)
|
|
32
|
+
- [A bearer client holds an expiry earlier than the session's](#a-bearer-client-holds-an-expiry-earlier-than-the-sessions)
|
|
33
|
+
|
|
34
|
+
## Types
|
|
35
|
+
|
|
36
|
+
### `'c.var.user' is possibly 'null'`
|
|
37
|
+
|
|
38
|
+
**When:** reading `c.var.user` in a route behind `session(auth)`.
|
|
39
|
+
|
|
40
|
+
**Why:** the route does not require a user, so an anonymous request reaches
|
|
41
|
+
it. The same holds for `required` given a computed `boolean`: only a literal
|
|
42
|
+
`true` removes the `null`.
|
|
43
|
+
|
|
44
|
+
**Fix:** require a user, or answer the anonymous request yourself.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
app.get('/me', session(auth, { required: true }), (c) => c.json(c.var.user));
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### `Property 'user' does not exist on type 'Readonly<ContextVariableMap>'`
|
|
51
|
+
|
|
52
|
+
**When:** `app.use(session(auth))` on its own line, then `c.var.user` in a
|
|
53
|
+
route declared after it.
|
|
54
|
+
|
|
55
|
+
**Why:** Hono types a chain of calls, not separate statements.
|
|
56
|
+
|
|
57
|
+
**Fix:** declare the app with the `Env`, or pass `session(auth)` to the route.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const app = new Hono<SessionEnv<typeof auth>>();
|
|
61
|
+
app.use(session(auth));
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### `Property 'email' does not exist on type 'User<"patient", …> | User<"staff", …>'`
|
|
65
|
+
|
|
66
|
+
**When:** several user types, and `session(auth)` without `type`.
|
|
67
|
+
|
|
68
|
+
**Why:** the route admits every user type, so `c.var.user` is their union, and
|
|
69
|
+
a field only one type has is not on it.
|
|
70
|
+
|
|
71
|
+
**Fix:** name the type the route admits, or narrow on `user.type`.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
app.get('/me', session(auth, { type: 'patient', required: true }), (c) =>
|
|
75
|
+
c.json({ email: c.var.user.email }),
|
|
76
|
+
);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `Type '"doctor"' is not assignable to type '"patient" | "staff"'`
|
|
80
|
+
|
|
81
|
+
**When:** `session(auth, { type: 'doctor' })`, or `SessionEnv<typeof auth, 'doctor'>`.
|
|
82
|
+
|
|
83
|
+
**Why:** `type` names a user type of the instance you pass, and `doctor` is
|
|
84
|
+
not one of them — a misspelling, or a type from another `janus()`.
|
|
85
|
+
|
|
86
|
+
**Fix:** use one of the names in the message, the keys of `users` in
|
|
87
|
+
`janus({ users })`.
|
|
88
|
+
|
|
89
|
+
### `Argument of type 'string | undefined' is not assignable to parameter of type 'string'`, in `load`
|
|
90
|
+
|
|
91
|
+
**When:** `permission(access, 'view', 'record', (c) => records.find(c.req.param('id')))`.
|
|
92
|
+
|
|
93
|
+
**Why:** `load` takes a plain `Context`: `permission()` is built before Hono
|
|
94
|
+
attaches it to a route, so `c.req.param('id')` is `string | undefined` there.
|
|
95
|
+
|
|
96
|
+
**Fix:** a missing id is nothing to load: answer `null`, which is a 404 —
|
|
97
|
+
what `byParam` does.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
permission(access, 'view', 'record', byParam('id', (id) => records.find(id)));
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `Expected 5 arguments, but got 4`, on `permission()`
|
|
104
|
+
|
|
105
|
+
**When:** the permission reaches a `when()` condition, and no options were
|
|
106
|
+
passed.
|
|
107
|
+
|
|
108
|
+
**Why:** a condition needs its `ctx`, and `permission()` requires it exactly
|
|
109
|
+
then — as `can()` does.
|
|
110
|
+
|
|
111
|
+
**Fix:** pass `ctx`, read from the request and the loaded object.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
permission(access, 'edit', 'record', load, {
|
|
115
|
+
ctx: (c, record) => ({ locked: record.locked }),
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `Type '{ id: string; }' is not assignable to type 'Awaitable<ObjectData<…> | null>'`
|
|
120
|
+
|
|
121
|
+
Also: `Property 'doctorId' is missing in type '{ id: string; }'`.
|
|
122
|
+
|
|
123
|
+
**When:** `load` answers an object without a field a `fromField` of its type
|
|
124
|
+
reads — `doctorId` for `fromField('doctorId', 'staff')`.
|
|
125
|
+
|
|
126
|
+
**Why:** `can()` reads that relation from the object. At run time a missing
|
|
127
|
+
field is a `TypeError` — `can: record.doctor reads doctorId, which the object
|
|
128
|
+
does not carry …` — never a denial, so the type refuses it first.
|
|
129
|
+
|
|
130
|
+
**Fix:** load the field — `null` when it holds nobody.
|
|
131
|
+
|
|
132
|
+
### `Property 'access' does not exist on type 'Readonly<ContextVariableMap & …>'`
|
|
133
|
+
|
|
134
|
+
**When:** `c.var.access` or `c.var.auth` in a route. With nothing before the
|
|
135
|
+
route that sets a variable, the type is `'Readonly<ContextVariableMap>'`, with
|
|
136
|
+
no `&`.
|
|
137
|
+
|
|
138
|
+
**Why:** `provide()` was not in the chain before the route, or was not given
|
|
139
|
+
that instance: it sets, and types, only what it is given.
|
|
140
|
+
|
|
141
|
+
**Fix:**
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const app = new Hono().use(session(auth), provide({ auth, access }));
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Runtime
|
|
148
|
+
|
|
149
|
+
### `TypeError: permission(): c.var.object is already set`
|
|
150
|
+
|
|
151
|
+
**When:** the first request to a route with two `permission()` guards, or one
|
|
152
|
+
behind a middleware of yours that sets `c.var.object`.
|
|
153
|
+
|
|
154
|
+
**Why:** `permission()` hands the route its object as `c.var.object`. Two
|
|
155
|
+
would claim the one variable, and the route would be typed as if it held both.
|
|
156
|
+
|
|
157
|
+
**Fix:** one `permission()` per route. Check a parent through an arrow in the
|
|
158
|
+
model, so the one check covers it.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
record: {
|
|
162
|
+
relations: { owner: ['patient'], folder: ['folder'] },
|
|
163
|
+
permissions: { view: ['owner', 'folder->view'] },
|
|
164
|
+
},
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### `TypeError: permission(): c.var.user is not set`
|
|
168
|
+
|
|
169
|
+
**When:** the first request to a route guarded by `permission()`.
|
|
170
|
+
|
|
171
|
+
**Why:** nothing set `c.var.user`: no `session()` before `permission()` in that
|
|
172
|
+
route's chain, and no `subject` option. It is a wiring error, so it is thrown
|
|
173
|
+
rather than answered 401.
|
|
174
|
+
|
|
175
|
+
**Fix:** put `session(auth)` before it — on the route or with `app.use` — or
|
|
176
|
+
say who the subject is.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
app.get('/records/:id', session(auth), permission(access, 'view', 'record', load), handler);
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### `403` where the user should be allowed
|
|
183
|
+
|
|
184
|
+
**When:** a user who holds the relation is refused.
|
|
185
|
+
|
|
186
|
+
**Why**, in the order to check:
|
|
187
|
+
|
|
188
|
+
1. The user holds a relation, and the route asks a permission that does not
|
|
189
|
+
include it — `edit` is not `view`.
|
|
190
|
+
2. A condition answered `false`: check what `ctx` computes.
|
|
191
|
+
3. A `fromField`'s field holds another id, or `null`, in what `load` answered.
|
|
192
|
+
4. The tuple names another user type than the one signed in — granted to
|
|
193
|
+
`patient:u1`, checked for `staff:u1`: the same id under another type is
|
|
194
|
+
somebody else.
|
|
195
|
+
|
|
196
|
+
**Fix:** ask `can()` directly with the same arguments; it answers the same.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
await access.can(user, 'view', { ...record, type: 'record' }); // the check permission() makes
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### `500 Internal Server Error` for every refusal
|
|
203
|
+
|
|
204
|
+
**When:** a sign-in with a wrong password, a taken login or an outage answers
|
|
205
|
+
500 instead of 401, 409 or 503.
|
|
206
|
+
|
|
207
|
+
**Why:** no `janusErrors()` on the app that ran the throwing code. A sub-app's
|
|
208
|
+
own `onError` is captured when `app.route()` mounts it; one with none uses the
|
|
209
|
+
parent's. An error from a middleware on the parent — `app.use(session(auth))`
|
|
210
|
+
— always uses the parent's.
|
|
211
|
+
|
|
212
|
+
**Fix:** set it on the app you serve, and on a sub-app before mounting it if
|
|
213
|
+
that sub-app has a handler of its own.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
app.onError(janusErrors());
|
|
217
|
+
sub.onError(janusErrors());
|
|
218
|
+
app.route('/staff', sub);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### `401` with an empty body, for a signed-in user
|
|
222
|
+
|
|
223
|
+
**When:** a route with `required: true` answers 401 although the user signed
|
|
224
|
+
in.
|
|
225
|
+
|
|
226
|
+
**Why**, in the order to check:
|
|
227
|
+
|
|
228
|
+
1. The route passes `type`, and the user is of another type. The session
|
|
229
|
+
stands; it does not authenticate that user on this route.
|
|
230
|
+
2. The request presents two session credentials, and the first one is stale.
|
|
231
|
+
**The first present wins, not the first valid one**: a lapsed
|
|
232
|
+
`Authorization: Bearer` beside a live cookie is anonymous.
|
|
233
|
+
3. The session lapsed or was revoked — `signOut`, `signOutEverywhere`, a
|
|
234
|
+
password reset — or the user was deactivated.
|
|
235
|
+
4. The browser did not send the cookie: see
|
|
236
|
+
[The browser never sends the cookie back](#the-browser-never-sends-the-cookie-back).
|
|
237
|
+
|
|
238
|
+
**Fix:** for 2, stop sending the stale header. For 1 and 3, it is the answer
|
|
239
|
+
you asked for.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
fetch(url, { credentials: 'include' }); // the cookie, and no stale Authorization header
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### `503 {"code":"STORE_FAILED"}` on every route
|
|
246
|
+
|
|
247
|
+
**When:** every request that presents a session credential answers 503.
|
|
248
|
+
|
|
249
|
+
**Why:** a store cannot answer. `session()` calls the sessions store on every
|
|
250
|
+
request that presents a session token, and **an outage is never anonymous**: it
|
|
251
|
+
is `STORE_FAILED`, answered 503, so that nobody is told they are signed out
|
|
252
|
+
while the database is down. A request that presents nothing never reaches the
|
|
253
|
+
store, and stays anonymous.
|
|
254
|
+
|
|
255
|
+
**Fix:** the database. `janusErrors()` answers without logging: pass `report`
|
|
256
|
+
to see which store failed and why.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
app.onError(
|
|
260
|
+
janusErrors({
|
|
261
|
+
report: (error) =>
|
|
262
|
+
logger.error({ slot: error.slot, operation: error.operation }, error.cause),
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### `400 {"code":"HASH_UNSUPPORTED"}` on sign-in
|
|
268
|
+
|
|
269
|
+
**When:** users imported from another system, or after a change of hasher.
|
|
270
|
+
|
|
271
|
+
**Why:** the stored password hash has a prefix no wired hasher reads. It is a
|
|
272
|
+
wiring fault, not the user's: they cannot act on the 400.
|
|
273
|
+
|
|
274
|
+
**Fix:** wire the hasher that wrote those hashes back as a verifier — see
|
|
275
|
+
[`@nxgt/janus`'s passwords guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/passwords.md).
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
janus({ …, hasher: bunHasher(), verifiers: [scryptHasher()] }); // old hashes still verify
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### The browser never sends the cookie back
|
|
282
|
+
|
|
283
|
+
**When:** `sendSession` answered a `Set-Cookie`, and the next request presents
|
|
284
|
+
no cookie.
|
|
285
|
+
|
|
286
|
+
**Why:** one of three.
|
|
287
|
+
|
|
288
|
+
- The cookie is `Secure` by default, and the page is served over plain
|
|
289
|
+
`http://` on a host other than `localhost`.
|
|
290
|
+
- The front end is on another origin: the browser sends a cookie cross-origin
|
|
291
|
+
only with `credentials: 'include'`, to a server that allows credentials, and
|
|
292
|
+
`SameSite=Lax` (the default) drops it from a cross-site `POST`.
|
|
293
|
+
- `sameSite: 'none'` without `secure` is refused when `janus()` is called —
|
|
294
|
+
`cookie.sameSite "none" requires cookie.secure` — so it never gets that far.
|
|
295
|
+
|
|
296
|
+
**Fix:** serve over HTTPS; for a development server off `localhost`,
|
|
297
|
+
`cookie: { secure: false }` in `janus()`, never in production. For a front end
|
|
298
|
+
on another origin:
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import { cors } from 'hono/cors';
|
|
302
|
+
|
|
303
|
+
// janus({ …, cookie: { sameSite: 'none' } }) — secure stays true
|
|
304
|
+
app.use(cors({ origin: 'https://front.example', credentials: true }));
|
|
305
|
+
// in the front end: fetch(url, { credentials: 'include' })
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### A bearer client holds an expiry earlier than the session's
|
|
309
|
+
|
|
310
|
+
**When:** a bearer client caches `session.expiresAt` from its sign-in, and
|
|
311
|
+
treats the session as over at that date.
|
|
312
|
+
|
|
313
|
+
**Why:** the session was renewed in passing, and its expiry moved in the store.
|
|
314
|
+
A bearer client is never sent a cookie saying so, and its session token does
|
|
315
|
+
not change.
|
|
316
|
+
|
|
317
|
+
**Fix:** nothing to fix unless the client caches the expiry. If it does, answer
|
|
318
|
+
`c.var.session.expiresAt` from a route it calls.
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
app.get('/session', session(auth, { required: true }), (c) =>
|
|
322
|
+
c.json({ expiresAt: c.var.session.expiresAt }),
|
|
323
|
+
);
|
|
324
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nxgt/janus-hono",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Hono middleware for @nxgt/janus: the signed-in user on every request, the session cookie, routes guarded by a permission, and every error as its status",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"package.json",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"identity",
|
|
26
|
+
"authentication",
|
|
27
|
+
"hono",
|
|
28
|
+
"middleware",
|
|
29
|
+
"janus",
|
|
30
|
+
"typescript"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/softistx/nxgt-janus.git",
|
|
35
|
+
"directory": "packages/janus-hono"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"registry": "https://registry.npmjs.org",
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "bun run ../../build.ts",
|
|
43
|
+
"test": "bun test src",
|
|
44
|
+
"typecheck": "tsc --noEmit"
|
|
45
|
+
},
|
|
46
|
+
"nxgt": {
|
|
47
|
+
"entrypoints": [
|
|
48
|
+
"src/index.ts"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@nxgt/janus": "^0.1.3",
|
|
53
|
+
"@types/bun": "^1.4.2",
|
|
54
|
+
"hono": "^4.13.9",
|
|
55
|
+
"zod": "^4.6.5"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@nxgt/janus": "^0.1.3",
|
|
59
|
+
"hono": "^4.13.4",
|
|
60
|
+
"typescript": "^6.0.3"
|
|
61
|
+
}
|
|
62
|
+
}
|