@nxgt/janus-graphql 0.2.0 → 0.4.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.
@@ -64,7 +64,7 @@ would not.
64
64
  | `requireUser(ctx)`, `can(ctx, …)` | `SERVICE_UNAVAILABLE`, 503 — with or without `janusMaskError()` |
65
65
  | `ctx.janus.user()` read in a resolver, any `auth.*` or `access.*` call | `SERVICE_UNAVAILABLE`, 503 with `janusMaskError()`; Yoga's masked 500 without it |
66
66
 
67
- ## `janusMaskError(fallback?)`
67
+ ## `janusMaskError({ report?, fallback? })`
68
68
 
69
69
  Yoga masks every error that is not a `GraphQLError` into `Unexpected error.`
70
70
  with a 500. That is right for a bug and wrong for a `JanusError`: a wrong
@@ -77,7 +77,7 @@ import { createYoga, maskError } from 'graphql-yoga';
77
77
  createYoga({
78
78
  schema,
79
79
  plugins: [useJanus({ auth })],
80
- maskedErrors: { maskError: janusMaskError(maskError) },
80
+ maskedErrors: { maskError: janusMaskError({ fallback: maskError }) },
81
81
  });
82
82
  ```
83
83
 
@@ -88,8 +88,51 @@ else goes to `fallback`.
88
88
  Without a `fallback`, a `GraphQLError` of your own is kept as it is, and
89
89
  anything else becomes the mask's message with the code
90
90
  `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 })`.
91
+ to keep what it shows in development — as `{ fallback }`, or alone:
92
+ `janusMaskError(maskError)`, the form from before `report`, still works. The
93
+ signature is envelop's too, so the same function fits
94
+ `useMaskedErrors({ maskError })`.
95
+
96
+ ## Every outage in your logs: `report`
97
+
98
+ The client reads a fixed message, and nothing of the store's: which store
99
+ failed, and why, is for your logs. A directive — `@authenticated`, `@fresh`,
100
+ `@permission` — and `requireUser()`, `requireFresh()` and `can()` answer an
101
+ outage 503 themselves, before any resolver of yours runs, so there is no
102
+ `try` of yours to log it in. `report` is where it goes:
103
+
104
+ ```ts
105
+ createYoga({
106
+ schema,
107
+ plugins: [useJanus({ auth, access })],
108
+ maskedErrors: {
109
+ maskError: janusMaskError({
110
+ report: (error) => logger.error({ code: error.code, cause: error.cause }, error.message),
111
+ fallback: maskError,
112
+ }),
113
+ },
114
+ });
115
+ ```
116
+
117
+ - **Every 5xx, whichever path it took**: `STORE_FAILED`, `UNSUPPORTED`,
118
+ `PERMISSION_DEPTH` — from a directive, a helper, a loader, or a resolver
119
+ that let it through. `report` is given `@nxgt/janus`'s own error: a
120
+ `StoreFailure` carries the `slot` and `operation` of the call that failed,
121
+ its message and its `cause`.
122
+ - **Once per failure.** The user is authenticated once per request, so an
123
+ outage that fails three guarded fields is one error, reported once — and
124
+ answered three times.
125
+ - **Never a 4xx**: a denial, `CREDENTIALS_INVALID`, `STEP_UP_REQUIRED` are the
126
+ client's to fix, not yours.
127
+ - **It cannot change the answer.** A `report` that throws, or rejects, is a
128
+ `process.emitWarning` naming the code, and the 503 is sent all the same.
129
+ What it answers is ignored.
130
+
131
+ Yoga's own logger does not cover this: it logs an error only when the mask
132
+ replaced it, and a directive's refusal reaches the mask already a
133
+ `GraphQLError`, kept as it is — so it is never logged. The same `report` is
134
+ `janusErrors({ report })` in `@nxgt/janus-hono`, for the HTTP routes beside
135
+ the GraphQL server.
93
136
 
94
137
  `janusGraphQLError(error)` is the conversion alone, for a resolver that
95
138
  catches a `JanusError` to answer it itself:
@@ -106,6 +149,27 @@ try {
106
149
  }
107
150
  ```
108
151
 
