@nxgt/janus-graphql 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -12
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/errors.d.ts +30 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +97 -15
- package/dist/index.js.map +8 -6
- package/dist/plugin.d.ts +13 -2
- package/dist/plugin.d.ts.map +1 -1
- package/dist/renewal.d.ts +30 -0
- package/dist/renewal.d.ts.map +1 -0
- package/dist/report.d.ts +22 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/types.d.ts +6 -2
- package/dist/types.d.ts.map +1 -1
- package/docs/README.md +3 -3
- package/docs/guide/context.md +30 -2
- package/docs/guide/errors.md +85 -5
- package/docs/guide/subscriptions.md +5 -1
- package/docs/roadmap.md +16 -4
- package/docs/troubleshooting.md +55 -2
- package/package.json +3 -3
package/docs/guide/errors.md
CHANGED
|
@@ -43,7 +43,8 @@ the table `@nxgt/janus-hono` answers with too.
|
|
|
43
43
|
|
|
44
44
|
What a refusal carries beyond `code` is only what the client can act on:
|
|
45
45
|
`issues` for `USER_INVALID`, `minLength` for `PASSWORD_TOO_SHORT`,
|
|
46
|
-
`attemptsLeft` for `CODE_INVALID
|
|
46
|
+
`attemptsLeft` for `CODE_INVALID`, `retryAfter` for a throttled
|
|
47
|
+
`CREDENTIALS_INVALID` — with its `Retry-After` header. **Never** `reason`, `login`, `slot`,
|
|
47
48
|
`operation`, a hash prefix or a cause — those are for your logs. **The
|
|
48
49
|
message is never the core's**: `@nxgt/janus`'s messages name a hash prefix or
|
|
49
50
|
a record's versions, so every `JanusError` gets the fixed message of its
|
|
@@ -64,7 +65,7 @@ would not.
|
|
|
64
65
|
| `requireUser(ctx)`, `can(ctx, …)` | `SERVICE_UNAVAILABLE`, 503 — with or without `janusMaskError()` |
|
|
65
66
|
| `ctx.janus.user()` read in a resolver, any `auth.*` or `access.*` call | `SERVICE_UNAVAILABLE`, 503 with `janusMaskError()`; Yoga's masked 500 without it |
|
|
66
67
|
|
|
67
|
-
## `janusMaskError(fallback?)`
|
|
68
|
+
## `janusMaskError({ report?, fallback? })`
|
|
68
69
|
|
|
69
70
|
Yoga masks every error that is not a `GraphQLError` into `Unexpected error.`
|
|
70
71
|
with a 500. That is right for a bug and wrong for a `JanusError`: a wrong
|
|
@@ -77,7 +78,7 @@ import { createYoga, maskError } from 'graphql-yoga';
|
|
|
77
78
|
createYoga({
|
|
78
79
|
schema,
|
|
79
80
|
plugins: [useJanus({ auth })],
|
|
80
|
-
maskedErrors: { maskError: janusMaskError(maskError) },
|
|
81
|
+
maskedErrors: { maskError: janusMaskError({ fallback: maskError }) },
|
|
81
82
|
});
|
|
82
83
|
```
|
|
83
84
|
|
|
@@ -88,8 +89,51 @@ else goes to `fallback`.
|
|
|
88
89
|
Without a `fallback`, a `GraphQLError` of your own is kept as it is, and
|
|
89
90
|
anything else becomes the mask's message with the code
|
|
90
91
|
`INTERNAL_SERVER_ERROR` and no detail. Pass Yoga's own `maskError`, as above,
|
|
91
|
-
to keep what it shows in development
|
|
92
|
-
|
|
92
|
+
to keep what it shows in development — as `{ fallback }`, or alone:
|
|
93
|
+
`janusMaskError(maskError)`, the form from before `report`, still works. The
|
|
94
|
+
signature is envelop's too, so the same function fits
|
|
95
|
+
`useMaskedErrors({ maskError })`.
|
|
96
|
+
|
|
97
|
+
## Every outage in your logs: `report`
|
|
98
|
+
|
|
99
|
+
The client reads a fixed message, and nothing of the store's: which store
|
|
100
|
+
failed, and why, is for your logs. A directive — `@authenticated`, `@fresh`,
|
|
101
|
+
`@permission` — and `requireUser()`, `requireFresh()` and `can()` answer an
|
|
102
|
+
outage 503 themselves, before any resolver of yours runs, so there is no
|
|
103
|
+
`try` of yours to log it in. `report` is where it goes:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
createYoga({
|
|
107
|
+
schema,
|
|
108
|
+
plugins: [useJanus({ auth, access })],
|
|
109
|
+
maskedErrors: {
|
|
110
|
+
maskError: janusMaskError({
|
|
111
|
+
report: (error) => logger.error({ code: error.code, cause: error.cause }, error.message),
|
|
112
|
+
fallback: maskError,
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- **Every 5xx, whichever path it took**: `STORE_FAILED`, `UNSUPPORTED`,
|
|
119
|
+
`PERMISSION_DEPTH` — from a directive, a helper, a loader, or a resolver
|
|
120
|
+
that let it through. `report` is given `@nxgt/janus`'s own error: a
|
|
121
|
+
`StoreFailure` carries the `slot` and `operation` of the call that failed,
|
|
122
|
+
its message and its `cause`.
|
|
123
|
+
- **Once per failure.** The user is authenticated once per request, so an
|
|
124
|
+
outage that fails three guarded fields is one error, reported once — and
|
|
125
|
+
answered three times.
|
|
126
|
+
- **Never a 4xx**: a denial, `CREDENTIALS_INVALID`, `STEP_UP_REQUIRED` are the
|
|
127
|
+
client's to fix, not yours.
|
|
128
|
+
- **It cannot change the answer.** A `report` that throws, or rejects, is a
|
|
129
|
+
`process.emitWarning` naming the code, and the 503 is sent all the same.
|
|
130
|
+
What it answers is ignored.
|
|
131
|
+
|
|
132
|
+
Yoga's own logger does not cover this: it logs an error only when the mask
|
|
133
|
+
replaced it, and a directive's refusal reaches the mask already a
|
|
134
|
+
`GraphQLError`, kept as it is — so it is never logged. The same `report` is
|
|
135
|
+
`janusErrors({ report })` in `@nxgt/janus-hono`, for the HTTP routes beside
|
|
136
|
+
the GraphQL server.
|
|
93
137
|
|
|
94
138
|
`janusGraphQLError(error)` is the conversion alone, for a resolver that
|
|
95
139
|
catches a `JanusError` to answer it itself:
|
|
@@ -106,6 +150,42 @@ try {
|
|
|
106
150
|
}
|
|
107
151
|
```
|
|
108
152
|
|
|
153
|
+
**Password guessing is throttled per login by `@nxgt/janus`**: past ten
|
|
154
|
+
passwords tried at one login in a 15-minute window, `signIn` refuses every
|
|
155
|
+
one, the right password included, until the window ends — nothing locks. A
|
|
156
|
+
sign-in mutation that lets the refusal through answers it with no code of
|
|
157
|
+
its own:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"errors": [{ "message": "Invalid credentials", "path": ["signIn"], "extensions": { "code": "CREDENTIALS_INVALID", "retryAfter": 840 } }]
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
with a 401 and a `Retry-After: 840` header — `janusGraphQLError()` puts it
|
|
166
|
+
in `extensions.http.headers`, which Yoga answers with and strips from the
|
|
167
|
+
body. `retryAfter` is the seconds until the login's next window.
|
|
168
|
+
`janus({ signIn: { throttle } })` changes the limit
|
|
169
|
+
([passwords](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/passwords.md#password-guessing-is-throttled)).
|
|
170
|
+
|
|
171
|
+
What the throttle does not see is one password tried against many logins:
|
|
172
|
+
limit a sign-in mutation **per client address** as well, in the resolver,
|
|
173
|
+
with the limiter you already run:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { GraphQLError } from 'graphql';
|
|
177
|
+
|
|
178
|
+
if (!(await limiter.consume(`sign-in:${clientAddress}`))) {
|
|
179
|
+
throw new GraphQLError('Too many attempts', {
|
|
180
|
+
extensions: { code: 'TOO_MANY_REQUESTS', http: { status: 429 } },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return await auth.patient.signIn(input);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Yoga answers the `http.status` of the extensions. Limit a `changePassword`
|
|
187
|
+
mutation per user too: it compares the current password.
|
|
188
|
+
|
|
109
189
|
## The status of a response
|
|
110
190
|
|
|
111
191
|
Yoga answers **the highest status among the errors**. A query whose fields
|
|
@@ -248,4 +248,8 @@ cookie is never read.
|
|
|
248
248
|
operation on a connection no `onConnect` accepted has no credential: its
|
|
249
249
|
first guarded field, or `ctx.janus.user()`, throws a `TypeError`, a 500.
|
|
250
250
|
- **The renewed session cookie.** A WebSocket has no response to set one
|
|
251
|
-
on
|
|
251
|
+
on. An operation that renews the session renews it in the store, and the
|
|
252
|
+
browser's cookie keeps its old expiry until the next renewal over HTTP,
|
|
253
|
+
which `useJanus()` sends — a `renewAfter` later. A browser whose only
|
|
254
|
+
traffic is the WebSocket should renew through an HTTP route of yours:
|
|
255
|
+
[the context guide](context.md#ctxjanus) has the detail.
|
package/docs/roadmap.md
CHANGED
|
@@ -9,10 +9,7 @@ Nothing between releases.
|
|
|
9
9
|
|
|
10
10
|
## Next
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
renews a sliding session in passing; the response would then carry its
|
|
14
|
-
`Set-Cookie`, so a browser keeps the new expiry without an HTTP route of
|
|
15
|
-
its own.
|
|
12
|
+
Nothing yet.
|
|
16
13
|
|
|
17
14
|
## Later
|
|
18
15
|
|
|
@@ -43,6 +40,21 @@ Nothing between releases.
|
|
|
43
40
|
|
|
44
41
|
Newest first; from the first release on, the package's CHANGELOG holds every one.
|
|
45
42
|
|
|
43
|
+
- **A throttled sign-in's `retryAfter`, v0.5.0.** `janusGraphQLError()`
|
|
44
|
+
answers `@nxgt/janus`'s throttled `CREDENTIALS_INVALID` with `retryAfter`
|
|
45
|
+
in `extensions` and a `Retry-After` header in `extensions.http.headers`,
|
|
46
|
+
which Yoga answers with. Needs `@nxgt/janus` 0.14.0.
|
|
47
|
+
|
|
48
|
+
- **The renewed session cookie, and `report` for every outage, v0.4.0.**
|
|
49
|
+
Under Yoga, `useJanus()` sends a session `authenticate` renewed back as
|
|
50
|
+
`Set-Cookie`, so a browser keeps the new expiry without an HTTP route of
|
|
51
|
+
its own — only to a request that presented the session cookie, never to a
|
|
52
|
+
bearer token or `X-Session-Token`, and never when nothing authenticated.
|
|
53
|
+
`janusMaskError({ report, fallback })` hands every `JanusError` answered
|
|
54
|
+
5xx to your logger, once, including the outages a directive or a helper
|
|
55
|
+
answers 503 on its own; a `report` that throws never changes the answer.
|
|
56
|
+
`janusMaskError(fallback)` still works.
|
|
57
|
+
|
|
46
58
|
- **Subscriptions over graphql-ws, `janusConnection()`, v0.3.0.** The same
|
|
47
59
|
`@authenticated`, `@fresh` and `@permission` on subscriptions served over
|
|
48
60
|
a WebSocket with graphql-ws: `onConnect` for its `useServer()`
|
package/docs/troubleshooting.md
CHANGED
|
@@ -41,6 +41,7 @@ for what causes each.
|
|
|
41
41
|
|
|
42
42
|
**In a response**
|
|
43
43
|
- [`UNAUTHENTICATED` for a signed-in user](#unauthenticated-for-a-signed-in-user)
|
|
44
|
+
- [`CREDENTIALS_INVALID` with `retryAfter`, 401, for the right password](#credentials_invalid-with-retryafter-401-for-the-right-password)
|
|
44
45
|
- [`FORBIDDEN` where the user should be allowed](#forbidden-where-the-user-should-be-allowed)
|
|
45
46
|
- [`STEP_UP_REQUIRED`, 403](#step_up_required-403)
|
|
46
47
|
- [`STEP_UP_REQUIRED` for a user who just signed in](#step_up_required-for-a-user-who-just-signed-in)
|
|
@@ -59,6 +60,8 @@ for what causes each.
|
|
|
59
60
|
- [`Unexpected error.`, 500, on a field `@permission` guards](#unexpected-error-500-on-a-field-permission-guards)
|
|
60
61
|
- [`TypeError: @permission on …: ctx.janus.access is not set`](#typeerror-permission-on--ctxjanusaccess-is-not-set)
|
|
61
62
|
- [`TypeError: @permission on …: loaders.… answered …`](#typeerror-permission-on--loaders-answered-)
|
|
63
|
+
- [The session cookie expires while the user is active](#the-session-cookie-expires-while-the-user-is-active)
|
|
64
|
+
- [`Warning: janusMaskError: report failed on …`](#warning-janusmaskerror-report-failed-on-)
|
|
62
65
|
- [A 401 whose `data` still holds the other fields](#a-401-whose-data-still-holds-the-other-fields)
|
|
63
66
|
|
|
64
67
|
**Types**
|
|
@@ -366,6 +369,20 @@ was revoked; or `useJanus({ type })` treats this user's type as anonymous.
|
|
|
366
369
|
**Fix:** send `Authorization: Bearer <token>`, `X-Session-Token`, or the
|
|
367
370
|
session cookie; check `useJanus()`'s `type`.
|
|
368
371
|
|
|
372
|
+
### `CREDENTIALS_INVALID` with `retryAfter`, 401, for the right password
|
|
373
|
+
|
|
374
|
+
With `extensions.retryAfter` and a `Retry-After` header.
|
|
375
|
+
|
|
376
|
+
**Why:** `@nxgt/janus` throttles password guessing per login, on by default:
|
|
377
|
+
past ten passwords at one login in a 15-minute window, `signIn` refuses every
|
|
378
|
+
one — the right password included — until the window ends. `retryAfter` is
|
|
379
|
+
the seconds until then. Nothing locks.
|
|
380
|
+
|
|
381
|
+
**Fix:** show the wait, not "wrong password": read `extensions.retryAfter`
|
|
382
|
+
in the client. To change the limit, `janus({ signIn: { throttle: { attempts,
|
|
383
|
+
window } } })`; in a test over a `fixedClock`, advance the clock past
|
|
384
|
+
`retryAfter`, or wire `signIn: { throttle: false }`.
|
|
385
|
+
|
|
369
386
|
### `FORBIDDEN` where the user should be allowed
|
|
370
387
|
|
|
371
388
|
**Why:** a `type:` that applies does not name the user's type. Every
|
|
@@ -410,8 +427,16 @@ for `can()` and `@permission` — and a loader of yours that threw
|
|
|
410
427
|
`StoreFailure`. It is never answered `UNAUTHENTICATED`, `FORBIDDEN` or `STEP_UP_REQUIRED`.
|
|
411
428
|
|
|
412
429
|
**Fix:** the store: its connection, its credentials, its availability. The
|
|
413
|
-
message carries nothing of the store's
|
|
414
|
-
|
|
430
|
+
message carries nothing of the store's, and a directive or a helper answers
|
|
431
|
+
the outage before any resolver of yours could catch it: pass `report` to
|
|
432
|
+
the mask, which is given `@nxgt/janus`'s error — its `slot` and `operation`
|
|
433
|
+
name the call that failed, its `cause` the driver's error:
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
maskedErrors: {
|
|
437
|
+
maskError: janusMaskError({ report: (error) => logger.error(error), fallback: maskError }),
|
|
438
|
+
},
|
|
439
|
+
```
|
|
415
440
|
|
|
416
441
|
### `Unexpected error.`, 500, where a `JanusError` was thrown
|
|
417
442
|
|
|
@@ -580,6 +605,34 @@ from the same options.
|
|
|
580
605
|
|
|
581
606
|
**Fix:** answer the object, or `null` when there is none.
|
|
582
607
|
|
|
608
|
+
### The session cookie expires while the user is active
|
|
609
|
+
|
|
610
|
+
**Why:** `authenticate` renewed the session in the store, and no
|
|
611
|
+
`Set-Cookie` carried the new expiry back, so the browser drops the cookie at
|
|
612
|
+
the expiry it was given at sign-in. `useJanus()` sends it only on Yoga's
|
|
613
|
+
HTTP response (`onResponse`), and not when:
|
|
614
|
+
|
|
615
|
+
- the server runs on envelop without Yoga, which never calls `onResponse`;
|
|
616
|
+
- the `auth` given to `useJanus()` is a wrapper of yours without `cookie`;
|
|
617
|
+
- the only traffic is over graphql-ws, which has no response to carry it;
|
|
618
|
+
- the client sent `Authorization: Bearer` or `X-Session-Token`, which is
|
|
619
|
+
never answered with a cookie.
|
|
620
|
+
|
|
621
|
+
**Fix:** under Yoga, pass `janus()`'s own instance, or a wrapper with
|
|
622
|
+
`cookie: auth.cookie`. Elsewhere — and for a browser that talks only over
|
|
623
|
+
the WebSocket — renew through an HTTP route of yours that sends
|
|
624
|
+
`auth.cookie.serialize(token, session)`, as `@nxgt/janus-hono`'s
|
|
625
|
+
`session()` does.
|
|
626
|
+
|
|
627
|
+
### `Warning: janusMaskError: report failed on …`
|
|
628
|
+
|
|
629
|
+
**Why:** the `report` given to `janusMaskError()` threw, or rejected — a
|
|
630
|
+
logger that is down, a serializer that meets a circular `cause`. The name
|
|
631
|
+
after the code is the class of what it threw.
|
|
632
|
+
|
|
633
|
+
**Fix:** your `report`. The response was sent all the same: a `report`
|
|
634
|
+
never changes the answer, so an outage is still a 503 and never a 500.
|
|
635
|
+
|
|
583
636
|
### A 401 whose `data` still holds the other fields
|
|
584
637
|
|
|
585
638
|
**Why:** Yoga answers the highest status among the errors. One refused field
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/janus-graphql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "GraphQL for @nxgt/janus: an envelop plugin with the signed-in user on the context, fields guarded by @authenticated, @fresh and @permission, and every error with the status it deserves",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@envelop/core": "^5.6.1",
|
|
57
57
|
"@graphql-tools/utils": "^12.0.1",
|
|
58
|
-
"@nxgt/janus": "^0.
|
|
58
|
+
"@nxgt/janus": "^0.14.0",
|
|
59
59
|
"@types/bun": "^1.4.2",
|
|
60
60
|
"@types/ws": "^8.18.1",
|
|
61
61
|
"graphql": "^17.0.2",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"@envelop/core": "^5.0.0",
|
|
69
69
|
"@graphql-tools/utils": ">=10.0.0 <13",
|
|
70
|
-
"@nxgt/janus": "^0.
|
|
70
|
+
"@nxgt/janus": "^0.14.0",
|
|
71
71
|
"graphql": "^16.9.0 || ^17.0.0",
|
|
72
72
|
"graphql-ws": "^6.0.0",
|
|
73
73
|
"typescript": "^6.0.3"
|