@nxgt/janus-graphql 0.1.1 → 0.3.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.
Files changed (41) hide show
  1. package/README.md +110 -21
  2. package/dist/connection/connection.d.ts +36 -0
  3. package/dist/connection/connection.d.ts.map +1 -0
  4. package/dist/connection/credential.d.ts +34 -0
  5. package/dist/connection/credential.d.ts.map +1 -0
  6. package/dist/connection/types.d.ts +57 -0
  7. package/dist/connection/types.d.ts.map +1 -0
  8. package/dist/context.d.ts +11 -2
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/directives/apply.d.ts +10 -9
  11. package/dist/directives/apply.d.ts.map +1 -1
  12. package/dist/directives/guard.d.ts +2 -1
  13. package/dist/directives/guard.d.ts.map +1 -1
  14. package/dist/directives/read.d.ts +9 -1
  15. package/dist/directives/read.d.ts.map +1 -1
  16. package/dist/directives/validate.d.ts +7 -2
  17. package/dist/directives/validate.d.ts.map +1 -1
  18. package/dist/fresh.d.ts +37 -0
  19. package/dist/fresh.d.ts.map +1 -0
  20. package/dist/index.d.ts +12 -5
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +170 -23
  23. package/dist/index.js.map +14 -10
  24. package/dist/options.d.ts +17 -0
  25. package/dist/options.d.ts.map +1 -0
  26. package/dist/plugin.d.ts +8 -3
  27. package/dist/plugin.d.ts.map +1 -1
  28. package/dist/sdl.d.ts.map +1 -1
  29. package/dist/types.d.ts +6 -1
  30. package/dist/types.d.ts.map +1 -1
  31. package/docs/README.md +5 -3
  32. package/docs/guide/context.md +36 -3
  33. package/docs/guide/directives.md +64 -5
  34. package/docs/guide/errors.md +2 -1
  35. package/docs/guide/federation.md +2 -1
  36. package/docs/guide/step-up.md +186 -0
  37. package/docs/guide/subscriptions.md +251 -0
  38. package/docs/roadmap.md +29 -9
  39. package/docs/troubleshooting.md +218 -11
  40. package/graphql/janus.graphqls +8 -0
  41. package/package.json +11 -2
@@ -1,7 +1,8 @@
1
1
  # Directives
2
2
 
3
3
  This page is for guarding a schema: `@authenticated`, which asks who the user
4
- is, and `@permission`, which asks what the user may do to one object. Both
4
+ is, `@fresh`, which asks how recently their session proved it, and
5
+ `@permission`, which asks what the user may do to one object. All three
5
6
  are declared in `janusTypeDefs`, applied by `useJanus()` when the server
6
7
  builds its schema, and checked before the resolver runs — a refused request
7
8
  never reaches it.
