@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 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: `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 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. Exhaustive: a code added to `@nxgt/janus`
6
- * stops this file compiling instead of answering `undefined`.
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.
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAElD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAEnE;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,cAAc,GAAG,oBAAoB,CA+BnE;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"}
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 { JanusError } from "@nxgt/janus";
99
+ import {
100
+ JanusError,
101
+ statusOf as janusStatusOf
102
+ } from "@nxgt/janus";
100
103
  function statusOf(code) {
101
- switch (code) {
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=C2DF5327C05EE7CF64756E2164756E21
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 { JanusError, type JanusErrorCode } 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. Exhaustive: a code added to `@nxgt/janus`\n * stops this file compiling instead of answering `undefined`.\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\tswitch (code) {\n\t\tcase 'STORE_FAILED':\n\t\t\treturn 503;\n\t\tcase 'NOT_FOUND':\n\t\t\treturn 404;\n\t\tcase 'LOGIN_TAKEN':\n\t\tcase 'VERSION_CONFLICT':\n\t\t\treturn 409;\n\t\tcase 'USER_INVALID':\n\t\tcase 'PASSWORD_TOO_SHORT':\n\t\tcase 'HASH_UNSUPPORTED':\n\t\tcase 'INVALID_CURSOR':\n\t\tcase 'TOKEN_UNKNOWN':\n\t\tcase 'TOKEN_SPENT':\n\t\tcase 'TOKEN_EXPIRED':\n\t\tcase 'TOKEN_STALE':\n\t\t\treturn 400;\n\t\tcase 'CREDENTIALS_INVALID':\n\t\tcase 'CODE_INVALID':\n\t\t\treturn 401;\n\t\tcase 'SECOND_FACTOR_NOT_ENROLLED':\n\t\tcase 'SECOND_FACTOR_ACTIVE':\n\t\t\treturn 409;\n\t\tcase 'USER_INACTIVE':\n\t\t\treturn 403;\n\t\tcase 'UNSUPPORTED':\n\t\t\treturn 501;\n\t\tcase 'PERMISSION_DEPTH':\n\t\t\treturn 500;\n\t}\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
10
  ],
11
- "mappings": "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",
12
- "debugId": "C2DF5327C05EE7CF64756E2164756E21",
11
+ "mappings": "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",
12
+ "debugId": "0EF2A2F91E745DFA64756E2164756E21",
13
13
  "names": []
14
14
  }
@@ -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
- return c.body(null, 204);
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 }` — `activate` counts no attempts, so there is no `attemptsLeft` |
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).
@@ -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)` or
320
- `secondFactor.activate(user, code)`, with a code that does not match or was
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
- **Fix:** read `attemptsLeft` in the client: ask again while it is above `0`,
335
- and go back to the sign-in form once it is `0`.
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, or `secondFactor.confirm`
363
- after the factor was disabled, answers `SECOND_FACTOR_NOT_ENROLLED`.
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 is the other 409: the same code submitted twice at once,
368
- where the first call already opened the session.
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, send the
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.4",
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.9.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.9.0",
58
+ "@nxgt/janus": "^0.11.0",
59
59
  "hono": "^4.13.4",
60
60
  "typescript": "^6.0.3"
61
61
  }