@nxgt/janus-hono 0.5.1 → 0.6.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/README.md CHANGED
@@ -47,7 +47,11 @@ declarations import without extensions, so `nodenext` is not supported.
47
47
  | Export | What it is |
48
48
  | --- | --- |
49
49
  | `session(auth, options?)` | Middleware. Reads who the request belongs to — `auth.authenticate(c.req.raw)` — and sets `c.var.user` and `c.var.session`, `null` for an anonymous request. `{ required: true }` answers an anonymous request 401 and types `c.var.user` as never `null`. `{ type: 'staff' }` treats a user of any other type as anonymous. Sends a renewed session's cookie again |
50
- | `sendSession(c, auth, signedIn)` | Appends the session cookie to the response — after `signUp`, `signIn`, `secondFactor.confirm`, `secondFactor.recover`, or anything that answered `{ token, session, user }` — and answers the user. With a second factor configured, narrow `signIn`'s answer on `status` first |
50
+ | `sendSession(c, auth, signedIn, options?)` | Appends the session cookie to the response — after `signUp`, `signIn`, `secondFactor.confirm`, `secondFactor.recover`, or anything that answered `{ token, session, user }` — and answers the user. When the answer carries a `deviceToken` — the sign-in was given a `device` — it appends the device cookie too, or sets it again for another `maxAge`; `options.device` is its `DeviceCookieOptions`. With a second factor configured, narrow `signIn`'s answer on `status` first |
51
+ | `deviceOf(c, options?)` | The device token the request's device cookie holds, or `null` when there is none or it is empty: what a sign-in takes as `device` — `auth.signIn(input, { device: deviceOf(c) })`. `options` are the `DeviceCookieOptions` given to `sendSession` |
52
+ | `DeviceCookieOptions` | The device cookie: `{ name?, domain?, path?, sameSite?, secure?, maxAge? }` — `'janus-device'`, none, `'/'`, `'Lax'`, `true` and 400 days (`34560000` seconds) by default. Always `HttpOnly`. `sameSite` is Hono's capitalised `'Lax' \| 'Strict' \| 'None'` |
53
+ | `DEVICE_COOKIE` | `'janus-device'`, the device cookie's default name |
54
+ | `SendSessionOptions` | `{ device? }`, the options of `sendSession` — for a wrapper of your own |
51
55
  | `fresh(maxAge, { clock? })` | Middleware, after `session()`. The route runs only for a session that proved who it is less than `maxAge` ago — signed in, or confirmed since by `auth.stepUp.confirm`. An older one throws `STEP_UP_REQUIRED`, which `janusErrors()` answers 403; an anonymous request is 401 with no body. `clock` is the one given to `janus()`, in a spec |
52
56
  | `signOut(c, auth)` | Revokes the session the request presents and clears the cookie, whatever the answer. `false` when the request presented no session, or an unknown one |
53
57
  | `janusErrors({ report?, fallback? })` | An `app.onError` handler: every `JanusError` answered with `statusOf(code)` and `bodyOf(error)` — and a `Retry-After` header when it carries `retryAfter`, a throttled sign-in; anything else to `fallback`, or to Hono's own handling. `report(error, c)` sees every one answered 5xx first — `STORE_FAILED` and the like, for your logs — and cannot change the answer: one that throws or rejects is a warning, and the 503 is sent |
@@ -58,7 +62,7 @@ declarations import without extensions, so `nodenext` is not supported.
58
62
  | `UserOfAuth<typeof auth>` | The users an instance knows, as a union narrowed by `user.type` |
59
63
  | `permission(access, permission, type, load, options?)` | Middleware. Loads the object with `load(c)`, checks `access.can(c.var.user, permission, object)` with `type` added, and sets `c.var.object` to what `load` answered. Anonymous: 401, before loading. `load` answers `null`: 404. A denial: 403. `{ ctx: (c, object) => … }` is required exactly when the permission reaches a condition; `{ subject: (c) => … }` replaces `c.var.user`. One per route |
60
64
  | `byParam(name, find)` | A `load` for `permission()`: `find(c.req.param(name))`, or `null` — a 404 — when the route has no such parameter |
61
- | `bindJanus({ auth?, access? })` | The functions above with the instances bound: `session(options?)`, `sendSession(c, signedIn)` and `signOut(c)` with `auth`; `permission(permission, type, load, options?)` with `access`; `provide()` always |
65
+ | `bindJanus({ auth?, access? })` | The functions above with the instances bound: `session(options?)`, `sendSession(c, signedIn, options?)` and `signOut(c)` with `auth`; `permission(permission, type, load, options?)` with `access`; `provide()` always |
62
66
  | `provide({ auth?, access? })` | Middleware. Sets `c.var.auth` and `c.var.access` to the instances given — only those — for a route that writes users or tuples |
63
67
  | `ObjectData<C, Type>`, `PermissionOptions`, `Instances` | The types of `load`'s answer, of `permission()`'s options and of `provide()`'s argument |
64
68
  | `FreshOptions` | `{ clock? }`, the options of `fresh()` |
@@ -123,6 +127,33 @@ app.onError(janusErrors()); // a wrong code: 401 { code: 'CODE_INVALID', attempt
123
127
  `SECOND_FACTOR_ACTIVE` 409. The enrolment routes and a bearer client's
