@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.
@@ -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`. **Never** `reason`, `login`, `slot`,
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. The signature is envelop's too, so the
92
- same function fits `useMaskedErrors({ maskError })`.
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: renew through your HTTP routes.
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
- - **The renewed session cookie on GraphQL responses** — `authenticate`
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()`
@@ -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; log the error where you call
414
- `auth` — `@nxgt/janus`'s `slot` and `operation` name the call that failed.
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.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.12.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.12.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"