@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.
- package/README.md +110 -24
- package/dist/connection/connection.d.ts +36 -0
- package/dist/connection/connection.d.ts.map +1 -0
- package/dist/connection/credential.d.ts +34 -0
- package/dist/connection/credential.d.ts.map +1 -0
- package/dist/connection/types.d.ts +57 -0
- package/dist/connection/types.d.ts.map +1 -0
- package/dist/context.d.ts +10 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/errors.d.ts +26 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +173 -30
- package/dist/index.js.map +12 -7
- package/dist/options.d.ts +17 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/plugin.d.ts +16 -1
- 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 +4 -3
- package/docs/guide/context.md +34 -3
- package/docs/guide/errors.md +68 -4
- package/docs/guide/step-up.md +7 -3
- package/docs/guide/subscriptions.md +255 -0
- package/docs/roadmap.md +29 -8
- package/docs/troubleshooting.md +130 -9
- package/package.json +12 -3
package/docs/guide/errors.md
CHANGED
|
@@ -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
|
|
92
|
-
|
|
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
|
package/docs/guide/step-up.md
CHANGED
|
@@ -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. `@
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`:
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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:
|
|
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
|
|
338
|
-
|
|
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:
|
|
442
|
+
### `TypeError: …: the GraphQL context has no request to authenticate`
|
|
357
443
|
|
|
358
|
-
**Why:** the context was built without `request
|
|
359
|
-
|
|
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:**
|
|
362
|
-
|
|
363
|
-
the
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|