@@ -26,7 +27,7 @@ export const yoga = createYoga({
26
27
 
27
28
  ## Where a directive applies
28
29
 
29
- Both directives go on a field, an object type or an interface:
30
+ Every directive goes on a field, an object type or an interface:
30
31
 
31
32
  | Written on | Guards |
32
33
  | --- | --- |
@@ -78,6 +79,57 @@ On `Ward.chart` above, the type's `["staff"]` and the field's
78
79
  Under `useJanus({ type: 'staff' })`, the only user type a directive may name
79
80
  is `'staff'`.
80
81
 
82
+ ## `@fresh(maxAge: Int!)`
83
+
84
+ ```graphql
85
+ type Mutation {
86
+ changeEmail(email: String!): User @fresh(maxAge: 600) # signed in, or confirmed, in the last ten minutes
87
+ }
88
+
89
+ type PaymentMethods @fresh(maxAge: 600) { # every field of PaymentMethods
90
+ cards: [Card!]!
91
+ add(token: String!): Card @fresh(maxAge: 60) # the smallest maxAge holds: one minute
92
+ }
93
+ ```
94
+
95
+ Only a session that proved who it is **less than `maxAge` seconds ago**
96
+ reaches the field: signed in, or confirmed since by `auth.stepUp.confirm`,
97
+ which moves the session's `authenticatedAt` to now. Renewing a sliding
98
+ session does not. It is `@nxgt/janus`'s `assertFresh`, and
99
+ `@nxgt/janus-hono`'s `fresh()`, for a schema.
100
+
101
+ **`maxAge` is in seconds**, an `Int`: `600` is ten minutes. Seconds because
102
+ a GraphQL `Int` is 32 bits — about 24 days in milliseconds — and because a
103
+ freshness limit is written in seconds elsewhere, such as OpenID Connect's
104
+ `max_age`. `@nxgt/janus`'s durations read a bare number as milliseconds;
105
+ here there is no unit to write, so it is seconds, always.
106
+
107
+ | The request | Answered |
108
+ | --- | --- |
109
+ | anonymous | `UNAUTHENTICATED`, 401 |
110
+ | a user of a type `@authenticated` does not name | `FORBIDDEN`, 403, before freshness is read |
111
+ | a session that proved who it is `maxAge` seconds ago or more | `STEP_UP_REQUIRED`, 403 — the client asks for a step-up |
112
+ | a store that cannot answer | `SERVICE_UNAVAILABLE`, 503, never a denial |
113
+ | a fresh session | every `@permission`, then the resolver |
114
+
115
+ `@fresh` implies a signed-in user, as `@permission` does. **It is checked
116
+ after `@authenticated` and before any `@permission`**, so a stale session
117
+ asks no permission check and loads no object. Where several apply — the
118
+ field's, its type's, its interfaces' — **the smallest `maxAge` holds**. The
119
+ refusal is at `maxAge` exactly: a session `maxAge` seconds old is refused,
120
+ as `assertFresh` refuses it.
121
+
122
+ **A `@fresh` on a subscription field is checked when it subscribes**: a
123
+ fresh one keeps receiving events past `maxAge`. A `@fresh` on the payload's
124
+ type or its fields is checked on every event, as any field. See
125
+ [the step-up over GraphQL](step-up.md#subscriptions).
126
+
127
+ The time is `useJanus({ clock })`'s — give it the clock given to `janus()`
128
+ when that is not the system's, such as `fixedClock` in a spec. The flow a
129
+ client follows on `STEP_UP_REQUIRED`, with `stepUp.request` and `confirm` as
130
+ mutations, is in [the step-up over GraphQL](step-up.md); `requireFresh(ctx,
131
+ maxAge)` is the same check in a resolver.
132
+
81
133
  ## `@permission(name, type, id, onDeny)`
82
134
 
83
135
  ```graphql
@@ -102,7 +154,8 @@ reads reaches the field. It is `@nxgt/janus-hono`'s
102
154
  | `onDeny` | What a denial answers: `NOT_FOUND`, 404, by default, or `FORBIDDEN`, 403 |
103
155
 
104
156
  `@permission` implies `@authenticated`: an anonymous request is answered
105
- `UNAUTHENTICATED`, 401, before any object is loaded or any check asked.
157
+ `UNAUTHENTICATED`, 401, before any object is loaded or any check asked. A
158
+ `@fresh` that applies is checked before it, too.
106
159
 
107
160
  ### Reading the id
108
161
 
@@ -213,7 +266,9 @@ permission whose rules name both. They are asked **one at a time, in order**,
213
266
  and the first that denies answers: the ones after it are never asked.
214
267
 
215
268
  The order across locations is outermost first: the interfaces' directives,
216
- the type's, the interfaces' fields', then the field's own. So a type's
269
+ the type's, the interfaces' fields', then the field's own. Across
270
+ directives, every `@authenticated` comes first, then `@fresh`, then the
271
+ `@permission`s. So a type's
217
272
  `NOT_FOUND` answers before a field's `FORBIDDEN` could tell that the object
218
273
  exists.
219
274
 
@@ -235,6 +290,7 @@ type Record @permission(name: "view", type: "record") {
235
290
  | --- | --- |
236
291
  | anonymous | `UNAUTHENTICATED`, 401 |
237
292
  | a user of a type `@authenticated` does not name | `FORBIDDEN`, 403, before any check |
293
+ | a session older than a `@fresh` that applies allows | `STEP_UP_REQUIRED`, 403, before any check |
238
294
  | no id at the path, an empty list in the parent, or an id no object can hold | `NOT_FOUND`, 404 |
239
295
  | a loader answering `null` | `NOT_FOUND`, 404 |
240
296
  | a denial | `onDeny`: `NOT_FOUND`, 404, or `FORBIDDEN`, 403 |
@@ -278,6 +334,7 @@ field — `(read by Ward.name)`:
278
334
  TypeError: applyJanusDirectives(): @authenticated on Query.me names the user type 'doctor', which is not one of 'patient', 'staff'
279
335
  TypeError: applyJanusDirectives(): @authenticated on Query.me names no user type — leave type: out to admit any signed-in user
280
336
  TypeError: applyJanusDirectives(): the @authenticated on Ward.chart and on its type or interfaces admit no user type in common
337
+ TypeError: applyJanusDirectives(): @fresh on Mutation.changeEmail asks maxAge: 0 — write a number of seconds above zero, such as maxAge: 600 for ten minutes
281
338
  TypeError: applyJanusDirectives(): @permission on Query.ward needs the permissions() instance — pass useJanus({ auth, access }), with access what permissions() answered
282
339
  TypeError: applyJanusDirectives(): @permission on Query.invoice names the object type 'invoice', which is not one of 'record', 'ward'
283
340
  TypeError: applyJanusDirectives(): @permission on Query.ward asks 'delete', which ward does not declare — it declares 'nurses', 'visitors', 'enter', 'manage'
@@ -292,7 +349,9 @@ Each is in [troubleshooting](../troubleshooting.md), with its fix. The
292
349
  compiler refuses the wiring's own mistakes before that: a loader for a type
293
350
  the model does not declare, a loader answering an object without a field a
294
351
  `fromField` reads, a condition for a type no `when()` is reached on, a `ctx`
295
- of the wrong shape, and `loaders` or `conditions` without `access`.
352
+ of the wrong shape, `loaders` or `conditions` without `access`, and a
353
+ `clock` that is not a `Clock`. A `maxAge` that is not an `Int`, or is
354
+ missing, is refused by graphql-js itself, at start-up too.
296
355
 
297
356
  ## The transform alone
298
357
 
@@ -30,8 +30,9 @@ Yoga reads `extensions.http.status` to answer the HTTP response, and strips
30
30
 
31
31
  | Code | Status | Message | When |
32
32
  | --- | --- | --- | --- |
33
- | `UNAUTHENTICATED` | 401 | `Not signed in` | An anonymous request reached a field `@authenticated` guards, or `requireUser()` |
33
+ | `UNAUTHENTICATED` | 401 | `Not signed in` | An anonymous request reached a guarded field, `requireUser()` or `requireFresh()` |
34
34
  | `FORBIDDEN` | 403 | `Forbidden` | A user of a type the directive or `requireUser({ type })` does not name, or a `@permission(onDeny: FORBIDDEN)` denied |
35
+ | `STEP_UP_REQUIRED` | 403 | `Forbidden` | A session older than `@fresh(maxAge)` or `requireFresh(ctx, maxAge)` allows: the client asks for a step-up — see [the step-up over GraphQL](step-up.md) |
35
36
  | `NOT_FOUND` | 404 | `Not found` | `@permission`'s default denial, a loader answering `null`, an id `@permission` could not read — and `denial('NOT_FOUND')` |
36
37
  | `SERVICE_UNAVAILABLE` | 503 | `The service is unavailable, retry later` | A store could not answer: `STORE_FAILED` |
37
38
  | any other `JanusErrorCode` | its `statusOf(code)` | fixed per status: `Invalid request` 400, `Invalid credentials` 401 (`CREDENTIALS_INVALID`, `CODE_INVALID`), `Forbidden` 403, `Not found` 404, `Conflict` 409, `Internal server error` 500 and 501 | A `JanusError` a resolver let through — `CREDENTIALS_INVALID` 401, `LOGIN_TAKEN` 409, `TOKEN_EXPIRED` 400, … |
@@ -53,8 +53,9 @@ specification URL of your own and compose them:
53
53
  ```graphql
54
54
  extend schema
55
55
  @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective"])
56
- @link(url: "https://example.com/janus/v1.0", import: ["@authenticated", "@permission"])
56
+ @link(url: "https://example.com/janus/v1.0", import: ["@authenticated", "@fresh", "@permission"])
57
57
  @composeDirective(name: "@authenticated")
58
+ @composeDirective(name: "@fresh")
58
59
  @composeDirective(name: "@permission")
59
60
  ```
60
61
 
@@ -0,0 +1,186 @@
1
+ # A step-up over GraphQL
2
+
3
+ This page is for a mutation a stolen session should not run alone: changing
4
+ the e-mail, a payment method, deleting the account. `@fresh(maxAge)` lets it
5
+ run only for a session that proved who it is less than `maxAge` seconds ago;
6
+ older, it answers `STEP_UP_REQUIRED`, and the client proves it again with a
7
+ step-up. `@nxgt/janus` issues and checks the code, and stamps the session.
8
+ This page shows the whole flow as GraphQL. For the directive alone, see
9
+ [directives](directives.md#freshmaxage-int); for the step-up itself, every
10
+ error and the codes an app confirms, see
11
+ [`@nxgt/janus`'s step-up guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/step-up.md).
12
+
13
+ ## The schema
14
+
15
+ ```graphql
16
+ type Mutation {
17
+ changeEmail(email: String!): User @fresh(maxAge: 600) # ten minutes
18
+ deleteAccount: Boolean! @fresh(maxAge: 300) # five
19
+
20
+ requestStepUp: StepUpChallenge! @authenticated
21
+ confirmStepUp(challenge: String!, code: String!): Boolean! @authenticated
22
+ }
23
+
24
+ type StepUpChallenge {
25
+ challenge: String!
26
+ via: String! # "email", or "secondFactor": the code is in the user's app
27
+ }
28
+ ```
29
+
30
+ `@fresh` implies a signed-in user: an anonymous request is `UNAUTHENTICATED`,
31
+ 401, before freshness is read. The two step-up mutations need a signed-in
32
+ user and no fresh one: they are how a session becomes fresh.
33
+
34
+ ## The resolvers
35
+
36
+ ```ts
37
+ import { requireUser, type JanusContext } from '@nxgt/janus-graphql';
38
+ import type { YogaInitialContext } from 'graphql-yoga';
39
+
40
+ type Context = YogaInitialContext & JanusContext<typeof auth>;
41
+
42
+ const resolvers = {
43
+ Mutation: {
44
+ changeEmail: (_: unknown, { email }: { email: string }, ctx: Context) =>
45
+ users.changeEmail(ctx, email), // runs only on a fresh session
46
+
47
+ requestStepUp: async (_: unknown, __: unknown, ctx: Context) => {
48
+ const user = await requireUser(ctx); // the session's user — never an id the client sent
49
+ const issued = await auth.stepUp.request(user);
50
+ if (issued.via === 'email') {
51
+ await sendMail(issued.email, 'Confirm it is you', `${issued.code} confirms the action.`);
52
+ }
53
+ return { challenge: issued.challenge, via: issued.via }; // never the code
54
+ },
55
+
56
+ confirmStepUp: async (
57
+ _: unknown,
58
+ { challenge, code }: { challenge: string; code: string },
59
+ ctx: Context,
60
+ ) => {
61
+ await auth.stepUp.confirm(ctx.request, challenge, code); // stamps this request's session
62
+ return true;
63
+ },
64
+ },
65
+ };
66
+ ```
67
+
68
+ `stepUp.confirm` takes the **request**, not a user: it stamps the session
69
+ that request presents, and only a standing session of the challenge's user.
70
+ `ctx.request` is the `Request` Yoga puts on the context, and carries the same
71
+ token or cookie as the mutation that was refused. With several user types,
72
+ `stepUp` is on each type that has an e-mail — `auth.patient.stepUp` — and
73
+ `requireUser(ctx, { type: 'patient' })` narrows the user to it.
74
+
75
+ **Wire `janusMaskError()`.** A wrong code is a `JanusError` the resolver lets
76
+ through — `CODE_INVALID`, 401, with `attemptsLeft` — and without the mask
77
+ Yoga answers it as a 500.
78
+
79
+ ## What the client does
80
+
81
+ 1. It sends `changeEmail`. The session is older than ten minutes:
82
+
83
+ ```json
84
+ { "data": { "changeEmail": null },
85
+ "errors": [{ "message": "Forbidden", "path": ["changeEmail"], "extensions": { "code": "STEP_UP_REQUIRED" } }] }
86
+ ```
87
+
88
+ The response is a 403. The client reads `extensions.code`, never the
89
+ message, which is the fixed one of the status.
90
+
91
+ 2. It sends `requestStepUp`, and keeps `challenge`. The user reads the code
92
+ in the e-mail — or in their app, when `via` is `"secondFactor"`.
93
+
94
+ 3. It sends `confirmStepUp(challenge, code)`. From now on, the session is
95
+ fresh for `maxAge` seconds.
96
+
97
+ 4. It sends `changeEmail` again, which runs.
98
+
99
+ | `confirmStepUp` answers | Status | The client |
100
+ | --- | --- | --- |
101
+ | `true` | 200 | sends the refused mutation again |
102
+ | `CODE_INVALID`, with `attemptsLeft` | 401 | asks for the code again; at `0`, starts over at `requestStepUp` |
103
+ | `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_UNKNOWN`, `TOKEN_STALE` | 400 | starts over at `requestStepUp` |
104
+ | `SECOND_FACTOR_ACTIVE` | 409 | starts over: the user's app confirms now |
105
+ | `SECOND_FACTOR_NOT_ENROLLED` | 409 | starts over: the factor was disabled, an e-mailed code confirms now |
106
+ | `USER_INACTIVE` | 403 | stops: the account was deactivated |
107
+ | `SERVICE_UNAVAILABLE` | 503 | retries later |
108
+
109
+ `requestStepUp` itself answers `USER_INACTIVE`, 403, for a deactivated
110
+ user, and `NOT_FOUND`, 404, for a user with no e-mail to send a code to
111
+ — an optional e-mail left empty.
112
+
113
+ **Rate-limit `requestStepUp` per user.** A new challenge takes only a new
114
+ request, and each one sends an e-mail: a few an hour is plenty for a person.
115
+
116
+ ## In a resolver: `requireFresh(ctx, maxAge)`
117
+
118
+ Where the rule depends on the arguments, ask in the resolver:
119
+
120
+ ```ts
121
+ import { requireFresh } from '@nxgt/janus-graphql';
122
+
123
+ const resolvers = {
124
+ Mutation: {
125
+ transfer: async (_: unknown, { amount }: { amount: number }, ctx: Context) => {
126
+ if (amount > 1_000) await requireFresh(ctx, '5m'); // STEP_UP_REQUIRED past five minutes
127
+ return payments.transfer(ctx, amount);
128
+ },
129
+ },
130
+ };
131
+ ```
132
+
133
+ It answers the session, and refuses as `@fresh` does. `maxAge` is a
134
+ duration with its unit — `'5m'`, `'300s'` — never a bare number: the
135
+ compiler refuses one, and so does the call at run time, because
136
+ `@nxgt/janus` reads a bare number as milliseconds where `@fresh` reads
137
+ seconds.
138
+
139
+ ## Subscriptions
140
+
141
+ **A `@fresh` on the subscription field is checked when it subscribes.** A
142
+ stale session is refused before the stream starts; a fresh one keeps
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
147
+ it from the server.
148
+
149
+ Over a WebSocket, the same holds once `janusConnection()` authenticates
150
+ the connection: [subscriptions over graphql-ws](subscriptions.md).
151
+
152
+ **A `@fresh` on the payload's type, or on one of its fields, is checked on
153
+ every event**, as any field is: those fields resolve anew for each event,
154
+ and once the session is past `maxAge` they answer `STEP_UP_REQUIRED` while
155
+ the stream goes on.
156
+
157
+ ```graphql
158
+ type Subscription {
159
+ receipts: Receipt @fresh(maxAge: 600) # checked once, when it subscribes
160
+ }
161
+ type Receipt @fresh(maxAge: 600) { # checked on every event that carries one
162
+ amount: Int
163
+ }
164
+ ```
165
+
166
+ ## Testing it
167
+
168
+ `useJanus({ clock })` is the clock `@fresh` and `requireFresh()` read. Give
169
+ it the one given to `janus()`, and a spec moves both at once:
170
+
171
+ ```ts
172
+ import { fixedClock, janus } from '@nxgt/janus';
173
+
174
+ const clock = fixedClock(Date.UTC(2026, 0, 1));
175
+ const auth = janus({ /* … */ clock });
176
+ const yoga = createYoga({ schema, plugins: [useJanus({ auth, clock })] });
177
+
178
+ const { token } = await auth.signUp({ email, password }); // signed in now
179
+ clock.advance(10 * 60_000); // ten minutes later
180
+ // changeEmail now answers STEP_UP_REQUIRED
181
+ ```
182
+
183
+ Without `clock`, both read the system's. A `janus()` on a fixed clock and a
184
+ `useJanus()` without one disagree on the time: a session signed in on
185
+ the fixed clock looks days old, and every `@fresh` field answers
186
+ `STEP_UP_REQUIRED`.
@@ -0,0 +1,251 @@
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: renew through your HTTP routes.
package/docs/roadmap.md CHANGED
@@ -9,14 +9,6 @@ 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
- - **`@fresh(maxAge)`** — a field only a session that proved who it is less
16
- than `maxAge` ago may resolve — signed in, or confirmed since by
17
- `auth.stepUp.confirm`: a password change, a new e-mail, a payment method,
18
- asked again of a session that is days old. `@nxgt/janus` 0.12's
19
- `assertFresh` is the check, and `STEP_UP_REQUIRED` the error it answers.
20
12
  - **The renewed session cookie on GraphQL responses** — `authenticate`
21
13
  renews a sliding session in passing; the response would then carry its
22
14
  `Set-Cookie`, so a browser keeps the new expiry without an HTTP route of
@@ -24,7 +16,12 @@ Nothing between releases.
24
16
 
25
17
  ## Later
26
18
 
27
- Nothing yet.
19
+ - **Anonymous graphql-ws connections** — `janusConnection({ anonymous:
20
+ true })`, accepting a connection with no credential so its public fields
21
+ answer and its guarded ones deny, as over HTTP.
22
+ - **Re-authenticating a subscription on each event** — an opt-in for
23
+ streams that must end the moment their session is revoked, at the cost
24
+ of a sessions-store read per event.
28
25
 
29
26
  ## Not planned
30
27
 
@@ -46,6 +43,29 @@ Nothing yet.
46
43
 
47
44
  Newest first; from the first release on, the package's CHANGELOG holds every one.
48
45
 
46
+ - **Subscriptions over graphql-ws, `janusConnection()`, v0.3.0.** The same
47
+ `@authenticated`, `@fresh` and `@permission` on subscriptions served over
48
+ a WebSocket with graphql-ws: `onConnect` for its `useServer()`
49
+ authenticates the connection from `connectionParams.authorization` — else
50
+ from the upgrade request's headers and the browser's session cookie —
51
+ and refuses it `4403`, or rejects on an outage, closed `4500`. Each
52
+ operation's `ctx.janus` is built by `useJanus()` from that credential, in
53
+ Yoga's recommended setup, and authenticated again when it subscribes, so
54
+ a session revoked since the connection opened is refused at the next
55
+ subscribe. `context` for a server without Yoga, `upgrade` for Bun.
56
+ `graphql-ws` is an optional peer, `^6.0.0`, whose floor runs in CI.
57
+
58
+ - **`@fresh(maxAge)` and `requireFresh()`, v0.2.0.** A field, a type or an
59
+ interface only a session that proved who it is less than `maxAge` seconds
60
+ ago may resolve — signed in, or confirmed since by `auth.stepUp.confirm`:
61
+ a new e-mail or a payment method, asked again of a session that is days
62
+ old. An older session is `STEP_UP_REQUIRED`, 403, which tells the client
63
+ to ask for a step-up; checked after `@authenticated` and before any
64
+ `@permission`, and for a subscription when it subscribes.
65
+ `requireFresh(ctx, '10m')` is the same check in a resolver, and
66
+ `useJanus({ clock })` the clock both read. `@nxgt/janus` 0.12's
67
+ `assertFresh` is the check.
68
+
49
69
  - **The first release, v0.1.0.** GraphQL for `@nxgt/janus`, as an envelop
50
70
  plugin: `useJanus()` with a lazy `ctx.janus`, authenticated only when a
51
71
  field asks; `@authenticated` on fields, types and interfaces, for any user