124
128
  sign-in are in [the routes guide](docs/guide/routes.md#a-second-factor).
125
129
 
130
+ ## Devices
131
+
132
+ With `janus({ devices })`, a sign-in given the device token the browser
133
+ holds tells a new device from a known one. `deviceOf(c)` reads the device
134
+ cookie, and `sendSession` sets it from the answer's `deviceToken`:
135
+
136
+ ```ts
137
+ import { deviceOf, sendSession } from '@nxgt/janus-hono';
138
+ import { Hono } from 'hono';
139
+ import { auth } from './auth'; // janus({ …, devices: { keys } })
140
+
141
+ const app = new Hono().post('/sign-in', async (c) => {
142
+ const { email, password } = await c.req.json();
143
+ const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });
144
+ const user = sendSession(c, auth, signedIn); // the session cookie, and janus-device for 400 days
145
+ if (signedIn.newDevice) await tellTheUser(user, c.req.header('user-agent')); // yours — @nxgt/janus-mail's newSignIn
146
+ return c.json({ id: user.id });
147
+ });
148
+ ```
149
+
150
+ The device cookie is `janus-device`, `HttpOnly`, `Secure`, `SameSite=Lax`,
151
+ `Path=/`, for 400 days; `{ device: { name, domain, path, sameSite, secure,
152
+ maxAge } }` changes it — give `deviceOf` the same options. With a second
153
+ factor, give `deviceOf(c)` again to `secondFactor.confirm` and `recover`:
154
+ the challenge carries no device. [The routes guide](docs/guide/routes.md#the-device-cookie)
155
+ has the second factor's routes and a bearer client.
156
+
126
157
  ## Step-up
127
158
 
128
159
  A sensitive route asks for a recent proof: `fresh(maxAge)` after
@@ -295,18 +326,31 @@ passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
295
326
  - **`permission()` needs `session()` before it**, or `{ subject }`. Without
296
327
  either, it throws a `TypeError` at the first request — a wiring error, not
297
328
  an anonymous 401.
329
+ - **Give `deviceOf` and `sendSession` the same options.** A cookie written
330
+ under one name or path and read under another is never found: every
331
+ sign-in is then a new device. Keep one `DeviceCookieOptions` and pass it
332
+ to both.
333
+ - **The device cookie's `sameSite` is Hono's, capitalised.** `'Lax'`, not
334
+ the `'lax'` of `janus({ cookie })` — a compile error, where a cast would
335
+ hand Hono a value it does not know.
336
+ - **`signOut` leaves the device cookie.** The device stays known after a
337
+ sign-out, which is the point: a user who signs out and back in is not
338
+ told of a new device.
339
+ - **With a second factor, give the device again.** `auth.secondFactor.confirm(challenge,
340
+ code, { device: deviceOf(c) })`: the challenge carries none, and a
341
+ confirmation given no device sets no device cookie and reports nothing.
298
342
  - **A denial is 403, so it tells that the object exists.** Where that is a
299
343
  leak, load only what the user may see and let `load` answer `null` — a 404.
300
344
 
301
345
  ## Documentation
302
346
 
303
- - [Guides](docs/README.md) — wiring the middleware, the routes of a sign-in, of a second factor and of a code or a link sent by e-mail, guarded routes
347
+ - [Guides](docs/README.md) — wiring the middleware, the routes of a sign-in, of a second factor, of a code or a link sent by e-mail and of the device cookie, guarded routes
304
348
  - [Troubleshooting](docs/troubleshooting.md) — by the symptom or message you see
305
349
  - [Roadmap](docs/roadmap.md) — what is next, and what is not planned
306
350
 
307
351
  ## Type safety, counted
308
352
 
309
- Twenty-six plausible mistakes are refused by the compiler, each with a
353
+ Twenty-eight plausible mistakes are refused by the compiler, each with a
310
354
  `@ts-expect-error` case in `test/types/`:
311
355
 
312
356
  - six in `session.ts`: reading `c.var.user` where it may be `null` (twice,
@@ -323,9 +367,12 @@ Twenty-six plausible mistakes are refused by the compiler, each with a
323
367
  - nine in `bind.ts`: the same refusals through `bindJanus()` — a nullable
324
368
  user, an unknown user type, a missing `ctx`, a misspelled permission — a
325
369
  `permission`, `session`, `sendSession` or `signOut` it was not given the
326
- instance for, and an `access` that is no `permissions()` instance.
370
+ instance for, and an `access` that is no `permissions()` instance;
327
371
  - one in `fresh.ts`: a `maxAge` that is no duration — `'10 minutes'` for
328
- `'10m'`.
372
+ `'10m'`;
373
+ - two in `device.ts`: the session cookie's lowercase `sameSite` given to the
374
+ device cookie, whose `'Lax'` is Hono's, and the device cookie's options
375
+ given to `sendSession` at the top rather than under `device`.
329
376
 
330
377
  ## Licence
331
378
 
package/dist/bind.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Session } from '@nxgt/janus';
2
2
  import type { CheckableOf, ModelConfig, ObjectTypeOf, Permissions } from '@nxgt/janus/permissions';
3
3
  import type { Context, MiddlewareHandler } from 'hono';
4
4
  import { type Awaitable, type ObjectData, type OptionsArgs } from './permission';
5
- import { type Auth, type SessionEnv, type SessionOptions, type UserOfAuth } from './session';
5
+ import { type Auth, type SendSessionOptions, type SessionEnv, type SessionOptions, type UserOfAuth } from './session';
6
6
  /** `session(auth, options?)`, with `auth` bound: the same three forms. */
7
7
  export interface BoundSession<A> {
8
8
  <const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(options: SessionOptions<T> & {
@@ -22,12 +22,13 @@ export type BoundPermission<C extends ModelConfig> = <const T extends ObjectType
22
22
  /** The functions of this package that take `auth`, with `auth` bound. */
23
23
  export interface BoundAuth<A> {
24
24
  readonly session: BoundSession<A>;
25
- /** `sendSession(c, auth, signedIn)`: sets the cookie, answers the user. */
25
+ /** `sendSession(c, auth, signedIn, options?)`: sets the cookies, answers the user. */
26
26
  readonly sendSession: <U>(c: Context, signedIn: {
27
27
  readonly token: string;
28
28
  readonly session: Session;
29
29
  readonly user: U;
30
- }) => U;
30
+ readonly deviceToken?: string | null;
31
+ }, options?: SendSessionOptions) => U;
31
32
  /** `signOut(c, auth)`. */
32
33
  readonly signOut: (c: Context) => Promise<boolean>;
33
34
  }
@@ -1 +1 @@
1
- {"version":3,"file":"bind.d.ts","sourceRoot":"","sources":["../src/bind.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"bind.d.ts","sourceRoot":"","sources":["../src/bind.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ import type { Context } from 'hono';
2
+ /** The device cookie's name when none is given. */
3
+ export declare const DEVICE_COOKIE = "janus-device";
4
+ /**
5
+ * The long-lived cookie that holds the device token `@nxgt/janus` answers
6
+ * as `deviceToken`. Every default is the strict one, as for the session
7
+ * cookie; give `deviceOf` and `sendSession` the same options.
8
+ */
9
+ export interface DeviceCookieOptions {
10
+ /** `'janus-device'` when absent. */
11
+ readonly name?: string;
12
+ readonly domain?: string;
13
+ /** `'/'` when absent. */
14
+ readonly path?: string;
15
+ /** `'Lax'` when absent. */
16
+ readonly sameSite?: 'Lax' | 'Strict' | 'None';
17
+ /** `true` when absent. */
18
+ readonly secure?: boolean;
19
+ /** In seconds. Four hundred days when absent — the longest a browser keeps one. */
20
+ readonly maxAge?: number;
21
+ }
22
+ /**
23
+ * The device token the request's cookie holds, or `null` when it holds none:
24
+ * what a sign-in takes as `device`.
25
+ *
26
+ * ```ts
27
+ * const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });
28
+ * ```
29
+ */
30
+ export declare function deviceOf(c: Context, options?: DeviceCookieOptions): string | null;
31
+ /** Sets the device cookie, or sets it again so it lasts another `maxAge`. Appends. */
32
+ export declare function sendDevice(c: Context, deviceToken: string, options?: DeviceCookieOptions): void;
33
+ //# sourceMappingURL=device.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device.d.ts","sourceRoot":"","sources":["../src/device.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAGpC,mDAAmD;AACnD,eAAO,MAAM,aAAa,iBAAiB,CAAC;AAK5C;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IACnC,oCAAoC;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,yBAAyB;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,2BAA2B;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC9C,0BAA0B;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CACvB,CAAC,EAAE,OAAO,EACV,OAAO,GAAE,mBAAwB,GAC/B,MAAM,GAAG,IAAI,CAGf;AAED,sFAAsF;AACtF,wBAAgB,UAAU,CACzB,CAAC,EAAE,OAAO,EACV,WAAW,EAAE,MAAM,EACnB,OAAO,GAAE,mBAAwB,GAC/B,IAAI,CASN"}
package/dist/index.d.ts CHANGED
@@ -7,8 +7,11 @@
7
7
  * - `fresh(maxAge, options?)` — middleware, behind `session(auth)`: the
8
8
  * route runs only for a session that proved who it is less than `maxAge`
9
9
  * ago, `STEP_UP_REQUIRED` otherwise;
10
- * - `sendSession(c, auth, signedIn)` and `signOut(c, auth)` — the cookie, set
11
- * and cleared; `sendSession` answers the user;
10
+ * - `sendSession(c, auth, signedIn, options?)` and `signOut(c, auth)` — the
11
+ * cookie, set and cleared; `sendSession` answers the user, and sets the
12
+ * device cookie when the sign-in answered a `deviceToken`;
13
+ * - `deviceOf(c, options?)` — the device token the request's cookie holds,
14
+ * or `null`: what a sign-in takes as `device`;
12
15
  * - `permission(access, permission, type, load)` — middleware: the route runs
13
16
  * only if the subject holds the permission on the object `load` answers,
14
17
  * and gets that object as `c.var.object`; `byParam(name, find)` is a `load`
@@ -25,9 +28,10 @@
25
28
  * It defines no error class: what it lets through is `@nxgt/janus`'s own.
26
29
  */
27
30
  export { type Bindable, type Bound, type BoundAuth, type BoundPermission, type BoundSession, bindJanus, } from './bind';
31
+ export { DEVICE_COOKIE, type DeviceCookieOptions, deviceOf } from './device';
28
32
  export { bodyOf, type JanusErrorsOptions, janusErrors, statusOf, } from './errors';
29
33
  export { type FreshOptions, fresh } from './fresh';
30
34
  export { byParam, type ObjectData, type PermissionOptions, permission, } from './permission';
31
35
  export { type Instances, provide } from './provide';
32
- export { type SessionEnv, type SessionOptions, sendSession, session, signOut, type UserOfAuth, } from './session';
36
+ export { type SendSessionOptions, type SessionEnv, type SessionOptions, sendSession, session, signOut, type UserOfAuth, } from './session';
33
37
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EACN,KAAK,QAAQ,EACb,KAAK,KAAK,EACV,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,SAAS,GACT,MAAM,QAAQ,CAAC;AAChB,OAAO,EACN,MAAM,EACN,KAAK,kBAAkB,EACvB,WAAW,EACX,QAAQ,GACR,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,OAAO,EACP,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,UAAU,GACV,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,KAAK,SAAS,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpD,OAAO,EACN,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,WAAW,EACX,OAAO,EACP,OAAO,EACP,KAAK,UAAU,GACf,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EACN,KAAK,QAAQ,EACb,KAAK,KAAK,EACV,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,SAAS,GACT,MAAM,QAAQ,CAAC;AAChB,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAC7E,OAAO,EACN,MAAM,EACN,KAAK,kBAAkB,EACvB,WAAW,EACX,QAAQ,GACR,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,OAAO,EACP,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,UAAU,GACV,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,KAAK,SAAS,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpD,OAAO,EACN,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,WAAW,EACX,OAAO,EACP,OAAO,EACP,KAAK,UAAU,GACf,MAAM,WAAW,CAAC"}
package/dist/index.js CHANGED
@@ -50,7 +50,28 @@ function provide(instances) {
50
50
  }
51
51
 
52
52
  // src/session.ts
53
- import { getCookie } from "hono/cookie";
53
+ import { getCookie as getCookie2 } from "hono/cookie";
54
+
55
+ // src/device.ts
56
+ import { getCookie, setCookie } from "hono/cookie";
57
+ var DEVICE_COOKIE = "janus-device";
58
+ var MAX_AGE = 400 * 24 * 60 * 60;
59
+ function deviceOf(c, options = {}) {
60
+ const token = getCookie(c, options.name ?? DEVICE_COOKIE);
61
+ return token === undefined || token === "" ? null : token;
62
+ }
63
+ function sendDevice(c, deviceToken, options = {}) {
64
+ setCookie(c, options.name ?? DEVICE_COOKIE, deviceToken, {
65
+ path: options.path ?? "/",
66
+ ...options.domain === undefined ? {} : { domain: options.domain },
67
+ sameSite: options.sameSite ?? "Lax",
68
+ secure: options.secure ?? true,
69
+ httpOnly: true,
70
+ maxAge: options.maxAge ?? MAX_AGE
71
+ });
72
+ }
73
+
74
+ // src/session.ts
54
75
  function session(auth, options = {}) {
55
76
  return async (c, next) => {
56
77
  const current = await auth.authenticate(c.req.raw, options.type === undefined ? undefined : { type: options.type });
@@ -61,15 +82,18 @@ function session(auth, options = {}) {
61
82
  c.set("session", current?.session ?? null);
62
83
  await next();
63
84
  const name = `${auth.cookie.name}=`;
64
- if (current?.renewed === true && getCookie(c, auth.cookie.name) === current.token && !c.res.headers.getSetCookie().some((value) => value.startsWith(name))) {
85
+ if (current?.renewed === true && getCookie2(c, auth.cookie.name) === current.token && !c.res.headers.getSetCookie().some((value) => value.startsWith(name))) {
65
86
  sendSession(c, auth, current);
66
87
  }
67
88
  };
68
89
  }
69
- function sendSession(c, auth, signedIn) {
90
+ function sendSession(c, auth, signedIn, options = {}) {
70
91
  c.header("Set-Cookie", auth.cookie.serialize(signedIn.token, signedIn.session), {
71
92
  append: true
72
93
  });
94
+ if (typeof signedIn.deviceToken === "string") {
95
+ sendDevice(c, signedIn.deviceToken, options.device);
96
+ }
73
97
  return signedIn.user;
74
98
  }
75
99
  async function signOut(c, auth) {
@@ -86,7 +110,7 @@ function bindJanus(instances) {
86
110
  };
87
111
  if (auth !== undefined) {
88
112
  bound.session = (options = {}) => session(auth, options);
89
- bound.sendSession = (c, signedIn) => sendSession(c, auth, signedIn);
113
+ bound.sendSession = (c, signedIn, options) => sendSession(c, auth, signedIn, options);
90
114
  bound.signOut = (c) => signOut(c, auth);
91
115
  }
92
116
  if (access !== undefined) {
@@ -168,9 +192,11 @@ function fresh(maxAge, options = {}) {
168
192
  };
169
193
  }
170
194
  export {
195
+ DEVICE_COOKIE,
171
196
  bindJanus,
172
197
  bodyOf,
173
198
  byParam,
199
+ deviceOf,
174
200
  fresh,
175
201
  janusErrors,
176
202
  permission,
@@ -181,5 +207,5 @@ export {
181
207
  statusOf
182
208
  };
183
209
 
184
- //# debugId=B7AF7A43DA4D8D7A64756E2164756E21
210
+ //# debugId=21C4E2121FB06E7D64756E2164756E21
185
211
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/permission.ts", "../src/provide.ts", "../src/session.ts", "../src/bind.ts", "../src/errors.ts", "../src/fresh.ts"],
3
+ "sources": ["../src/permission.ts", "../src/provide.ts", "../src/session.ts", "../src/device.ts", "../src/bind.ts", "../src/errors.ts", "../src/fresh.ts"],
4
4
  "sourcesContent": [
5
5
  "import type {\n\tCheckableOf,\n\tCtxOf,\n\tFieldsOf,\n\tModelConfig,\n\tObjectTypeOf,\n\tPermissions,\n\tSubjectRef,\n} from '@nxgt/janus/permissions';\nimport type { Context, MiddlewareHandler } from 'hono';\n\n/**\n * An object of type `T` as the application loads it: its id, **every field a\n * `fromField` of its type reads**, and whatever else it carries. The type is\n * the middleware's to add.\n */\nexport type ObjectData<C extends ModelConfig, T extends ObjectTypeOf<C>> = {\n\treadonly id: string;\n} & { readonly [F in FieldsOf<C, T>]: string | null };\n\nexport type Awaitable<V> = V | Promise<V>;\n\n/**\n * The options of `permission()`: `ctx` required exactly when a condition of\n * the permission is reachable, as for `can()`.\n */\nexport type PermissionOptions<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = {\n\t/**\n\t * Who asks. `c.var.user` — set by `session()` — when absent; pass it when\n\t * your users are not `janus()`'s. `null` is anonymous.\n\t */\n\treadonly subject?: (c: Context) => Awaitable<SubjectRef<C> | null>;\n} & ([CtxOf<C, T, P>] extends [never]\n\t? { readonly ctx?: never }\n\t: {\n\t\t\t/** The condition's context, read from the request and the loaded object. */\n\t\t\treadonly ctx: (c: Context, object: O) => Awaitable<CtxOf<C, T, P>>;\n\t\t});\n\n/**\n * The options argument: required exactly when `ctx` is. Loose when `T` or `P`\n * is its whole constraint — what the compiler falls back to when the object\n * type or the permission was wrong — so the error names that argument, not a\n * missing one; the rule `can()` follows.\n */\nexport type OptionsArgs<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = [ObjectTypeOf<C>] extends [T]\n\t? IsSingle<ObjectTypeOf<C>> extends true\n\t\t? PermissionArgs<C, T, P, O>\n\t\t: [options?: LooseOptions]\n\t: PermissionArgs<C, T, P, O>;\n\n/** `OptionsArgs` once `T` is known right: loose when `P` is its whole constraint. */\ntype PermissionArgs<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = [CheckableOf<C, T>] extends [P]\n\t? IsSingle<CheckableOf<C, T>> extends true\n\t\t? StrictArgs<C, T, P, O>\n\t\t: [options?: LooseOptions]\n\t: StrictArgs<C, T, P, O>;\n\ntype StrictArgs<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = [CtxOf<C, T, P>] extends [never]\n\t? [options?: PermissionOptions<C, T, P, O>]\n\t: [options: PermissionOptions<C, T, P, O>];\n\ntype LooseOptions = {\n\treadonly subject?: (c: Context) => unknown;\n\treadonly ctx?: (c: Context, object: never) => unknown;\n};\n\ntype UnionToIntersection<U> = (\n\tU extends unknown\n\t\t? (union: U) => void\n\t\t: never\n) extends (intersection: infer I) => void\n\t? I\n\t: never;\n\n/** Whether `U` is one type rather than a union of several. */\ntype IsSingle<U> = [U] extends [UnionToIntersection<U>] ? true : false;\n\ntype LooseCan = (\n\tsubject: SubjectRef<ModelConfig> | null,\n\tpermission: string,\n\tobject: { readonly type: string; readonly id: string },\n\toptions?: { readonly ctx: unknown },\n) => Promise<boolean>;\n\n/**\n * The middleware that lets a request through only if its subject holds\n * `permission` on an object of `type`: it loads the object once, with `load`,\n * checks it with `access.can`, and hands it to the route as `c.var.object`.\n *\n * | The request | Answered |\n * | --- | --- |\n * | anonymous | 401, no body — before the object is loaded |\n * | `load` answers `null` | 404, no body |\n * | a denial | 403, no body |\n * | allowed | the route runs, `c.var.object` set |\n *\n * One per route: a second one throws a `TypeError`, since both would claim\n * `c.var.object`. A parent object is checked through an arrow in the model.\n *\n * **A failure throws**, as everywhere: a store that cannot answer is\n * `STORE_FAILED`, never a 403 — `janusErrors()` answers it 503.\n */\nexport function permission<\n\tC extends ModelConfig,\n\tconst T extends ObjectTypeOf<C>,\n\tconst P extends CheckableOf<C, T>,\n\tO extends ObjectData<C, T>,\n>(\n\taccess: Pick<Permissions<C>, 'can'>,\n\tpermission: P,\n\ttype: T,\n\tload: (c: Context) => Awaitable<O | null>,\n\t...options: OptionsArgs<C, T, P, O>\n): MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { object: O };\n}>;\nexport function permission(\n\taccess: { readonly can: unknown },\n\tpermission: string,\n\ttype: string,\n\tload: (c: Context) => Awaitable<{ readonly id: string } | null>,\n\toptions: {\n\t\treadonly subject?: (\n\t\t\tc: Context,\n\t\t) => Awaitable<SubjectRef<ModelConfig> | null>;\n\t\treadonly ctx?: (c: Context, object: unknown) => Awaitable<unknown>;\n\t} = {},\n): MiddlewareHandler {\n\tconst can = access.can as LooseCan;\n\n\treturn async (c, next) => {\n\t\tif (c.get('object') !== undefined) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'permission(): c.var.object is already set — one permission() per route; check a parent object through an arrow in the model',\n\t\t\t);\n\t\t}\n\t\tconst subject =\n\t\t\toptions.subject === undefined ? userOf(c) : await options.subject(c);\n\t\tif (subject === null || subject === undefined) return c.body(null, 401);\n\n\t\tconst object = await load(c);\n\t\tif (object === null) return c.body(null, 404);\n\n\t\tconst allowed = await can(\n\t\t\tsubject,\n\t\t\tpermission,\n\t\t\tview(object, type),\n\t\t\toptions.ctx === undefined\n\t\t\t\t? undefined\n\t\t\t\t: { ctx: await options.ctx(c, object) },\n\t\t);\n\t\tif (!allowed) return c.body(null, 403);\n\n\t\tc.set('object', object);\n\t\tawait next();\n\t};\n}\n\n/**\n * A `load` reading one path parameter: `find(id)`, or `null` — a 404 — when\n * the route has no such parameter.\n *\n * ```ts\n * permission(access, 'view', 'record', byParam('id', (id) => records.find(id)))\n * ```\n */\nexport function byParam<O>(\n\tname: string,\n\tfind: (id: string) => Awaitable<O | null>,\n): (c: Context) => Awaitable<O | null> {\n\treturn (c) => {\n\t\tconst id = c.req.param(name);\n\t\treturn id === undefined ? null : find(id);\n\t};\n}\n\n/**\n * The object as `can()` reads it: `type` added, every other field read from\n * the loaded object itself — so a getter, a class's `#private` state or an\n * ORM document's accessors answer as they do in the route. A spread would\n * copy own enumerable fields only.\n */\nfunction view(\n\tobject: object,\n\ttype: string,\n): { readonly type: string; readonly id: string } {\n\treturn new Proxy(object, {\n\t\tget: (target, key) => (key === 'type' ? type : Reflect.get(target, key)),\n\t\thas: (target, key) => key === 'type' || Reflect.has(target, key),\n\t}) as { readonly type: string; readonly id: string };\n}\n\n/** `c.var.user`, which `session()` sets — or a wiring error when nothing did. */\nfunction userOf(c: Context): SubjectRef<ModelConfig> | null {\n\tconst user: unknown = c.get('user');\n\tif (user === undefined) {\n\t\tthrow new TypeError(\n\t\t\t'permission(): c.var.user is not set — put session(auth) before it, or pass { subject }',\n\t\t);\n\t}\n\treturn user as SubjectRef<ModelConfig> | null;\n}\n",
6
6
  "import type { MiddlewareHandler } from 'hono';\n\n/** What `provide()` takes: the `janus()` instance, the `permissions()` one, or both. */\nexport interface Instances {\n\treadonly auth?: object;\n\treadonly access?: object;\n}\n\n/**\n * The middleware that puts the instances on the context — `c.var.auth`, and\n * `c.var.access` — so a route writes users and tuples through them rather\n * than through a module import: `c.var.access.grant(record, 'owners', user)`.\n *\n * Only what is passed is set, and typed.\n */\nexport function provide<const I extends Instances>(\n\tinstances: I,\n): MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { [K in keyof I & keyof Instances]: I[K] };\n}> {\n\treturn async (c, next) => {\n\t\tif (instances.auth !== undefined) c.set('auth', instances.auth);\n\t\tif (instances.access !== undefined) c.set('access', instances.access);\n\t\tawait next();\n\t};\n}\n",
7
- "import type { Session, SharedApi } from '@nxgt/janus';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport { getCookie } from 'hono/cookie';\n\n/** Anything `janus()` answered: the part of it this package calls. */\nexport type Auth<U extends { readonly type: string }> = Pick<\n\tSharedApi<U>,\n\t'authenticate' | 'signOut' | 'cookie'\n>;\n\n/** The users an `auth` instance knows, as a union narrowed by `user.type`. */\nexport type UserOfAuth<A> = A extends Auth<infer U> ? U : never;\n\n/**\n * What `session()` sets on the context: `c.var.user` and `c.var.session`,\n * both `null` for an anonymous request unless the route requires a user.\n *\n * `new Hono<SessionEnv<typeof auth>>()` types every route of an app that\n * wires `app.use(session(auth))` once.\n */\nexport type SessionEnv<\n\tA,\n\tT extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n\tRequired extends boolean = false,\n> = {\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: Required extends true\n\t\t? {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }>;\n\t\t\t\tsession: Session;\n\t\t\t}\n\t\t: {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }> | null;\n\t\t\t\tsession: Session | null;\n\t\t\t};\n};\n\nexport interface SessionOptions<T extends string> {\n\t/** Only a user of this type is authenticated here; any other is anonymous. */\n\treadonly type?: T;\n\t/**\n\t * `true`: an anonymous request is answered 401, with no body, and the\n\t * route never runs — so `c.var.user` is never `null` in it. Only a literal\n\t * `true` types it so: a computed `boolean` leaves `c.var.user` nullable.\n\t */\n\treadonly required?: boolean;\n}\n\n/**\n * The middleware that reads who a request belongs to — `auth.authenticate`\n * on the request — and sets `c.var.user` and `c.var.session`.\n *\n * **An outage is not anonymous**: a store that cannot answer rejects with\n * `STORE_FAILED`, and this middleware lets it through to `app.onError` —\n * `janusErrors()` answers it 503, never 401.\n *\n * A session renewed in passing is sent again as a cookie, after the route\n * ran — but only to a request that presented it as one: a client that sends\n * `Authorization: Bearer` is not handed a cookie it never asked for. And\n * never over a session cookie the route set itself — `sendSession` after a\n * sign-in, `signOut`'s clearing — which a browser would otherwise overwrite.\n */\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions: SessionOptions<T> & { readonly required: true },\n): MiddlewareHandler<SessionEnv<A, T, true>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions?: SessionOptions<T> & { readonly required?: false },\n): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(auth: A, options: SessionOptions<T>): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session(\n\tauth: Auth<{ readonly type: string }>,\n\toptions: SessionOptions<string> = {},\n): MiddlewareHandler {\n\treturn async (c, next) => {\n\t\tconst current = await auth.authenticate(\n\t\t\tc.req.raw,\n\t\t\toptions.type === undefined ? undefined : { type: options.type },\n\t\t);\n\n\t\tif (current === null && options.required === true) {\n\t\t\treturn c.body(null, 401);\n\t\t}\n\t\tc.set('user', current?.user ?? null);\n\t\tc.set('session', current?.session ?? null);\n\n\t\tawait next();\n\n\t\t// The route's own cookie wins: a sign-in or a sign-out behind this\n\t\t// middleware must not be undone by the session it replaced.\n\t\tconst name = `${auth.cookie.name}=`;\n\t\tif (\n\t\t\tcurrent?.renewed === true &&\n\t\t\tgetCookie(c, auth.cookie.name) === current.token &&\n\t\t\t!c.res.headers.getSetCookie().some((value) => value.startsWith(name))\n\t\t) {\n\t\t\tsendSession(c, auth, current);\n\t\t}\n\t};\n}\n\n/**\n * Sends the session cookie — after `signUp`, `signIn`, or anything else that\n * answered a session token and its session — and answers the user. Appends: a\n * cookie set before stays.\n *\n * ```ts\n * const user = sendSession(c, auth, await auth.signIn({ email, password }));\n * return c.json({ id: user.id }); // the token is in the cookie, never in the body\n * ```\n */\nexport function sendSession<U>(\n\tc: Context,\n\tauth: Pick<Auth<{ readonly type: string }>, 'cookie'>,\n\tsignedIn: {\n\t\treadonly token: string;\n\t\treadonly session: Session;\n\t\treadonly user: U;\n\t},\n): U {\n\tc.header(\n\t\t'Set-Cookie',\n\t\tauth.cookie.serialize(signedIn.token, signedIn.session),\n\t\t{\n\t\t\tappend: true,\n\t\t},\n\t);\n\treturn signedIn.user;\n}\n\n/**\n * Revokes the session the request presents and clears the cookie — cleared\n * whatever the answer, so a browser holding a stale cookie drops it too.\n * `false` when the request presented no session, or an unknown one.\n */\nexport async function signOut(\n\tc: Context,\n\tauth: Pick<Auth<{ readonly type: string }>, 'signOut' | 'cookie'>,\n): Promise<boolean> {\n\tconst revoked = await auth.signOut(c.req.raw);\n\tc.header('Set-Cookie', auth.cookie.clear(), { append: true });\n\treturn revoked;\n}\n",
8
- "import type { Session } from '@nxgt/janus';\nimport type {\n\tCheckableOf,\n\tModelConfig,\n\tObjectTypeOf,\n\tPermissions,\n} from '@nxgt/janus/permissions';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport {\n\ttype Awaitable,\n\ttype ObjectData,\n\ttype OptionsArgs,\n\tpermission,\n} from './permission';\nimport { provide } from './provide';\nimport {\n\ttype Auth,\n\ttype SessionEnv,\n\ttype SessionOptions,\n\tsendSession,\n\tsession,\n\tsignOut,\n\ttype UserOfAuth,\n} from './session';\n\n/** `session(auth, options?)`, with `auth` bound: the same three forms. */\nexport interface BoundSession<A> {\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions: SessionOptions<T> & { readonly required: true },\n\t): MiddlewareHandler<SessionEnv<A, T, true>>;\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions?: SessionOptions<T> & { readonly required?: false },\n\t): MiddlewareHandler<SessionEnv<A, T>>;\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions: SessionOptions<T>,\n\t): MiddlewareHandler<SessionEnv<A, T>>;\n}\n\n/** `permission(access, …)`, with `access` bound. */\nexport type BoundPermission<C extends ModelConfig> = <\n\tconst T extends ObjectTypeOf<C>,\n\tconst P extends CheckableOf<C, T>,\n\tO extends ObjectData<C, T>,\n>(\n\tpermission: P,\n\ttype: T,\n\tload: (c: Context) => Awaitable<O | null>,\n\t...options: OptionsArgs<C, T, P, O>\n) => MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { object: O };\n}>;\n\n/** The functions of this package that take `auth`, with `auth` bound. */\nexport interface BoundAuth<A> {\n\treadonly session: BoundSession<A>;\n\t/** `sendSession(c, auth, signedIn)`: sets the cookie, answers the user. */\n\treadonly sendSession: <U>(\n\t\tc: Context,\n\t\tsignedIn: {\n\t\t\treadonly token: string;\n\t\t\treadonly session: Session;\n\t\t\treadonly user: U;\n\t\t},\n\t) => U;\n\t/** `signOut(c, auth)`. */\n\treadonly signOut: (c: Context) => Promise<boolean>;\n}\n\n/** What `bindJanus()` takes: the `janus()` instance, the `permissions()` one, or both. */\nexport interface Bindable {\n\treadonly auth?: Auth<{ readonly type: string }>;\n\t/** What `permission()` takes: a `permissions()` instance, or its `can`. */\n\treadonly access?: { readonly can: unknown };\n}\n\n/**\n * What `bindJanus(instances)` answers: `provide()` always; `session`,\n * `sendSession` and `signOut` with `auth`; `permission` with `access`.\n */\nexport type Bound<I extends Bindable> = {\n\t/** `provide(instances)`: every instance bound, on the context. */\n\treadonly provide: () => MiddlewareHandler<{\n\t\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\t\tVariables: { [K in keyof I & keyof Bindable]: I[K] };\n\t}>;\n} & (I['auth'] extends Auth<{ readonly type: string }>\n\t? BoundAuth<I['auth']>\n\t: unknown) &\n\t(I['access'] extends Pick<Permissions<infer C>, 'can'>\n\t\t? { readonly permission: BoundPermission<C> }\n\t\t: unknown);\n\n/**\n * This package's functions with the instances bound once, so no route\n * repeats them:\n *\n * ```ts\n * const j = bindJanus({ auth, access });\n * const app = new Hono()\n * .use(j.session(), j.provide())\n * .get('/records/:id', j.permission('view', 'record', recordOf), (c) => c.json(c.var.object));\n * ```\n *\n * The same functions, typed the same way: `j.session({ required: true })` is\n * `session(auth, { required: true })`. Only what is given is bound — a\n * `permission` without `access` is absent from the type.\n */\nexport function bindJanus<const I extends Bindable>(instances: I): Bound<I> {\n\tconst { auth, access } = instances;\n\tconst bound: Record<string, unknown> = {\n\t\tprovide: () => provide(instances),\n\t};\n\tif (auth !== undefined) {\n\t\tbound.session = (options: SessionOptions<string> = {}) =>\n\t\t\tsession(auth, options);\n\t\tbound.sendSession = (\n\t\t\tc: Context,\n\t\t\tsignedIn: Parameters<typeof sendSession>[2],\n\t\t) => sendSession(c, auth, signedIn);\n\t\tbound.signOut = (c: Context) => signOut(c, auth);\n\t}\n\tif (access !== undefined) {\n\t\tconst guard = permission as unknown as (\n\t\t\taccess: object,\n\t\t\t...rest: unknown[]\n\t\t) => MiddlewareHandler;\n\t\tbound.permission = (...rest: unknown[]) => guard(access, ...rest);\n\t}\n\treturn bound as Bound<I>;\n}\n",
7
+ "import type { Session, SharedApi } from '@nxgt/janus';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport { getCookie } from 'hono/cookie';\nimport { type DeviceCookieOptions, sendDevice } from './device';\n\n/** Anything `janus()` answered: the part of it this package calls. */\nexport type Auth<U extends { readonly type: string }> = Pick<\n\tSharedApi<U>,\n\t'authenticate' | 'signOut' | 'cookie'\n>;\n\n/** The users an `auth` instance knows, as a union narrowed by `user.type`. */\nexport type UserOfAuth<A> = A extends Auth<infer U> ? U : never;\n\n/**\n * What `session()` sets on the context: `c.var.user` and `c.var.session`,\n * both `null` for an anonymous request unless the route requires a user.\n *\n * `new Hono<SessionEnv<typeof auth>>()` types every route of an app that\n * wires `app.use(session(auth))` once.\n */\nexport type SessionEnv<\n\tA,\n\tT extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n\tRequired extends boolean = false,\n> = {\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: Required extends true\n\t\t? {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }>;\n\t\t\t\tsession: Session;\n\t\t\t}\n\t\t: {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }> | null;\n\t\t\t\tsession: Session | null;\n\t\t\t};\n};\n\nexport interface SessionOptions<T extends string> {\n\t/** Only a user of this type is authenticated here; any other is anonymous. */\n\treadonly type?: T;\n\t/**\n\t * `true`: an anonymous request is answered 401, with no body, and the\n\t * route never runs — so `c.var.user` is never `null` in it. Only a literal\n\t * `true` types it so: a computed `boolean` leaves `c.var.user` nullable.\n\t */\n\treadonly required?: boolean;\n}\n\n/**\n * The middleware that reads who a request belongs to — `auth.authenticate`\n * on the request — and sets `c.var.user` and `c.var.session`.\n *\n * **An outage is not anonymous**: a store that cannot answer rejects with\n * `STORE_FAILED`, and this middleware lets it through to `app.onError` —\n * `janusErrors()` answers it 503, never 401.\n *\n * A session renewed in passing is sent again as a cookie, after the route\n * ran — but only to a request that presented it as one: a client that sends\n * `Authorization: Bearer` is not handed a cookie it never asked for. And\n * never over a session cookie the route set itself — `sendSession` after a\n * sign-in, `signOut`'s clearing — which a browser would otherwise overwrite.\n */\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions: SessionOptions<T> & { readonly required: true },\n): MiddlewareHandler<SessionEnv<A, T, true>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions?: SessionOptions<T> & { readonly required?: false },\n): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(auth: A, options: SessionOptions<T>): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session(\n\tauth: Auth<{ readonly type: string }>,\n\toptions: SessionOptions<string> = {},\n): MiddlewareHandler {\n\treturn async (c, next) => {\n\t\tconst current = await auth.authenticate(\n\t\t\tc.req.raw,\n\t\t\toptions.type === undefined ? undefined : { type: options.type },\n\t\t);\n\n\t\tif (current === null && options.required === true) {\n\t\t\treturn c.body(null, 401);\n\t\t}\n\t\tc.set('user', current?.user ?? null);\n\t\tc.set('session', current?.session ?? null);\n\n\t\tawait next();\n\n\t\t// The route's own cookie wins: a sign-in or a sign-out behind this\n\t\t// middleware must not be undone by the session it replaced.\n\t\tconst name = `${auth.cookie.name}=`;\n\t\tif (\n\t\t\tcurrent?.renewed === true &&\n\t\t\tgetCookie(c, auth.cookie.name) === current.token &&\n\t\t\t!c.res.headers.getSetCookie().some((value) => value.startsWith(name))\n\t\t) {\n\t\t\tsendSession(c, auth, current);\n\t\t}\n\t};\n}\n\n/** What `sendSession` takes besides: the device cookie's options. */\nexport interface SendSessionOptions {\n\treadonly device?: DeviceCookieOptions;\n}\n\n/**\n * Sends the session cookie — after `signUp`, `signIn`, or anything else that\n * answered a session token and its session — and answers the user. Appends: a\n * cookie set before stays.\n *\n * When the answer carries a `deviceToken` — the sign-in was given a\n * `device` — the device cookie is set too, or set again so it lasts another\n * four hundred days.\n *\n * ```ts\n * const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });\n * const user = sendSession(c, auth, signedIn);\n * return c.json({ id: user.id }); // the token is in the cookie, never in the body\n * ```\n */\nexport function sendSession<U>(\n\tc: Context,\n\tauth: Pick<Auth<{ readonly type: string }>, 'cookie'>,\n\tsignedIn: {\n\t\treadonly token: string;\n\t\treadonly session: Session;\n\t\treadonly user: U;\n\t\treadonly deviceToken?: string | null;\n\t},\n\toptions: SendSessionOptions = {},\n): U {\n\tc.header(\n\t\t'Set-Cookie',\n\t\tauth.cookie.serialize(signedIn.token, signedIn.session),\n\t\t{\n\t\t\tappend: true,\n\t\t},\n\t);\n\tif (typeof signedIn.deviceToken === 'string') {\n\t\tsendDevice(c, signedIn.deviceToken, options.device);\n\t}\n\treturn signedIn.user;\n}\n\n/**\n * Revokes the session the request presents and clears the cookie — cleared\n * whatever the answer, so a browser holding a stale cookie drops it too.\n * `false` when the request presented no session, or an unknown one.\n */\nexport async function signOut(\n\tc: Context,\n\tauth: Pick<Auth<{ readonly type: string }>, 'signOut' | 'cookie'>,\n): Promise<boolean> {\n\tconst revoked = await auth.signOut(c.req.raw);\n\tc.header('Set-Cookie', auth.cookie.clear(), { append: true });\n\treturn revoked;\n}\n",
8
+ "import type { Context } from 'hono';\nimport { getCookie, setCookie } from 'hono/cookie';\n\n/** The device cookie's name when none is given. */\nexport const DEVICE_COOKIE = 'janus-device';\n\n/** Four hundred days: the longest a browser keeps a cookie. */\nconst MAX_AGE = 400 * 24 * 60 * 60;\n\n/**\n * The long-lived cookie that holds the device token `@nxgt/janus` answers\n * as `deviceToken`. Every default is the strict one, as for the session\n * cookie; give `deviceOf` and `sendSession` the same options.\n */\nexport interface DeviceCookieOptions {\n\t/** `'janus-device'` when absent. */\n\treadonly name?: string;\n\treadonly domain?: string;\n\t/** `'/'` when absent. */\n\treadonly path?: string;\n\t/** `'Lax'` when absent. */\n\treadonly sameSite?: 'Lax' | 'Strict' | 'None';\n\t/** `true` when absent. */\n\treadonly secure?: boolean;\n\t/** In seconds. Four hundred days when absent — the longest a browser keeps one. */\n\treadonly maxAge?: number;\n}\n\n/**\n * The device token the request's cookie holds, or `null` when it holds none:\n * what a sign-in takes as `device`.\n *\n * ```ts\n * const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });\n * ```\n */\nexport function deviceOf(\n\tc: Context,\n\toptions: DeviceCookieOptions = {},\n): string | null {\n\tconst token = getCookie(c, options.name ?? DEVICE_COOKIE);\n\treturn token === undefined || token === '' ? null : token;\n}\n\n/** Sets the device cookie, or sets it again so it lasts another `maxAge`. Appends. */\nexport function sendDevice(\n\tc: Context,\n\tdeviceToken: string,\n\toptions: DeviceCookieOptions = {},\n): void {\n\tsetCookie(c, options.name ?? DEVICE_COOKIE, deviceToken, {\n\t\tpath: options.path ?? '/',\n\t\t...(options.domain === undefined ? {} : { domain: options.domain }),\n\t\tsameSite: options.sameSite ?? 'Lax',\n\t\tsecure: options.secure ?? true,\n\t\thttpOnly: true,\n\t\tmaxAge: options.maxAge ?? MAX_AGE,\n\t});\n}\n",
9
+ "import type { Session } from '@nxgt/janus';\nimport type {\n\tCheckableOf,\n\tModelConfig,\n\tObjectTypeOf,\n\tPermissions,\n} from '@nxgt/janus/permissions';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport {\n\ttype Awaitable,\n\ttype ObjectData,\n\ttype OptionsArgs,\n\tpermission,\n} from './permission';\nimport { provide } from './provide';\nimport {\n\ttype Auth,\n\ttype SendSessionOptions,\n\ttype SessionEnv,\n\ttype SessionOptions,\n\tsendSession,\n\tsession,\n\tsignOut,\n\ttype UserOfAuth,\n} from './session';\n\n/** `session(auth, options?)`, with `auth` bound: the same three forms. */\nexport interface BoundSession<A> {\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions: SessionOptions<T> & { readonly required: true },\n\t): MiddlewareHandler<SessionEnv<A, T, true>>;\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions?: SessionOptions<T> & { readonly required?: false },\n\t): MiddlewareHandler<SessionEnv<A, T>>;\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions: SessionOptions<T>,\n\t): MiddlewareHandler<SessionEnv<A, T>>;\n}\n\n/** `permission(access, …)`, with `access` bound. */\nexport type BoundPermission<C extends ModelConfig> = <\n\tconst T extends ObjectTypeOf<C>,\n\tconst P extends CheckableOf<C, T>,\n\tO extends ObjectData<C, T>,\n>(\n\tpermission: P,\n\ttype: T,\n\tload: (c: Context) => Awaitable<O | null>,\n\t...options: OptionsArgs<C, T, P, O>\n) => MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { object: O };\n}>;\n\n/** The functions of this package that take `auth`, with `auth` bound. */\nexport interface BoundAuth<A> {\n\treadonly session: BoundSession<A>;\n\t/** `sendSession(c, auth, signedIn, options?)`: sets the cookies, answers the user. */\n\treadonly sendSession: <U>(\n\t\tc: Context,\n\t\tsignedIn: {\n\t\t\treadonly token: string;\n\t\t\treadonly session: Session;\n\t\t\treadonly user: U;\n\t\t\treadonly deviceToken?: string | null;\n\t\t},\n\t\toptions?: SendSessionOptions,\n\t) => U;\n\t/** `signOut(c, auth)`. */\n\treadonly signOut: (c: Context) => Promise<boolean>;\n}\n\n/** What `bindJanus()` takes: the `janus()` instance, the `permissions()` one, or both. */\nexport interface Bindable {\n\treadonly auth?: Auth<{ readonly type: string }>;\n\t/** What `permission()` takes: a `permissions()` instance, or its `can`. */\n\treadonly access?: { readonly can: unknown };\n}\n\n/**\n * What `bindJanus(instances)` answers: `provide()` always; `session`,\n * `sendSession` and `signOut` with `auth`; `permission` with `access`.\n */\nexport type Bound<I extends Bindable> = {\n\t/** `provide(instances)`: every instance bound, on the context. */\n\treadonly provide: () => MiddlewareHandler<{\n\t\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\t\tVariables: { [K in keyof I & keyof Bindable]: I[K] };\n\t}>;\n} & (I['auth'] extends Auth<{ readonly type: string }>\n\t? BoundAuth<I['auth']>\n\t: unknown) &\n\t(I['access'] extends Pick<Permissions<infer C>, 'can'>\n\t\t? { readonly permission: BoundPermission<C> }\n\t\t: unknown);\n\n/**\n * This package's functions with the instances bound once, so no route\n * repeats them:\n *\n * ```ts\n * const j = bindJanus({ auth, access });\n * const app = new Hono()\n * .use(j.session(), j.provide())\n * .get('/records/:id', j.permission('view', 'record', recordOf), (c) => c.json(c.var.object));\n * ```\n *\n * The same functions, typed the same way: `j.session({ required: true })` is\n * `session(auth, { required: true })`. Only what is given is bound — a\n * `permission` without `access` is absent from the type.\n */\nexport function bindJanus<const I extends Bindable>(instances: I): Bound<I> {\n\tconst { auth, access } = instances;\n\tconst bound: Record<string, unknown> = {\n\t\tprovide: () => provide(instances),\n\t};\n\tif (auth !== undefined) {\n\t\tbound.session = (options: SessionOptions<string> = {}) =>\n\t\t\tsession(auth, options);\n\t\tbound.sendSession = (\n\t\t\tc: Context,\n\t\t\tsignedIn: Parameters<typeof sendSession>[2],\n\t\t\toptions?: SendSessionOptions,\n\t\t) => sendSession(c, auth, signedIn, options);\n\t\tbound.signOut = (c: Context) => signOut(c, auth);\n\t}\n\tif (access !== undefined) {\n\t\tconst guard = permission as unknown as (\n\t\t\taccess: object,\n\t\t\t...rest: unknown[]\n\t\t) => MiddlewareHandler;\n\t\tbound.permission = (...rest: unknown[]) => guard(access, ...rest);\n\t}\n\treturn bound as Bound<I>;\n}\n",
9
10
  "import {\n\tJanusError,\n\ttype JanusErrorCode,\n\tstatusOf as janusStatusOf,\n} from '@nxgt/janus';\nimport type { Context, ErrorHandler } from 'hono';\nimport type { HTTPException } from 'hono/http-exception';\nimport type { ContentfulStatusCode } from 'hono/utils/http-status';\n\n/**\n * The status each code deserves: `@nxgt/janus`'s own table, typed as the\n * `ContentfulStatusCode` `c.json()` takes. Kept as an export of its own so a\n * route that answers by hand reads the same status `janusErrors()` does.\n *\n * `STORE_FAILED` is 503 and nothing else — **an outage is not a negative\n * answer**, and a 401 or a 404 would tell every user they do not exist.\n */\nexport function statusOf(code: JanusErrorCode): ContentfulStatusCode {\n\treturn janusStatusOf(code);\n}\n\n/**\n * The body a refusal is answered with: its `code`, and only what the client\n * can act on — the fields that failed the schema, the policy's minimum, the\n * attempts a challenge has left, the seconds a throttled sign-in waits.\n * Never `reason`, `login`, a hash prefix or a cause: those are for your logs.\n */\nexport function bodyOf(error: JanusError): {\n\treadonly code: JanusErrorCode;\n\treadonly issues?: JanusError['issues'];\n\treadonly minLength?: number;\n\treadonly attemptsLeft?: number;\n\treadonly retryAfter?: number;\n} {\n\tswitch (error.code) {\n\t\tcase 'USER_INVALID':\n\t\t\treturn { code: error.code, issues: error.issues ?? [] };\n\t\tcase 'PASSWORD_TOO_SHORT':\n\t\t\treturn error.minLength === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, minLength: error.minLength };\n\t\tcase 'CODE_INVALID':\n\t\t\treturn error.attemptsLeft === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, attemptsLeft: error.attemptsLeft };\n\t\tcase 'CREDENTIALS_INVALID':\n\t\t\treturn error.retryAfter === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, retryAfter: error.retryAfter };\n\t\tdefault:\n\t\t\treturn { code: error.code };\n\t}\n}\n\n/**\n * Hono's own answer to an error it was not told about, copied from its\n * default handler: the response of an `HTTPException` — merged with the\n * headers the route set before throwing — and a logged 500 for anything else.\n */\nfunction honoDefault(error: Error | HTTPException, c: Context): Response {\n\tif ('getResponse' in error) {\n\t\tconst response = error.getResponse();\n\t\treturn c.newResponse(response.body, response);\n\t}\n\tconsole.error(error);\n\treturn c.text('Internal Server Error', 500);\n}\n\n/** What `janusErrors()` takes. */\nexport interface JanusErrorsOptions {\n\t/**\n\t * Called with every `JanusError` answered 5xx — `STORE_FAILED`,\n\t * `UNSUPPORTED`, `PERMISSION_DEPTH`: the ones the server must fix, not the\n\t * client — before it is answered. Its `slot`, `operation`, `reason` and\n\t * `cause` are for your logs; the body never carries them.\n\t *\n\t * **It cannot stop the answer**: one that throws, or rejects, is a\n\t * `process.emitWarning`, and the 503 is sent all the same.\n\t */\n\treadonly report?: (error: JanusError, c: Context) => unknown;\n\t/** Every error that is not a `JanusError`. Hono's own handling when absent. */\n\treadonly fallback?: ErrorHandler;\n}\n\n/**\n * An `app.onError` handler: every `JanusError` answered with its status and\n * `bodyOf(error)` — and a throttled sign-in with a `Retry-After` header, its\n * `retryAfter` in seconds; anything else handed to `fallback` — Hono's own\n * behaviour when absent.\n *\n * Works for either side of `@nxgt/janus`: `can()`'s `STORE_FAILED` is a 503\n * here too, never a 403.\n *\n * ```ts\n * app.onError(janusErrors({ report: (error) => logger.error(error) }));\n * ```\n */\nexport function janusErrors(options: JanusErrorsOptions = {}): ErrorHandler {\n\tconst { report, fallback = honoDefault } = options;\n\treturn (error, c) => {\n\t\tif (!(error instanceof JanusError)) return fallback(error, c);\n\t\tconst status = statusOf(error.code);\n\t\tif (status >= 500 && report !== undefined) reportSafely(report, error, c);\n\t\tconst headers =\n\t\t\terror.retryAfter === undefined\n\t\t\t\t? undefined\n\t\t\t\t: { 'retry-after': String(error.retryAfter) };\n\t\treturn c.json(bodyOf(error), status, headers);\n\t};\n}\n\n/** `report`, whose own failure is a warning — never a lost answer. */\nfunction reportSafely(\n\treport: NonNullable<JanusErrorsOptions['report']>,\n\terror: JanusError,\n\tc: Context,\n): void {\n\tconst warn = (failure: unknown) =>\n\t\tprocess.emitWarning(\n\t\t\t`janusErrors: report failed on ${error.code}: ${failure instanceof Error ? failure.name : typeof failure}`,\n\t\t);\n\ttry {\n\t\t// A thenable of any make, as a native promise: its rejection warns too.\n\t\tPromise.resolve(report(error, c)).then(undefined, warn);\n\t} catch (failure) {\n\t\twarn(failure);\n\t}\n}\n",
10
11
  "import {\n\ttype Clock,\n\ttype Duration,\n\tparseDuration,\n\ttype Session,\n\tStepUpRequiredError,\n\tsystemClock,\n} from '@nxgt/janus';\nimport type { MiddlewareHandler } from 'hono';\n\n/** What `fresh()` takes besides `maxAge`. */\nexport interface FreshOptions {\n\t/** The clock given to `janus()`, when it is not the system's: `fixedClock` in a spec. */\n\treadonly clock?: Clock;\n}\n\n/**\n * The middleware that lets a route run only for a session that proved who\n * it is less than `maxAge` ago — signed in, or confirmed by\n * `auth.stepUp.confirm`, since. Behind `session(auth)`, which sets\n * `c.var.session`:\n *\n * ```ts\n * app.delete('/account', session(auth, { required: true }), fresh('10m'), deleteAccount);\n * ```\n *\n * A session older than that throws `STEP_UP_REQUIRED`, which `janusErrors()`\n * answers 403 with `{ code: 'STEP_UP_REQUIRED' }`: the client asks for a\n * step-up, then sends the request again. An anonymous request is answered\n * 401, with no body, as `session(auth, { required: true })` answers it.\n *\n * A `maxAge` that is not a duration is a `TypeError` here, when the app is\n * wired — not on the first request.\n * No `session()` before it is a `TypeError` at the first request — a\n * wiring error, not an anonymous 401 — as for `permission()`.\n */\nexport function fresh(\n\tmaxAge: Duration,\n\toptions: FreshOptions = {},\n): MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { session: Session | null };\n}> {\n\tconst maxAgeMs = parseDuration(maxAge, 'fresh: maxAge');\n\treturn async (c, next) => {\n\t\tconst current = c.var.session;\n\t\tif (current === undefined) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'fresh(): c.var.session is not set — put session(auth) before it',\n\t\t\t);\n\t\t}\n\t\tif (current === null) return c.body(null, 401);\n\t\t// `assertFresh`'s rule, with a message naming the call written here.\n\t\tconst now = (options.clock ?? systemClock).now().getTime();\n\t\tif (now - current.authenticatedAt.getTime() >= maxAgeMs) {\n\t\t\tthrow new StepUpRequiredError(\n\t\t\t\t'fresh(): the session proved who it is longer ago than maxAge — confirm with a step-up',\n\t\t\t\t{ userId: current.userId },\n\t\t\t);\n\t\t}\n\t\tawait next();\n\t};\n}\n"
11
12
  ],
12
- "mappings": "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",
13
- "debugId": "B7AF7A43DA4D8D7A64756E2164756E21",
13
+ "mappings": "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",
14
+ "debugId": "21C4E2121FB06E7D64756E2164756E21",
14
15
  "names": []
15
16
  }
package/dist/session.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Session, SharedApi } from '@nxgt/janus';
2
2
  import type { Context, MiddlewareHandler } from 'hono';
3
+ import { type DeviceCookieOptions } from './device';
3
4
  /** Anything `janus()` answered: the part of it this package calls. */
4
5
  export type Auth<U extends {
5
6
  readonly type: string;
@@ -63,13 +64,22 @@ export declare function session<A extends Auth<{
63
64
  export declare function session<A extends Auth<{
64
65
  readonly type: string;
65
66
  }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options: SessionOptions<T>): MiddlewareHandler<SessionEnv<A, T>>;
67
+ /** What `sendSession` takes besides: the device cookie's options. */
68
+ export interface SendSessionOptions {
69
+ readonly device?: DeviceCookieOptions;
70
+ }
66
71
  /**
67
72
  * Sends the session cookie — after `signUp`, `signIn`, or anything else that
68
73
  * answered a session token and its session — and answers the user. Appends: a
69
74
  * cookie set before stays.
70
75
  *
76
+ * When the answer carries a `deviceToken` — the sign-in was given a
77
+ * `device` — the device cookie is set too, or set again so it lasts another
78
+ * four hundred days.
79
+ *
71
80
  * ```ts
72
- * const user = sendSession(c, auth, await auth.signIn({ email, password }));
81
+ * const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });
82
+ * const user = sendSession(c, auth, signedIn);
73
83
  * return c.json({ id: user.id }); // the token is in the cookie, never in the body
74
84
  * ```
75
85
  */
@@ -79,7 +89,8 @@ export declare function sendSession<U>(c: Context, auth: Pick<Auth<{
79
89
  readonly token: string;
80
90
  readonly session: Session;
81
91
  readonly user: U;
82
- }): U;
92
+ readonly deviceToken?: string | null;
93
+ }, options?: SendSessionOptions): U;
83
94
  /**
84
95
  * Revokes the session the request presents and clears the cookie — cleared
85
96
  * whatever the answer, so a browser holding a stale cookie drops it too.
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
package/docs/README.md CHANGED
@@ -5,7 +5,7 @@ give the detail.
5
5
 
6
6
  | Page | Read it when |
7
7
  | --- | --- |
8
- | [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, a second factor's code, a sign-in by e-mailed code or link, sign-out and the e-mail flows as Hono routes, with one user type or several |
8
+ | [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, a second factor's code, a sign-in by e-mailed code or link, the device cookie of a sign-in from a new device, sign-out and the e-mail flows as Hono routes, with one user type or several |
9
9
  | [Guarded routes and writing tuples](guide/permissions.md) | You are guarding a route with a permission, passing a condition's context, or granting and revoking from a route |
10
10
  | [Troubleshooting](troubleshooting.md) | A route answers something you did not expect, or the compiler refuses one |
11
11
  | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -495,7 +495,7 @@ app.post('/sign-in/email/link', async (c) => {
495
495
  // GET: what the e-mail links to. It renders a page and does nothing else.
496
496
  app.get('/sign-in/link', (c) => {
497
497
  const token = c.req.query('token') ?? '';
498
- c.header('Referrer-Policy', 'no-referrer'); // the URL holds the token: send it nowhere
498
+ c.header('Referrer-Policy', 'strict-origin'); // the origin only: never the token
499
499
  c.header('Cache-Control', 'no-store');
500
500
  if (!TOKEN.test(token)) return c.text('This link is not valid.', 400);
501
501
  return c.html(`<!doctype html><meta charset="utf-8"><title>Sign in</title>
@@ -533,6 +533,13 @@ app.post('/sign-in/link', csrf({ origin: ORIGIN }), async (c) => {
533
533
  - **Put `csrf({ origin })` on the `POST`.** The token does not identify the
534
534
  browser that asked for it. Without the check, any site could post a token
535
535
  for an account it controls and sign its visitor into that account.
536
+ - **Send the page with `Referrer-Policy: strict-origin`.** A `Referer` then
537
+ holds the origin only — never the path and the token in its query — so
538
+ nothing the page loads learns the token. Not `no-referrer`: with it the
539
+ browser posts the form with `Origin: null`, and wherever `csrf()` falls
540
+ back to the `Origin` — no `Sec-Fetch-Site` on plain HTTP off `localhost`,
541
+ or from an older browser — the button's own `POST` answers
542
+ [403](../troubleshooting.md#403-forbidden-on-post-sign-inlink).
536
543
  - **Answer the `POST` with a `303`**, so that reloading the page does not
537
544
  post the spent token again. The cookie that `sendSession` set is sent with
538
545
  the redirect.
@@ -569,6 +576,103 @@ this e-mail with `mail.magicLink(issued)`, building its button with
569
576
  For the lifetime and every refusal, see [`@nxgt/janus`'s sign-in link
570
577
  guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/magic-link.md).
571
578
 
579
+ ## The device cookie
580
+
581
+ With `janus({ devices })`, every sign-in can tell the user it came from a
582
+ device they had not signed in from. The browser keeps a **device token** in
583
+ a long-lived cookie of its own; `deviceOf(c)` reads it for the sign-in's
584
+ `device`, and `sendSession` sets it from the answer's `deviceToken` — the
585
+ first time, and again at every sign-in, so it lasts another 400 days:
586
+
587
+ ```ts
588
+ import { deviceOf, sendSession } from '@nxgt/janus-hono';
589
+
590
+ app.post('/sign-up', async (c) => {
591
+ const { email, name, password } = await c.req.json();
592
+ const signedUp = await auth.signUp({ email, name, password }, { device: deviceOf(c) });
593
+ return c.json({ id: sendSession(c, auth, signedUp).id }, 201); // the device is known from the start
594
+ });
595
+
596
+ app.post('/sign-in', async (c) => {
597
+ const { email, password } = await c.req.json();
598
+ const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });
599
+ const user = sendSession(c, auth, signedIn);
600
+ return c.json({ id: user.id, newDevice: signedIn.newDevice });
601
+ });
602
+ ```
603
+
604
+ `deviceOf(c)` answers `null` for a browser with no device cookie, or an
605
+ empty one: the sign-in then mints its first token. A token that proves
606
+ nothing — another user's, a forged one, one whose key was removed — is a
607
+ new device, never an error. A sign-in given no `device` answers no
608
+ `deviceToken`, and `sendSession` sets no device cookie.
609
+
610
+ | Option | Type | Default | Effect |
611
+ | --- | --- | --- | --- |
612
+ | `name` | `string` | `'janus-device'` (`DEVICE_COOKIE`) | The cookie's name |
613
+ | `domain` | `string` | none | `Domain=` |
614
+ | `path` | `string` | `'/'` | `Path=`: keep it covering every sign-in route |
615
+ | `sameSite` | `'Lax' \| 'Strict' \| 'None'` | `'Lax'` | `SameSite=` — Hono's capitalised values, unlike `janus({ cookie })`'s `'lax'` |
616
+ | `secure` | `boolean` | `true` | `Secure` |
617
+ | `maxAge` | seconds | `34560000`, 400 days | `Max-Age=` — the longest a browser keeps a cookie; above it, Hono's `setCookie` throws |
618
+
619
+ The cookie is always `HttpOnly`. Give `deviceOf` and `sendSession` the same
620
+ options — one object, passed to both:
621
+
622
+ ```ts
623
+ import { type DeviceCookieOptions, bindJanus, deviceOf } from '@nxgt/janus-hono';
624
+
625
+ const device: DeviceCookieOptions = { name: 'device', sameSite: 'Strict' };
626
+ const j = bindJanus({ auth });
627
+
628
+ app.post('/sign-in', async (c) => {
629
+ const signedIn = await auth.signIn(await c.req.json(), { device: deviceOf(c, device) });
630
+ j.sendSession(c, signedIn, { device });
631
+ return c.body(null, 204);
632
+ });
633
+ ```
634
+
635
+ **With a second factor, give the device again.** The challenge `signIn`
636
+ answers carries no device, and sets no device cookie: `confirm` and
637
+ `recover` decide, from what they are given. The device cookie's path is
638
+ `/`, so the code routes read it too:
639
+
640
+ ```ts
641
+ app.post('/sign-in/code', async (c) => {
642
+ const { code } = await c.req.json();
643
+ const challenge = getCookie(c, CHALLENGE);
644
+ if (challenge === undefined) return c.json({ code: 'TOKEN_UNKNOWN' }, 400);
645
+ const signedIn = await auth.secondFactor.confirm(challenge, code, { device: deviceOf(c) });
646
+ const user = sendSession(c, auth, signedIn); // and the device cookie
647
+ deleteCookie(c, CHALLENGE, scope);
648
+ return c.json({ id: user.id });
649
+ });
650
+ ```
651
+
652
+ The same holds for `/sign-in/recovery`, `signInCode.confirm` and
653
+ `magicLink.confirm`: pass `{ device: deviceOf(c) }` to each.
654
+
655
+ **A sign-out leaves the device cookie**: `signOut` clears the session
656
+ cookie only, so the device stays known. A browser two people take turns
657
+ signing in on holds one user's token at a time, so each of them is told of
658
+ a new device at every turn.
659
+
660
+ A bearer client — a mobile app — has no cookie jar to share: answer it the
661
+ `deviceToken` in the body, and take it back as `device`:
662
+
663
+ ```ts
664
+ app.post('/api/sign-in', async (c) => {
665
+ const { email, password, deviceToken } = await c.req.json();
666
+ const signedIn = await auth.signIn({ email, password }, { device: deviceToken ?? null });
667
+ return c.json({ token: signedIn.token, deviceToken: signedIn.deviceToken });
668
+ });
669
+ ```
670
+
671
+ The notice itself — which device, when — is
672
+ [`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail)'s
673
+ `newSignIn`, sent when `signedIn.newDevice` is `true`, and everything about
674
+ the token is in [`@nxgt/janus`'s devices guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/devices.md).
675
+
572
676
  ## A step-up
573
677
 
574
678
  A route a stolen session should not run alone — deleting the account,
@@ -708,6 +812,7 @@ as staff replaces the patient's cookie.
708
812
  - [`@nxgt/janus` — sessions](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sessions.md) — lifespans, renewal, the cookie's attributes
709
813
  - [`@nxgt/janus` — errors](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/errors.md) — every code
710
814
  - [`@nxgt/janus` — the second factor](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/second-factor.md) — keys, states, attempts
815
+ - [`@nxgt/janus` — devices](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/devices.md) — the device token, the event, rotating the keys
711
816
  - [`@nxgt/janus` — sign-in codes](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sign-in-code.md) — requesting, the challenge, attempts, passwordless types
712
817
  - [Guarded routes and writing tuples](permissions.md) — `permission()`, `provide()`
713
818
  - [Troubleshooting](../troubleshooting.md)
package/docs/roadmap.md CHANGED
@@ -36,6 +36,12 @@ Nothing yet.
36
36
 
37
37
  Newest first; the [CHANGELOG](../CHANGELOG.md) holds every release.
38
38
 
39
+ - **The device cookie, v0.6.0** — `deviceOf(c)` reads the device token a
40
+ browser holds, for a sign-in's `device`, and `sendSession` sets it — or
41
+ sets it again for another 400 days — whenever `@nxgt/janus`'s answer
42
+ carries a `deviceToken`: `janus-device` (`DEVICE_COOKIE`), `HttpOnly`,
43
+ `Secure`, `SameSite=Lax`, `Path=/`, changed by `DeviceCookieOptions`.
44
+ `signOut` leaves it. Needs `@nxgt/janus` 0.17.0.
39
45
  - **The routes of a sign-in link, v0.5.1** — the routes guide's
40
46
  [A link sent by e-mail](guide/routes.md#a-link-sent-by-e-mail): a page
41
47
  that spends nothing, a `POST` behind `csrf()` that confirms, and the
@@ -19,6 +19,8 @@ for what causes each.
19
19
  - [`Expected 5 arguments, but got 4`, on `permission()`](#expected-5-arguments-but-got-4-on-permission)
20
20
  - [`Type '{ id: string; }' is not assignable to type 'Awaitable<ObjectData<…> | null>'`](#type--id-string--is-not-assignable-to-type-awaitableobjectdata--null)
21
21
  - [`Property 'access' does not exist on type 'Readonly<ContextVariableMap & …>'`](#property-access-does-not-exist-on-type-readonlycontextvariablemap--)
22
+ - [`Type '"lax"' is not assignable to type '"Lax" | "Strict" | "None" | undefined'. Did you mean '"Lax"'?`](#type-lax-is-not-assignable-to-type-lax--strict--none--undefined-did-you-mean-lax), on `deviceOf` or `sendSession`
23
+ - [`Object literal may only specify known properties, and 'name' does not exist in type 'SendSessionOptions'.`](#object-literal-may-only-specify-known-properties-and-name-does-not-exist-in-type-sendsessionoptions)
22
24
  - [`Argument of type 'SignInResult<…>' is not assignable to parameter of type …`, on `sendSession`](#argument-of-type-signinresult-is-not-assignable-to-parameter-of-type--readonly-token-string-readonly-session-session-readonly-user--)
23
25
 
24
26
  **Runtime**
@@ -35,9 +37,14 @@ for what causes each.
35
37
  - [`400 {"code":"HASH_UNSUPPORTED"}` on sign-in](#400-codehash_unsupported-on-sign-in)
36
38
  - [`401 {"code":"CODE_INVALID","attemptsLeft":<n>}` on the code form](#401-codecode_invalidattemptsleftn-on-the-code-form) — a second factor's, or the code sent by e-mail
37
39
  - [`401 {"code":"CREDENTIALS_INVALID","retryAfter":<n>}` with the right password](#401-codecredentials_invalidretryaftern-with-the-right-password)
40
+ - [`403 Forbidden` on `POST /sign-in/link`](#403-forbidden-on-post-sign-inlink) — Hono's `csrf()`, not `janusErrors()`
38
41
  - [`409 {"code":"SECOND_FACTOR_ACTIVE"}` or `409 {"code":"SECOND_FACTOR_NOT_ENROLLED"}`](#409-codesecond_factor_active-or-409-codesecond_factor_not_enrolled)
39
42
  - [The browser never sends the cookie back](#the-browser-never-sends-the-cookie-back)
40
43
  - [A bearer client holds an expiry earlier than the session's](#a-bearer-client-holds-an-expiry-earlier-than-the-sessions)
44
+ - [Every sign-in reports a new device](#every-sign-in-reports-a-new-device)
45
+ - [No device cookie is set](#no-device-cookie-is-set)
46
+ - [`Cookies Max-Age SHOULD NOT be greater than 400 days (34560000 seconds) in duration.`](#cookies-max-age-should-not-be-greater-than-400-days-34560000-seconds-in-duration)
47
+ - [`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-)
41
48
 
42
49
  ## Types
43
50
 
@@ -152,19 +159,39 @@ that instance: it sets, and types, only what it is given.
152
159
  const app = new Hono().use(session(auth), provide({ auth, access }));
153
160
  ```
154
161
 
162
+ ### `Type '"lax"' is not assignable to type '"Lax" | "Strict" | "None" | undefined'. Did you mean '"Lax"'?`
163
+
164
+ **When:** `tsc`, on `deviceOf(c, { sameSite: 'lax' })` or
165
+ `sendSession(c, auth, signedIn, { device: { sameSite: 'lax' } })` — the
166
+ session cookie's option copied over.
167
+ **Why:** the device cookie is set by Hono's `setCookie`, whose `sameSite` is
168
+ capitalised; `janus({ cookie })`'s is lowercase.
169
+ **Fix:** `{ sameSite: 'Lax' }` — or leave it out: `'Lax'` is the default.
170
+
171
+ ### `Object literal may only specify known properties, and 'name' does not exist in type 'SendSessionOptions'.`
172
+
173
+ **When:** `tsc`, on `sendSession(c, auth, signedIn, { name: 'device' })`.
174
+ **Why:** `sendSession`'s options hold the device cookie's under `device`.
175
+ **Fix:** `sendSession(c, auth, signedIn, { device: { name: 'device' } })`,
176
+ and the same object to `deviceOf(c, { name: 'device' })`.
177
+
155
178
  ### `Argument of type 'SignInResult<…>' is not assignable to parameter of type '{ readonly token: string; readonly session: Session; readonly user: … }'`
156
179
 
157
180
  Also: `Type 'SecondFactorRequired' is missing the following properties …: token, session, user`.
158
181
 
159
- **When:** `sendSession(c, auth, await auth.signIn(...))`, once `janus()` is
160
- given a `secondFactor`.
182
+ **When:** `sendSession(c, auth, await auth.signIn(...))`,
183
+ `sendSession(c, auth, await auth.magicLink.confirm(token))` or
184
+ `sendSession(c, auth, await auth.signInCode.confirm(challenge, code))`, once
185
+ `janus()` is given a `secondFactor`, for a user type with a password.
161
186
 
162
- **Why:** `signIn` then answers a session, or `{ status: 'secondFactor',
187
+ **Why:** each of the three then answers a session, or `{ status: 'secondFactor',
163
188
  challenge, expiresAt, userId }` for a user whose second factor is active — and a
164
- challenge is no session to send.
189
+ challenge is no session to send. A link or a code sent by e-mail proves the
190
+ e-mail, not the factor. A user type with no password has no second factor, so
191
+ its `confirm` answers a session and compiles as it is.
165
192
 
166
193
  **Fix:** switch on `status`, and send the session `secondFactor.confirm`
167
- answers on the next request.
194
+ answers on the next request. For the password form:
168
195
 
169
196
  ```ts
170
197
  app.post('/sign-in', async (c) => {
@@ -182,6 +209,23 @@ app.post('/sign-in/code', async (c) => {
182
209
  });
183
210
  ```
184
211
 
212
+ `magicLink.confirm` and `signInCode.confirm` narrow the same way. They hand
213
+ the challenge on in a cookie, which the guide's `/sign-in/code` reads instead of
214
+ the body. `CHALLENGE`, `scope` and that route are under
215
+ [A second factor](guide/routes.md#a-second-factor). The link route redirects, as below. The code route answers
216
+ `c.json({ next: 'secondFactor' })` instead. See "With a second factor" under
217
+ [a code sent by e-mail](guide/routes.md#with-a-second-factor) and under
218
+ [a link sent by e-mail](guide/routes.md#with-a-second-factor-1).
219
+
220
+ ```ts
221
+ const result = await auth.magicLink.confirm(token);
222
+ if (result.status === 'secondFactor') {
223
+ setCookie(c, CHALLENGE, result.challenge, { ...scope, expires: result.expiresAt });
224
+ return c.redirect('/sign-in/second-factor', 303); // a page whose form posts the code to /sign-in/code
225
+ }
226
+ sendSession(c, auth, result);
227
+ ```
228
+
185
229
  ## Runtime
186
230
 
187
231
  ### `TypeError: permission(): c.var.object is already set`
@@ -452,6 +496,52 @@ To change the limit, `janus({ signIn: { throttle: { attempts, window } } })`;
452
496
  in a test, advance the clock past `retryAfter`, or wire
453
497
  `signIn: { throttle: false }`.
454
498
 
499
+ ### `403 Forbidden` on `POST /sign-in/link`
500
+
501
+ The body is the text `Forbidden`, not a `{ code }`. It is not a `JanusError`:
502
+ `csrf()` threw it, and `janusErrors()` hands it to Hono's default response, or
503
+ to your `fallback`.
504
+
505
+ **When:** the button of the page the e-mailed link opens, on the route
506
+ guarded by `csrf({ origin: ORIGIN })` — see
507
+ [a link sent by e-mail](guide/routes.md#a-link-sent-by-e-mail).
508
+
509
+ **Why:** Hono's `csrf()` lets a form post through when the browser sends
510
+ `Sec-Fetch-Site: same-origin`, or else an `Origin` header equal to `origin`,
511
+ character for character. Neither held:
512
+
513
+ - **The page and the route are on two origins.** The page may be served by a
514
+ development server on another port, or on `www.` while the route is on the
515
+ bare host. The browser then sends `same-site` or `cross-site`, and the
516
+ `Origin` decides.
517
+ - **The browser sends no `Sec-Fetch-Site`.** It sends none to plain `http://`
518
+ on a host other than `localhost` or a loopback address, and an older browser
519
+ sends none at all. The `Origin` decides here too.
520
+ - **The `Origin` is `null`.** A page sent with `Referrer-Policy: no-referrer`
521
+ makes the browser send `Origin: null` for every form it posts. So whenever
522
+ the `Origin` decides, the answer is 403, whatever `ORIGIN` holds.
523
+ - **`ORIGIN` is not what the browser shows.** Check for `http` against
524
+ `https`, a missing or extra port, or a trailing `/`. Leaving `origin` out does
525
+ not help behind a proxy: `csrf()` then compares with the URL the app sees,
526
+ which is the proxy's `http://` upstream, not the public one.
527
+
528
+ **Fix:** serve the page and the route on one origin, over HTTPS or on
529
+ `localhost` in development. Redirect `www.` to the bare host, and post the form
530
+ to a relative `action`. Set `ORIGIN` to the scheme, host and port the address
531
+ bar shows. Send the page with `strict-origin`, as
532
+ [the guide](guide/routes.md#a-link-sent-by-e-mail) does, never
533
+ `no-referrer`. A `Referer` then holds the origin only, never the path and its
534
+ token, and the form carries its real `Origin`.
535
+
536
+ ```ts
537
+ const ORIGIN = process.env.ORIGIN ?? 'https://app.example'; // what the address bar shows, no trailing slash
538
+
539
+ // on the GET page: the token's URL still reaches nobody, and Origin is no longer null
540
+ c.header('Referrer-Policy', 'strict-origin');
541
+
542
+ app.post('/sign-in/link', csrf({ origin: ORIGIN }), handler);
543
+ ```
544
+
455
545
  ### `409 {"code":"SECOND_FACTOR_ACTIVE"}` or `409 {"code":"SECOND_FACTOR_NOT_ENROLLED"}`
456
546
 
457
547
  **When:** a route calling `secondFactor.enroll` or `secondFactor.activate`
@@ -524,3 +614,63 @@ app.get('/session', session(auth, { required: true }), (c) =>
524
614
  c.json({ expiresAt: c.var.session.expiresAt }),
525
615
  );
526
616
  ```
617
+
618
+ ### Every sign-in reports a new device
619
+
620
+ **When:** `signedIn.newDevice` is `true` — and a notice sent — at every
621
+ sign-in, from a browser the user always uses.
622
+ **Why:** the token `deviceOf` reads does not prove this user signed in
623
+ there. With this package, most often:
624
+
625
+ - `deviceOf` and `sendSession` were given different options — another
626
+ `name`, or a `path` the sign-in route is outside of — so the cookie written
627
+ is never the one read;
628
+ - the device cookie is `Secure`, and the page is served over plain `http://`
629
+ on a host other than `localhost`: see
630
+ [The browser never sends the cookie back](#the-browser-never-sends-the-cookie-back);
631
+ - a confirmation route passes `{ device: null }` rather than
632
+ `{ device: deviceOf(c) }`.
633
+
634
+ `@nxgt/janus`'s [troubleshooting](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/troubleshooting.md#every-sign-in-reports-a-new-device)
635
+ has the other causes: a device key removed, two people sharing a browser.
636
+ **Fix:** one `DeviceCookieOptions`, passed to both:
637
+
638
+ ```ts
639
+ const device = { name: 'device' } as const;
640
+ const signedIn = await auth.signIn(input, { device: deviceOf(c, device) });
641
+ sendSession(c, auth, signedIn, { device });
642
+ ```
643
+
644
+ ### No device cookie is set
645
+
646
+ **When:** `sendSession` appends the session cookie and no `janus-device`.
647
+ **Why:** the answer carries no `deviceToken`: the sign-in was given no
648
+ `device` — `{ device: deviceOf(c) }` left out, or a second factor's
649
+ `confirm` called without it, since the challenge carries none. The session
650
+ renewal of `session()` never sets it either.
651
+ **Fix:** give every call that opens a session `{ device: deviceOf(c) }`.
652
+
653
+ ### `Cookies Max-Age SHOULD NOT be greater than 400 days (34560000 seconds) in duration.`
654
+
655
+ **When:** `sendSession` answers a sign-in that carries a `deviceToken`, with
656
+ `device: { maxAge }` above 34560000 — most often milliseconds given where
657
+ seconds belong. It throws after the sign-in completed: the session exists,
658
+ the response is not sent.
659
+ **Why:** Hono's `setCookie` refuses a `Max-Age` beyond 400 days, the longest
660
+ a browser keeps a cookie.
661
+ **Fix:** give seconds, 400 days at most — or leave `maxAge` out, which is
662
+ 400 days.
663
+
664
+ ```ts
665
+ sendSession(c, auth, signedIn, { device: { maxAge: 60 * 60 * 24 * 180 } }); // 180 days
666
+ ```
667
+
668
+ ### `TypeError: signIn: a device was given, but janus() has no devices — pass devices: { keys }`
669
+
670
+ **When:** a route passes `{ device: deviceOf(c) }` — even when it answers
671
+ `null` — to a `janus()` wired without `devices`. It reaches
672
+ `janusErrors()`'s `fallback`, or Hono's 500: a `TypeError` is a wiring
673
+ mistake, not a refusal.
674
+ **Fix:** give that `janus()` `devices: { keys }` — every instance that signs
675
+ users in needs the same — or stop passing `device`. See
676
+ [`@nxgt/janus`'s devices guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/devices.md#wiring).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus-hono",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
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
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,13 +49,13 @@
49
49
  ]
50
50
  },
51
51
  "devDependencies": {
52
- "@nxgt/janus": "^0.15.0",
52
+ "@nxgt/janus": "^0.17.0",
53
53
  "@types/bun": "^1.4.2",
54
54
  "hono": "^4.13.9",
55
55
  "zod": "^4.6.5"
56
56
  },
57
57
  "peerDependencies": {
58
- "@nxgt/janus": "^0.15.0",
58
+ "@nxgt/janus": "^0.17.0",
59
59
  "hono": "^4.13.4",
60
60
  "typescript": "^6.0.3"
61
61
  }