152
+ **Rate-limit a sign-in mutation per login and per client: `@nxgt/janus`
153
+ counts no failed password**, so without a limit anyone can guess as fast as
154
+ the server hashes. A sign-in code, a second factor and a step-up count their
155
+ guesses; a password does not. Check the limit in the resolver, before
156
+ `signIn`, with the limiter you already run:
157
+
158
+ ```ts
159
+ import { GraphQLError } from 'graphql';
160
+
161
+ if (!(await limiter.consume(`sign-in:${input.email.trim().toLowerCase()}`))) {
162
+ throw new GraphQLError('Too many attempts', {
163
+ extensions: { code: 'TOO_MANY_REQUESTS', http: { status: 429 } },
164
+ });
165
+ }
166
+ return await auth.patient.signIn(input);
167
+ ```
168
+
169
+ Yoga answers the `http.status` of the extensions. Add a second limit per
170
+ client address, and limit a `changePassword` mutation per user: it compares
171
+ the current password too.
172
+
109
173
  ## The status of a response
110
174
 
111
175
  Yoga answers **the highest status among the errors**. A query whose fields
@@ -140,11 +140,15 @@ seconds.
140
140
 
141
141
  **A `@fresh` on the subscription field is checked when it subscribes.** A
142
142
  stale session is refused before the stream starts; a fresh one keeps
143
- receiving events past `maxAge`, as it keeps its session. `@authenticated`
144
- and `@permission` on the field are asked again on every event; its
145
- `@fresh` is not. To end a stream when the session is no longer fresh, close
143
+ receiving events past `maxAge`, as it keeps its session. `@permission` on
144
+ the field is asked again on every event; `@authenticated` reads the
145
+ operation's one authentication, made when it subscribed; its `@fresh` is
146
+ not asked again. To end a stream when the session is no longer fresh, close
146
147
  it from the server.
147
148
 
149
+ Over a WebSocket, the same holds once `janusConnection()` authenticates
150
+ the connection: [subscriptions over graphql-ws](subscriptions.md).
151
+
148
152
  **A `@fresh` on the payload's type, or on one of its fields, is checked on
149
153
  every event**, as any field is: those fields resolve anew for each event,
150
154
  and once the session is past `maxAge` they answer `STEP_UP_REQUIRED` while
