@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.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/device.d.ts +21 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +15 -0
- package/dist/lookup.d.ts +9 -0
- package/dist/lookup.d.ts.map +1 -0
- package/dist/permission-options.d.ts +45 -0
- package/dist/permission-options.d.ts.map +1 -0
- package/dist/permission.d.ts +52 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/send.d.ts +34 -0
- package/dist/send.d.ts.map +1 -0
- package/dist/session.d.ts +67 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/README.md +16 -0
- package/docs/guide/errors.md +176 -0
- package/docs/guide/permissions.md +362 -0
- package/docs/guide/sessions.md +350 -0
- package/docs/guide/sign-in-and-out.md +340 -0
- package/docs/roadmap.md +68 -0
- package/docs/troubleshooting.md +600 -0
- package/package.json +55 -0
|
@@ -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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"}
|
package/dist/types.d.ts
ADDED
|
@@ -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).
|