@nxgt/janus-hono 0.3.4 → 0.3.6
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 +41 -3
- package/dist/errors.d.ts +3 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.js +6 -32
- package/dist/index.js.map +3 -3
- package/docs/guide/routes.md +70 -5
- package/docs/troubleshooting.md +24 -10
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -47,11 +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`, 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)` | 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
51
|
| `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
52
|
| `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
|
|
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 and `signInCode.confirm`'s — what the client can act on, and nothing else |
|
|
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 `signInCode.confirm`'s — what the client can act on, and nothing else |
|
|
55
55
|
| `SessionOptions<Type>` | `{ type?, required? }`, the options of `session()` — for a wrapper of your own |
|
|
56
56
|
| `SessionEnv<typeof auth, Type?, Required?>` | The `Env` `session()` sets, for `new Hono<SessionEnv<typeof auth>>()` |
|
|
57
57
|
| `UserOfAuth<typeof auth>` | The users an instance knows, as a union narrowed by `user.type` |
|
|
@@ -60,9 +60,29 @@ declarations import without extensions, so `nodenext` is not supported.
|
|
|
60
60
|
| `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
61
|
| `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
62
|
| `ObjectData<C, Type>`, `PermissionOptions`, `Instances` | The types of `load`'s answer, of `permission()`'s options and of `provide()`'s argument |
|
|
63
|
+
| `JanusErrorsOptions` | `{ report?, fallback? }`, the options of `janusErrors()` — for a wrapper of your own |
|
|
64
|
+
| `Bound<I>` | What `bindJanus(instances)` answers, for `I` the type of `instances` — to pass a bound `j` to a module of routes |
|
|
65
|
+
| `Bindable` | What `bindJanus()` takes: `{ auth?, access? }`, the constraint on `Bound`'s `I` |
|
|
66
|
+
| `BoundAuth<typeof auth>` | The part of `Bound` that `auth` brings: `session`, `sendSession` and `signOut` |
|
|
67
|
+
| `BoundSession<typeof auth>` | `j.session`: `session(auth, options?)` with `auth` bound, typed as `session()` is — `{ required: true }` types `c.var.user` as never `null` |
|
|
68
|
+
| `BoundPermission<C>` | `j.permission`: `permission(access, …)` with `access` bound, for `C` the model's config: `ConfigOf<typeof model>` from `@nxgt/janus/permissions`, or take the whole type as `Bound<…>['permission']` |
|
|
63
69
|
|
|
64
70
|
The whole status table is in [`@nxgt/janus`'s errors guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/errors.md).
|
|
65
71
|
|
|
72
|
+
### A wrapper of your own
|
|
73
|
+
|
|
74
|
+
A wrapper around `janusErrors()` takes the same options, with your defaults:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { type JanusErrorsOptions, janusErrors } from '@nxgt/janus-hono';
|
|
78
|
+
import { logger } from './logger'; // your own
|
|
79
|
+
|
|
80
|
+
export const appErrors = (options: JanusErrorsOptions = {}) =>
|
|
81
|
+
janusErrors({ report: (error) => logger.error(error), ...options });
|
|
82
|
+
|
|
83
|
+
app.onError(appErrors());
|
|
84
|
+
```
|
|
85
|
+
|
|
66
86
|
## Second factor
|
|
67
87
|
|
|
68
88
|
With `janus({ secondFactor })`, `signIn` answers a session or a challenge.
|
|
@@ -165,6 +185,24 @@ const app = new Hono()
|
|
|
165
185
|
|
|
166
186
|
Only what is given is bound: `bindJanus({ auth })` has no `permission`.
|
|
167
187
|
|
|
188
|
+
A module of routes that receives `j` types it `Bound<…>`, over the types of
|
|
189
|
+
the instances it was given:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import { type Bound, bindJanus, byParam } from '@nxgt/janus-hono';
|
|
193
|
+
|
|
194
|
+
export type Janus = Bound<{ auth: typeof auth; access: typeof access }>;
|
|
195
|
+
|
|
196
|
+
export const recordRoutes = (j: Janus) =>
|
|
197
|
+
new Hono()
|
|
198
|
+
.use(j.session(), j.provide())
|
|
199
|
+
.get('/records/:id', j.permission('view', 'record', byParam('id', (id) => records.find(id))), (c) =>
|
|
200
|
+
c.json(c.var.object), // typed, as with the unbound functions
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
app.route('/', recordRoutes(bindJanus({ auth, access })));
|
|
204
|
+
```
|
|
205
|
+
|
|
168
206
|
Each side is usable alone. An application that signs users in some other way
|
|
169
207
|
passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
|
|
170
208
|
`session()` and `janusErrors()` only.
|
package/dist/errors.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { JanusError, type JanusErrorCode } from '@nxgt/janus';
|
|
|
2
2
|
import type { Context, ErrorHandler } from 'hono';
|
|
3
3
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
4
4
|
/**
|
|
5
|
-
* The status each code deserves
|
|
6
|
-
*
|
|
5
|
+
* The status each code deserves: `@nxgt/janus`'s own table, typed as the
|
|
6
|
+
* `ContentfulStatusCode` `c.json()` takes. Kept as an export of its own so a
|
|
7
|
+
* route that answers by hand reads the same status `janusErrors()` does.
|
|
7
8
|
*
|
|
8
9
|
* `STORE_FAILED` is 503 and nothing else — **an outage is not a negative
|
|
9
10
|
* answer**, and a 401 or a 404 would tell every user they do not exist.
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,UAAU,EACV,KAAK,cAAc,EAEnB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAElD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAEnE;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,cAAc,GAAG,oBAAoB,CAEnE;AAED;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG;IAC1C,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAC;IACvC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAC/B,CAeA;AAgBD,kCAAkC;AAClC,MAAM,WAAW,kBAAkB;IAClC;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC;IAC7D,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAC;CACjC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,YAAY,CAQ1E"}
|
package/dist/index.js
CHANGED
|
@@ -96,38 +96,12 @@ function bindJanus(instances) {
|
|
|
96
96
|
return bound;
|
|
97
97
|
}
|
|
98
98
|
// src/errors.ts
|
|
99
|
-
import {
|
|
99
|
+
import {
|
|
100
|
+
JanusError,
|
|
101
|
+
statusOf as janusStatusOf
|
|
102
|
+
} from "@nxgt/janus";
|
|
100
103
|
function statusOf(code) {
|
|
101
|
-
|
|
102
|
-
case "STORE_FAILED":
|
|
103
|
-
return 503;
|
|
104
|
-
case "NOT_FOUND":
|
|
105
|
-
return 404;
|
|
106
|
-
case "LOGIN_TAKEN":
|
|
107
|
-
case "VERSION_CONFLICT":
|
|
108
|
-
return 409;
|
|
109
|
-
case "USER_INVALID":
|
|
110
|
-
case "PASSWORD_TOO_SHORT":
|
|
111
|
-
case "HASH_UNSUPPORTED":
|
|
112
|
-
case "INVALID_CURSOR":
|
|
113
|
-
case "TOKEN_UNKNOWN":
|
|
114
|
-
case "TOKEN_SPENT":
|
|
115
|
-
case "TOKEN_EXPIRED":
|
|
116
|
-
case "TOKEN_STALE":
|
|
117
|
-
return 400;
|
|
118
|
-
case "CREDENTIALS_INVALID":
|
|
119
|
-
case "CODE_INVALID":
|
|
120
|
-
return 401;
|
|
121
|
-
case "SECOND_FACTOR_NOT_ENROLLED":
|
|
122
|
-
case "SECOND_FACTOR_ACTIVE":
|
|
123
|
-
return 409;
|
|
124
|
-
case "USER_INACTIVE":
|
|
125
|
-
return 403;
|
|
126
|
-
case "UNSUPPORTED":
|
|
127
|
-
return 501;
|
|
128
|
-
case "PERMISSION_DEPTH":
|
|
129
|
-
return 500;
|
|
130
|
-
}
|
|
104
|
+
return janusStatusOf(code);
|
|
131
105
|
}
|
|
132
106
|
function bodyOf(error) {
|
|
133
107
|
switch (error.code) {
|
|
@@ -183,5 +157,5 @@ export {
|
|
|
183
157
|
statusOf
|
|
184
158
|
};
|
|
185
159
|
|
|
186
|
-
//# debugId=
|
|
160
|
+
//# debugId=0EF2A2F91E745DFA64756E2164756E21
|
|
187
161
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
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 {
|
|
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
10
|
],
|
|
11
|
-
"mappings": ";AA0IO,SAAS,UAAU,CACzB,QACA,YACA,MACA,MACA,UAKI,CAAC,GACe;AAAA,EACpB,MAAM,MAAM,OAAO;AAAA,EAEnB,OAAO,OAAO,GAAG,SAAS;AAAA,IACzB,IAAI,EAAE,IAAI,QAAQ,MAAM,WAAW;AAAA,MAClC,MAAM,IAAI,UACT,6HACD;AAAA,IACD;AAAA,IACA,MAAM,UACL,QAAQ,YAAY,YAAY,OAAO,CAAC,IAAI,MAAM,QAAQ,QAAQ,CAAC;AAAA,IACpE,IAAI,YAAY,QAAQ,YAAY;AAAA,MAAW,OAAO,EAAE,KAAK,MAAM,GAAG;AAAA,IAEtE,MAAM,SAAS,MAAM,KAAK,CAAC;AAAA,IAC3B,IAAI,WAAW;AAAA,MAAM,OAAO,EAAE,KAAK,MAAM,GAAG;AAAA,IAE5C,MAAM,UAAU,MAAM,IACrB,SACA,YACA,KAAK,QAAQ,IAAI,GACjB,QAAQ,QAAQ,YACb,YACA,EAAE,KAAK,MAAM,QAAQ,IAAI,GAAG,MAAM,EAAE,CACxC;AAAA,IACA,IAAI,CAAC;AAAA,MAAS,OAAO,EAAE,KAAK,MAAM,GAAG;AAAA,IAErC,EAAE,IAAI,UAAU,MAAM;AAAA,IACtB,MAAM,KAAK;AAAA;AAAA;AAYN,SAAS,OAAU,CACzB,MACA,MACsC;AAAA,EACtC,OAAO,CAAC,MAAM;AAAA,IACb,MAAM,KAAK,EAAE,IAAI,MAAM,IAAI;AAAA,IAC3B,OAAO,OAAO,YAAY,OAAO,KAAK,EAAE;AAAA;AAAA;AAU1C,SAAS,IAAI,CACZ,QACA,MACiD;AAAA,EACjD,OAAO,IAAI,MAAM,QAAQ;AAAA,IACxB,KAAK,CAAC,QAAQ,QAAS,QAAQ,SAAS,OAAO,QAAQ,IAAI,QAAQ,GAAG;AAAA,IACtE,KAAK,CAAC,QAAQ,QAAQ,QAAQ,UAAU,QAAQ,IAAI,QAAQ,GAAG;AAAA,EAChE,CAAC;AAAA;AAIF,SAAS,MAAM,CAAC,GAA4C;AAAA,EAC3D,MAAM,OAAgB,EAAE,IAAI,MAAM;AAAA,EAClC,IAAI,SAAS,WAAW;AAAA,IACvB,MAAM,IAAI,UACT,wFACD;AAAA,EACD;AAAA,EACA,OAAO;AAAA;;;AC/MD,SAAS,OAAkC,CACjD,WAIE;AAAA,EACF,OAAO,OAAO,GAAG,SAAS;AAAA,IACzB,IAAI,UAAU,SAAS;AAAA,MAAW,EAAE,IAAI,QAAQ,UAAU,IAAI;AAAA,IAC9D,IAAI,UAAU,WAAW;AAAA,MAAW,EAAE,IAAI,UAAU,UAAU,MAAM;AAAA,IACpE,MAAM,KAAK;AAAA;AAAA;;;ACtBb;AA8EO,SAAS,OAAO,CACtB,MACA,UAAkC,CAAC,GACf;AAAA,EACpB,OAAO,OAAO,GAAG,SAAS;AAAA,IACzB,MAAM,UAAU,MAAM,KAAK,aAC1B,EAAE,IAAI,KACN,QAAQ,SAAS,YAAY,YAAY,EAAE,MAAM,QAAQ,KAAK,CAC/D;AAAA,IAEA,IAAI,YAAY,QAAQ,QAAQ,aAAa,MAAM;AAAA,MAClD,OAAO,EAAE,KAAK,MAAM,GAAG;AAAA,IACxB;AAAA,IACA,EAAE,IAAI,QAAQ,SAAS,QAAQ,IAAI;AAAA,IACnC,EAAE,IAAI,WAAW,SAAS,WAAW,IAAI;AAAA,IAEzC,MAAM,KAAK;AAAA,IAIX,MAAM,OAAO,GAAG,KAAK,OAAO;AAAA,IAC5B,IACC,SAAS,YAAY,QACrB,UAAU,GAAG,KAAK,OAAO,IAAI,MAAM,QAAQ,SAC3C,CAAC,EAAE,IAAI,QAAQ,aAAa,EAAE,KAAK,CAAC,UAAU,MAAM,WAAW,IAAI,CAAC,GACnE;AAAA,MACD,YAAY,GAAG,MAAM,OAAO;AAAA,IAC7B;AAAA;AAAA;AAcK,SAAS,WAAc,CAC7B,GACA,MACA,UAKI;AAAA,EACJ,EAAE,OACD,cACA,KAAK,OAAO,UAAU,SAAS,OAAO,SAAS,OAAO,GACtD;AAAA,IACC,QAAQ;AAAA,EACT,CACD;AAAA,EACA,OAAO,SAAS;AAAA;AAQjB,eAAsB,OAAO,CAC5B,GACA,MACmB;AAAA,EACnB,MAAM,UAAU,MAAM,KAAK,QAAQ,EAAE,IAAI,GAAG;AAAA,EAC5C,EAAE,OAAO,cAAc,KAAK,OAAO,MAAM,GAAG,EAAE,QAAQ,KAAK,CAAC;AAAA,EAC5D,OAAO;AAAA;;;AC3CD,SAAS,SAAmC,CAAC,WAAwB;AAAA,EAC3E,QAAQ,MAAM,WAAW;AAAA,EACzB,MAAM,QAAiC;AAAA,IACtC,SAAS,MAAM,QAAQ,SAAS;AAAA,EACjC;AAAA,EACA,IAAI,SAAS,WAAW;AAAA,IACvB,MAAM,UAAU,CAAC,UAAkC,CAAC,MACnD,QAAQ,MAAM,OAAO;AAAA,IACtB,MAAM,cAAc,CACnB,GACA,aACI,YAAY,GAAG,MAAM,QAAQ;AAAA,IAClC,MAAM,UAAU,CAAC,MAAe,QAAQ,GAAG,IAAI;AAAA,EAChD;AAAA,EACA,IAAI,WAAW,WAAW;AAAA,IACzB,MAAM,QAAQ;AAAA,IAId,MAAM,aAAa,IAAI,SAAoB,MAAM,QAAQ,GAAG,IAAI;AAAA,EACjE;AAAA,EACA,OAAO;AAAA;;ACjIR;
|
|
12
|
-
"debugId": "
|
|
11
|
+
"mappings": "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",
|
|
12
|
+
"debugId": "0EF2A2F91E745DFA64756E2164756E21",
|
|
13
13
|
"names": []
|
|
14
14
|
}
|
package/docs/guide/routes.md
CHANGED
|
@@ -39,6 +39,16 @@ app.onError((error, c) => {
|
|
|
39
39
|
an `HTTPException` answers its own response and anything else is a logged
|
|
40
40
|
500, as Hono does by default.
|
|
41
41
|
|
|
42
|
+
A wrapper around `janusErrors()` takes the same options, `JanusErrorsOptions`,
|
|
43
|
+
with your defaults:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { type JanusErrorsOptions, janusErrors } from '@nxgt/janus-hono';
|
|
47
|
+
|
|
48
|
+
export const appErrors = (options: JanusErrorsOptions = {}) =>
|
|
49
|
+
janusErrors({ report: (error) => logger.error(error), ...options });
|
|
50
|
+
```
|
|
51
|
+
|
|
42
52
|
### Binding the instances once
|
|
43
53
|
|
|
44
54
|
Every function of this package takes `auth` or `access` first.
|
|
@@ -57,6 +67,27 @@ app.post('/sign-in', async (c) => {
|
|
|
57
67
|
});
|
|
58
68
|
```
|
|
59
69
|
|
|
70
|
+
A module of routes that receives `j` rather than importing it types it
|
|
71
|
+
`Bound<…>`, over the types of the instances it was given — only what is given
|
|
72
|
+
is bound, so `Bound<{ auth: typeof auth }>` has no `permission`:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { type Bound, byParam } from '@nxgt/janus-hono';
|
|
76
|
+
|
|
77
|
+
export type Janus = Bound<{ auth: typeof auth; access: typeof access }>;
|
|
78
|
+
|
|
79
|
+
export const recordRoutes = (j: Janus) =>
|
|
80
|
+
new Hono()
|
|
81
|
+
.use(j.session(), j.provide())
|
|
82
|
+
.get('/records/:id', j.permission('view', 'record', byParam('id', (id) => records.find(id))), (c) =>
|
|
83
|
+
c.json(c.var.object),
|
|
84
|
+
);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`BoundSession`, `BoundAuth` and `BoundPermission` are its parts — `j.session`,
|
|
88
|
+
the three functions `auth` brings, and `j.permission` — for a helper that needs
|
|
89
|
+
only one of them.
|
|
90
|
+
|
|
60
91
|
The rest of this guide writes the unbound form; each call reads the same with
|
|
61
92
|
`j.` and without its first argument.
|
|
62
93
|
|
|
@@ -218,6 +249,25 @@ A wrong code leaves the cookie in place, so the visitor types the next one.
|
|
|
218
249
|
`attemptsLeft` is the only thing the body adds: which cause of
|
|
219
250
|
`CODE_INVALID` it was — a wrong code or a reused one — is not told.
|
|
220
251
|
|
|
252
|
+
A user whose phone is gone answers the same challenge with a **recovery
|
|
253
|
+
code** instead, on a route of its own under the cookie's path:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
app.post('/sign-in/recovery', async (c) => {
|
|
257
|
+
const { code } = await c.req.json();
|
|
258
|
+
const challenge = getCookie(c, CHALLENGE);
|
|
259
|
+
if (challenge === undefined) return c.json({ code: 'TOKEN_UNKNOWN' }, 400);
|
|
260
|
+
const recovered = await auth.secondFactor.recover(challenge, code);
|
|
261
|
+
const user = sendSession(c, auth, recovered);
|
|
262
|
+
deleteCookie(c, CHALLENGE, scope);
|
|
263
|
+
return c.json({ id: user.id, recoveryCodesLeft: recovered.recoveryCodesLeft });
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
It shares the challenge's five attempts with `/sign-in/code`, and
|
|
268
|
+
`janusErrors()` answers it as above — plus `VERSION_CONFLICT`, 409, when the
|
|
269
|
+
same code signed in elsewhere at the same instant: sign in again.
|
|
270
|
+
|
|
221
271
|
A bearer client gets the challenge in the body instead, and sends it back
|
|
222
272
|
with the code:
|
|
223
273
|
|
|
@@ -231,7 +281,7 @@ app.post('/api/sign-in', async (c) => {
|
|
|
231
281
|
});
|
|
232
282
|
```
|
|
233
283
|
|
|
234
|
-
### Enrolling, activating, disabling
|
|
284
|
+
### Enrolling, activating, recovery codes, disabling
|
|
235
285
|
|
|
236
286
|
The user is signed in for these, so `session()` names them:
|
|
237
287
|
|
|
@@ -244,8 +294,16 @@ app.post('/account/second-factor', session(auth, { required: true }), async (c)
|
|
|
244
294
|
|
|
245
295
|
app.post('/account/second-factor/activate', session(auth, { required: true }), async (c) => {
|
|
246
296
|
const { code } = await c.req.json();
|
|
247
|
-
await auth.secondFactor.activate(c.var.user, code);
|
|
248
|
-
|
|
297
|
+
const { recoveryCodes } = await auth.secondFactor.activate(c.var.user, code);
|
|
298
|
+
c.header('Cache-Control', 'no-store'); // the recovery codes are shown once
|
|
299
|
+
return c.json({ recoveryCodes });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
app.post('/account/second-factor/recovery-codes', session(auth, { required: true }), async (c) => {
|
|
303
|
+
const { code } = await c.req.json(); // a fresh code from the app
|
|
304
|
+
const { recoveryCodes } = await auth.secondFactor.regenerateRecoveryCodes(c.var.user, code);
|
|
305
|
+
c.header('Cache-Control', 'no-store');
|
|
306
|
+
return c.json({ recoveryCodes });
|
|
249
307
|
});
|
|
250
308
|
|
|
251
309
|
app.delete('/account/second-factor', session(auth, { required: true }), async (c) => {
|
|
@@ -261,8 +319,15 @@ app.delete('/account/second-factor', session(auth, { required: true }), async (c
|
|
|
261
319
|
| Thrown | Answered |
|
|
262
320
|
| --- | --- |
|
|
263
321
|
| `SECOND_FACTOR_ACTIVE` | 409 `{ code }` — `enroll` or `activate` on a factor already active |
|
|
264
|
-
| `SECOND_FACTOR_NOT_ENROLLED` | 409 `{ code }` — `activate` before `enroll` |
|
|
265
|
-
| `CODE_INVALID` | 401 `{ code }`
|
|
322
|
+
| `SECOND_FACTOR_NOT_ENROLLED` | 409 `{ code }` — `activate` before `enroll`, or `regenerateRecoveryCodes` without an active factor |
|
|
323
|
+
| `CODE_INVALID` | 401 `{ code }` from `activate`, which counts no attempts; 401 `{ code, attemptsLeft }` from `regenerateRecoveryCodes` |
|
|
324
|
+
|
|
325
|
+
**The regenerate route needs no limiter of its own.** `regenerateRecoveryCodes`
|
|
326
|
+
counts five attempts per user per 15-minute window in the store, so a stolen
|
|
327
|
+
session cannot guess the app's code there one request at a time: past the
|
|
328
|
+
fifth, it answers `401 {"code":"CODE_INVALID","attemptsLeft":0}`, the right
|
|
329
|
+
code included, until the next window. A limiter in front of it — per IP, per
|
|
330
|
+
user — is still yours to add for load, never needed for the guesses.
|
|
266
331
|
|
|
267
332
|
The key rotation, the attempts and the replay rules are
|
|
268
333
|
[`@nxgt/janus`'s second factor guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/second-factor.md).
|
package/docs/troubleshooting.md
CHANGED
|
@@ -314,10 +314,12 @@ janus({ …, hasher: bunHasher(), verifiers: [scryptHasher()] }); // old hashes
|
|
|
314
314
|
### `401 {"code":"CODE_INVALID","attemptsLeft":<n>}` on the code form
|
|
315
315
|
|
|
316
316
|
Also `401 {"code":"CODE_INVALID"}`, with no `attemptsLeft`, from
|
|
317
|
-
`secondFactor.activate
|
|
317
|
+
`secondFactor.activate`: it has no challenge to count attempts against.
|
|
318
318
|
|
|
319
|
-
**When:** a route calling `secondFactor.confirm(challenge, code)
|
|
320
|
-
`secondFactor.
|
|
319
|
+
**When:** a route calling `secondFactor.confirm(challenge, code)`,
|
|
320
|
+
`secondFactor.recover(challenge, code)` with a recovery code,
|
|
321
|
+
`secondFactor.activate(user, code)` or
|
|
322
|
+
`secondFactor.regenerateRecoveryCodes(user, code)`, with a code that does not match or was
|
|
321
323
|
already used — or a route calling `signInCode.confirm(challenge, code)`, the
|
|
322
324
|
code sent by e-mail, with a code that does not match or is not six digits.
|
|
323
325
|
|
|
@@ -331,8 +333,16 @@ is a clock off by more than about 30 seconds, or a code used already — see
|
|
|
331
333
|
On `signInCode.confirm`, `attemptsLeft` counts the same five attempts, and
|
|
332
334
|
at `0` the challenge is spent too: the right code no longer works with it.
|
|
333
335
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
+
On `secondFactor.regenerateRecoveryCodes`, `attemptsLeft` is what the user
|
|
337
|
+
has left of five attempts in the current 15-minute window. At `0`, every
|
|
338
|
+
call answers `401 {"code":"CODE_INVALID","attemptsLeft":0}` — the right code
|
|
339
|
+
included, compared by nobody — until the next window: tell the user to wait,
|
|
340
|
+
at most 15 minutes. The message, in your logs, is
|
|
341
|
+
`secondFactor.regenerateRecoveryCodes: too many codes tried — wait for the next 15-minute window`.
|
|
342
|
+
|
|
343
|
+
**Fix:** read `attemptsLeft` in the client: ask again while it is above `0`.
|
|
344
|
+
Once it is `0`, a sign-in goes back to the sign-in form; the regenerate form
|
|
345
|
+
tells the signed-in user to wait for the next window.
|
|
336
346
|
|
|
337
347
|
```ts
|
|
338
348
|
const response = await fetch('/sign-in/code', { method: 'POST', body: JSON.stringify({ challenge, code }) });
|
|
@@ -359,16 +369,20 @@ if (response.status === 401) {
|
|
|
359
369
|
|
|
360
370
|
**When:** a route calling `secondFactor.enroll` or `secondFactor.activate`
|
|
361
371
|
answers `SECOND_FACTOR_ACTIVE` for a user whose factor is already on;
|
|
362
|
-
`secondFactor.activate` with no `enroll` before it,
|
|
363
|
-
|
|
372
|
+
`secondFactor.activate` with no `enroll` before it,
|
|
373
|
+
`secondFactor.regenerateRecoveryCodes` for a user with no active factor, or
|
|
374
|
+
`secondFactor.confirm` or `secondFactor.recover` after the factor was
|
|
375
|
+
disabled, answers `SECOND_FACTOR_NOT_ENROLLED`.
|
|
364
376
|
|
|
365
377
|
**Why:** both say the factor is not in the state the call needs — a conflict
|
|
366
378
|
with the user's state, so 409, not 400. A `409 {"code":"VERSION_CONFLICT"}`
|
|
367
|
-
from the code form
|
|
368
|
-
|
|
379
|
+
from the code form, or from the recovery code route (`/sign-in/recovery`), is
|
|
380
|
+
the other 409: the same code submitted twice at once, where the first call
|
|
381
|
+
already opened the session.
|
|
369
382
|
|
|
370
383
|
**Fix:** read `c.var.user.hasSecondFactor` before offering "set up" or
|
|
371
|
-
"turn off"; after `SECOND_FACTOR_NOT_ENROLLED` on the code form
|
|
384
|
+
"turn off"; after `SECOND_FACTOR_NOT_ENROLLED` on the code form or the recovery code
|
|
385
|
+
route, send the
|
|
372
386
|
visitor back to sign in. The causes of each are in
|
|
373
387
|
[`@nxgt/janus`'s troubleshooting](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/troubleshooting.md#second-factor).
|
|
374
388
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/janus-hono",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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.11.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.11.0",
|
|
59
59
|
"hono": "^4.13.4",
|
|
60
60
|
"typescript": "^6.0.3"
|
|
61
61
|
}
|