@nxgt/janus-hono 0.3.6 → 0.4.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 +44 -3
- package/dist/fresh.d.ts +33 -0
- package/dist/fresh.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -1
- package/dist/index.js.map +5 -4
- package/docs/guide/routes.md +64 -5
- package/docs/roadmap.md +4 -0
- package/docs/troubleshooting.md +48 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -48,10 +48,11 @@ declarations import without extensions, so `nodenext` is not supported.
|
|
|
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
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 |
|
|
51
|
+
| `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 |
|
|
51
52
|
| `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 |
|
|
52
53
|
| `janusErrors({ report?, fallback? })` | An `app.onError` handler: every `JanusError` answered with `statusOf(code)` and `bodyOf(error)`; 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 |
|
|
53
|
-
| `statusOf(code)` | The status a code deserves — `@nxgt/janus`'s `statusOf`, typed as Hono's `ContentfulStatusCode`: `STORE_FAILED` 503, `CREDENTIALS_INVALID` and `CODE_INVALID` 401, `USER_INACTIVE` 403, `LOGIN_TAKEN`, `SECOND_FACTOR_NOT_ENROLLED` and `SECOND_FACTOR_ACTIVE` 409, … Exhaustive over `JanusErrorCode` |
|
|
54
|
-
| `bodyOf(error)` | `{ code }`, plus `issues` for `USER_INVALID`, `minLength` for `PASSWORD_TOO_SHORT` and `attemptsLeft` for any `CODE_INVALID` that carries it — `secondFactor.confirm`'s, `secondFactor.recover`'s and `
|
|
54
|
+
| `statusOf(code)` | The status a code deserves — `@nxgt/janus`'s `statusOf`, typed as Hono's `ContentfulStatusCode`: `STORE_FAILED` 503, `CREDENTIALS_INVALID` and `CODE_INVALID` 401, `USER_INACTIVE` and `STEP_UP_REQUIRED` 403, `LOGIN_TAKEN`, `SECOND_FACTOR_NOT_ENROLLED` and `SECOND_FACTOR_ACTIVE` 409, … Exhaustive over `JanusErrorCode` |
|
|
55
|
+
| `bodyOf(error)` | `{ code }`, plus `issues` for `USER_INVALID`, `minLength` for `PASSWORD_TOO_SHORT` and `attemptsLeft` for any `CODE_INVALID` that carries it — `secondFactor.confirm`'s, `secondFactor.recover`'s, `signInCode.confirm`'s and `stepUp.confirm`'s — what the client can act on, and nothing else |
|
|
55
56
|
| `SessionOptions<Type>` | `{ type?, required? }`, the options of `session()` — for a wrapper of your own |
|
|
56
57
|
| `SessionEnv<typeof auth, Type?, Required?>` | The `Env` `session()` sets, for `new Hono<SessionEnv<typeof auth>>()` |
|
|
57
58
|
| `UserOfAuth<typeof auth>` | The users an instance knows, as a union narrowed by `user.type` |
|
|
@@ -60,6 +61,7 @@ declarations import without extensions, so `nodenext` is not supported.
|
|
|
60
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 |
|
|
61
62
|
| `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 |
|
|
62
63
|
| `ObjectData<C, Type>`, `PermissionOptions`, `Instances` | The types of `load`'s answer, of `permission()`'s options and of `provide()`'s argument |
|
|
64
|
+
| `FreshOptions` | `{ clock? }`, the options of `fresh()` |
|
|
63
65
|
| `JanusErrorsOptions` | `{ report?, fallback? }`, the options of `janusErrors()` — for a wrapper of your own |
|
|
64
66
|
| `Bound<I>` | What `bindJanus(instances)` answers, for `I` the type of `instances` — to pass a bound `j` to a module of routes |
|
|
65
67
|
| `Bindable` | What `bindJanus()` takes: `{ auth?, access? }`, the constraint on `Bound`'s `I` |
|
|
@@ -121,6 +123,40 @@ app.onError(janusErrors()); // a wrong code: 401 { code: 'CODE_INVALID', attempt
|
|
|
121
123
|
`SECOND_FACTOR_ACTIVE` 409. The enrolment routes and a bearer client's
|
|
122
124
|
sign-in are in [the routes guide](docs/guide/routes.md#a-second-factor).
|
|
123
125
|
|
|
126
|
+
## Step-up
|
|
127
|
+
|
|
128
|
+
A sensitive route asks for a recent proof: `fresh(maxAge)` after
|
|
129
|
+
`session()`. An older session is answered 403 `STEP_UP_REQUIRED`; the client
|
|
130
|
+
asks for a step-up, confirms it, and sends the request again:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { fresh, janusErrors, session } from '@nxgt/janus-hono';
|
|
134
|
+
import { getCookie, setCookie } from 'hono/cookie';
|
|
135
|
+
|
|
136
|
+
const app = new Hono()
|
|
137
|
+
.delete('/account', session(auth, { required: true }), fresh('10m'), deleteAccount)
|
|
138
|
+
.post('/step-up', session(auth, { required: true }), async (c) => {
|
|
139
|
+
const issued = await auth.stepUp.request(c.var.user);
|
|
140
|
+
if (issued.via === 'email') await sendMail(issued.email, issued.code); // yours
|
|
141
|
+
setCookie(c, 'step-up-challenge', issued.challenge, {
|
|
142
|
+
path: '/step-up', httpOnly: true, secure: true, sameSite: 'Strict', expires: issued.expiresAt,
|
|
143
|
+
});
|
|
144
|
+
return c.json({ via: issued.via }); // 'secondFactor': ask for the app's code
|
|
145
|
+
})
|
|
146
|
+
.post('/step-up/code', session(auth, { required: true }), async (c) => {
|
|
147
|
+
const { code } = await c.req.json();
|
|
148
|
+
const challenge = getCookie(c, 'step-up-challenge') ?? '';
|
|
149
|
+
await auth.stepUp.confirm(c.req.raw, challenge, code); // this request's session, fresh now
|
|
150
|
+
return c.body(null, 204);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
app.onError(janusErrors()); // 403 { code: 'STEP_UP_REQUIRED' }, 401 { code: 'CODE_INVALID', attemptsLeft }
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`confirm` stamps the session the request presents and opens none: there is
|
|
157
|
+
no cookie to send again. The whole flow is in
|
|
158
|
+
[the routes guide](docs/guide/routes.md#a-step-up).
|
|
159
|
+
|
|
124
160
|
## Permissions
|
|
125
161
|
|
|
126
162
|
`permission()` guards a route: the object is loaded once, checked, and handed
|
|
@@ -253,6 +289,9 @@ passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
|
|
|
253
289
|
- **One `permission()` per route.** Both would claim `c.var.object`, so a
|
|
254
290
|
second throws a `TypeError`. Check a parent object through an arrow in the
|
|
255
291
|
model.
|
|
292
|
+
- **`fresh()` needs `session()` before it**, as `permission()` does: without
|
|
293
|
+
it, `fresh()` throws a `TypeError` at the first request. A 403
|
|
294
|
+
`STEP_UP_REQUIRED` is not a denial — ask for a step-up, then retry.
|
|
256
295
|
- **`permission()` needs `session()` before it**, or `{ subject }`. Without
|
|
257
296
|
either, it throws a `TypeError` at the first request — a wiring error, not
|
|
258
297
|
an anonymous 401.
|
|
@@ -267,7 +306,7 @@ passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
|
|
|
267
306
|
|
|
268
307
|
## Type safety, counted
|
|
269
308
|
|
|
270
|
-
Twenty-
|
|
309
|
+
Twenty-six plausible mistakes are refused by the compiler, each with a
|
|
271
310
|
`@ts-expect-error` case in `test/types/`:
|
|
272
311
|
|
|
273
312
|
- six in `session.ts`: reading `c.var.user` where it may be `null` (twice,
|
|
@@ -285,6 +324,8 @@ Twenty-five plausible mistakes are refused by the compiler, each with a
|
|
|
285
324
|
user, an unknown user type, a missing `ctx`, a misspelled permission — a
|
|
286
325
|
`permission`, `session`, `sendSession` or `signOut` it was not given the
|
|
287
326
|
instance for, and an `access` that is no `permissions()` instance.
|
|
327
|
+
- one in `fresh.ts`: a `maxAge` that is no duration — `'10 minutes'` for
|
|
328
|
+
`'10m'`.
|
|
288
329
|
|
|
289
330
|
## Licence
|
|
290
331
|
|
package/dist/fresh.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Clock, type Duration, type Session } from '@nxgt/janus';
|
|
2
|
+
import type { MiddlewareHandler } from 'hono';
|
|
3
|
+
/** What `fresh()` takes besides `maxAge`. */
|
|
4
|
+
export interface FreshOptions {
|
|
5
|
+
/** The clock given to `janus()`, when it is not the system's: `fixedClock` in a spec. */
|
|
6
|
+
readonly clock?: Clock;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The middleware that lets a route run only for a session that proved who
|
|
10
|
+
* it is less than `maxAge` ago — signed in, or confirmed by
|
|
11
|
+
* `auth.stepUp.confirm`, since. Behind `session(auth)`, which sets
|
|
12
|
+
* `c.var.session`:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* app.delete('/account', session(auth, { required: true }), fresh('10m'), deleteAccount);
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* A session older than that throws `STEP_UP_REQUIRED`, which `janusErrors()`
|
|
19
|
+
* answers 403 with `{ code: 'STEP_UP_REQUIRED' }`: the client asks for a
|
|
20
|
+
* step-up, then sends the request again. An anonymous request is answered
|
|
21
|
+
* 401, with no body, as `session(auth, { required: true })` answers it.
|
|
22
|
+
*
|
|
23
|
+
* A `maxAge` that is not a duration is a `TypeError` here, when the app is
|
|
24
|
+
* wired — not on the first request.
|
|
25
|
+
* No `session()` before it is a `TypeError` at the first request — a
|
|
26
|
+
* wiring error, not an anonymous 401 — as for `permission()`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function fresh(maxAge: Duration, options?: FreshOptions): MiddlewareHandler<{
|
|
29
|
+
Variables: {
|
|
30
|
+
session: Session | null;
|
|
31
|
+
};
|
|
32
|
+
}>;
|
|
33
|
+
//# sourceMappingURL=fresh.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fresh.d.ts","sourceRoot":"","sources":["../src/fresh.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,KAAK,EACV,KAAK,QAAQ,EAEb,KAAK,OAAO,EAGZ,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAE9C,6CAA6C;AAC7C,MAAM,WAAW,YAAY;IAC5B,yFAAyF;IACzF,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,KAAK,CACpB,MAAM,EAAE,QAAQ,EAChB,OAAO,GAAE,YAAiB,GACxB,iBAAiB,CAAC;IAEpB,SAAS,EAAE;QAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;KAAE,CAAC;CACvC,CAAC,CAoBD"}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* - `session(auth, options?)` — middleware: `c.var.user` and `c.var.session`
|
|
5
5
|
* on every request, a 401 where a user is required, the renewed cookie sent
|
|
6
6
|
* again;
|
|
7
|
+
* - `fresh(maxAge, options?)` — middleware, behind `session(auth)`: the
|
|
8
|
+
* route runs only for a session that proved who it is less than `maxAge`
|
|
9
|
+
* ago, `STEP_UP_REQUIRED` otherwise;
|
|
7
10
|
* - `sendSession(c, auth, signedIn)` and `signOut(c, auth)` — the cookie, set
|
|
8
11
|
* and cleared; `sendSession` answers the user;
|
|
9
12
|
* - `permission(access, permission, type, load)` — middleware: the route runs
|
|
@@ -23,6 +26,7 @@
|
|
|
23
26
|
*/
|
|
24
27
|
export { type Bindable, type Bound, type BoundAuth, type BoundPermission, type BoundSession, bindJanus, } from './bind';
|
|
25
28
|
export { bodyOf, type JanusErrorsOptions, janusErrors, statusOf, } from './errors';
|
|
29
|
+
export { type FreshOptions, fresh } from './fresh';
|
|
26
30
|
export { byParam, type ObjectData, type PermissionOptions, permission, } from './permission';
|
|
27
31
|
export { type Instances, provide } from './provide';
|
|
28
32
|
export { type SessionEnv, type SessionOptions, sendSession, session, signOut, type UserOfAuth, } from './session';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
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"}
|
package/dist/index.js
CHANGED
|
@@ -144,10 +144,33 @@ function reportSafely(report, error, c) {
|
|
|
144
144
|
warn(failure);
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
|
+
// src/fresh.ts
|
|
148
|
+
import {
|
|
149
|
+
parseDuration,
|
|
150
|
+
StepUpRequiredError,
|
|
151
|
+
systemClock
|
|
152
|
+
} from "@nxgt/janus";
|
|
153
|
+
function fresh(maxAge, options = {}) {
|
|
154
|
+
const maxAgeMs = parseDuration(maxAge, "fresh: maxAge");
|
|
155
|
+
return async (c, next) => {
|
|
156
|
+
const current = c.var.session;
|
|
157
|
+
if (current === undefined) {
|
|
158
|
+
throw new TypeError("fresh(): c.var.session is not set — put session(auth) before it");
|
|
159
|
+
}
|
|
160
|
+
if (current === null)
|
|
161
|
+
return c.body(null, 401);
|
|
162
|
+
const now = (options.clock ?? systemClock).now().getTime();
|
|
163
|
+
if (now - current.authenticatedAt.getTime() >= maxAgeMs) {
|
|
164
|
+
throw new StepUpRequiredError("fresh(): the session proved who it is longer ago than maxAge — confirm with a step-up", { userId: current.userId });
|
|
165
|
+
}
|
|
166
|
+
await next();
|
|
167
|
+
};
|
|
168
|
+
}
|
|
147
169
|
export {
|
|
148
170
|
bindJanus,
|
|
149
171
|
bodyOf,
|
|
150
172
|
byParam,
|
|
173
|
+
fresh,
|
|
151
174
|
janusErrors,
|
|
152
175
|
permission,
|
|
153
176
|
provide,
|
|
@@ -157,5 +180,5 @@ export {
|
|
|
157
180
|
statusOf
|
|
158
181
|
};
|
|
159
182
|
|
|
160
|
-
//# debugId=
|
|
183
|
+
//# debugId=5F4368188D8CD0B564756E2164756E21
|
|
161
184
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/permission.ts", "../src/provide.ts", "../src/session.ts", "../src/bind.ts", "../src/errors.ts"],
|
|
3
|
+
"sources": ["../src/permission.ts", "../src/provide.ts", "../src/session.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
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
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",
|
|
9
|
-
"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. Never `reason`, `login`, a hash prefix or a\n * 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} {\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\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)`; anything else handed to `fallback` — Hono's own behaviour\n * 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\treturn c.json(bodyOf(error), status);\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\tconst reported = report(error, c);\n\t\tif (reported instanceof Promise) reported.then(undefined, warn);\n\t} catch (failure) {\n\t\twarn(failure);\n\t}\n}\n"
|
|
9
|
+
"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. Never `reason`, `login`, a hash prefix or a\n * 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} {\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\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)`; anything else handed to `fallback` — Hono's own behaviour\n * 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\treturn c.json(bodyOf(error), status);\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\tconst reported = report(error, c);\n\t\tif (reported instanceof Promise) reported.then(undefined, warn);\n\t} catch (failure) {\n\t\twarn(failure);\n\t}\n}\n",
|
|
10
|
+
"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"
|
|
10
11
|
],
|
|
11
|
-
"mappings": "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",
|
|
12
|
-
"debugId": "
|
|
12
|
+
"mappings": "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",
|
|
13
|
+
"debugId": "5F4368188D8CD0B564756E2164756E21",
|
|
13
14
|
"names": []
|
|
14
15
|
}
|
package/docs/guide/routes.md
CHANGED
|
@@ -286,6 +286,8 @@ app.post('/api/sign-in', async (c) => {
|
|
|
286
286
|
The user is signed in for these, so `session()` names them:
|
|
287
287
|
|
|
288
288
|
```ts
|
|
289
|
+
import { fresh, session } from '@nxgt/janus-hono';
|
|
290
|
+
|
|
289
291
|
app.post('/account/second-factor', session(auth, { required: true }), async (c) => {
|
|
290
292
|
const { secret, uri } = await auth.secondFactor.enroll(c.var.user);
|
|
291
293
|
c.header('Cache-Control', 'no-store'); // the secret is shown once, and never cached
|
|
@@ -306,11 +308,8 @@ app.post('/account/second-factor/recovery-codes', session(auth, { required: true
|
|
|
306
308
|
return c.json({ recoveryCodes });
|
|
307
309
|
});
|
|
308
310
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (Date.now() - c.var.session.authenticatedAt.getTime() > 5 * 60_000) {
|
|
312
|
-
return c.json({ error: 'signInAgain' }, 403);
|
|
313
|
-
}
|
|
311
|
+
// Your policy: a proof in the last five minutes — a sign-in or a step-up.
|
|
312
|
+
app.delete('/account/second-factor', session(auth, { required: true }), fresh('5m'), async (c) => {
|
|
314
313
|
await auth.secondFactor.disable(c.var.user);
|
|
315
314
|
return c.body(null, 204);
|
|
316
315
|
});
|
|
@@ -440,6 +439,66 @@ A user type with no password has no second factor, so its `confirm` always
|
|
|
440
439
|
answers a session. The attempts, the lifetime and every refusal are
|
|
441
440
|
[`@nxgt/janus`'s sign-in code guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sign-in-code.md).
|
|
442
441
|
|
|
442
|
+
## A step-up
|
|
443
|
+
|
|
444
|
+
A route a stolen session should not run alone — deleting the account,
|
|
445
|
+
changing the e-mail, disabling the second factor — asks for a **recent
|
|
446
|
+
proof** with `fresh(maxAge)`, after `session()`:
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
import { fresh, session } from '@nxgt/janus-hono';
|
|
450
|
+
|
|
451
|
+
app.delete('/account', session(auth, { required: true }), fresh('10m'), async (c) => {
|
|
452
|
+
await auth.delete(c.var.user);
|
|
453
|
+
return c.body(null, 204);
|
|
454
|
+
});
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
A session that signed in, or was confirmed by a step-up, less than ten
|
|
458
|
+
minutes ago runs the route. An older one is answered
|
|
459
|
+
`403 {"code":"STEP_UP_REQUIRED"}` by `janusErrors()` — not a denial: the
|
|
460
|
+
client runs a step-up, then sends the same request again. Two routes, built
|
|
461
|
+
like [the code sent by e-mail](#a-code-sent-by-e-mail), except that both
|
|
462
|
+
need the session:
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
|
466
|
+
|
|
467
|
+
app
|
|
468
|
+
.post('/step-up', session(auth, { required: true }), async (c) => {
|
|
469
|
+
const issued = await auth.stepUp.request(c.var.user);
|
|
470
|
+
if (issued.via === 'email') {
|
|
471
|
+
await mailer.send(issued.email, `Your confirmation code: ${issued.code}`); // yours
|
|
472
|
+
}
|
|
473
|
+
setCookie(c, 'step-up-challenge', issued.challenge, {
|
|
474
|
+
path: '/step-up', httpOnly: true, secure: true, sameSite: 'Strict', expires: issued.expiresAt,
|
|
475
|
+
});
|
|
476
|
+
return c.json({ via: issued.via }); // 'secondFactor': the code comes from their app
|
|
477
|
+
})
|
|
478
|
+
.post('/step-up/code', session(auth, { required: true }), async (c) => {
|
|
479
|
+
const { code } = await c.req.json();
|
|
480
|
+
const challenge = getCookie(c, 'step-up-challenge') ?? '';
|
|
481
|
+
await auth.stepUp.confirm(c.req.raw, challenge, code);
|
|
482
|
+
deleteCookie(c, 'step-up-challenge', { path: '/step-up' });
|
|
483
|
+
return c.body(null, 204);
|
|
484
|
+
});
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
- **`confirm` takes the request**: it stamps the session that request
|
|
488
|
+
presents — `c.req.raw`, with its cookie or its `Authorization` header —
|
|
489
|
+
and opens none, so there is no cookie to send again. A challenge carried
|
|
490
|
+
to another session is `TOKEN_UNKNOWN`, 400.
|
|
491
|
+
- **`via: 'secondFactor'`** — a user whose second factor is active confirms
|
|
492
|
+
with their app's code, and no e-mail is sent. A step-up is never weaker
|
|
493
|
+
than the sign-in the account asks for.
|
|
494
|
+
- **A wrong code** is `401 {"code":"CODE_INVALID","attemptsLeft":n}`, as on
|
|
495
|
+
the sign-in code form; the fifth spends the challenge.
|
|
496
|
+
|
|
497
|
+
`fresh()` reads `session.authenticatedAt` and nothing else: no store read.
|
|
498
|
+
In a spec, pass `janus()`'s clock — `fresh('10m', { clock })` — so
|
|
499
|
+
`clock.advance()` ages the session. Every refusal is in
|
|
500
|
+
[`@nxgt/janus`'s step-up guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/step-up.md).
|
|
501
|
+
|
|
443
502
|
## Sign-out
|
|
444
503
|
|
|
445
504
|
```ts
|
package/docs/roadmap.md
CHANGED
|
@@ -36,6 +36,10 @@ Nothing yet.
|
|
|
36
36
|
|
|
37
37
|
Newest first; the [CHANGELOG](../CHANGELOG.md) holds every release.
|
|
38
38
|
|
|
39
|
+
- **`fresh(maxAge)`, v0.4.0** — a middleware that lets a route run only for
|
|
40
|
+
a session that proved who it is less than `maxAge` ago, signed in or
|
|
41
|
+
confirmed by a step-up; `janusErrors()` answers `STEP_UP_REQUIRED` with
|
|
42
|
+
403. Needs `@nxgt/janus` 0.12.0.
|
|
39
43
|
- **The second factor's errors as statuses, v0.3.0** — `janusErrors()`
|
|
40
44
|
answers `CODE_INVALID` with 401 and `attemptsLeft` in the body, and
|
|
41
45
|
`SECOND_FACTOR_NOT_ENROLLED` and `SECOND_FACTOR_ACTIVE` with 409. Needs
|
package/docs/troubleshooting.md
CHANGED
|
@@ -25,6 +25,9 @@ for what causes each.
|
|
|
25
25
|
- [`500 Internal Server Error` for every refusal](#500-internal-server-error-for-every-refusal)
|
|
26
26
|
- [`TypeError: permission(): c.var.object is already set`](#typeerror-permission-cvarobject-is-already-set)
|
|
27
27
|
- [`TypeError: permission(): c.var.user is not set`](#typeerror-permission-cvaruser-is-not-set)
|
|
28
|
+
- [`TypeError: fresh(): c.var.session is not set — put session(auth) before it`](#typeerror-fresh-cvarsession-is-not-set--put-sessionauth-before-it)
|
|
29
|
+
- [`TypeError: fresh: maxAge: "<value>" is not a duration …`](#typeerror-fresh-maxage-value-is-not-a-duration-)
|
|
30
|
+
- [`403 {"code":"STEP_UP_REQUIRED"}` for a signed-in user](#403-codestep_up_required-for-a-signed-in-user)
|
|
28
31
|
- [`403` where the user should be allowed](#403-where-the-user-should-be-allowed)
|
|
29
32
|
- [`401` with an empty body, for a signed-in user](#401-with-an-empty-body-for-a-signed-in-user)
|
|
30
33
|
- [`503 {"code":"STORE_FAILED"}` on every route](#503-codestore_failed-on-every-route)
|
|
@@ -212,6 +215,51 @@ say who the subject is.
|
|
|
212
215
|
app.get('/records/:id', session(auth), permission(access, 'view', 'record', load), handler);
|
|
213
216
|
```
|
|
214
217
|
|
|
218
|
+
### `TypeError: fresh(): c.var.session is not set — put session(auth) before it`
|
|
219
|
+
|
|
220
|
+
**When:** the first request to a route guarded by `fresh()`.
|
|
221
|
+
|
|
222
|
+
**Why:** nothing set `c.var.session`: no `session()` before `fresh()` in
|
|
223
|
+
that route's chain. It is a wiring error, so it is thrown rather than
|
|
224
|
+
answered 401.
|
|
225
|
+
|
|
226
|
+
**Fix:** put `session(auth)` before it — on the route or with `app.use`:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
app.delete('/account', session(auth, { required: true }), fresh('10m'), handler);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### `TypeError: fresh: maxAge: "<value>" is not a duration …`
|
|
233
|
+
|
|
234
|
+
**When:** when the app is wired: the line that calls `fresh('10 m')`, or
|
|
235
|
+
`fresh()` with a `maxAge` computed as a string, throws before any request.
|
|
236
|
+
|
|
237
|
+
**Why:** a `maxAge` is a number of milliseconds, or a number followed by
|
|
238
|
+
one unit — `ms`, `s`, `m`, `h` or `d`. The type refuses most mistakes;
|
|
239
|
+
a computed string or a space (`'10 m'`) reaches here.
|
|
240
|
+
|
|
241
|
+
**Fix:**
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
fresh('10m'); // or fresh(600_000)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### `403 {"code":"STEP_UP_REQUIRED"}` for a signed-in user
|
|
248
|
+
|
|
249
|
+
**When:** a route guarded by `fresh(maxAge)`, for a session that signed in
|
|
250
|
+
— or was last confirmed by a step-up — `maxAge` ago or more.
|
|
251
|
+
|
|
252
|
+
**Why:** the route asks for a recent proof of who the user is. A session
|
|
253
|
+
renewed yesterday is still as old as its sign-in: renewal never moves
|
|
254
|
+
`authenticatedAt`. In a spec, a `fresh()` without `janus()`'s clock reads
|
|
255
|
+
the system time, not `clock.advance()`'s.
|
|
256
|
+
|
|
257
|
+
**Fix:** it is not a denial. Have the client run a step-up —
|
|
258
|
+
`auth.stepUp.request`, then `auth.stepUp.confirm(c.req.raw, challenge,
|
|
259
|
+
code)` — and send the same request again; see
|
|
260
|
+
[the routes guide](guide/routes.md#a-step-up). In a spec, pass the clock:
|
|
261
|
+
`fresh('10m', { clock })`.
|
|
262
|
+
|
|
215
263
|
### `403` where the user should be allowed
|
|
216
264
|
|
|
217
265
|
**When:** a user who holds the relation is refused.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/janus-hono",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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.
|
|
52
|
+
"@nxgt/janus": "^0.12.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.
|
|
58
|
+
"@nxgt/janus": "^0.12.0",
|
|
59
59
|
"hono": "^4.13.4",
|
|
60
60
|
"typescript": "^6.0.3"
|
|
61
61
|
}
|