@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.
@@ -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)
@@ -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
+ }