@@ -0,0 +1,255 @@
1
+ # Subscriptions over graphql-ws
2
+
3
+ A subscription over server-sent events is an HTTP request like any other:
4
+ `useJanus()` authenticates it from its headers, and nothing more is needed.
5
+ Over a WebSocket, every operation travels over one socket, opened once:
6
+ `janusConnection()` authenticates that socket, with
7
+ [graphql-ws](https://github.com/enisdenjo/graphql-ws), the library Yoga
8
+ recommends, so the directives hold on it unchanged.
9
+
10
+ ## Install
11
+
12
+ `graphql-ws` is an **optional** peer: install it only when you serve
13
+ subscriptions over a WebSocket.
14
+
15
+ ```sh
16
+ bun add graphql-ws ws
17
+ ```
18
+
19
+ Its range, `^6.0.0`, and the `graphql` 17 note are in the
20
+ [README's Install](../../README.md#install).
21
+
22
+ ## Wiring, with Yoga
23
+
24
+ This is Yoga's recommended graphql-ws setup, with one line added:
25
+ `onConnect`.
26
+
27
+ ```ts
28
+ import { createServer } from 'node:http';
29
+ import { janusConnection, janusMaskError, janusTypeDefs, useJanus } from '@nxgt/janus-graphql';
30
+ import { useServer } from 'graphql-ws/use/ws';
31
+ import { createSchema, createYoga } from 'graphql-yoga';
32
+ import { WebSocketServer } from 'ws';
33
+ import { access } from './access'; // what permissions() answered
34
+ import { auth } from './auth'; // what janus() answered
35
+
36
+ const yoga = createYoga({
37
+ schema: createSchema({ typeDefs: [janusTypeDefs, typeDefs], resolvers }),
38
+ plugins: [useJanus({ auth, access })],
39
+ maskedErrors: { maskError: janusMaskError() },
40
+ });
41
+
42
+ const server = createServer(yoga);
43
+ const wsServer = new WebSocketServer({ server, path: yoga.graphqlEndpoint });
44
+
45
+ useServer(
46
+ {
47
+ onConnect: janusConnection({ auth }).onConnect, // refuses a connection with no valid session
48
+ execute: (args: any) => args.rootValue.execute(args),
49
+ subscribe: (args: any) => args.rootValue.subscribe(args),
50
+ onSubscribe: async (ctx, _id, params) => {
51
+ const { schema, execute, subscribe, contextFactory, parse, validate } = yoga.getEnveloped({
52
+ ...ctx, // carries ctx.extra, where useJanus() finds the connection
53
+ req: ctx.extra.request,
54
+ socket: ctx.extra.socket,
55
+ params,
56
+ });
57
+ const args = {
58
+ schema,
59
+ operationName: params.operationName,
60
+ document: parse(params.query),
61
+ variableValues: params.variables,
62
+ contextValue: await contextFactory(),
63
+ rootValue: { execute, subscribe },
64
+ };
65
+ const errors = validate(args.schema, args.document);
66
+ if (errors.length) return errors;
67
+ return args;
68
+ },
69
+ },
70
+ wsServer,
71
+ );
72
+
73
+ server.listen(4000);
74
+ ```
75
+
76
+ `useJanus()` needs nothing new. When it builds the context of an operation
77
+ with no `request`, it looks for the connection the operation came from —
78
+ by `ctx.extra`, which the `...ctx` spread carries — and authenticates the
79
+ credential that connection presented. **Keep the spread**: a context built
80
+ without `ctx.extra` has no connection to find, and its first guarded field
81
+ throws [`the GraphQL context has no request to authenticate`](../troubleshooting.md#typeerror--the-graphql-context-has-no-request-to-authenticate).
82
+
83
+ ## How the session travels
84
+
85
+ `onConnect` reads one credential per connection, in this order:
86
+
87
+ 1. **`connectionParams.authorization`** — for a client that is not a
88
+ browser, or a browser that holds a bearer token:
89
+
90
+ ```ts
91
+ import { createClient } from 'graphql-ws';
92
+
93
+ const client = createClient({
94
+ url: 'wss://api.example.com/graphql',
95
+ connectionParams: async () => ({ authorization: `Bearer ${await tokens.current()}` }),
96
+ });
97
+ ```
98
+
99
+ The key is `authorization`, lower-case, and its value a string — the
100
+ same `Bearer <token>` an HTTP request sends. A value that is not a
101
+ string refuses the connection; a string of another scheme (`Basic …`)
102
+ is not a session credential, as in `@nxgt/janus`, and the upgrade
103
+ request is read instead.
104
+
105
+ 2. **The upgrade request**, read as `auth.authenticate` reads any request:
106
+ `Authorization: Bearer`, then `X-Session-Token`, then the session
107
+ cookie. A browser sends its cookies with the upgrade, so a signed-in
108
+ browser needs no `connectionParams` at all — provided the WebSocket goes
109
+ to the origin that set the cookie, or to one its `SameSite` admits.
110
+
111
+ **The first credential present wins, not the first valid one**, as in
112
+ `@nxgt/janus`: a lapsed token in `connectionParams` beside a live cookie is
113
+ refused, rather than rescued in silence. Fix the client's token.
114
+
115
+ `type` restricts who may connect:
116
+
117
+ ```ts
118
+ janusConnection({ auth, type: 'staff' }); // a patient's session is refused
119
+ ```
120
+
121
+ ## What a client sees
122
+
123
+ | What happened | The client sees |
124
+ | --- | --- |
125
+ | No credential, an unknown or lapsed one, a user gone, inactive or of another `type`, an `authorization` that is not a string | The socket closed **`4403: Forbidden`**, before any operation — graphql-ws's own close for an `onConnect` that answers `false` |
126
+ | The sessions store could not answer at connect | The socket closed **`4500`**, by `graphql-ws/use/ws`, which also logs the error — **never `4403`** |
127
+ | Accepted | `connection_ack`, then operations as over HTTP |
128
+
129
+ graphql-ws's client treats `4403` as retryable — a `connectionParams`
130
+ function asked again can present a fresh token — and `4500` as fatal. Set
131
+ `retryAttempts` and `shouldRetry` on the client to change that. (`4401` is
132
+ the protocol's answer to an operation sent before the acknowledgement;
133
+ `onConnect` cannot choose it.)
134
+
135
+ ## Each operation
136
+
137
+ Each operation on an accepted connection gets **a `ctx.janus` of its own**,
138
+ built by `useJanus()` as for an HTTP request, and authenticated again from
139
+ the connection's credential the first time a field asks — once per
140
+ operation, never per event:
141
+
142
+ - `@authenticated` and `@fresh` on a subscription field are checked **when
143
+ it subscribes**. A denial answers the operation with its `GraphQLError` —
144
+ `UNAUTHENTICATED`, `FORBIDDEN`, `STEP_UP_REQUIRED` — and the source stream
145
+ never starts; the connection stays open.
146
+ - `@permission` on a subscription field is asked **again on every event**,
147
+ bypassing the request's memo, so a `revoke()` stops the stream at its
148
+ next event with `NOT_FOUND` (or `FORBIDDEN`).
149
+ - A directive on the payload's type or its fields is checked on every
150
+ event, as any field.
151
+ - An outage at subscribe, or on an event, answers `SERVICE_UNAVAILABLE` —
152
+ never a denial.
153
+
154
+ ## A session revoked while connected
155
+
156
+ `auth.signOut` or `signOutEverywhere` while a socket is open:
157
+
158
+ - **the stream already running keeps its events coming.** Its user was
159
+ authenticated once, when it subscribed, and each event's
160
+ `@authenticated` reads that answer — authenticating on every event would
161
+ cost a sessions-store read per event per subscriber;
162
+ - **the next operation on the same connection is refused**
163
+ `UNAUTHENTICATED`, since it authenticates again;
164
+ - **a reconnection is refused `4403`**.
165
+
166
+ To cut a revoked session's streams at once, close its sockets yourself.
167
+ graphql-ws hands every callback the transport's socket as
168
+ `ctx.extra.socket`: a server that keeps its open sockets by user can close
169
+ them with `4403` when it revokes, and a client that reconnects is then
170
+ refused.
171
+
172
+ ```ts
173
+ const connection = janusConnection({ auth });
174
+
175
+ useServer({
176
+ onConnect: async (ctx) => {
177
+ const accepted = await connection.onConnect(ctx);
178
+ if (accepted) track(ctx.extra.socket, ctx); // your own bookkeeping, keyed as you revoke
179
+ return accepted;
180
+ },
181
+ onClose: (ctx) => untrack(ctx.extra.socket),
182
+ // … Yoga's recipe …
183
+ }, wsServer);
184
+ ```
185
+
186
+ A permission revoked while connected needs none of this: `@permission` sees
187
+ it at the next event.
188
+
189
+ ## Without Yoga
190
+
191
+ A server that runs graphql-ws alone passes `context` as well, and applies
192
+ the directives itself:
193
+
194
+ ```ts
195
+ import { applyJanusDirectives, janusConnection } from '@nxgt/janus-graphql';
196
+ import { useServer } from 'graphql-ws/use/ws';
197
+ import { WebSocketServer } from 'ws';
198
+ import { schema } from './schema'; // your executable schema, with janusTypeDefs
199
+
200
+ const wsServer = new WebSocketServer({ port: 4000, path: '/graphql' });
201
+ const connection = janusConnection({ auth, access, clock }); // clock: the one given to janus()
202
+
203
+ useServer(
204
+ {
205
+ schema: applyJanusDirectives(schema, { auth, access }),
206
+ onConnect: connection.onConnect,
207
+ context: connection.context, // { janus } for each operation
208
+ },
209
+ wsServer,
210
+ );
211
+ ```
212
+
213
+ With Yoga, leave `context` out: Yoga's recipe builds the context itself,
214
+ and `useJanus()`'s `access` and `clock` are the ones that apply.
215
+
216
+ ## On Bun, or another transport
217
+
218
+ `onConnect` reads the upgrade request from `ctx.extra.request`, where
219
+ `graphql-ws/use/ws` and `graphql-ws/use/@fastify/websocket` put it.
220
+ `graphql-ws/use/bun`'s `extra` is `{ socket }` alone: pass the request as
221
+ the socket's `data` when you upgrade, and tell `janusConnection` where it is.
222
+
223
+ ```ts
224
+ import type { ServerWebSocket } from 'bun';
225
+ import { makeHandler } from 'graphql-ws/use/bun';
226
+
227
+ type Upgraded = ServerWebSocket<{ readonly request: Request }>;
228
+
229
+ const connection = janusConnection({
230
+ auth,
231
+ upgrade: (ctx: { readonly extra: { readonly socket: Upgraded } }) => ctx.extra.socket.data.request,
232
+ });
233
+
234
+ Bun.serve({
235
+ fetch: (request, server) =>
236
+ server.upgrade(request, { data: { request } }) ? undefined : yoga.fetch(request, server),
237
+ websocket: makeHandler({ onConnect: connection.onConnect, /* … Yoga's recipe … */ }),
238
+ });
239
+ ```
240
+
241
+ Without `upgrade` on Bun, only `connectionParams` authenticates: a browser's
242
+ cookie is never read.
243
+
244
+ ## What is not covered
245
+
246
+ - **An anonymous connection.** `onConnect` refuses every connection it
247
+ cannot authenticate; serve anonymous clients over server-sent events. An
248
+ operation on a connection no `onConnect` accepted has no credential: its
249
+ first guarded field, or `ctx.janus.user()`, throws a `TypeError`, a 500.
250
+ - **The renewed session cookie.** A WebSocket has no response to set one
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,17 +9,16 @@ Nothing between releases.
9
9
 
10
10
  ## Next
11
11
 
12
- - **Subscriptions over graphql-ws** — `janusConnection`, which authenticates
13
- a WebSocket connection once, from its `connectionParams` or its upgrade
14
- request, and gives every operation on it the same `ctx.janus`.
15
- - **The renewed session cookie on GraphQL responses** — `authenticate`
16
- renews a sliding session in passing; the response would then carry its
17
- `Set-Cookie`, so a browser keeps the new expiry without an HTTP route of
18
- its own.
12
+ Nothing yet.
19
13
 
20
14
  ## Later
21
15
 
22
- Nothing yet.
16
+ - **Anonymous graphql-ws connections** — `janusConnection({ anonymous:
17
+ true })`, accepting a connection with no credential so its public fields
18
+ answer and its guarded ones deny, as over HTTP.
19
+ - **Re-authenticating a subscription on each event** — an opt-in for
20
+ streams that must end the moment their session is revoked, at the cost
21
+ of a sessions-store read per event.
23
22
 
24
23
  ## Not planned
25
24
 
@@ -41,6 +40,28 @@ Nothing yet.
41
40
 
42
41
  Newest first; from the first release on, the package's CHANGELOG holds every one.
43
42
 
43
+ - **The renewed session cookie, and `report` for every outage, v0.4.0.**
44
+ Under Yoga, `useJanus()` sends a session `authenticate` renewed back as
45
+ `Set-Cookie`, so a browser keeps the new expiry without an HTTP route of
46
+ its own — only to a request that presented the session cookie, never to a
47
+ bearer token or `X-Session-Token`, and never when nothing authenticated.
48
+ `janusMaskError({ report, fallback })` hands every `JanusError` answered
49
+ 5xx to your logger, once, including the outages a directive or a helper
50
+ answers 503 on its own; a `report` that throws never changes the answer.
51
+ `janusMaskError(fallback)` still works.
52
+
53
+ - **Subscriptions over graphql-ws, `janusConnection()`, v0.3.0.** The same
54
+ `@authenticated`, `@fresh` and `@permission` on subscriptions served over
55
+ a WebSocket with graphql-ws: `onConnect` for its `useServer()`
56
+ authenticates the connection from `connectionParams.authorization` — else
57
+ from the upgrade request's headers and the browser's session cookie —
58
+ and refuses it `4403`, or rejects on an outage, closed `4500`. Each
59
+ operation's `ctx.janus` is built by `useJanus()` from that credential, in
60
+ Yoga's recommended setup, and authenticated again when it subscribes, so
61
+ a session revoked since the connection opened is refused at the next
62
+ subscribe. `context` for a server without Yoga, `upgrade` for Bun.
63
+ `graphql-ws` is an optional peer, `^6.0.0`, whose floor runs in CI.
64
+
44
65
  - **`@fresh(maxAge)` and `requireFresh()`, v0.2.0.** A field, a type or an
45
66
  interface only a session that proved who it is less than `maxAge` seconds
46
67
  ago may resolve — signed in, or confirmed since by `auth.stepUp.confirm`:
@@ -30,6 +30,14 @@ for what causes each.
30
30
  - [`TypeError: useJanus(): auth is not what janus() answered`](#typeerror-usejanus-auth-is-not-what-janus-answered)
31
31
  - [`TypeError: useJanus(): access is not what permissions() answered`](#typeerror-usejanus-access-is-not-what-permissions-answered)
32
32
  - [`TypeError: useJanus(): clock is not a Clock`](#typeerror-usejanus-clock-is-not-a-clock)
33
+ - [`TypeError: janusConnection(): auth is not what janus() answered`, `access …`, `clock …`](#typeerror-janusconnection-auth-is-not-what-janus-answered-access--clock-)
34
+ - [`TypeError: janusConnection(): upgrade is not a function`](#typeerror-janusconnection-upgrade-is-not-a-function)
35
+
36
+ **Over graphql-ws**
37
+ - [The socket closed `4403: Forbidden` right after connecting](#the-socket-closed-4403-forbidden-right-after-connecting)
38
+ - [The socket closed `4500`](#the-socket-closed-4500)
39
+ - [A stream keeps its events after its session was revoked](#a-stream-keeps-its-events-after-its-session-was-revoked)
40
+ - [`TypeError: janusConnection(): the connection's extra is not an object`](#typeerror-janusconnection-the-connections-extra-is-not-an-object)
33
41
 
34
42
  **In a response**
35
43
  - [`UNAUTHENTICATED` for a signed-in user](#unauthenticated-for-a-signed-in-user)
@@ -39,7 +47,7 @@ for what causes each.
39
47
  - [`SERVICE_UNAVAILABLE`, 503, on every guarded field](#service_unavailable-503-on-every-guarded-field)
40
48
  - [`Unexpected error.`, 500, where a `JanusError` was thrown](#unexpected-error-500-where-a-januserror-was-thrown)
41
49
  - [`TypeError: … ctx.janus is not set`](#typeerror--ctxjanus-is-not-set)
42
- - [`TypeError: useJanus(): the GraphQL context has no request to authenticate`](#typeerror-usejanus-the-graphql-context-has-no-request-to-authenticate)
50
+ - [`TypeError: …: the GraphQL context has no request to authenticate`](#typeerror--the-graphql-context-has-no-request-to-authenticate)
43
51
  - [`TypeError: can(): ctx.janus.access is not set`](#typeerror-can-ctxjanusaccess-is-not-set)
44
52
  - [`TypeError: requireUser(): type is an empty list, which no user could pass`](#typeerror-requireuser-type-is-an-empty-list-which-no-user-could-pass)
45
53
  - [`TypeError: requireFresh(): maxAge is a duration with its unit`](#typeerror-requirefresh-maxage-is-a-duration-with-its-unit)
@@ -51,6 +59,8 @@ for what causes each.
51
59
  - [`Unexpected error.`, 500, on a field `@permission` guards](#unexpected-error-500-on-a-field-permission-guards)
52
60
  - [`TypeError: @permission on …: ctx.janus.access is not set`](#typeerror-permission-on--ctxjanusaccess-is-not-set)
53
61
  - [`TypeError: @permission on …: loaders.… answered …`](#typeerror-permission-on--loaders-answered-)
62
+ - [The session cookie expires while the user is active](#the-session-cookie-expires-while-the-user-is-active)
63
+ - [`Warning: janusMaskError: report failed on …`](#warning-janusmaskerror-report-failed-on-)
54
64
  - [A 401 whose `data` still holds the other fields](#a-401-whose-data-still-holds-the-other-fields)
55
65
 
56
66
  **Types**
@@ -278,6 +288,74 @@ answered — or leave `access` out when no field uses `@permission` or `can()`.
278
288
  `@nxgt/janus` in a spec — or leave `clock` out for the system's:
279
289
  `useJanus({ auth, clock })`.
280
290
 
291
+ ### `TypeError: janusConnection(): auth is not what janus() answered`, `access …`, `clock …`
292
+
293
+ **Why:** the same checks as `useJanus()`'s, named for `janusConnection()`:
294
+ `auth` is not what `janus()` answered, `access` not what `permissions()`
295
+ answered, `clock` not a `Clock`.
296
+
297
+ **Fix:** `janusConnection({ auth })`, with the `access` and `clock` given to
298
+ `useJanus()` when you pass them at all.
299
+
300
+ ### `TypeError: janusConnection(): upgrade is not a function`
301
+
302
+ **Why:** `upgrade` is how `onConnect` finds the upgrade request on a
303
+ transport whose `extra` does not carry it, and it was given something else.
304
+
305
+ **Fix:** a function of graphql-ws's context, or nothing — `ctx.extra.request`
306
+ is then read, as `graphql-ws/use/ws` sets it:
307
+
308
+ ```ts
309
+ janusConnection({ auth, upgrade: (ctx: { readonly extra: { readonly socket: Upgraded } }) => ctx.extra.socket.data.request });
310
+ ```
311
+
312
+ ## Over graphql-ws
313
+
314
+ ### The socket closed `4403: Forbidden` right after connecting
315
+
316
+ **Why:** `onConnect` found no credential that authenticates: none at all,
317
+ an unknown or lapsed token, a user gone or inactive, a user of another
318
+ type than `janusConnection({ type })`, or a `connectionParams.authorization`
319
+ that is not a string. A `Bearer` `connectionParams.authorization` is
320
+ read before the upgrade request, and **the first present wins**: a lapsed
321
+ token there refuses the connection even beside a live cookie. One of
322
+ another scheme (`Basic …`) does not count, and the cookie is read.
323
+
324
+ **Fix:** send `connectionParams: { authorization: 'Bearer <token>' }` —
325
+ the key lower-case — or connect from a browser holding the session cookie,
326
+ to the origin that set it. On Bun, pass `upgrade`, or the cookie is never
327
+ read. graphql-ws's client retries a `4403`; a `connectionParams` function
328
+ is asked again each time.
329
+
330
+ ### The socket closed `4500`
331
+
332
+ **Why:** the sessions store could not answer while `onConnect`
333
+ authenticated — `STORE_FAILED`. `graphql-ws/use/ws` closes the socket
334
+ `4500` and logs the error with `console.error`. It is an outage, **never a
335
+ refusal**: `4403` would tell the client its session is bad.
336
+
337
+ **Fix:** the store. graphql-ws's client does not retry a `4500` by default;
338
+ set `shouldRetry` to retry an outage.
339
+
340
+ ### A stream keeps its events after its session was revoked
341
+
342
+ **Why:** an operation is authenticated once, when it subscribes, and
343
+ each event's `@authenticated` reads that answer. The next operation on
344
+ the connection is refused `UNAUTHENTICATED`, and a reconnection `4403`.
345
+
346
+ **Fix:** close the session's sockets when you revoke it
347
+ ([the subscriptions guide](guide/subscriptions.md#a-session-revoked-while-connected)).
348
+ A permission revoked needs nothing: `@permission` asks again on every event.
349
+
350
+ ### `TypeError: janusConnection(): the connection's extra is not an object`
351
+
352
+ **Why:** `onConnect` was called with a context whose `extra` is not an
353
+ object — by hand, or by a transport of your own. It remembers the
354
+ connection by that object.
355
+
356
+ **Fix:** pass `onConnect` to graphql-ws's `useServer()` or `makeHandler()`,
357
+ which give it the context they made.
358
+
281
359
  ## In a response
282
360
 
283
361
  ### `UNAUTHENTICATED` for a signed-in user
@@ -334,8 +412,16 @@ for `can()` and `@permission` — and a loader of yours that threw
334
412
  `StoreFailure`. It is never answered `UNAUTHENTICATED`, `FORBIDDEN` or `STEP_UP_REQUIRED`.
335
413
 
336
414
  **Fix:** the store: its connection, its credentials, its availability. The
337
- message carries nothing of the store's; log the error where you call
338
- `auth` — `@nxgt/janus`'s `slot` and `operation` name the call that failed.
415
+ message carries nothing of the store's, and a directive or a helper answers
416
+ the outage before any resolver of yours could catch it: pass `report` to
417
+ the mask, which is given `@nxgt/janus`'s error — its `slot` and `operation`
418
+ name the call that failed, its `cause` the driver's error:
419
+
420
+ ```ts
421
+ maskedErrors: {
422
+ maskError: janusMaskError({ report: (error) => logger.error(error), fallback: maskError }),
423
+ },
424
+ ```
339
425
 
340
426
  ### `Unexpected error.`, 500, where a `JanusError` was thrown
341
427
 
@@ -353,14 +439,21 @@ transformed with `applyJanusDirectives()` and served by something else.
353
439
 
354
440
  **Fix:** `plugins: [useJanus({ auth })]`.
355
441
 
356
- ### `TypeError: useJanus(): the GraphQL context has no request to authenticate`
442
+ ### `TypeError: …: the GraphQL context has no request to authenticate`
357
443
 
358
- **Why:** the context was built without `request` — a transport that is not
359
- HTTP, such as a WebSocket subscription.
444
+ **Why:** the context was built without `request`, and for no connection
445
+ `janusConnection().onConnect` accepted: a transport that is not HTTP; a
446
+ graphql-ws server without `onConnect`; or Yoga's recipe with the context
447
+ built from something other than `{ ...ctx, … }`, which drops `ctx.extra`,
448
+ where `useJanus()` finds the connection. The message opens with
449
+ `useJanus():` under Yoga, and with `janusConnection().context:` without
450
+ it — the `context` of a server whose `onConnect` is not
451
+ `janusConnection()`'s.
360
452
 
361
- **Fix:** for now, authenticate subscriptions over HTTP (Yoga's server-sent
362
- events carry the request). A connection-level wiring for graphql-ws is on
363
- the [roadmap](roadmap.md).
453
+ **Fix:** over graphql-ws, pass `onConnect: janusConnection({ auth }).onConnect`
454
+ to `useServer()`, and keep the `...ctx` spread in `yoga.getEnveloped()`
455
+ ([the subscriptions guide](guide/subscriptions.md)). Without Yoga, pass
456
+ `context: connection.context` too.
364
457
 
365
458
  ### `TypeError: can(): ctx.janus.access is not set`
366
459
 
@@ -497,6 +590,34 @@ from the same options.
497
590
 
498
591
  **Fix:** answer the object, or `null` when there is none.
499
592
 
593
+ ### The session cookie expires while the user is active
594
+
595
+ **Why:** `authenticate` renewed the session in the store, and no
596
+ `Set-Cookie` carried the new expiry back, so the browser drops the cookie at
597
+ the expiry it was given at sign-in. `useJanus()` sends it only on Yoga's
598
+ HTTP response (`onResponse`), and not when:
599
+
600
+ - the server runs on envelop without Yoga, which never calls `onResponse`;
601
+ - the `auth` given to `useJanus()` is a wrapper of yours without `cookie`;
602
+ - the only traffic is over graphql-ws, which has no response to carry it;
603
+ - the client sent `Authorization: Bearer` or `X-Session-Token`, which is
604
+ never answered with a cookie.
605
+
606
+ **Fix:** under Yoga, pass `janus()`'s own instance, or a wrapper with
607
+ `cookie: auth.cookie`. Elsewhere — and for a browser that talks only over
608
+ the WebSocket — renew through an HTTP route of yours that sends
609
+ `auth.cookie.serialize(token, session)`, as `@nxgt/janus-hono`'s
610
+ `session()` does.
611
+
612
+ ### `Warning: janusMaskError: report failed on …`
613
+
614
+ **Why:** the `report` given to `janusMaskError()` threw, or rejected — a
615
+ logger that is down, a serializer that meets a circular `cause`. The name
616
+ after the code is the class of what it threw.
617
+
618
+ **Fix:** your `report`. The response was sent all the same: a `report`
619
+ never changes the answer, so an outage is still a 503 and never a 500.
620
+
500
621
  ### A 401 whose `data` still holds the other fields
501
622
 
502
623
  **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.2.0",
3
+ "version": "0.4.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,17 +55,26 @@
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.13.0",
59
59
  "@types/bun": "^1.4.2",
60
+ "@types/ws": "^8.18.1",
60
61
  "graphql": "^17.0.2",
62
+ "graphql-ws": "^6.3.0",
61
63
  "graphql-yoga": "^5.24.1",
64
+ "ws": "^8.22.0",
62
65
  "zod": "^4.6.5"
63
66
  },
64
67
  "peerDependencies": {
65
68
  "@envelop/core": "^5.0.0",
66
69
  "@graphql-tools/utils": ">=10.0.0 <13",
67
- "@nxgt/janus": "^0.12.0",
70
+ "@nxgt/janus": "^0.13.0",
68
71
  "graphql": "^16.9.0 || ^17.0.0",
72
+ "graphql-ws": "^6.0.0",
69
73
  "typescript": "^6.0.3"
74
+ },
75
+ "peerDependenciesMeta": {
76
+ "graphql-ws": {
77
+ "optional": true
78
+ }
70
79
  }
71
80
  }