@alxia/janus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,350 @@
1
+ # Sessions
2
+
3
+ This page covers `session()`: the plugin that reads who a request belongs
4
+ to, hands the routes after it a typed `user` and `session` and a bound
5
+ `auth`, refuses an anonymous request when asked to, and sends a renewed
6
+ session's cookie again.
7
+
8
+ ```ts
9
+ import { alxia } from '@alxia/core';
10
+ import { janusErrors, session } from '@alxia/janus';
11
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
12
+ import { z } from 'zod';
13
+
14
+ const accounts = janus({
15
+ user: z.object({ email: z.email(), name: z.string() }),
16
+ password: { login: 'email' },
17
+ store: createMemoryStores(), // your database's adapter in production
18
+ hasher: scryptHasher(),
19
+ });
20
+
21
+ const app = alxia()
22
+ .use(janusErrors())
23
+ .get('/health', ({ reply }) => reply(200, 'ok')) // before it: open
24
+ .use(session(accounts, { required: true }))
25
+ .get('/me', ({ user, reply }) => reply(200, { name: user.name })); // user: never null
26
+ ```
27
+
28
+ `session(accounts)` calls `accounts.authenticate(request)` once per request, for
29
+ the routes declared **after** it. A route declared before it is not
30
+ touched, and has no `user`, `session` or `auth`.
31
+
32
+ ## Where the session is read from
33
+
34
+ `accounts.authenticate` reads, in this order, `Authorization: Bearer <token>`,
35
+ `X-Session-Token: <token>`, and the session cookie (`janus-session` unless
36
+ `janus({ cookie: { name } })` says otherwise). **The first credential
37
+ present wins, not the first valid one**: a request sending a lapsed bearer
38
+ beside a live cookie is anonymous.
39
+
40
+ ```ts
41
+ await app.request('/me', { headers: { cookie: `janus-session=${token}` } }); // 200
42
+ await app.request('/me', { headers: { authorization: `Bearer ${token}` } }); // 200
43
+ await app.request('/me', { headers: { 'x-session-token': token } }); // 200
44
+ await app.request('/me'); // 401
45
+ ```
46
+
47
+ A lapsed or revoked session, a user deleted or made inactive, or a user of
48
+ another type than `type` is anonymous.
49
+
50
+ ## Options
51
+
52
+ | Option | Type | Default | Effect |
53
+ | --- | --- | --- | --- |
54
+ | `required` | `boolean` | `false` | `true`: an anonymous request is answered `401 { error: 'unauthenticated' }` and the route never runs; a literal `true` also types `user` as never `null` |
55
+ | `type` | one of the user types of `janus()` | every type | only a user of this type is authenticated; any other is anonymous, and `user` is narrowed to it |
56
+ | `device` | `DeviceCookieOptions` | the `janus-device` cookie | the device cookie `ctx.auth.device` reads and `ctx.auth.send` sets ([Devices](sign-in-and-out.md#devices)) |
57
+
58
+ ### `required`
59
+
60
+ Without it, `user` and `session` are `null` for an anonymous request, and
61
+ the route decides:
62
+
63
+ ```ts
64
+ const app = alxia()
65
+ .use(session(accounts))
66
+ .get('/greeting', ({ user, reply }) =>
67
+ reply(200, user === null ? 'Hello, stranger' : `Hello, ${user.name}`),
68
+ );
69
+ ```
70
+
71
+ With `required: true`, the 401 is in the type of every route after it, so a
72
+ client generated from the app reads it:
73
+
74
+ ```text
75
+ 401 {"error":"unauthenticated"}
76
+ ```
77
+
78
+ A `boolean` known only at run time is accepted too — and so is a whole
79
+ `SessionOptions` value, for a wrapper that forwards it. The compiler cannot
80
+ tell which it will be, so the routes get both answers: the 401 is in their
81
+ type, and `user` may be `null`. At run time, `true` answers an anonymous
82
+ request 401 and `false` lets it through with `user: null`:
83
+
84
+ ```ts
85
+ const strict = Bun.env['STRICT'] === '1';
86
+
87
+ const app = alxia()
88
+ .use(session(accounts, { required: strict }))
89
+ .get('/me', ({ user, reply }) => reply(200, user === null ? 'anonymous' : user.name));
90
+
91
+ // STRICT=1: 401 {"error":"unauthenticated"}; otherwise: 200 "anonymous"
92
+ ```
93
+
94
+ Write the literal `true` when the session is always required: only then
95
+ does the compiler know `user` is never `null`.
96
+
97
+ ### `type`
98
+
99
+ With several user types, `type` narrows `user` to one of them; a user of
100
+ another type is anonymous there. Scope it with `group`:
101
+
102
+ ```ts
103
+ import { alxia } from '@alxia/core';
104
+ import { janusErrors, session } from '@alxia/janus';
105
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
106
+ import { z } from 'zod';
107
+
108
+ const accounts = janus({
109
+ users: {
110
+ patient: {
111
+ schema: z.object({ email: z.email(), name: z.string() }),
112
+ password: { login: 'email' },
113
+ },
114
+ staff: {
115
+ schema: z.object({ username: z.string() }),
116
+ password: { login: 'username' },
117
+ },
118
+ },
119
+ store: createMemoryStores(),
120
+ hasher: scryptHasher(),
121
+ });
122
+
123
+ const app = alxia()
124
+ .use(janusErrors())
125
+ .group('/staff', (staff) =>
126
+ staff
127
+ .use(session(accounts, { type: 'staff', required: true }))
128
+ .get('/me', ({ user, reply }) => reply(200, { username: user.username })), // a staff user
129
+ )
130
+ .use(session(accounts))
131
+ .get('/whoami', ({ user, reply }) =>
132
+ reply(200, user === null ? 'anonymous' : user.type), // 'patient' | 'staff'
133
+ );
134
+ ```
135
+
136
+ A patient's session on `/staff/me` is a 401, as if they had sent none. A
137
+ `type` that is not one of `janus()`'s user types does not compile.
138
+
139
+ ## What the routes read
140
+
141
+ | Field | Without `required`, or `false` | With a `boolean` | With `required: true` |
142
+ | --- | --- | --- | --- |
143
+ | `user` | the user, typed by its schema and narrowed by `type`, or `null` | the user, or `null` | the user |
144
+ | `session` | `Session` or `null` | `Session` or `null` | `Session` |
145
+ | `auth` | `RequestAuth`: `send`, `signOut`, `device` | the same | the same |
146
+ | a 401 in the routes' type | no | yes | yes |
147
+
148
+ `Session` is `@nxgt/janus`'s: `id`, `userId`, `authenticatedAt`,
149
+ `expiresAt`, `revokedAt`, `createdAt`. The token is never in it.
150
+
151
+ `user` is the whole user as `janus()` answers it — your schema's fields,
152
+ plus `id`, `type`, `emailVerified`, `active`, `hasPassword`,
153
+ `hasSecondFactor`, `version`, `createdAt`, `updatedAt` — and never a
154
+ password hash. Reply with the fields a client needs rather than the whole
155
+ object when the user schema holds anything private.
156
+
157
+ `auth` is the request's half of signing in and out, bound to it:
158
+ `auth.send(signedIn)` sets the session cookie and returns the user,
159
+ `auth.signOut()` revokes the session and clears it, `auth.device` is the
160
+ device token the request carries. It is there whether or not the request
161
+ is signed in — [Signing in and out](sign-in-and-out.md) is its page.
162
+
163
+ ## Where the sign-in routes go
164
+
165
+ A sign-in route is called by someone who is not signed in yet, so it sits
166
+ behind a `session()` that is **not** required: behind `required: true` it
167
+ answers `401 {"error":"unauthenticated"}` before it runs. Use the plugin
168
+ twice — once open, for the sign-in routes; once required, for the rest:
169
+
170
+ ```ts
171
+ import { alxia } from '@alxia/core';
172
+ import { janusErrors, session } from '@alxia/janus';
173
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
174
+ import { z } from 'zod';
175
+
176
+ const accounts = janus({
177
+ user: z.object({ email: z.email(), name: z.string() }),
178
+ password: { login: 'email' },
179
+ store: createMemoryStores(),
180
+ hasher: scryptHasher(),
181
+ });
182
+
183
+ const SignIn = z.object({ email: z.string(), password: z.string() });
184
+
185
+ const app = alxia()
186
+ .use(janusErrors())
187
+ .use(session(accounts)) // open: user may be null
188
+ .post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
189
+ const signedIn = await accounts.signIn(body);
190
+ return reply.ok({ id: auth.send(signedIn).id });
191
+ })
192
+ .post('/signout', async ({ auth, reply }) => reply.ok(await auth.signOut()))
193
+ .group('/account', (account) =>
194
+ account
195
+ .use(session(accounts, { required: true })) // required: user never null
196
+ .get('/me', ({ user, reply }) => reply.ok({ name: user.name })),
197
+ )
198
+ .get('/greeting', ({ user, reply }) => reply.ok(user === null ? 'Hello, stranger' : `Hello, ${user.name}`));
199
+ ```
200
+
201
+ The second `session()` replaces the first's `user`, `session` and `auth`
202
+ for the routes after it, with its own types: `user` is never `null` under
203
+ `/account`, and still may be on `/greeting`, outside the group. Without the
204
+ `group`, `.use(session(accounts, { required: true }))` on the app itself works
205
+ the same for every route declared after it.
206
+
207
+ A route behind both plugins still looks the session up once: the plugins
208
+ of one instance share a request's lookup, for the same `type`. A different
209
+ `type` is its own lookup.
210
+
211
+ ## A renewed session is sent again
212
+
213
+ A session type with a `renewAfter` slides: `authenticate` renews it in
214
+ passing once that much has passed, and its expiry moves. `session()` then
215
+ sends the cookie again, after the route, with the new `Expires` — under two
216
+ conditions:
217
+
218
+ - **the request presented the session as a cookie.** A client sending
219
+ `Authorization: Bearer` is never handed a cookie; it keeps its token,
220
+ which is still the same one;
221
+ - **the route did not set a session cookie itself.** A route behind the
222
+ plugin that signs in, or signs out, keeps its own `Set-Cookie`: the
223
+ session it replaced does not undo it.
224
+
225
+ ```ts
226
+ import { alxia } from '@alxia/core';
227
+ import { session } from '@alxia/janus';
228
+ import { createMemoryStores, fixedClock, janus, scryptHasher } from '@nxgt/janus';
229
+ import { z } from 'zod';
230
+
231
+ const clock = fixedClock(Date.UTC(2026, 8, 24));
232
+ const accounts = janus({
233
+ user: z.object({ email: z.email() }),
234
+ password: { login: 'email' },
235
+ session: { lifespan: '7d', renewAfter: '1d' },
236
+ store: createMemoryStores(),
237
+ hasher: scryptHasher(),
238
+ clock,
239
+ });
240
+ const app = alxia()
241
+ .use(session(accounts, { required: true }))
242
+ .get('/me', ({ user, reply }) => reply(200, user.email));
243
+
244
+ const { token } = await accounts.signUp({ email: 'ada@example.com', password: 'correct horse' });
245
+ clock.advance(2 * 86_400_000);
246
+
247
+ const viaCookie = await app.request('/me', { headers: { cookie: `janus-session=${token}` } });
248
+ viaCookie.headers.getSetCookie(); // ['janus-session=…; Expires=Sat, 03 Oct 2026 00:00:00 GMT; Path=/; HttpOnly; SameSite=Lax; Secure']
249
+
250
+ const viaBearer = await app.request('/me', { headers: { authorization: `Bearer ${token}` } });
251
+ viaBearer.headers.getSetCookie(); // []
252
+ ```
253
+
254
+ The cookie's attributes are `janus()`'s `cookie` option — `name`, `domain`,
255
+ `path` (`'/'`), `sameSite` (`'lax'`) and `secure` (`true`). This package
256
+ writes it with `accounts.cookie.serialize`, so they are set in one place.
257
+
258
+ ## An outage is not anonymous
259
+
260
+ A store that cannot answer makes `authenticate` throw `STORE_FAILED`.
261
+ `session()` does not catch it: the request fails, and `janusErrors()`
262
+ answers it **503** `{ "code": "STORE_FAILED" }` — never a 401 that would
263
+ send every user to the sign-in page. Without `janusErrors()`, it is the
264
+ app's 500. See [Errors](errors.md).
265
+
266
+ Routes declared before `session()` do not call the store, and keep
267
+ answering.
268
+
269
+ ## Testing it
270
+
271
+ `app.request` calls the app in process; `fixedClock` moves time.
272
+
273
+ ```ts
274
+ import { expect, test } from 'bun:test';
275
+ import { alxia } from '@alxia/core';
276
+ import { session } from '@alxia/janus';
277
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
278
+ import { z } from 'zod';
279
+
280
+ const accounts = janus({
281
+ user: z.object({ email: z.email() }),
282
+ password: { login: 'email' },
283
+ store: createMemoryStores(),
284
+ hasher: scryptHasher({ cost: 10 }), // fast in tests
285
+ });
286
+ const app = alxia()
287
+ .use(session(accounts, { required: true }))
288
+ .get('/me', ({ user, reply }) => reply(200, { email: user.email }));
289
+
290
+ test('the session reads the user, and anonymous is a 401', async () => {
291
+ const { token } = await accounts.signUp({ email: 'ada@example.com', password: 'correct horse' });
292
+ const me = await app.request('/me', { headers: { authorization: `Bearer ${token}` } });
293
+ expect(await me.json()).toEqual({ email: 'ada@example.com' });
294
+ expect((await app.request('/me')).status).toBe(401);
295
+ });
296
+ ```
297
+
298
+ ## Signature
299
+
300
+ ```ts
301
+ function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
302
+ auth: A,
303
+ options: SessionOptions<T> & { readonly required: true },
304
+ ): Alxia<
305
+ { readonly user: UserOf<A, T>; readonly session: Session; readonly auth: RequestAuth },
306
+ Empty, '', Reply<401, UnauthenticatedBody>
307
+ >;
308
+
309
+ function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
310
+ auth: A,
311
+ options?: SessionOptions<T> & { readonly required?: false },
312
+ ): Alxia<
313
+ { readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth },
314
+ Empty, '', never
315
+ >;
316
+
317
+ function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
318
+ auth: A,
319
+ options?: SessionOptions<T>,
320
+ ): Alxia<
321
+ { readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth },
322
+ Empty, '', Reply<401, UnauthenticatedBody>
323
+ >;
324
+
325
+ interface SessionOptions<T extends string> {
326
+ readonly type?: T;
327
+ readonly required?: boolean;
328
+ /** The device cookie `ctx.auth.device` reads and `ctx.auth.send` sets. */
329
+ readonly device?: DeviceCookieOptions;
330
+ }
331
+
332
+ interface UnauthenticatedBody {
333
+ readonly error: 'unauthenticated';
334
+ }
335
+
336
+ /** The part of janus() this package calls: what any janus() instance answers. */
337
+ type Auth<U extends { readonly type: string }> = Pick<SharedApi<U>, 'authenticate' | 'signOut' | 'cookie'>;
338
+
339
+ /** The users a janus() instance knows, as a union narrowed by user.type. */
340
+ type UserOfAuth<A> = A extends Auth<infer U> ? U : never;
341
+ ```
342
+
343
+ `RequestAuth` and `DeviceCookieOptions` are shown in
344
+ [Signing in and out](sign-in-and-out.md#signatures).
345
+ `UserOf<A, T>` is `Extract<UserOfAuth<A>, { readonly type: T }>`; it is not
346
+ exported. `Alxia`, `Empty` and `Reply` are `@alxia/core`'s; `Session` and
347
+ `SharedApi` are `@nxgt/janus`'s.
348
+
349
+ Next: [Signing in and out](sign-in-and-out.md) sets the cookie this plugin
350
+ reads.
@@ -0,0 +1,340 @@
1
+ # Signing in and out
2
+
3
+ This page covers `ctx.auth`, the request's half of signing in and out —
4
+ `send` after a sign-up or a sign-in, `signOut`, and `device`, the device
5
+ cookie that tells `janus()` a sign-in comes from a device it has seen
6
+ before — and the unbound functions for code outside a route.
7
+
8
+ ```ts
9
+ import { alxia } from '@alxia/core';
10
+ import { janusErrors, session } from '@alxia/janus';
11
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
12
+ import { z } from 'zod';
13
+
14
+ const accounts = janus({
15
+ user: z.object({ email: z.email(), name: z.string() }),
16
+ password: { login: 'email' },
17
+ store: createMemoryStores(),
18
+ hasher: scryptHasher(),
19
+ });
20
+
21
+ const SignUp = z.object({ email: z.string(), name: z.string(), password: z.string() });
22
+ const SignIn = z.object({ email: z.string(), password: z.string() });
23
+
24
+ const app = alxia()
25
+ .use(janusErrors()) // a refused sign-in answered 401, a login taken 409
26
+ .use(session(accounts)) // not required: the people signing in are anonymous
27
+ .post('/signup', { body: SignUp }, async ({ body, auth, reply }) => {
28
+ const signedUp = await accounts.signUp(body);
29
+ return reply.created({ id: auth.send(signedUp).id });
30
+ })
31
+ .post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
32
+ const signedIn = await accounts.signIn(body);
33
+ return reply.ok({ id: auth.send(signedIn).id });
34
+ })
35
+ .post('/signout', async ({ auth, reply }) => reply.ok(await auth.signOut()));
36
+ ```
37
+
38
+ **The sign-in itself stays on your `janus()` instance**: `accounts.signIn`,
39
+ `accounts.signUp`, `accounts.patient.signIn` for a user type, a sign-in by code or
40
+ by link — each flow has its own arguments, so the context does not wrap
41
+ them. `ctx.auth` does what comes after: the cookies.
42
+
43
+ **Behind a `session()` that is not required.** `ctx.auth` exists only on
44
+ routes declared after `session()`, and behind `required: true` an
45
+ anonymous request — anyone signing in — is answered 401 before the route
46
+ runs. [Sessions](sessions.md#where-the-sign-in-routes-go) shows the layout
47
+ with a required group after the sign-in routes.
48
+
49
+ ## `ctx.auth.send(signedIn)`
50
+
51
+ Appends the session cookie to the response — `accounts.cookie.serialize(token,
52
+ session)` — and returns `signedIn.user`. **The token goes in the cookie,
53
+ never in the body**: answer the fields of the user a client needs.
54
+
55
+ ```text
56
+ Set-Cookie: janus-session=yA_6A8zn…; Expires=Fri, 09 Oct 2026 03:50:45 GMT; Path=/; HttpOnly; SameSite=Lax; Secure
57
+ ```
58
+
59
+ It takes a `SessionOpened` — anything holding a `token`, a `session` and
60
+ a `user`, what `signUp`, `signIn` and the other flows of `janus()` that open
61
+ a session answer — so it works after a sign-in by code, by link, or after
62
+ a second factor is confirmed too. With a `deviceToken`, it sets the device
63
+ cookie as well, with the plugin's `device` options ([below](#devices)).
64
+
65
+ The cookie's attributes — `name` (`janus-session`), `domain`, `path`
66
+ (`'/'`), `sameSite` (`'lax'`), `secure` (`true`) — are set once, in
67
+ `janus({ cookie })`, and `session()` reads and renews the same cookie. A
68
+ route that sends a session keeps its own `Set-Cookie`: the plugin never
69
+ overwrites it with the session the request came in with.
70
+
71
+ ### A user type
72
+
73
+ With several user types, the flow is the type's; `ctx.auth.send` is the
74
+ same:
75
+
76
+ ```ts
77
+ import { alxia } from '@alxia/core';
78
+ import { janusErrors, session } from '@alxia/janus';
79
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
80
+ import { z } from 'zod';
81
+
82
+ const accounts = janus({
83
+ users: {
84
+ patient: { schema: z.object({ email: z.email(), name: z.string() }), password: { login: 'email' } },
85
+ staff: { schema: z.object({ username: z.string() }), password: { login: 'username' } },
86
+ },
87
+ store: createMemoryStores(),
88
+ hasher: scryptHasher(),
89
+ });
90
+
91
+ const SignIn = z.object({ email: z.string(), password: z.string() });
92
+
93
+ const app = alxia()
94
+ .use(janusErrors())
95
+ .use(session(accounts))
96
+ .post('/patients/signin', { body: SignIn }, async ({ body, auth, reply }) => {
97
+ const signedIn = await accounts.patient.signIn(body);
98
+ return reply.ok({ id: auth.send(signedIn).id });
99
+ });
100
+ ```
101
+
102
+ ### A second factor
103
+
104
+ With `janus({ secondFactor })`, `signIn` answers either a session or a
105
+ challenge, and `send` refuses the union at compile time
106
+ ([troubleshooting](../troubleshooting.md#type-secondfactorrequired-is-missing-the-following-properties-from-type-sessionopeneduser-token-session-user)).
107
+ Switch on `status` first:
108
+
109
+ ```ts
110
+ .post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
111
+ const result = await accounts.signIn(body);
112
+ if (result.status === 'secondFactor') {
113
+ return reply.ok({ status: result.status, challenge: result.challenge });
114
+ }
115
+ return reply.ok({ status: result.status, id: auth.send(result).id });
116
+ })
117
+ ```
118
+
119
+ The challenge is a secret like a session token; `@nxgt/janus`'s
120
+ second-factor guide says where to keep it and how to confirm it.
121
+
122
+ ## `ctx.auth.signOut()`
123
+
124
+ Revokes the session the request presents — cookie, bearer or
125
+ `X-Session-Token` — and clears the session cookie, **whatever the answer**,
126
+ so a browser holding a stale cookie drops it too. It resolves to `true`
127
+ when a session was revoked, `false` when the request presented none or an
128
+ unknown one.
129
+
130
+ ```ts
131
+ const out = await app.request('/signout', { method: 'POST', headers: { cookie: `janus-session=${token}` } });
132
+ await out.json(); // true
133
+ out.headers.getSetCookie(); // ['janus-session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure']
134
+
135
+ const again = await app.request('/signout', { method: 'POST' });
136
+ await again.json(); // false, and the cookie is cleared all the same
137
+ ```
138
+
139
+ Behind `session(accounts)`, an anonymous request gets that `false`; behind
140
+ `session(accounts, { required: true })`, it gets a 401 instead. Signing out of
141
+ every device is `accounts.signOutEverywhere(user)`, `@nxgt/janus`'s.
142
+
143
+ A store that cannot answer makes it throw `STORE_FAILED`, a 503 through
144
+ [`janusErrors()`](errors.md); the cookie is not cleared then.
145
+
146
+ ## Devices
147
+
148
+ `janus({ devices })` tells a user when their account signs in from a
149
+ device it had not signed in from. The device is a **device token** the
150
+ client keeps in a long-lived cookie and presents at its next sign-in.
151
+ `ctx.auth.device` reads it, `ctx.auth.send` writes it back:
152
+
153
+ ```ts
154
+ import { alxia } from '@alxia/core';
155
+ import { janusErrors, session } from '@alxia/janus';
156
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
157
+ import { z } from 'zod';
158
+
159
+ const accounts = janus({
160
+ user: z.object({ email: z.email() }),
161
+ password: { login: 'email' },
162
+ store: createMemoryStores(),
163
+ hasher: scryptHasher(),
164
+ devices: { keys: [{ id: '2026-09', key: Bun.env['DEVICES_KEY'] ?? '' }] }, // openssl rand -base64 32
165
+ });
166
+
167
+ const SignIn = z.object({ email: z.string(), password: z.string() });
168
+
169
+ const app = alxia()
170
+ .use(janusErrors())
171
+ .use(session(accounts))
172
+ .post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
173
+ const signedIn = await accounts.signIn(body, { device: auth.device });
174
+ return reply.ok({
175
+ id: auth.send(signedIn).id,
176
+ newDevice: signedIn.newDevice, // true for a browser with no device cookie
177
+ });
178
+ });
179
+ ```
180
+
181
+ A first sign-in sends two cookies; the next one, presenting the device
182
+ cookie, answers `newDevice: false` and sets the device cookie again so it
183
+ lasts another `maxAge`:
184
+
185
+ ```text
186
+ Set-Cookie: janus-session=5f1I36hr…; Expires=…; Path=/; HttpOnly; SameSite=Lax; Secure
187
+ Set-Cookie: janus-device=d1.2026-09.By5A2egs…; Path=/; Max-Age=34560000; Secure; HttpOnly; SameSite=Lax
188
+ ```
189
+
190
+ **Only with `devices`.** `ctx.auth.device` is `null` for a browser with no
191
+ device cookie, and `janus()` treats `{ device: null }` as a device given:
192
+ without `devices`, every such sign-in throws a `TypeError`, a 500
193
+ ([troubleshooting](../troubleshooting.md#typeerror-signin-a-device-was-given-but-janus-has-no-devices--pass-devices--keys-)).
194
+ Leave `device` out when `janus()` has no `devices`.
195
+
196
+ ### Another device cookie
197
+
198
+ `session(accounts, { device })` names the cookie both `ctx.auth.device` reads
199
+ and `ctx.auth.send` sets, so they always agree:
200
+
201
+ ```ts
202
+ const device = { name: 'shop-device', domain: 'example.com' };
203
+
204
+ alxia()
205
+ .use(session(accounts, { device }))
206
+ .post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
207
+ const signedIn = await accounts.signIn(body, { device: auth.device });
208
+ return reply.ok({ id: auth.send(signedIn).id });
209
+ });
210
+ ```
211
+
212
+ ### `DeviceCookieOptions`
213
+
214
+ Given to `session(accounts, { device })`, to `sendSession` as
215
+ `{ device: { … } }`, or to `sendDevice`:
216
+
217
+ | Option | Type | Default | Effect |
218
+ | --- | --- | --- | --- |
219
+ | `name` | `string` | `'janus-device'` (`DEVICE_COOKIE`) | the cookie's name, read and set |
220
+ | `domain` | `string` | none: the host that set it | the cookie's `Domain` |
221
+ | `path` | `string` | `'/'` | the cookie's `Path` |
222
+ | `sameSite` | `'lax' \| 'strict' \| 'none'` | `'lax'` | the cookie's `SameSite` |
223
+ | `secure` | `boolean` | `true` | the cookie's `Secure` |
224
+ | `maxAge` | `number`, seconds | `34_560_000` (400 days, the most a browser keeps) | the cookie's `Max-Age` |
225
+
226
+ The cookie is always `HttpOnly`. `DEVICE_COOKIE` is `'janus-device'`, the
227
+ name `@nxgt/janus-hono` uses, so a device one of them remembers, the other
228
+ does too:
229
+
230
+ ```ts
231
+ import { DEVICE_COOKIE } from '@alxia/janus';
232
+
233
+ DEVICE_COOKIE; // 'janus-device'
234
+ ```
235
+
236
+ ## Outside a route: `sendSession`, `signOut`, `deviceOf`
237
+
238
+ `ctx.auth` is these functions bound to the request. Unbound, they take the
239
+ context and the `janus()` instance, for code that has no `ctx.auth`: a
240
+ helper shared by several routes, a test, or a route declared **before**
241
+ any `session()`, which then looks up no session at all:
242
+
243
+ ```ts
244
+ import { alxia } from '@alxia/core';
245
+ import { deviceOf, janusErrors, sendSession, session, signOut } from '@alxia/janus';
246
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
247
+ import { z } from 'zod';
248
+
249
+ const accounts = janus({
250
+ user: z.object({ email: z.email(), name: z.string() }),
251
+ password: { login: 'email' },
252
+ store: createMemoryStores(),
253
+ hasher: scryptHasher(),
254
+ devices: { keys: [{ id: '2026-09', key: Bun.env['DEVICES_KEY'] ?? '' }] },
255
+ });
256
+
257
+ const SignIn = z.object({ email: z.string(), password: z.string() });
258
+
259
+ const app = alxia()
260
+ .use(janusErrors())
261
+ .post('/signin', { body: SignIn }, async (ctx) => {
262
+ const signedIn = await accounts.signIn(ctx.body, { device: deviceOf(ctx) });
263
+ return ctx.reply.ok({ id: sendSession(ctx, accounts, signedIn).id });
264
+ })
265
+ .post('/signout', async (ctx) => ctx.reply.ok(await signOut(ctx, accounts)))
266
+ .use(session(accounts, { required: true }))
267
+ .get('/me', ({ user, reply }) => reply.ok({ name: user.name }));
268
+ ```
269
+
270
+ They behave as `ctx.auth.send`, `ctx.auth.signOut` and `ctx.auth.device`
271
+ above. With a device cookie of another name, pass the same options to both
272
+ ends: `deviceOf(ctx, device)` and `sendSession(ctx, accounts, signedIn, { device })`.
273
+
274
+ ### `deviceOf(ctx, options?)`
275
+
276
+ ```ts
277
+ function deviceOf(ctx: { readonly request: Request }, options?: { readonly name?: string }): string | null;
278
+ ```
279
+
280
+ The device cookie's value, or `null` when the request has none or an empty
281
+ one. `options.name` reads a cookie of another name.
282
+
283
+ ### `sendDevice(ctx, token, options?)`
284
+
285
+ ```ts
286
+ function sendDevice(ctx: { readonly set: ResponseSettings }, token: string, options?: DeviceCookieOptions): void;
287
+ ```
288
+
289
+ Sets the device cookie through `ctx.set.cookies`. `send` and `sendSession`
290
+ call it when `signedIn.deviceToken` is a string; call it yourself only for
291
+ a flow that answers a device token without a session.
292
+
293
+ ## Signatures
294
+
295
+ ```ts
296
+ /** auth, on every route after session(). */
297
+ interface RequestAuth {
298
+ /** The device token the request carries, for signIn(…, { device }): null when none. */
299
+ readonly device: string | null;
300
+ /** Sends the session cookie — and the device cookie, with a deviceToken — and answers the user. */
301
+ send<U>(signedIn: SessionOpened<U>): U;
302
+ /** Revokes the request's session and clears its cookie. */
303
+ signOut(): Promise<boolean>;
304
+ }
305
+
306
+ /** The part of @nxgt/janus's SignedIn — or of signUp's answer — that opens a session. */
307
+ interface SessionOpened<U> extends Pick<SignedIn<U>, 'token' | 'session' | 'user'> {
308
+ readonly deviceToken?: string | null;
309
+ }
310
+
311
+ function sendSession<U>(
312
+ ctx: { readonly set: ResponseSettings },
313
+ auth: Pick<Auth<{ readonly type: string }>, 'cookie'>,
314
+ signedIn: SessionOpened<U>,
315
+ options?: SendSessionOptions,
316
+ ): U;
317
+
318
+ interface SendSessionOptions {
319
+ readonly device?: DeviceCookieOptions;
320
+ }
321
+
322
+ function signOut(
323
+ ctx: { readonly request: Request; readonly set: ResponseSettings },
324
+ auth: Pick<Auth<{ readonly type: string }>, 'signOut' | 'cookie'>,
325
+ ): Promise<boolean>;
326
+
327
+ interface DeviceCookieOptions {
328
+ readonly name?: string;
329
+ readonly domain?: string;
330
+ readonly path?: string;
331
+ readonly sameSite?: 'lax' | 'strict' | 'none';
332
+ readonly secure?: boolean;
333
+ readonly maxAge?: number;
334
+ }
335
+
336
+ const DEVICE_COOKIE = 'janus-device';
337
+ ```
338
+
339
+ `ResponseSettings` is `@alxia/core`'s, `Session` is `@nxgt/janus`'s;
340
+ `Auth` is described in [Sessions](sessions.md#signature).