@alxia/janus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ import { type BaseContext, type RequiresOf } from '@alxia/core';
2
+ import type { CheckableOf, ModelConfig, ObjectTypeOf, Permissions } from '@nxgt/janus/permissions';
3
+ import type { Awaitable, ObjectData, OptionsArgs } from './permission-options';
4
+ /** The body of each refusal. */
5
+ export interface PermissionRefusedBody {
6
+ readonly error: 'unauthenticated' | 'not_found' | 'forbidden';
7
+ }
8
+ /**
9
+ * A guard, as a plugin: the routes declared after it run only if the
10
+ * subject holds `permission` on an object of `type`. It loads the object
11
+ * once, checks it with `access.can`, and hands it to them as `object`.
12
+ *
13
+ * | the request | answered |
14
+ * | --- | --- |
15
+ * | anonymous | 401 — before the object is loaded |
16
+ * | `load` answers `null` | 404 |
17
+ * | a denial | 403 |
18
+ * | allowed | the route runs, `object` set |
19
+ *
20
+ * Each refusal is typed on those routes. **A failure throws**: a store that
21
+ * cannot answer is `STORE_FAILED`, never a 403 — `janusErrors()` answers
22
+ * it 503. Scope it with `group`, so it guards only its routes:
23
+ *
24
+ * ```ts
25
+ * app.use(session(accounts)).group('/records/:id', (records) =>
26
+ * records.use(permission(access, 'view', 'record', byParam('id', findRecord)))
27
+ * .get('/', ({ object, reply }) => reply(200, object)));
28
+ * ```
29
+ */
30
+ export declare function permission<C extends ModelConfig, const T extends ObjectTypeOf<C>, const P extends CheckableOf<C, T>, O extends ObjectData<C, T>, LoadCtx extends object = BaseContext, SubjectCtx extends object = BaseContext, CheckCtx extends object = BaseContext>(access: Pick<Permissions<C>, 'can'>, permission: P, type: T, load: (ctx: BaseContext & LoadCtx) => Awaitable<O | null>, ...options: OptionsArgs<C, T, P, O, SubjectCtx, CheckCtx>): import("@alxia/core").Alxia<RequiresOf<LoadCtx & SubjectCtx & CheckCtx, AnnotatedAny<LoadCtx, SubjectCtx>> & {
31
+ object: O;
32
+ }, import("@alxia/core").Empty, "", import("@alxia/core").Reply<401, PermissionRefusedBody> | import("@alxia/core").Reply<404, PermissionRefusedBody> | import("@alxia/core").Reply<403, PermissionRefusedBody>> & import("@alxia/core").Requiring<RequiresOf<LoadCtx & SubjectCtx & CheckCtx, AnnotatedAny<LoadCtx, SubjectCtx>>>;
33
+ /**
34
+ * The callback to name when `RequiresOf` refuses one annotated `any`: `load`
35
+ * or `subject` when it is that one, `ctx` otherwise. An `any` in any of the
36
+ * three makes their intersection `any`, so `RequiresOf` cannot tell which;
37
+ * a `Record<string, any>` passes the same test.
38
+ */
39
+ type AnnotatedAny<LoadCtx, SubjectCtx> = [LoadCtx] extends [
40
+ {
41
+ readonly '~any': 1;
42
+ }
43
+ ] ? 'load' : [SubjectCtx] extends [{
44
+ readonly '~any': 1;
45
+ }] ? 'subject' : 'ctx';
46
+ /**
47
+ * A `load` reading one path parameter, as it arrived: `find(id)`, or `null`
48
+ * — a 404 — when the route has no such parameter.
49
+ */
50
+ export declare function byParam<O>(name: string, find: (id: string) => Awaitable<O | null>): (ctx: BaseContext) => Awaitable<O | null>;
51
+ export {};
52
+ //# sourceMappingURL=permission.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission.d.ts","sourceRoot":"","sources":["../src/permission.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAgB,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9E,OAAO,KAAK,EACX,WAAW,EACX,WAAW,EACX,YAAY,EACZ,WAAW,EAEX,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAE/E,gCAAgC;AAChC,MAAM,WAAW,qBAAqB;IACrC,QAAQ,CAAC,KAAK,EAAE,iBAAiB,GAAG,WAAW,GAAG,WAAW,CAAC;CAC9D;AASD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,UAAU,CACzB,CAAC,SAAS,WAAW,EACrB,KAAK,CAAC,CAAC,SAAS,YAAY,CAAC,CAAC,CAAC,EAC/B,KAAK,CAAC,CAAC,SAAS,WAAW,CAAC,CAAC,EAAE,CAAC,CAAC,EACjC,CAAC,SAAS,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EAC1B,OAAO,SAAS,MAAM,GAAG,WAAW,EACpC,UAAU,SAAS,MAAM,GAAG,WAAW,EACvC,QAAQ,SAAS,MAAM,GAAG,WAAW,EAErC,MAAM,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,EACnC,UAAU,EAAE,CAAC,EACb,IAAI,EAAE,CAAC,EACP,IAAI,EAAE,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,KAAK,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,EACzD,GAAG,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,QAAQ,CAAC;;mUAoCzD;AAED;;;;;GAKG;AACH,KAAK,YAAY,CAAC,OAAO,EAAE,UAAU,IAAI,CAAC,OAAO,CAAC,SAAS;IAC1D;QAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAA;KAAE;CACtB,GACE,MAAM,GACN,CAAC,UAAU,CAAC,SAAS,CAAC;IAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAA;CAAE,CAAC,GAC5C,SAAS,GACT,KAAK,CAAC;AAEV;;;GAGG;AACH,wBAAgB,OAAO,CAAC,CAAC,EACxB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,GACvC,CAAC,GAAG,EAAE,WAAW,KAAK,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAK3C"}
package/dist/send.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { ResponseSettings } from '@alxia/core';
2
+ import { type DeviceCookieOptions } from './device';
3
+ import type { Auth, SessionOpened } from './types';
4
+ /** What `sendSession` takes besides: the device cookie's options. */
5
+ export interface SendSessionOptions {
6
+ readonly device?: DeviceCookieOptions;
7
+ }
8
+ /**
9
+ * Sends the session cookie — after `signUp`, `signIn`, anything that
10
+ * answered a token and its session — and answers the user. The token is in
11
+ * the cookie, never in the body. With a `deviceToken`, the device cookie
12
+ * too.
13
+ *
14
+ * ```ts
15
+ * const signedIn = await auth.signIn(body, { device: deviceOf(ctx) });
16
+ * return reply(200, { id: sendSession(ctx, auth, signedIn).id });
17
+ * ```
18
+ */
19
+ export declare function sendSession<U>(ctx: {
20
+ readonly set: ResponseSettings;
21
+ }, auth: Pick<Auth<{
22
+ readonly type: string;
23
+ }>, 'cookie'>, signedIn: SessionOpened<U>, options?: SendSessionOptions): U;
24
+ /**
25
+ * Revokes the session the request presents and clears the cookie — cleared
26
+ * whatever the answer, so a browser holding a stale cookie drops it too.
27
+ */
28
+ export declare function signOut(ctx: {
29
+ readonly request: Request;
30
+ readonly set: ResponseSettings;
31
+ }, auth: Pick<Auth<{
32
+ readonly type: string;
33
+ }>, 'signOut' | 'cookie'>): Promise<boolean>;
34
+ //# sourceMappingURL=send.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"send.d.ts","sourceRoot":"","sources":["../src/send.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAE,KAAK,mBAAmB,EAAc,MAAM,UAAU,CAAC;AAChE,OAAO,KAAK,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAEnD,qEAAqE;AACrE,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,MAAM,CAAC,EAAE,mBAAmB,CAAC;CACtC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAC5B,GAAG,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,gBAAgB,CAAA;CAAE,EACvC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAAE,QAAQ,CAAC,EACrD,QAAQ,EAAE,aAAa,CAAC,CAAC,CAAC,EAC1B,OAAO,GAAE,kBAAuB,GAC9B,CAAC,CASH;AAED;;;GAGG;AACH,wBAAsB,OAAO,CAC5B,GAAG,EAAE;IAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,gBAAgB,CAAA;CAAE,EAClE,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC,GAC/D,OAAO,CAAC,OAAO,CAAC,CAIlB"}
@@ -0,0 +1,67 @@
1
+ import { type Alxia, type Empty, type Reply } from '@alxia/core';
2
+ import type { Session } from '@nxgt/janus';
3
+ import { type DeviceCookieOptions } from './device';
4
+ import type { Auth, RequestAuth, UserOfAuth } from './types';
5
+ export interface SessionOptions<T extends string> {
6
+ /** Only a user of this type is authenticated here; any other is anonymous. */
7
+ readonly type?: T;
8
+ /**
9
+ * `true`: an anonymous request is answered 401 and the route never runs,
10
+ * so `user` is never `null` in it — and the 401 is in its type. A
11
+ * `boolean` known only at runtime keeps the 401 in the type, and `user`
12
+ * may be `null`.
13
+ */
14
+ readonly required?: boolean;
15
+ /** The device cookie `auth.device` reads and `auth.send` sets. */
16
+ readonly device?: DeviceCookieOptions;
17
+ }
18
+ /** The body of the 401 a required session answers. */
19
+ export interface UnauthenticatedBody {
20
+ readonly error: 'unauthenticated';
21
+ }
22
+ type UserOf<A, T> = Extract<UserOfAuth<A>, {
23
+ readonly type: T;
24
+ }>;
25
+ /**
26
+ * Who a request belongs to, as a plugin: `auth.authenticate(request)`, and
27
+ * the routes declared after it read `user` and `session` — typed by the
28
+ * user schema, narrowed by `type`.
29
+ *
30
+ * **An outage is not anonymous**: a store that cannot answer throws
31
+ * `STORE_FAILED`, which `janusErrors()` answers 503, never 401.
32
+ *
33
+ * A session renewed in passing is sent again as a cookie, after the route —
34
+ * only to a request that presented it as one (a `Bearer` client is never
35
+ * handed a cookie), and never over a session cookie the route set itself.
36
+ *
37
+ * ```ts
38
+ * app.use(session(accounts, { required: true })).get('/me', ({ user, reply }) => reply.ok(user));
39
+ * ```
40
+ */
41
+ export declare function session<A extends Auth<{
42
+ readonly type: string;
43
+ }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options: SessionOptions<T> & {
44
+ readonly required: true;
45
+ }): Alxia<{
46
+ readonly user: UserOf<A, T>;
47
+ readonly session: Session;
48
+ readonly auth: RequestAuth;
49
+ }, Empty, '', Reply<401, UnauthenticatedBody>>;
50
+ export declare function session<A extends Auth<{
51
+ readonly type: string;
52
+ }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options?: SessionOptions<T> & {
53
+ readonly required?: false;
54
+ }): Alxia<{
55
+ readonly user: UserOf<A, T> | null;
56
+ readonly session: Session | null;
57
+ readonly auth: RequestAuth;
58
+ }, Empty, '', never>;
59
+ export declare function session<A extends Auth<{
60
+ readonly type: string;
61
+ }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options?: SessionOptions<T>): Alxia<{
62
+ readonly user: UserOf<A, T> | null;
63
+ readonly session: Session | null;
64
+ readonly auth: RequestAuth;
65
+ }, Empty, '', Reply<401, UnauthenticatedBody>>;
66
+ export {};
67
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ import type { SharedApi, SignedIn } from '@nxgt/janus';
2
+ /** Anything `janus()` answered: the part of it this package calls. */
3
+ export type Auth<U extends {
4
+ readonly type: string;
5
+ }> = Pick<SharedApi<U>, 'authenticate' | 'signOut' | 'cookie'>;
6
+ /** The users an `auth` instance knows, as a union narrowed by `user.type`. */
7
+ export type UserOfAuth<A> = A extends Auth<infer U> ? U : never;
8
+ /**
9
+ * What `sendSession` takes: the part of `@nxgt/janus`'s `SignedIn` — or of
10
+ * `signUp`'s answer — that opens a session.
11
+ */
12
+ export interface SessionOpened<U> extends Pick<SignedIn<U>, 'token' | 'session' | 'user'> {
13
+ readonly deviceToken?: string | null;
14
+ }
15
+ /**
16
+ * What the routes behind `session()` call, bound to their request: no
17
+ * `ctx`, no `Auth` to pass.
18
+ *
19
+ * ```ts
20
+ * const signedIn = await accounts.patient.signIn(body, { device: auth.device });
21
+ * return reply.ok({ id: auth.send(signedIn).id });
22
+ * ```
23
+ */
24
+ export interface RequestAuth {
25
+ /** The device token the request carries, for `signIn(…, { device })`: `null` when none. */
26
+ readonly device: string | null;
27
+ /**
28
+ * Sends the session cookie — and the device cookie, with a
29
+ * `deviceToken` — and answers the user: `sendSession`, bound.
30
+ */
31
+ send<U>(signedIn: SessionOpened<U>): U;
32
+ /** Revokes the request's session and clears its cookie: `signOut`, bound. */
33
+ signOut(): Promise<boolean>;
34
+ }
35
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvD,sEAAsE;AACtE,MAAM,MAAM,IAAI,CAAC,CAAC,SAAS;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,IAAI,IAAI,CAC3D,SAAS,CAAC,CAAC,CAAC,EACZ,cAAc,GAAG,SAAS,GAAG,QAAQ,CACrC,CAAC;AAEF,8EAA8E;AAC9E,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;AAEhE;;;GAGG;AACH,MAAM,WAAW,aAAa,CAAC,CAAC,CAC/B,SAAQ,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;IACvD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,WAAW;IAC3B,2FAA2F;IAC3F,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B;;;OAGG;IACH,IAAI,CAAC,CAAC,EAAE,QAAQ,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;IACvC,6EAA6E;IAC7E,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5B"}
package/docs/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @alxia/janus documentation
2
+
3
+ The [package README](../README.md) is the short version. This folder is
4
+ the long one: a guide page per area, with the options, defaults, errors and
5
+ a realistic example for each.
6
+
7
+ ## Guide
8
+
9
+ | Page | Read it when |
10
+ | --- | --- |
11
+ | [Sessions](guide/sessions.md) | reading who a request belongs to, requiring a session, narrowing to one user type, placing the sign-in routes before the required ones, knowing where the token is read from and when a renewed cookie is sent, or testing a signed-in route |
12
+ | [Signing in and out](guide/sign-in-and-out.md) | setting the session cookie with `ctx.auth.send` after a sign-up or a sign-in, handling a second factor, signing out, remembering devices with the device cookie, or doing any of it outside a route |
13
+ | [Errors](guide/errors.md) | answering janus's refusals with their status and a safe body, knowing which code is which status, reporting the 5xx, or reading a refusal on the client |
14
+ | [Permissions](guide/permissions.md) | guarding routes with a permission on an object, loading it by a path parameter, passing a condition's context, checking for another subject than the session's user, or reading what an earlier plugin added |
15
+ | [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a request is anonymous, refused or a 404 when you did not expect it |
16
+ | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
@@ -0,0 +1,176 @@
1
+ # Errors
2
+
3
+ This page covers `janusErrors()`: the plugin that answers every refusal
4
+ `@nxgt/janus` throws — a wrong password, a login taken, a store down —
5
+ with its status and a body a client can act on, typed on the routes after
6
+ it; and `bodyOf` and `statusOf`, the two functions it is made of.
7
+
8
+ ```ts
9
+ import { alxia } from '@alxia/core';
10
+ import { janusErrors, session } from '@alxia/janus';
11
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
12
+ import { z } from 'zod';
13
+
14
+ const accounts = janus({
15
+ user: z.object({ email: z.email(), name: z.string() }),
16
+ password: { login: 'email', minLength: 12 },
17
+ store: createMemoryStores(),
18
+ hasher: scryptHasher(),
19
+ });
20
+
21
+ const SignUp = z.object({ email: z.string(), name: z.string(), password: z.string() });
22
+
23
+ const app = alxia()
24
+ .use(janusErrors())
25
+ .use(session(accounts))
26
+ .post('/signup', { body: SignUp }, async ({ body, auth, reply }) => {
27
+ const signedUp = await accounts.signUp(body); // throws a JanusError when it refuses
28
+ return reply.created({ id: auth.send(signedUp).id });
29
+ });
30
+ ```
31
+
32
+ A route needs no `try`: `signUp` throws, and the plugin answers.
33
+
34
+ ```text
35
+ 400 {"code":"PASSWORD_TOO_SHORT","minLength":12}
36
+ 400 {"code":"USER_INVALID","issues":[{"path":["email"],"message":"Invalid email address"}]}
37
+ 409 {"code":"LOGIN_TAKEN"}
38
+ 401 {"code":"CREDENTIALS_INVALID"}
39
+ 401 {"code":"CREDENTIALS_INVALID","retryAfter":900} + Retry-After: 900
40
+ 503 {"code":"STORE_FAILED"}
41
+ ```
42
+
43
+ ## Where to put it
44
+
45
+ `janusErrors()` is an `onError` hook: it answers the errors of the routes
46
+ declared **after** it. Put it first. A `JanusError` thrown by a route
47
+ declared before it is the app's 500 `{"error":"internal"}`.
48
+
49
+ It answers only a `JanusError`. Anything else goes on to the app's next
50
+ `onError`, or its 500.
51
+
52
+ ## Statuses
53
+
54
+ `statusOf(code)` is `@nxgt/janus`'s, re-exported:
55
+
56
+ | Status | Codes |
57
+ | --- | --- |
58
+ | 400 | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `HASH_UNSUPPORTED`, `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE`, `INVALID_CURSOR` |
59
+ | 401 | `CREDENTIALS_INVALID`, `CODE_INVALID` |
60
+ | 403 | `USER_INACTIVE`, `STEP_UP_REQUIRED` |
61
+ | 404 | `NOT_FOUND` |
62
+ | 409 | `LOGIN_TAKEN`, `VERSION_CONFLICT`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` |
63
+ | 500 | `PERMISSION_DEPTH` — a permission check walked past `maxDepth`: not a denial |
64
+ | 501 | `UNSUPPORTED` — a wiring mistake: a store lacks a method a flow needs |
65
+ | 503 | `STORE_FAILED` — **an outage, never a 401 or a 404** |
66
+
67
+ ```ts
68
+ import { statusOf } from '@alxia/janus';
69
+
70
+ statusOf('STORE_FAILED'); // 503
71
+ statusOf('TOKEN_SPENT'); // 400
72
+ ```
73
+
74
+ ## Bodies
75
+
76
+ `bodyOf(error)` is the body: the `code`, and only what a client can act on.
77
+ **Never** the login, the `reason`, a hash prefix or a cause — those are for
78
+ your logs, and stay on the `JanusError`.
79
+
80
+ | Code | Body |
81
+ | --- | --- |
82
+ | `USER_INVALID` | `{ code, issues }` — `issues` is `[]` when the error carries none |
83
+ | `PASSWORD_TOO_SHORT` | `{ code, minLength? }` |
84
+ | `CODE_INVALID` | `{ code, attemptsLeft? }` |
85
+ | `CREDENTIALS_INVALID` | `{ code, retryAfter? }` — seconds; also sent as `Retry-After` |
86
+ | every other code | `{ code }` |
87
+
88
+ ```ts
89
+ import { bodyOf } from '@alxia/janus';
90
+ import { NotFoundError } from '@nxgt/janus';
91
+
92
+ bodyOf(new NotFoundError('user')); // { code: 'NOT_FOUND' }
93
+ ```
94
+
95
+ `CREDENTIALS_INVALID` is one code for an unknown login, a wrong password and
96
+ a login throttled for too many attempts: a client cannot tell a login exists
97
+ from it. `retryAfter` is set only once `janus()`'s throttle trips — ten
98
+ passwords in fifteen minutes by default — and the plugin then adds the
99
+ `Retry-After` header.
100
+
101
+ ## Reporting the 5xx
102
+
103
+ ```ts
104
+ const app = alxia().use(
105
+ janusErrors({
106
+ report: (error, ctx) => console.error(`${ctx.route}: ${error.code}`, error.cause),
107
+ }),
108
+ );
109
+ ```
110
+
111
+ | Option | Type | Default | Effect |
112
+ | --- | --- | --- | --- |
113
+ | `report` | `(error: JanusError, ctx: BaseContext) => unknown` | none | called with every `JanusError` answered 500, 501 or 503 — `STORE_FAILED`, `UNSUPPORTED`, `PERMISSION_DEPTH` — the server's to fix |
114
+
115
+ `report` is started as the error is answered, not awaited: a slow report
116
+ never holds the response, and it cannot change the answer. One that throws
117
+ or rejects is a process warning, and the 503 is sent all the same:
118
+
119
+ ```text
120
+ (node:13837) Warning: janusErrors(): report failed: Error: boom
121
+ ```
122
+
123
+ The 4xx are not reported: they are the client's.
124
+
125
+ ## On the client
126
+
127
+ The refusals are in the type of every route after the plugin, so
128
+ `@alxia/client` reads them. A 401 from a route behind
129
+ `session(accounts, { required: true })` is either the session's
130
+ `{ error: 'unauthenticated' }` or janus's `{ code }`; narrow on the key:
131
+
132
+ ```ts
133
+ import { client } from '@alxia/client';
134
+ import { alxia } from '@alxia/core';
135
+ import { janusErrors, session } from '@alxia/janus';
136
+
137
+ const app = alxia()
138
+ .use(janusErrors())
139
+ .use(session(accounts, { required: true }))
140
+ .get('/me', ({ user, reply }) => reply(200, { name: user.name }));
141
+
142
+ const me = await client(app).get('/me');
143
+ if (me.status === 401) {
144
+ if ('error' in me.data) console.log('sign in first'); // the session's own 401
145
+ else console.log(me.data.code); // a JanusErrorCode
146
+ }
147
+ if (me.status === 503) console.log(me.data.code); // 'STORE_FAILED' and the like: try again later
148
+ ```
149
+
150
+ ## Signatures
151
+
152
+ ```ts
153
+ function janusErrors(options?: JanusErrorsOptions): Alxia<Empty, Empty, '', Reply<JanusErrorStatus, JanusErrorBody>>;
154
+
155
+ interface JanusErrorsOptions {
156
+ readonly report?: (error: JanusError, ctx: BaseContext) => unknown;
157
+ }
158
+
159
+ function bodyOf(error: JanusError): JanusErrorBody;
160
+
161
+ interface JanusErrorBody {
162
+ readonly code: JanusErrorCode;
163
+ readonly issues?: JanusError['issues'];
164
+ readonly minLength?: number;
165
+ readonly attemptsLeft?: number;
166
+ readonly retryAfter?: number;
167
+ }
168
+
169
+ function statusOf(code: JanusErrorCode): JanusErrorStatus; // @nxgt/janus's
170
+ ```
171
+
172
+ `JanusError`, `JanusErrorCode` and `JanusErrorStatus` are `@nxgt/janus`'s;
173
+ `Alxia`, `Empty`, `Reply` and `BaseContext` are `@alxia/core`'s.
174
+
175
+ The permission guard's own refusals — `{ error: 'forbidden' }` and the
176
+ like — are not `JanusError`s; see [Permissions](permissions.md#refusals).